diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-03-25 21:51:59 +1100 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-03-25 21:51:59 +1100 |
| commit | e8a40f31c904baeaa1817cd3e418df5ce71302c1 (patch) | |
| tree | de5b15228d2e7227560db91131006ee55af82049 /src | |
| parent | sidebar: media handle no players (diff) | |
| download | caelestia-shell-e8a40f31c904baeaa1817cd3e418df5ce71302c1.tar.gz caelestia-shell-e8a40f31c904baeaa1817cd3e418df5ce71302c1.tar.bz2 caelestia-shell-e8a40f31c904baeaa1817cd3e418df5ce71302c1.zip | |
sidebar: create upcoming module
Requires ical.js and curl
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/defaults.ts | 4 | ||||
| -rw-r--r-- | src/config/index.ts | 1 | ||||
| -rw-r--r-- | src/config/types.ts | 2 | ||||
| -rw-r--r-- | src/modules/sidebar/dashboard.tsx | 3 | ||||
| -rw-r--r-- | src/modules/sidebar/modules/upcoming.tsx | 83 | ||||
| -rw-r--r-- | src/services/calendar.ts | 116 | ||||
| -rw-r--r-- | src/utils/widgets.ts | 8 |
7 files changed, 215 insertions, 2 deletions
diff --git a/src/config/defaults.ts b/src/config/defaults.ts index d699a45..b6bee4f 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -143,4 +143,8 @@ export default { }, ], }, + calendar: { + webcals: [] as string[], // An array of urls to ICS files which you can curl + upcomingDays: 7, // Number of days which count as upcoming + }, }; diff --git a/src/config/index.ts b/src/config/index.ts index d09a668..3f9bf7a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -18,5 +18,6 @@ export const { memory, storage, wallpapers, + calendar, } = config; export default config; diff --git a/src/config/types.ts b/src/config/types.ts index aa0d921..cf828b6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -68,4 +68,6 @@ export default { "memory.interval": NUM, "storage.interval": NUM, "wallpapers.paths": OBJ_ARR({ recursive: BOOL, path: STR }), + "calendar.webcals": ARR(STR), + "calendar.upcomingDays": NUM, } as { [k: string]: string | string[] | number[] }; diff --git a/src/modules/sidebar/dashboard.tsx b/src/modules/sidebar/dashboard.tsx index 86921e6..936502b 100644 --- a/src/modules/sidebar/dashboard.tsx +++ b/src/modules/sidebar/dashboard.tsx @@ -5,6 +5,7 @@ import { bind, GLib, monitorFile, Variable } from "astal"; import { Gtk } from "astal/gtk3"; import AstalMpris from "gi://AstalMpris"; import Notifications from "./modules/notifications"; +import Upcoming from "./modules/upcoming"; const lengthStr = (length: number) => `${Math.floor(length / 60)}:${Math.floor(length % 60) @@ -127,5 +128,7 @@ export default () => ( ))} <box className="separator" /> <Notifications /> + <box className="separator" /> + <Upcoming /> </box> ); diff --git a/src/modules/sidebar/modules/upcoming.tsx b/src/modules/sidebar/modules/upcoming.tsx new file mode 100644 index 0000000..e2389e8 --- /dev/null +++ b/src/modules/sidebar/modules/upcoming.tsx @@ -0,0 +1,83 @@ +import Calendar, { type IEvent } from "@/services/calendar"; +import { setupCustomTooltip } from "@/utils/widgets"; +import { bind, GLib } from "astal"; +import { Gtk } from "astal/gtk3"; + +const getDateHeader = (events: IEvent[]) => { + const date = events[0].event.startDate; + const isToday = date.toJSDate().toDateString() === new Date().toDateString(); + return ( + (isToday ? "Today • " : "") + + GLib.DateTime.new_from_unix_local(date.toUnixTime()).format("%B %-d • %A") + + ` • ${events.length} event${events.length === 1 ? "" : "s"}` + ); +}; + +const getEventHeader = (e: IEvent) => { + const start = GLib.DateTime.new_from_unix_local(e.event.startDate.toUnixTime()); + const time = `${start.format("%-I")}${start.get_minute() > 0 ? `:${start.get_minute()}` : ""}${start.format("%P")}`; + return `${time} <b>${e.event.summary}</b>`; +}; + +const getEventTooltip = (e: IEvent) => { + const start = GLib.DateTime.new_from_unix_local(e.event.startDate.toUnixTime()); + const end = GLib.DateTime.new_from_unix_local(e.event.endDate.toUnixTime()); + const sameAmPm = start.format("%P") === end.format("%P"); + const time = `${start.format(`%A, %-d %B • %-I:%M${sameAmPm ? "" : "%P"}`)} — ${end.format("%-I:%M%P")}`; + const locIfExists = e.event.location ? ` ${e.event.location}\n` : ""; + const descIfExists = e.event.description ? ` ${e.event.description}\n` : ""; + return `<b>${e.event.summary}</b>\n${time}\n${locIfExists}${descIfExists} ${e.calendar}`; +}; + +const Event = (event: IEvent) => ( + <box className="event" setup={self => setupCustomTooltip(self, getEventTooltip(event), { useMarkup: true })}> + <box className={`calendar-indicator c${Calendar.get_default().getCalendarIndex(event.calendar)}`} /> + <box vertical> + <label truncate useMarkup xalign={0} label={getEventHeader(event)} /> + {event.event.location && <label truncate xalign={0} label={event.event.location} className="sublabel" />} + {event.event.description && ( + <label truncate useMarkup xalign={0} label={event.event.description} className="sublabel" /> + )} + </box> + </box> +); + +const Day = ({ events }: { events: IEvent[] }) => ( + <box vertical className="day"> + <label className="date" xalign={0} label={getDateHeader(events)} /> + <box vertical className="events"> + {events.map(Event)} + </box> + </box> +); + +const List = () => ( + <box vertical valign={Gtk.Align.START}> + {bind(Calendar.get_default(), "upcoming").as(u => + Object.values(u) + .sort((a, b) => a[0].event.startDate.compare(b[0].event.startDate)) + .map(e => <Day events={e} />) + )} + </box> +); + +export default () => ( + <box vertical className="upcoming"> + <box className="header-bar"> + <label + label={bind(Calendar.get_default(), "numUpcoming").as(n => `${n} upcoming event${n === 1 ? "" : "s"}`)} + /> + <box hexpand /> + <button + className={bind(Calendar.get_default(), "loading").as(l => (l ? "enabled" : ""))} + sensitive={bind(Calendar.get_default(), "loading").as(l => !l)} + cursor="pointer" + onClicked={() => Calendar.get_default().updateCalendars().catch(console.error)} + label=" Reload" + /> + </box> + <scrollable className="list" hscroll={Gtk.PolicyType.NEVER}> + <List /> + </scrollable> + </box> +); diff --git a/src/services/calendar.ts b/src/services/calendar.ts index e69de29..bc7c075 100644 --- a/src/services/calendar.ts +++ b/src/services/calendar.ts @@ -0,0 +1,116 @@ +import { execAsync, GObject, property, register } from "astal"; +import { calendar as config } from "config"; +import ical from "ical.js"; + +export interface IEvent { + calendar: string; + event: ical.Event; +} + +@register({ GTypeName: "Calendar" }) +export default class Calendar extends GObject.Object { + static instance: Calendar; + static get_default() { + if (!this.instance) this.instance = new Calendar(); + + return this.instance; + } + + #calCount = 1; + #loading = false; + #calendars: { [name: string]: ical.Component } = {}; + #upcoming: { [date: string]: IEvent[] } = {}; + + @property(Boolean) + get loading() { + return this.#loading; + } + + @property(Object) + get calendars() { + return this.#calendars; + } + + @property(Object) + get upcoming() { + return this.#upcoming; + } + + @property(Number) + get numUpcoming() { + return Object.values(this.#upcoming).reduce((acc, e) => acc + e.length, 0); + } + + getCalendarIndex(name: string) { + return Object.keys(this.#calendars).indexOf(name) + 1; + } + + async updateCalendars() { + this.#loading = true; + this.notify("loading"); + + this.#calendars = {}; + this.#calCount = 1; + + const cals = await Promise.allSettled(config.webcals.get().map(c => execAsync(["curl", c]))); + for (const cal of cals) { + if (cal.status === "fulfilled") { + const comp = new ical.Component(ical.parse(cal.value)); + const name = (comp.getFirstPropertyValue("x-wr-calname") ?? `Calendar ${this.#calCount++}`) as string; + this.#calendars[name] = comp; + } else console.error(`Failed to get calendar: ${cal.reason}`); + } + this.notify("calendars"); + + this.updateUpcoming(); + + this.#loading = false; + this.notify("loading"); + } + + updateUpcoming() { + this.#upcoming = {}; + + for (const [name, cal] of Object.entries(this.#calendars)) { + const today = ical.Time.now(); + const upcoming = ical.Time.now().adjust(config.upcomingDays.get(), 0, 0, 0); + + for (const e of cal.getAllSubcomponents()) { + const event = new ical.Event(e); + + // Skip invalid events + if (!event.startDate) continue; + + if (event.isRecurring()) { + // Recurring events + const iter = event.iterator(); + for (let next = iter.next(); next && next.compare(upcoming) <= 0; next = iter.next()) + if (next.compare(today) >= 0) { + const date = next.toJSDate().toDateString(); + if (!this.#upcoming.hasOwnProperty(date)) this.#upcoming[date] = []; + this.#upcoming[date].push({ calendar: name, event }); + } + } else if (event.startDate.compare(today) >= 0 && event.startDate.compare(upcoming) <= 0) { + // Add to upcoming if in upcoming range + const date = event.startDate.toJSDate().toDateString(); + if (!this.#upcoming.hasOwnProperty(date)) this.#upcoming[date] = []; + this.#upcoming[date].push({ calendar: name, event }); + } + } + } + + for (const events of Object.values(this.#upcoming)) + events.sort((a, b) => a.event.startDate.compare(b.event.startDate)); + + this.notify("upcoming"); + this.notify("num-upcoming"); + } + + constructor() { + super(); + + this.updateCalendars().catch(console.error); + config.webcals.subscribe(() => this.updateCalendars().catch(console.error)); + config.upcomingDays.subscribe(() => this.updateUpcoming()); + } +} diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 7b1eb5c..ef952f2 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -3,7 +3,11 @@ import { Astal, astalify, Gtk, Widget, type ConstructProps } from "astal/gtk3"; import AstalHyprland from "gi://AstalHyprland"; import type { AstalWidget } from "./types"; -export const setupCustomTooltip = (self: AstalWidget, text: string | Binding<string>) => { +export const setupCustomTooltip = ( + self: AstalWidget, + text: string | Binding<string>, + labelProps: Widget.LabelProps = {} +) => { if (!text) return null; self.set_has_tooltip(true); @@ -15,7 +19,7 @@ export const setupCustomTooltip = (self: AstalWidget, text: string | Binding<str keymode: Astal.Keymode.NONE, exclusivity: Astal.Exclusivity.IGNORE, anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT, - child: new Widget.Label({ className: "tooltip", label: text }), + child: new Widget.Label({ ...labelProps, className: "tooltip", label: text }), }); self.set_tooltip_window(window); |