neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Calendar: preview a feed, expand repeating events, and a Plus reading
bc256e5 · gmorell · 2026-11-09 05:34
Message
{commit_body(@commit)}
Files changed
modified
apps/room_calendar/lib/room_calendar/calendar_worker.ex
+133
−31
| 4 | 4 | |
| 5 | 5 | require Logger |
| 6 | 6 | |
| 7 | + import Ecto.Query, only: [from: 2] | |
| 8 | + | |
| 9 | + alias RoomCalendar.Occurrences | |
| 7 | 10 | alias RoomSanctum.Configuration |
| 8 | 11 | alias RoomSanctum.Storage |
| 12 | + alias RoomSanctum.Storage.ICalendar | |
| 9 | 13 | alias RoomSanctum.Repo |
| 10 | 14 | |
| 11 | 15 | @registry :zeus |
| 49 | 53 | val |
| 50 | 54 | end |
| 51 | 55 | |
| 56 | + # How far either side of now a repeating event is written out. Back far | |
| 57 | + # enough that this week's dates are there whatever day it is; ahead as far | |
| 58 | + # as any board looks. | |
| 59 | + @days_back 30 | |
| 60 | + @days_ahead 365 | |
| 61 | + | |
| 52 | 62 | def handle_cast(:update_static, state) do |
| 53 | 63 | Logger.info("iCAL::#{state.id} updating") |
| 54 | − dt = NaiveDateTime.local_now() | |
| 55 | 64 | cfg = Configuration.get_source!(state.id) |
| 56 | − url = cfg.config.url |> String.replace("webcal://", "http://") | |
| 57 | − | |
| 58 | − case HTTPoison.get(url) do | |
| 59 | − {:ok, result} -> | |
| 60 | − entries = ICalendar.from_ics(result.body) | |
| 61 | − | |
| 62 | − data = | |
| 63 | − entries | |
| 64 | − |> Enum.map(fn x -> | |
| 65 | − %{ | |
| 66 | − blob: x |> Map.from_struct(), | |
| 67 | − source_id: state.id |> intify, | |
| 68 | − date_start: x.dtstart, | |
| 69 | − date_end: x.dtend | |
| 70 | − } | |
| 71 | − end) | |
| 72 | − |> Enum.map(fn x -> | |
| 73 | − RoomSanctum.Storage.change_icalendar(%RoomSanctum.Storage.ICalendar{}, x).changes | |
| 74 | − |> Map.put(:inserted_at, dt) | |
| 75 | − |> Map.put(:updated_at, dt) | |
| 76 | − end) | |
| 77 | − | |
| 78 | − Repo.insert_all( | |
| 79 | − RoomSanctum.Storage.ICalendar, | |
| 80 | − data, | |
| 81 | − on_conflict: {:replace_all_except, [:id]}, | |
| 82 | − conflict_target: [:source_id, :blob] | |
| 83 | − ) | |
| 84 | − |> IO.inspect() | |
| 65 | + | |
| 66 | + case fetch(cfg.config.url) do | |
| 67 | + {:ok, events} -> | |
| 68 | + count = store(intify(state.id), window(events)) | |
| 69 | + Logger.info("iCAL::#{state.id} stored #{count} occurrences of #{length(events)} events") | |
| 85 | 70 | |
| 86 | 71 | {:error, reason} -> |
| 87 | − IO.puts("error #{reason.reason}") | |
| 72 | + Logger.warning("iCAL::#{state.id} #{reason}") | |
| 88 | 73 | end |
| 89 | 74 | |
| 90 | 75 | {:noreply, state} |
| 94 | 79 | {:noreply, state} |
| 95 | 80 | end |
| 96 | 81 | |
| 82 | + # The source's rows are the feed as it is now: replaced, not added to. Rows | |
| 83 | + # were once upserted on their whole content, so a moved or retitled event | |
| 84 | + # left its old self behind for ever. | |
| 85 | + defp store(source_id, occurrences) do | |
| 86 | + dt = NaiveDateTime.local_now() | |
| 87 | + | |
| 88 | + rows = | |
| 89 | + Enum.map(occurrences, fn o -> | |
| 90 | + %{ | |
| 91 | + source_id: source_id, | |
| 92 | + date_start: o.start, | |
| 93 | + date_end: o.end, | |
| 94 | + blob: blob(o), | |
| 95 | + inserted_at: dt, | |
| 96 | + updated_at: dt | |
| 97 | + } | |
| 98 | + end) | |
| 99 | + | |
| 100 | + {:ok, count} = | |
| 101 | + Repo.transaction(fn -> | |
| 102 | + Repo.delete_all(from(ic in ICalendar, where: ic.source_id == ^source_id)) | |
| 103 | + | |
| 104 | + rows | |
| 105 | + |> Enum.chunk_every(1_000) | |
| 106 | + |> Enum.reduce(0, fn chunk, n -> | |
| 107 | + # A feed listing the same event twice is one row, not a failed update. | |
| 108 | + {k, _} = Repo.insert_all(ICalendar, chunk, on_conflict: :nothing) | |
| 109 | + n + k | |
| 110 | + end) | |
| 111 | + end) | |
| 112 | + | |
| 113 | + count | |
| 114 | + end | |
| 115 | + | |
| 116 | + # What the condensers and pages read. The start goes in too: every date of a | |
| 117 | + # series is otherwise the same blob, and a source's blobs are unique. | |
| 118 | + defp blob(o) do | |
| 119 | + %{ | |
| 120 | + "summary" => o.summary, | |
| 121 | + "description" => o.description, | |
| 122 | + "location" => o.location, | |
| 123 | + "uid" => o.uid, | |
| 124 | + "all_day" => o.all_day, | |
| 125 | + "rrule" => o.rrule, | |
| 126 | + "occurrence" => DateTime.to_iso8601(o.start) | |
| 127 | + } | |
| 128 | + end | |
| 129 | + | |
| 130 | + defp window(events) do | |
| 131 | + now = DateTime.utc_now() | |
| 132 | + Occurrences.expand(events, DateTime.add(now, -@days_back, :day), DateTime.add(now, @days_ahead, :day)) | |
| 133 | + end | |
| 134 | + | |
| 135 | + @doc """ | |
| 136 | + Fetch and parse a feed without storing anything. | |
| 137 | + | |
| 138 | + `{:ok, events}` or `{:error, reason}`, the reason a sentence a person can | |
| 139 | + act on. A page that is not iCalendar at all -- a provider's "subscribe" | |
| 140 | + widget is the usual one -- parses to no events rather than failing, so it is | |
| 141 | + caught here before it can look like an empty calendar. | |
| 142 | + """ | |
| 143 | + def fetch(url) do | |
| 144 | + url = String.replace(url, "webcal://", "https://") | |
| 145 | + | |
| 146 | + case HTTPoison.get(url, [], follow_redirect: true, recv_timeout: 15_000) do | |
| 147 | + {:ok, %{status_code: 200, body: body} = resp} -> | |
| 148 | + if String.contains?(body, "BEGIN:VCALENDAR") do | |
| 149 | + parse(body) | |
| 150 | + else | |
| 151 | + {:error, "not an iCalendar feed (got #{content_type(resp)}, no BEGIN:VCALENDAR)"} | |
| 152 | + end | |
| 153 | + | |
| 154 | + {:ok, %{status_code: code}} -> | |
| 155 | + {:error, "HTTP #{code}"} | |
| 156 | + | |
| 157 | + {:error, %HTTPoison.Error{reason: reason}} -> | |
| 158 | + {:error, "request failed: #{inspect(reason)}"} | |
| 159 | + end | |
| 160 | + end | |
| 161 | + | |
| 162 | + defp parse(body) do | |
| 163 | + {:ok, ICal.from_ics(body).events} | |
| 164 | + rescue | |
| 165 | + e -> {:error, "could not read the feed: #{Exception.message(e)}"} | |
| 166 | + end | |
| 167 | + | |
| 168 | + @doc """ | |
| 169 | + What a feed holds that is not over yet: the next `limit` occurrences, with | |
| 170 | + repeating events written out, and the counts around them -- for checking a | |
| 171 | + URL before (or instead of) storing it. | |
| 172 | + """ | |
| 173 | + def preview(url, limit \\ 15) do | |
| 174 | + with {:ok, events} <- fetch(url) do | |
| 175 | + now = DateTime.utc_now() | |
| 176 | + | |
| 177 | + upcoming = | |
| 178 | + events | |
| 179 | + |> window() | |
| 180 | + # Not yet over, so today's all-day events -- midnight UTC -- stay in. | |
| 181 | + |> Enum.filter(&(DateTime.compare(&1.end || &1.start, now) == :gt)) | |
| 182 | + | |
| 183 | + {:ok, | |
| 184 | + %{ | |
| 185 | + total: length(events), | |
| 186 | + upcoming: length(upcoming), | |
| 187 | + recurring: Enum.count(events, &(&1.rrule && is_nil(&1.recurrence_id))), | |
| 188 | + events: Enum.take(upcoming, limit) | |
| 189 | + }} | |
| 190 | + end | |
| 191 | + end | |
| 192 | + | |
| 193 | + defp content_type(%{headers: headers}) do | |
| 194 | + Enum.find_value(headers, "unknown content", fn {k, v} -> | |
| 195 | + if String.downcase(k) == "content-type", do: v | |
| 196 | + end) | |
| 197 | + end | |
| 198 | + | |
| 97 | 199 | defp via_tuple(name), do: {:via, Registry, {@registry, name}} |
| 98 | 200 | end |
added
apps/room_calendar/lib/room_calendar/occurrences.ex
+134
−0
| 1 | +defmodule RoomCalendar.Occurrences do | |
| 2 | + @moduledoc """ | |
| 3 | + A feed's events as the dates they actually happen on. | |
| 4 | + | |
| 5 | + A repeating event is one VEVENT with an RRULE, not one per date, so storing | |
| 6 | + events as they come stores the first date and nothing after. This expands | |
| 7 | + each rule over a window, in the event's own zone -- 7pm every third Monday | |
| 8 | + stays 7pm across the clock change -- and settles the two ways a feed edits | |
| 9 | + one date of a series: | |
| 10 | + | |
| 11 | + * EXDATE on the series drops a date; the rule expansion applies it. | |
| 12 | + * A second VEVENT with the series' UID and a RECURRENCE-ID replaces the | |
| 13 | + date it names, moved, retitled or cancelled. Google writes most edits | |
| 14 | + this way, so the generated date it replaces is dropped here and the | |
| 15 | + override stands in its place. | |
| 16 | + | |
| 17 | + Cancelled events, and cancelled overrides with them, are left out. | |
| 18 | + """ | |
| 19 | + | |
| 20 | + # A runaway rule -- one that matches nothing, or matches every second -- | |
| 21 | + # stops here rather than taking the worker with it. | |
| 22 | + @max_per_rule 2_000 | |
| 23 | + | |
| 24 | + @type t :: %{ | |
| 25 | + start: DateTime.t(), | |
| 26 | + end: DateTime.t() | nil, | |
| 27 | + all_day: boolean, | |
| 28 | + summary: String.t() | nil, | |
| 29 | + description: String.t() | nil, | |
| 30 | + location: String.t() | nil, | |
| 31 | + uid: String.t() | nil, | |
| 32 | + rrule: map | nil | |
| 33 | + } | |
| 34 | + | |
| 35 | + @doc """ | |
| 36 | + Every occurrence of `events` overlapping `from`..`to`, soonest first, with | |
| 37 | + start and end in UTC. All-day occurrences start at midnight UTC on their | |
| 38 | + date, which is how the rest of the app reads a whole day. | |
| 39 | + """ | |
| 40 | + @spec expand([ICal.Event.t()], DateTime.t(), DateTime.t()) :: [t()] | |
| 41 | + def expand(events, from, to) do | |
| 42 | + replaced = | |
| 43 | + events | |
| 44 | + |> Enum.filter(& &1.recurrence_id) | |
| 45 | + |> MapSet.new(&{&1.uid, instant(&1.recurrence_id)}) | |
| 46 | + | |
| 47 | + events | |
| 48 | + |> Enum.filter(& &1.dtstart) | |
| 49 | + |> Enum.flat_map(fn e -> | |
| 50 | + if e.rrule && is_nil(e.recurrence_id) do | |
| 51 | + e | |
| 52 | + |> ICal.Recurrence.stream() | |
| 53 | + |> Stream.take(@max_per_rule) | |
| 54 | + |> Stream.take_while(&(DateTime.compare(instant(&1), to) == :lt)) | |
| 55 | + |> Stream.reject(&MapSet.member?(replaced, {e.uid, instant(&1)})) | |
| 56 | + |> Enum.map(&occurrence(e, &1)) | |
| 57 | + else | |
| 58 | + [occurrence(e, e.dtstart)] | |
| 59 | + end | |
| 60 | + end) | |
| 61 | + |> Enum.reject(&(&1.status == :cancelled)) | |
| 62 | + |> Enum.filter(&overlaps?(&1, from, to)) | |
| 63 | + |> Enum.sort_by(& &1.start, DateTime) | |
| 64 | + |> Enum.map(&Map.delete(&1, :status)) | |
| 65 | + end | |
| 66 | + | |
| 67 | + defp occurrence(e, start) do | |
| 68 | + {s, f, all_day} = span(e, start) | |
| 69 | + | |
| 70 | + %{ | |
| 71 | + start: s, | |
| 72 | + end: f, | |
| 73 | + all_day: all_day, | |
| 74 | + summary: e.summary, | |
| 75 | + description: e.description, | |
| 76 | + location: e.location, | |
| 77 | + uid: e.uid, | |
| 78 | + status: e.status, | |
| 79 | + # The series' rule, on every date it made. An override is a date of a | |
| 80 | + # series too, though it carries no rule of its own. | |
| 81 | + rrule: rule(e) | |
| 82 | + } | |
| 83 | + end | |
| 84 | + | |
| 85 | + # An occurrence lasts as long as the event it came from: DTEND less DTSTART, | |
| 86 | + # or DURATION, or -- with neither -- a whole day for a date, and no length | |
| 87 | + # at all for a time. | |
| 88 | + defp span(e, %Date{} = day) do | |
| 89 | + days = | |
| 90 | + cond do | |
| 91 | + match?(%Date{}, e.dtend) -> max(Date.diff(e.dtend, e.dtstart), 1) | |
| 92 | + e.duration -> max(div(seconds(e.duration), 86_400), 1) | |
| 93 | + true -> 1 | |
| 94 | + end | |
| 95 | + | |
| 96 | + s = DateTime.new!(day, ~T[00:00:00], "Etc/UTC") | |
| 97 | + {s, DateTime.add(s, days, :day), true} | |
| 98 | + end | |
| 99 | + | |
| 100 | + defp span(e, start) do | |
| 101 | + s = instant(start) | |
| 102 | + | |
| 103 | + f = | |
| 104 | + cond do | |
| 105 | + e.dtend -> DateTime.add(s, DateTime.diff(instant(e.dtend), instant(e.dtstart))) | |
| 106 | + e.duration -> DateTime.add(s, seconds(e.duration)) | |
| 107 | + true -> nil | |
| 108 | + end | |
| 109 | + | |
| 110 | + {s, f, false} | |
| 111 | + end | |
| 112 | + | |
| 113 | + defp overlaps?(%{start: s, end: f}, from, to) do | |
| 114 | + DateTime.compare(s, to) == :lt and DateTime.compare(f || s, from) != :lt | |
| 115 | + end | |
| 116 | + | |
| 117 | + # Floating times name no zone; read as UTC, as the old parser did. | |
| 118 | + defp instant(%DateTime{} = dt), do: DateTime.shift_zone!(dt, "Etc/UTC") | |
| 119 | + defp instant(%NaiveDateTime{} = dt), do: DateTime.from_naive!(dt, "Etc/UTC") | |
| 120 | + defp instant(%Date{} = d), do: DateTime.new!(d, ~T[00:00:00], "Etc/UTC") | |
| 121 | + | |
| 122 | + defp seconds(%ICal.Duration{positive: pos, weeks: w, days: d, time: {h, m, s}}) do | |
| 123 | + total = ((w * 7 + d) * 24 + h) * 3600 + m * 60 + s | |
| 124 | + if pos, do: total, else: -total | |
| 125 | + end | |
| 126 | + | |
| 127 | + # Enough to say that it repeats and how often; the rule itself has done its | |
| 128 | + # work by the time anything is stored. | |
| 129 | + defp rule(%{rrule: %ICal.Recurrence{frequency: freq, interval: interval}}), | |
| 130 | + do: %{"freq" => Atom.to_string(freq), "interval" => interval} | |
| 131 | + | |
| 132 | + defp rule(%{recurrence_id: rid}) when not is_nil(rid), do: %{"override" => true} | |
| 133 | + defp rule(_), do: nil | |
| 134 | +end |
modified
apps/room_calendar/mix.exs
+1
−1
| 25 | 25 | # Run "mix help deps" to learn about dependencies. |
| 26 | 26 | defp deps do |
| 27 | 27 | [ |
| 28 | − {:icalendar, "~> 1.1.0"}, | |
| 28 | + {:ical, "~> 3.2"}, | |
| 29 | 29 | {:room_sanctum, in_umbrella: true} |
| 30 | 30 | # {:dep_from_hexpm, "~> 0.3.0"}, |
| 31 | 31 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, |
added
apps/room_calendar/test/room_calendar_occurrences_test.exs
+108
−0
| 1 | +defmodule RoomCalendar.OccurrencesTest do | |
| 2 | + @moduledoc """ | |
| 3 | + A feed in the shape Google writes one: a monthly series on an ordinal | |
| 4 | + weekday, one date dropped by EXDATE, one moved by an override, one cancelled | |
| 5 | + by an override, and an all-day event beside them. | |
| 6 | + """ | |
| 7 | + use ExUnit.Case, async: true | |
| 8 | + | |
| 9 | + alias RoomCalendar.Occurrences | |
| 10 | + | |
| 11 | + @ics """ | |
| 12 | + BEGIN:VCALENDAR | |
| 13 | + VERSION:2.0 | |
| 14 | + BEGIN:VEVENT | |
| 15 | + UID:laser@example | |
| 16 | + SUMMARY:CO2 Laser Training | |
| 17 | + DTSTART;TZID=America/New_York:20260921T190000 | |
| 18 | + DTEND;TZID=America/New_York:20260921T210000 | |
| 19 | + RRULE:FREQ=MONTHLY;BYDAY=3MO | |
| 20 | + EXDATE;TZID=America/New_York:20261221T190000 | |
| 21 | + END:VEVENT | |
| 22 | + BEGIN:VEVENT | |
| 23 | + UID:laser@example | |
| 24 | + RECURRENCE-ID;TZID=America/New_York:20261116T190000 | |
| 25 | + SUMMARY:CO2 Laser Training (moved) | |
| 26 | + DTSTART;TZID=America/New_York:20261117T180000 | |
| 27 | + DTEND;TZID=America/New_York:20261117T200000 | |
| 28 | + END:VEVENT | |
| 29 | + BEGIN:VEVENT | |
| 30 | + UID:laser@example | |
| 31 | + RECURRENCE-ID;TZID=America/New_York:20270118T190000 | |
| 32 | + STATUS:CANCELLED | |
| 33 | + SUMMARY:CO2 Laser Training | |
| 34 | + DTSTART;TZID=America/New_York:20270118T190000 | |
| 35 | + DTEND;TZID=America/New_York:20270118T210000 | |
| 36 | + END:VEVENT | |
| 37 | + BEGIN:VEVENT | |
| 38 | + UID:hack@example | |
| 39 | + SUMMARY:Hack the Space | |
| 40 | + DTSTART;VALUE=DATE:20261031 | |
| 41 | + DTEND;VALUE=DATE:20261101 | |
| 42 | + RRULE:FREQ=MONTHLY;BYDAY=-1SA;COUNT=3 | |
| 43 | + END:VEVENT | |
| 44 | + BEGIN:VEVENT | |
| 45 | + UID:once@example | |
| 46 | + SUMMARY:Long ago | |
| 47 | + DTSTART:20200101T120000Z | |
| 48 | + DTEND:20200101T130000Z | |
| 49 | + END:VEVENT | |
| 50 | + END:VCALENDAR | |
| 51 | + """ | |
| 52 | + | |
| 53 | + defp expand(from \\ ~U[2026-10-01 00:00:00Z], to \\ ~U[2027-03-01 00:00:00Z]) do | |
| 54 | + @ics |> ICal.from_ics() |> Map.fetch!(:events) |> Occurrences.expand(from, to) | |
| 55 | + end | |
| 56 | + | |
| 57 | + defp starts(summary_prefix) do | |
| 58 | + expand() | |
| 59 | + |> Enum.filter(&String.starts_with?(&1.summary, summary_prefix)) | |
| 60 | + |> Enum.map(& &1.start) | |
| 61 | + end | |
| 62 | + | |
| 63 | + test "an ordinal-weekday series is written out, 7pm local across the clock change" do | |
| 64 | + # Oct is EDT (UTC-4), Feb is EST (UTC-5): the same 19:00 in New York. | |
| 65 | + assert ~U[2026-10-19 23:00:00Z] in starts("CO2") | |
| 66 | + assert ~U[2027-02-16 00:00:00Z] in starts("CO2") | |
| 67 | + end | |
| 68 | + | |
| 69 | + test "EXDATE drops its date, an override replaces its, a cancelled override removes its" do | |
| 70 | + laser = expand() |> Enum.filter(&(&1.uid == "laser@example")) | |
| 71 | + | |
| 72 | + assert Enum.map(laser, &{&1.summary, &1.start}) == [ | |
| 73 | + {"CO2 Laser Training", ~U[2026-10-19 23:00:00Z]}, | |
| 74 | + # 16 Nov moved to the 17th at 6pm EST. | |
| 75 | + {"CO2 Laser Training (moved)", ~U[2026-11-17 23:00:00Z]}, | |
| 76 | + # 21 Dec excluded; 18 Jan cancelled. | |
| 77 | + {"CO2 Laser Training", ~U[2027-02-16 00:00:00Z]} | |
| 78 | + ] | |
| 79 | + end | |
| 80 | + | |
| 81 | + test "each date keeps the event's length, and says which series it came from" do | |
| 82 | + [first | _] = expand() | |
| 83 | + | |
| 84 | + assert first.end == DateTime.add(first.start, 2, :hour) | |
| 85 | + assert first.rrule == %{"freq" => "monthly", "interval" => 1} | |
| 86 | + refute first.all_day | |
| 87 | + end | |
| 88 | + | |
| 89 | + test "an override is a date of a series though it carries no rule" do | |
| 90 | + assert %{rrule: %{"override" => true}} = Enum.find(expand(), &(&1.summary =~ "moved")) | |
| 91 | + end | |
| 92 | + | |
| 93 | + test "all-day dates are midnight UTC to midnight UTC, flagged, and honour COUNT" do | |
| 94 | + hack = expand() |> Enum.filter(&(&1.summary == "Hack the Space")) | |
| 95 | + | |
| 96 | + assert Enum.map(hack, & &1.start) == | |
| 97 | + [~U[2026-10-31 00:00:00Z], ~U[2026-11-28 00:00:00Z], ~U[2026-12-26 00:00:00Z]] | |
| 98 | + | |
| 99 | + assert Enum.all?(hack, &(&1.all_day and &1.end == DateTime.add(&1.start, 1, :day))) | |
| 100 | + end | |
| 101 | + | |
| 102 | + test "only what overlaps the window, soonest first" do | |
| 103 | + all = expand() | |
| 104 | + | |
| 105 | + refute Enum.any?(all, &(&1.summary == "Long ago")) | |
| 106 | + assert all == Enum.sort_by(all, & &1.start, DateTime) | |
| 107 | + end | |
| 108 | +end |
added
apps/room_calendar/test/room_calendar_worker_test.exs
+69
−0
| 1 | +defmodule RoomCalendar.WorkerTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias RoomCalendar.Worker | |
| 5 | + | |
| 6 | + # A one-shot HTTP server on a free port, answering every request with `body`. | |
| 7 | + defp serve(content_type, body) do | |
| 8 | + {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) | |
| 9 | + {:ok, port} = :inet.port(listen) | |
| 10 | + | |
| 11 | + spawn_link(fn -> | |
| 12 | + {:ok, sock} = :gen_tcp.accept(listen) | |
| 13 | + {:ok, _request} = :gen_tcp.recv(sock, 0) | |
| 14 | + | |
| 15 | + :gen_tcp.send(sock, [ | |
| 16 | + "HTTP/1.1 200 OK\r\ncontent-type: #{content_type}\r\n", | |
| 17 | + "content-length: #{byte_size(body)}\r\nconnection: close\r\n\r\n", | |
| 18 | + body | |
| 19 | + ]) | |
| 20 | + | |
| 21 | + :gen_tcp.close(sock) | |
| 22 | + end) | |
| 23 | + | |
| 24 | + "http://127.0.0.1:#{port}/feed" | |
| 25 | + end | |
| 26 | + | |
| 27 | + test "an HTML page is not an empty calendar" do | |
| 28 | + url = serve("text/html; charset=utf-8", "<!doctype html><html></html>") | |
| 29 | + | |
| 30 | + assert {:error, reason} = Worker.fetch(url) | |
| 31 | + assert reason =~ "not an iCalendar feed" | |
| 32 | + assert reason =~ "text/html" | |
| 33 | + end | |
| 34 | + | |
| 35 | + test "nobody answering is a failed request" do | |
| 36 | + assert {:error, "request failed: " <> _} = Worker.fetch("http://127.0.0.1:9/cal.ics") | |
| 37 | + end | |
| 38 | + | |
| 39 | + test "preview writes out repeating events and keeps what is still to come" do | |
| 40 | + future = Date.utc_today() |> Date.add(10) |> Calendar.strftime("%Y%m%d") | |
| 41 | + sooner = Date.utc_today() |> Date.add(2) |> Calendar.strftime("%Y%m%d") | |
| 42 | + | |
| 43 | + ics = """ | |
| 44 | + BEGIN:VCALENDAR | |
| 45 | + VERSION:2.0 | |
| 46 | + BEGIN:VEVENT | |
| 47 | + UID:later | |
| 48 | + SUMMARY:Later | |
| 49 | + DTSTART;VALUE=DATE:#{future} | |
| 50 | + END:VEVENT | |
| 51 | + BEGIN:VEVENT | |
| 52 | + UID:past | |
| 53 | + SUMMARY:Past | |
| 54 | + DTSTART;VALUE=DATE:20010101 | |
| 55 | + END:VEVENT | |
| 56 | + BEGIN:VEVENT | |
| 57 | + UID:sooner | |
| 58 | + SUMMARY:Sooner | |
| 59 | + DTSTART;VALUE=DATE:#{sooner} | |
| 60 | + RRULE:FREQ=WEEKLY;COUNT=3 | |
| 61 | + END:VEVENT | |
| 62 | + END:VCALENDAR | |
| 63 | + """ | |
| 64 | + | |
| 65 | + assert {:ok, preview} = Worker.preview(serve("text/calendar", ics)) | |
| 66 | + assert %{total: 3, upcoming: 4, recurring: 1} = preview | |
| 67 | + assert Enum.map(preview.events, & &1.summary) == ["Sooner", "Sooner", "Later", "Sooner"] | |
| 68 | + end | |
| 69 | +end |
modified
apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+3
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+36
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+52
−9
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/storage/calendar.ex
+53
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+94
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+50
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+79
−6
Click to load diff…
added
apps/room_sanctum/test/room_sanctum/calendar_condense_test.exs
+144
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/components/calendar_plus_test.exs
+79
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/live/source_live_calendar_preview_test.exs
+75
−0
Click to load diff…
modified
config/config.exs
+6
−0
Click to load diff…
modified
mix.lock
+1
−1
Click to load diff…
Parents: 7cea53b