diff --git a/server/app/caldav_client.py b/server/app/caldav_client.py index 46684af..5b7020a 100644 --- a/server/app/caldav_client.py +++ b/server/app/caldav_client.py @@ -1,8 +1,9 @@ """CalDAV account support: discovering which calendars an account exposes, -and fetching one calendar's events -- the second way (alongside +and fetching one calendar's events or tasks -- the second way (alongside calendar_feed.py's single-file ICS subscription) a user can link a calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale, -Baikal, ...). +Baikal, ...). Task lists (VTODO collections) are CalDAV-only -- a plain +ICS subscription doesn't meaningfully have one -- see fetch_tasks. Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0, @@ -117,3 +118,41 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str, "all_day": all_day, }) return events + + +def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]: + """Outstanding (not-completed) VTODOs from one CalDAV task list, + sorted by due date (tasks with no due date sort last). + {"summary", "due" (ISO date/datetime string, or None)}, ... + + Fetches every task including completed ones and filters/sorts + client-side rather than trusting get_todos()'s own + include_completed/sort_keys server-side filtering, same reasoning as + fetch_calendar_events not trusting the time-range REPORT filter -- + a simpler filter than a time range, but not worth re-litigating + which server-side filters are reliable one at a time.""" + try: + client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S) + calendar = caldav.Calendar(client=client, url=calendar_url) + objects = calendar.get_todos(include_completed=True) + except Exception as e: + raise CalDavError(str(e)) from e + + tasks: list[dict] = [] + for obj in objects: + try: + ical = icalendar.Calendar.from_ical(obj.data) + except Exception as e: # one malformed resource shouldn't blank the whole list + logger.warning("Could not parse a CalDAV task from %s: %s", calendar_url, e) + continue + for component in ical.walk("VTODO"): + status = str(component.get("STATUS") or "NEEDS-ACTION").upper() + if status == "COMPLETED": + continue + due = component.get("DUE") + tasks.append({ + "summary": str(component.get("SUMMARY") or "(untitled)"), + "due": due.dt.isoformat() if due is not None else None, + }) + tasks.sort(key=lambda t: (t["due"] is None, t["due"] or "")) + return tasks diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index d20d8c9..dbe53f5 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -140,6 +140,20 @@ def _fmt_time(dt: datetime) -> str: return text if text else "12:00 AM" +def _fmt_task_due(due: str | None) -> str: + """"2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks + only need a compact reminder of when they're due, not the precision + an event's own start/end time gets.""" + if not due: + return "" + try: + dt = datetime.fromisoformat(due) + except ValueError: + return "" + d = dt.date() if isinstance(dt, datetime) else dt + return d.strftime("%b %-d") + + # ImageFont.load_default() (used for everything else in this module -- # see the module docstring) has no emoji glyphs, and PIL/FreeType don't # skip an unsupported codepoint, they substitute a ".notdef" tofu box (a @@ -513,6 +527,56 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve y += row_h +def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int], + tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont, + margin: int = MARGIN) -> None: + """A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box + glyph + due date (if any) + summary per outstanding task, same + header/rule/row-cap/truncation shape as _draw_agenda_day's event + list so the "week view, one slot replaced by tasks instead of a day" + layout (see _build_week) reads as one consistent design rather than + two different widgets bolted together. Reuses _draw_mixed_line so a + task summary with emoji in it renders the same way an event + title's does. + + `margin` defaults to the module-wide MARGIN (vertical layout's + stacked bands are as wide as the whole content region, same as + _draw_agenda_day's own sections) but a narrow horizontal-layout + column passes a much smaller one -- MARGIN on both sides of an + already-cramped ~150px week column left almost nothing for the + title text itself.""" + x0, y0, w, h = region + text_x0, text_y0 = x0 + margin, y0 + margin + text_w = w - margin * 2 + draw_text(img, (text_x0, text_y0), "Tasks", title_font) + y = text_y0 + title_font.size + 12 + draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) + y += 12 + + row_h = body_font.size + 14 + max_rows = max(0, (y0 + h - margin - y) // row_h) + + if not tasks: + draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED) + return + for i, task in enumerate(tasks): + if i >= max_rows: + draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED) + break + box = body_font.size - 6 + box_y = y + (row_h - box) // 2 - 5 + draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2) + text_x = text_x0 + box + 10 + due_str = _fmt_task_due(task.get("due")) + prefix = f"{due_str} " if due_str else "" + if prefix: + draw_text(img, (text_x, y), prefix, body_font, MUTED) + prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0 + _draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"], + body_font, text_w - box - 10 - prefix_w) + y += row_h + + def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, @@ -577,8 +641,16 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None, - weather_cities: list[dict] | None = None, - weather_units: str = "fahrenheit") -> Image.Image: + weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", + days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None) -> Image.Image: + """`days` (2-10, see routers/api_frames.py's clamp) side-by-side + columns (layout="horizontal", the original fixed-at-7 behavior + generalized) or stacked bands (layout="vertical", reusing + _draw_agenda_day the same way _build_today_tomorrow does, just for + an arbitrary day count instead of a hardcoded 2). `tasks` (see + routers/common.py's get_or_refresh_tasks), if not None, takes the + LAST slot instead of adding an extra one -- "N days" always means N + slots total, whether they're all days or N-1 days plus a task list.""" logical_w, logical_h = logical_render_size(orientation) img = Image.new("RGB", (logical_w, logical_h), BG) if photo_inlay is not None: @@ -586,18 +658,39 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None) draw = ImageDraw.Draw(img) + today = datetime.now(tz).date() + days_since_start = (today.weekday() - week_start) % 7 + week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset) + day_count = days - 1 if tasks is not None else days + owners_seen: list[str] = [] + + if layout == "vertical": + title_font = ImageFont.load_default(size=max(14, 26 - days) if photo_inlay is None else max(11, 20 - days)) + body_font = ImageFont.load_default(size=max(11, 18 - days) if photo_inlay is None else max(9, 15 - days)) + weather_font = ImageFont.load_default(size=max(9, 16 - days) if photo_inlay is None else max(8, 13 - days)) + section_h = ch // days + for i in range(day_count): + section_y0 = cy0 + i * section_h + if i > 0: + draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE) + day = week_first_day + timedelta(days=i) + _draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h), + title_font, body_font, owners_seen, palette_rgb, + weather_cities, weather_font, weather_units) + if tasks is not None: + section_y0 = cy0 + day_count * section_h + if day_count > 0: + draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE) + _draw_tasks(img, draw, (cx0, section_y0, cw, section_h), tasks, title_font, body_font) + return img + header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14) chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12) weather_font = ImageFont.load_default(size=12 if photo_inlay is None else 10) - - today = datetime.now(tz).date() - days_since_start = (today.weekday() - week_start) % 7 - week_first_day = today - timedelta(days=days_since_start) + timedelta(weeks=browse_offset) - col_w = (cw - MARGIN * 2) // 7 + col_w = (cw - MARGIN * 2) // days header_h = 44 - owners_seen: list[str] = [] - for col in range(7): + for col in range(day_count): day = week_first_day + timedelta(days=col) x0 = cx0 + MARGIN + col * col_w if col > 0: @@ -633,6 +726,13 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo chip_font, col_w - 20 - prefix_w) y += row_h + if tasks is not None: + col = day_count + x0 = cx0 + MARGIN + col * col_w + if col > 0: + draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE) + _draw_tasks(img, draw, (x0, cy0, col_w, ch), tasks, header_font, chip_font, margin=6) + return img @@ -701,7 +801,8 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, " def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str, photo_inlay: Image.Image | None, fetch_summary: str, week_start: int, palette_rgb: list | None = None, - weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> Image.Image: + weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", + week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None) -> Image.Image: tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") if view == "agenda": img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb, @@ -711,14 +812,15 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str, weather_cities, weather_units) elif view == "week": img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb, - weather_cities, weather_units) + weather_cities, weather_units, week_days, week_layout, tasks) elif view == "month": - # Never given weather -- no room for it at typical month-cell size, - # same reasoning that already keeps this view to density dots - # instead of literal event text (see _build_month's own docstring). - # Colors are still passed through, though -- that's a different - # concern (legibility of individual events) than weather's - # (space for a whole extra strip of content). + # Never given weather or tasks -- no room for either at typical + # month-cell size, same reasoning that already keeps this view + # to density dots instead of literal event text (see + # _build_month's own docstring). Colors are still passed + # through, though -- that's a different concern (legibility of + # individual events) than needing a whole extra strip/slot of + # content. img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb) else: img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb, @@ -735,14 +837,18 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str, def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str, palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, - weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> bytes: + weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", + week_days: int = 7, week_layout: str = "horizontal", + tasks: list[dict] | None = None) -> bytes: """Renders one of CALENDAR_VIEWS to the panel's packed format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every other renderer honors. weather_cities is routers/common.py's get_or_refresh_weather() cache, or None/[] to omit the weather strip - entirely (also always omitted for view == "month").""" + entirely (also always omitted for view == "month"). tasks is + get_or_refresh_tasks()'s cache, or None to omit the task list + entirely -- only ever drawn for view == "week", see _build_week.""" img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start, - palette_rgb, weather_cities, weather_units) + palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _transpose_and_pack(quantized, orientation) @@ -751,12 +857,14 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str, palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, - weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> bytes: + weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", + week_days: int = 7, week_layout: str = "horizontal", + tasks: list[dict] | None = None) -> bytes: """Same pipeline as render_calendar, but a normal browser-viewable PNG in logical (upright) orientation -- mirrors image_pipeline.render_preview_png's relationship to render_frame.""" img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start, - palette_rgb, weather_cities, weather_units) + palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) buf = io.BytesIO() diff --git a/server/app/migration.py b/server/app/migration.py index be2ebc5..fbade0f 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -172,6 +172,23 @@ def _migration_11(conn) -> None: conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER")) +def _migration_12(conn) -> None: + """Week view flexibility: a configurable day count (2-10, default 7 + -- the original fixed behavior) and a horizontal/vertical layout + choice, plus an optional CalDAV task list that takes the space of + one day slot when enabled (see calendar_render.py's _build_week/ + _draw_tasks). Every new column has a behavior-preserving default -- + no existing frame's render changes until its Calendar tab touches + one of these.""" + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_days INTEGER NOT NULL DEFAULT 7")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_calendar_key TEXT")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -184,6 +201,7 @@ MIGRATIONS = [ (9, _migration_9), (10, _migration_10), (11, _migration_11), + (12, _migration_12), ] diff --git a/server/app/models.py b/server/app/models.py index 14e6819..fc597bd 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -202,6 +202,33 @@ class Frame(Base): calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0) calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) + # Week view: how many days to show (2-10, default 7 -- the original + # fixed behavior) and whether they're laid out as side-by-side + # columns or stacked bands (see calendar_render.py's _build_week). + calendar_week_days: Mapped[int] = mapped_column(Integer, default=7) + calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical" + + # Optional task list, week view only -- takes the space of one day + # slot rather than adding an extra one (see calendar_render.py's + # _draw_tasks). CalDAV only (a task list is a VTODO collection, not + # something a plain ICS subscription meaningfully has); source is + # one specific linked user's own CalDAV calendar, same + # owner-controls-their-own-data permission split as FrameCalendar -- + # see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id + # SET NULL on the user's deletion clears the source rather than + # leaving a dangling reference (checked_at isn't reset by that, but + # the next refresh attempt finds no source and just returns []). + calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + calendar_tasks_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True) + calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0) + # [{"summary", "due" (ISO date/datetime string or None)}, ...], + # already filtered to outstanding (not-completed) tasks and sorted + # by due date -- see caldav_client.fetch_tasks. + calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) + # -- state -- current_asset_id: Mapped[str] = mapped_column(String, default="") current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0) diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index f37637d..b687f9f 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -44,6 +44,7 @@ from .common import ( calendar_sources_for_frame, fetch_source_and_faces, get_or_refresh_calendar_events, + get_or_refresh_tasks, get_or_refresh_weather, immich_client_for, immich_creds, @@ -101,8 +102,11 @@ def api_config_save( calendar_view: str | None = Form(None), calendar_photo_inlay: bool | None = Form(None), calendar_week_start: int | None = Form(None), + calendar_week_days: int | None = Form(None), + calendar_week_layout: str | None = Form(None), calendar_weather_enabled: bool | None = Form(None), calendar_weather_units: str | None = Form(None), + calendar_tasks_enabled: bool | None = Form(None), frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): @@ -180,6 +184,16 @@ def api_config_save( cfg.calendar_photo_inlay = calendar_photo_inlay if calendar_week_start is not None: cfg.calendar_week_start = max(0, min(6, calendar_week_start)) + if calendar_week_days is not None: + new_days = max(2, min(10, calendar_week_days)) + if new_days != cfg.calendar_week_days: + # A stale offset counts a different-sized page under the + # old day count -- same reasoning as calendar_view's own + # reset below. + cfg.calendar_browse_offset = 0 + cfg.calendar_week_days = new_days + if calendar_week_layout is not None: + cfg.calendar_week_layout = calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" if calendar_weather_enabled is not None: cfg.calendar_weather_enabled = calendar_weather_enabled if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): @@ -188,6 +202,8 @@ def api_config_save( # rather than showing stale numbers under a new unit label. cfg.calendar_weather_checked_at = 0.0 cfg.calendar_weather_units = calendar_weather_units + if calendar_tasks_enabled is not None: + cfg.calendar_tasks_enabled = calendar_tasks_enabled cfg.stats_config_saves += 1 return {"status": "saved"} @@ -548,15 +564,51 @@ def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session photo_inlay = _calendar_photo_inlay(frame, db) view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" weather_cities = get_or_refresh_weather(db, frame) + tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None png = calendar_render.render_calendar_preview_png( events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation, palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary, week_start=frame.calendar_week_start, weather_cities=weather_cities, weather_units=frame.calendar_weather_units, + week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks, ) return Response(content=png, media_type="image/png") +class TasksSourceRequest(BaseModel): + calendar_key: str | None # None clears the source + + +@router.post("/api/frames/{frame_id}/tasks-source") +def api_tasks_source( + body: TasksSourceRequest, + request: Request, + frame: Frame = Depends(require_frame_view), + db: Session = Depends(get_db), +): + """Points this frame's week-view task list at one of the calling + user's own CalDAV calendars -- same owner-controls-their-own-data + permission split as calendar-select's included=True, since this is + volunteering personal calendar data, not a frame-wide display + setting a controller should get to pick on someone else's behalf. + None clears the source; clearing (unlike setting) isn't + ownership-gated -- like muting a shared calendar, anyone linked to + the frame can turn off a task list they'd rather not see, but only + its owner can point the frame at one of their calendars to begin + with.""" + user = require_user_api(request, db) + with frame_locked(db, frame.id) as cfg: + if body.calendar_key is None: + cfg.calendar_tasks_user_id = None + cfg.calendar_tasks_calendar_key = None + cfg.calendar_tasks_cached = None + else: + cfg.calendar_tasks_user_id = user.id + cfg.calendar_tasks_calendar_key = body.calendar_key + cfg.calendar_tasks_checked_at = 0.0 # pick up the change promptly + return {"status": "saved", "calendar_key": body.calendar_key} + + class WeatherCityAddRequest(BaseModel): name: str diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 54eba07..41fb69a 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -16,7 +16,7 @@ from PIL import Image from sqlalchemy import select from sqlalchemy.orm import Session -from .. import calendar_feed, quiet_hours, weather +from .. import caldav_client, calendar_feed, quiet_hours, weather from ..db import frame_locked from ..image_pipeline import render_frame from ..immich_client import ImmichClient @@ -511,3 +511,34 @@ def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]: locked.calendar_weather_cached = result locked.calendar_weather_checked_at = now return result + + +def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]: + """Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, + same cadence as event merging) -- [] if tasks are off, no source is + set, or the source user's CalDAV credentials/calendar_key have gone + missing (e.g. they unlinked their account). A refetch failure keeps + the last-known list rather than going blank for one bad cycle, same + reasoning as get_or_refresh_weather.""" + if not frame.calendar_tasks_enabled or not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id: + return [] + now = time.time() + if (frame.calendar_tasks_cached is not None + and now - frame.calendar_tasks_checked_at < calendar_feed.CHECK_INTERVAL_S): + return frame.calendar_tasks_cached + + user = db.get(User, frame.calendar_tasks_user_id) + key = frame.calendar_tasks_calendar_key + if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"): + return frame.calendar_tasks_cached or [] + href = key[len("caldav:"):] + try: + tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password) + except caldav_client.CalDavError as e: + logger.warning("Could not refresh tasks for frame %d: %s", frame.id, e) + return frame.calendar_tasks_cached or [] + + with frame_locked(db, frame.id) as locked: + locked.calendar_tasks_cached = tasks + locked.calendar_tasks_checked_at = now + return tasks diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 7f42fb5..73a503c 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -35,6 +35,7 @@ from .common import ( RECHARGE_LOOKBACK, build_manage_content, get_or_refresh_calendar_events, + get_or_refresh_tasks, get_or_refresh_weather, immich_client_for, immich_creds, @@ -146,10 +147,18 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d browse_offset = locked.calendar_browse_offset view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" week_start = locked.calendar_week_start + week_days = locked.calendar_week_days + week_layout = locked.calendar_week_layout inlay_wanted = locked.calendar_photo_inlay events, summary = get_or_refresh_calendar_events(db, frame) weather_cities = get_or_refresh_weather(db, frame) + # Only ever shown on the week view (see calendar_render._build_week) -- + # gated here too so a disabled/other-view frame never pays for the + # fetch, and so None (not just an empty list) reaches render_calendar + # to mean "no tasks slot at all", distinct from "slot reserved but + # nothing outstanding right now". + tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None photo_inlay = None if inlay_wanted and _frame_configured(frame): @@ -174,6 +183,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start, weather_cities=weather_cities, weather_units=frame.calendar_weather_units, + week_days=week_days, week_layout=week_layout, tasks=tasks, ) diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index cf4bdb6..3fe9f16 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -96,6 +96,24 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) return result +def _tasks_source_info(db: Session, frame: Frame) -> dict | None: + """Whose CalDAV calendar this frame's week-view task list currently + pulls from, and its label -- for showing "using 's Chores + list" to everyone linked, not just whoever set it. None if no + source is configured.""" + if not frame.calendar_tasks_user_id or not frame.calendar_tasks_calendar_key: + return None + user = db.get(User, frame.calendar_tasks_user_id) + if user is None: + return None + label = frame.calendar_tasks_calendar_key + for c in (user.calendar_caldav_calendars or []): + if f"caldav:{c['href']}" == frame.calendar_tasks_calendar_key: + label = c.get("display_name") or label + break + return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label} + + @router.get("/frames/{frame_id}/config", response_class=HTMLResponse) def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)): return _frame_page( @@ -115,6 +133,10 @@ WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday", @router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse) def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)): viewer = current_user(request, db) + frame = db.get(Frame, frame_id) + viewer_task_calendars = [] + if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): + viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")] return _frame_page( request, db, frame_id, "frame_calendar.html", "calendar", calendar_views=CALENDAR_VIEW_LABELS, @@ -123,6 +145,8 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g calendar_color_labels=PALETTE_LABELS, default_palette_rgb=DEFAULT_PALETTE_RGB, palette_to_hex=palette_to_hex, + viewer_task_calendars=viewer_task_calendars, + tasks_source=_tasks_source_info(db, frame) if frame is not None else None, ) diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js index b773b90..f4811c3 100644 --- a/server/app/static/frame_calendar.js +++ b/server/app/static/frame_calendar.js @@ -8,6 +8,8 @@ document.getElementById('calendar-config-form').addEventListener('submit', async const body = new URLSearchParams({ calendar_view: document.getElementById('calendar_view').value, calendar_week_start: document.getElementById('calendar_week_start').value, + calendar_week_days: document.getElementById('calendar_week_days').value, + calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked), }); try { @@ -162,6 +164,61 @@ document.getElementById('weather-city-add').addEventListener('click', async () = } }); +document.getElementById('tasks_enabled').addEventListener('change', async (e) => { + const el = e.target; + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadCalendarPreview(); + } catch (e) { + el.checked = !el.checked; + showStatus(false, e.message); + } +}); + +// Choosing one of your own CalDAV task lists as this frame's source -- +// owner-only (see api_frames.py's api_tasks_source), so these radios +// only ever render for the viewer's own calendars anyway. +document.querySelectorAll('.tasks-source-choice').forEach((el) => { + el.addEventListener('change', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/tasks-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ calendar_key: el.dataset.key }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Task list saved. Reload to see the updated source.'); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); +}); + +const tasksSourceClear = document.getElementById('tasks-source-clear'); +if (tasksSourceClear) { + tasksSourceClear.addEventListener('click', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/tasks-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ calendar_key: null }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Task list cleared. Reload to see the change.'); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); +} + function loadCalendarPreview() { document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; } diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html index 9b4e1d5..ffcbcc0 100644 --- a/server/app/templates/frame_calendar.html +++ b/server/app/templates/frame_calendar.html @@ -39,6 +39,15 @@

Only affects the Week and Month views.

+ +
@@ -131,6 +140,42 @@
+ +
+

Tasks

+

Week view only -- takes the place of one day slot + instead of adding an extra one.

+
+ + +
+ + {% if tasks_source %} +

+ Currently using {{ tasks_source.display_name }}'s + {{ tasks_source.label }} list. + +

+ {% endif %} + + {% if viewer_task_calendars %} +

Use one of your own CalDAV task lists:

+ + {% else %} +

You don't have any CalDAV + task lists available -- set up a CalDAV account in + Settings first (a plain ICS subscription + doesn't carry tasks).

+ {% endif %} +