Week view flexibility: configurable day count, layout, and a CalDAV task list
Build and push server image / build-and-push (push) Successful in 52s
Build and push server image / build-and-push (push) Successful in 52s
- Day count (2-10, was fixed at 7) -- 5 days trims the weekend clutter without losing the grid format. - Layout choice: days side by side (original behavior) or stacked vertically as full agenda-style sections (reuses _draw_agenda_day, same approach _build_today_tomorrow already used for a fixed 2 days). - Optional task list (CalDAV VTODO collections only -- a plain ICS subscription doesn't meaningfully have one) that takes the space of one day slot instead of adding an extra one. Same owner-controls- their-own-data permission split as calendar sources: only the calendar's owner can point a frame's task list at it, but anyone linked to the frame can clear it. Browse-offset paging now moves by N days (was hardcoded to weeks), identical to the old behavior when days=7. Changing the day count resets the browse offset, same reasoning as changing views already did.
This commit is contained in:
@@ -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
|
||||
|
||||
+130
-22
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 <name>'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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">Only affects the Week and Month views.</p>
|
||||
<label>Days to show (Week view)
|
||||
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ frame.calendar_week_days }}">
|
||||
</label>
|
||||
<label>Week view layout
|
||||
<select id="calendar_week_layout">
|
||||
<option value="horizontal" {% if frame.calendar_week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
|
||||
<option value="vertical" {% if frame.calendar_week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="checkbox-row" id="calendar-inlay-row">
|
||||
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
|
||||
<label for="calendar_photo_inlay">Show a photo alongside the calendar</label>
|
||||
@@ -131,6 +140,42 @@
|
||||
<button type="button" class="btn-inline" id="weather-city-add">Add</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Tasks</h2>
|
||||
<p class="sub">Week view only -- takes the place of one day slot
|
||||
instead of adding an extra one.</p>
|
||||
<div class="checkbox-row" id="tasks-enabled-row">
|
||||
<input type="checkbox" id="tasks_enabled" {% if frame.calendar_tasks_enabled %}checked{% endif %}>
|
||||
<label for="tasks_enabled">Show a task list</label>
|
||||
</div>
|
||||
|
||||
{% if tasks_source %}
|
||||
<p class="sub" style="margin-top: 10px;">
|
||||
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
|
||||
<strong>{{ tasks_source.label }}</strong> list.
|
||||
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if viewer_task_calendars %}
|
||||
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
|
||||
<ul class="calendar-user-list">
|
||||
{% for c in viewer_task_calendars %}
|
||||
<li class="checkbox-row" style="margin-top: 6px;">
|
||||
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
|
||||
{% if tasks_source and tasks_source.user_id == user.id and frame.calendar_tasks_calendar_key == c.key %}checked{% endif %}>
|
||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
|
||||
task lists available -- set up a CalDAV account in
|
||||
<a href="/settings">Settings</a> first (a plain ICS subscription
|
||||
doesn't carry tasks).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
|
||||
Reference in New Issue
Block a user