Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Build and push server image / build-and-push (push) Successful in 50s
Build and push server image / build-and-push (push) Successful in 50s
Calendar rule/grid lines were light gray, which dithers away to near-invisible on the 6-color e-ink palette -- now black. CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked alongside the existing single ICS subscription, since one account can expose several calendars. A frame's Calendar tab now lists calendars per person rather than one opt-in per person: your own row shows every calendar you have available with a full add/remove toggle, while other linked users' rows show only calendars they've included, toggleable off (mute) but not on -- only a calendar's owner can add it to a shared frame. FrameCalendar replaces the old single-boolean UserFrame.calendar_included; existing opt-ins are migrated forward.
This commit is contained in:
@@ -213,6 +213,17 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
never vendored or modified, so this project's own code stays under its
|
never vendored or modified, so this project's own code stays under its
|
||||||
own license; LGPL's copyleft terms apply to that library itself, not
|
own license; LGPL's copyleft terms apply to that library itself, not
|
||||||
to code that merely links against it dynamically.
|
to code that merely links against it dynamically.
|
||||||
|
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
|
||||||
|
subscription) wraps the `caldav` PyPI package. `caldav` itself is
|
||||||
|
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
|
||||||
|
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
|
||||||
|
copyleft in this project's dependency tree, and the one whose
|
||||||
|
network-use clause is written specifically for server applications
|
||||||
|
like this one (not just "don't vendor/modify it," which was enough
|
||||||
|
reasoning for the LGPL dependency above). Taking this on was an
|
||||||
|
explicit, informed call by the project owner, not a default -- anyone
|
||||||
|
redistributing this project (vs. just self-hosting it) should
|
||||||
|
re-evaluate that tradeoff for their own situation before doing so.
|
||||||
- The 6-color palette RGB values in `app/image_pipeline.py`
|
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||||
(Waveshare doesn't publish exact color primaries for this panel).
|
(Waveshare doesn't publish exact color primaries for this panel).
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""CalDAV account support: discovering which calendars an account exposes,
|
||||||
|
and fetching one calendar's events -- 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, ...).
|
||||||
|
|
||||||
|
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,
|
||||||
|
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
|
||||||
|
the strongest copyleft license in this project's dependency tree, and the
|
||||||
|
one whose network-use clause is specifically written for server
|
||||||
|
applications like this one. This was an explicit, informed call by the
|
||||||
|
project owner to accept that exposure rather than hand-roll a CalDAV
|
||||||
|
client -- see the server README's Notes section. Anyone redistributing
|
||||||
|
this project (as opposed to just self-hosting it) should reread that
|
||||||
|
tradeoff for their own situation.
|
||||||
|
|
||||||
|
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||||
|
philosophy as calendar_feed.py. Event parsing/expansion reuses
|
||||||
|
icalendar + recurring_ical_events directly (rather than trusting each
|
||||||
|
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
|
||||||
|
calendar and an ICS subscription behave identically once fetched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time as dtime
|
||||||
|
|
||||||
|
import caldav
|
||||||
|
import icalendar
|
||||||
|
import recurring_ical_events
|
||||||
|
|
||||||
|
HTTP_TIMEOUT_S = 15
|
||||||
|
|
||||||
|
|
||||||
|
class CalDavError(Exception):
|
||||||
|
"""Discovery or fetch failed -- network, auth, or an unexpected
|
||||||
|
server response. Raised loudly; callers (Settings' discover
|
||||||
|
endpoint, calendar_feed.merge_events) decide what to do. Wraps
|
||||||
|
whatever the caldav package/its transport raised, since that
|
||||||
|
exception hierarchy isn't something call sites should need to know
|
||||||
|
about directly."""
|
||||||
|
|
||||||
|
|
||||||
|
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
|
||||||
|
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
|
||||||
|
every calendar in this account. base_url is the server's CalDAV
|
||||||
|
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
|
||||||
|
Nextcloud) -- the caller supplies it directly, same idiom as the
|
||||||
|
plain ICS subscription URL."""
|
||||||
|
try:
|
||||||
|
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||||
|
calendars = client.principal().calendars()
|
||||||
|
except Exception as e:
|
||||||
|
raise CalDavError(str(e)) from e
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for cal in calendars:
|
||||||
|
try:
|
||||||
|
display_name = cal.get_display_name() or cal.name
|
||||||
|
except Exception:
|
||||||
|
display_name = None
|
||||||
|
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_calendar_events(calendar_url: str, username: str, password: str,
|
||||||
|
window_start: date, window_end: date) -> list[dict]:
|
||||||
|
"""One CalDAV calendar's events in [window_start, window_end] -- same
|
||||||
|
event dict shape as calendar_feed.fetch_source_events (no
|
||||||
|
"owner_display_name"; the caller adds that). Fetches raw (unexpanded)
|
||||||
|
calendar objects and runs them through the same icalendar +
|
||||||
|
recurring_ical_events pipeline calendar_feed.py uses for ICS feeds,
|
||||||
|
rather than relying on server-side expand (RFC 4791 leaves plenty of
|
||||||
|
corner cases server implementations disagree on)."""
|
||||||
|
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.date_search(
|
||||||
|
start=datetime.combine(window_start, dtime.min),
|
||||||
|
end=datetime.combine(window_end, dtime.min),
|
||||||
|
expand=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise CalDavError(str(e)) from e
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
for obj in objects:
|
||||||
|
try:
|
||||||
|
ical = icalendar.Calendar.from_ical(obj.data)
|
||||||
|
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
|
||||||
|
except Exception: # one malformed resource shouldn't blank the whole calendar
|
||||||
|
continue
|
||||||
|
for occ in occurrences:
|
||||||
|
dtstart = occ.get("DTSTART")
|
||||||
|
dtend = occ.get("DTEND")
|
||||||
|
if dtstart is None:
|
||||||
|
continue
|
||||||
|
start_dt = dtstart.dt
|
||||||
|
end_dt = dtend.dt if dtend is not None else start_dt
|
||||||
|
all_day = not isinstance(start_dt, datetime)
|
||||||
|
events.append({
|
||||||
|
"summary": str(occ.get("SUMMARY") or "(untitled)"),
|
||||||
|
"start": start_dt.isoformat(),
|
||||||
|
"end": end_dt.isoformat(),
|
||||||
|
"all_day": all_day,
|
||||||
|
})
|
||||||
|
return events
|
||||||
+41
-17
@@ -1,10 +1,11 @@
|
|||||||
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame
|
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
|
||||||
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
|
||||||
|
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||||
|
|
||||||
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
||||||
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
|
get_or_refresh_calendar_events) supply plain CalendarSource values, not
|
||||||
pairs, not ORM objects, so this module stays testable against fixture .ics
|
ORM objects, so this module stays testable against fixture .ics text with
|
||||||
text with no database or app involved.
|
no database or app involved.
|
||||||
|
|
||||||
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
||||||
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
||||||
@@ -16,12 +17,15 @@ code under LGPL terms).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import icalendar
|
import icalendar
|
||||||
import recurring_ical_events
|
import recurring_ical_events
|
||||||
|
|
||||||
|
from . import caldav_client
|
||||||
|
|
||||||
HTTP_TIMEOUT_S = 15.0
|
HTTP_TIMEOUT_S = 15.0
|
||||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
||||||
|
|
||||||
@@ -83,26 +87,46 @@ def fetch_source_events(url: str, window_start: date, window_end: date) -> list[
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalendarSource:
|
||||||
|
"""One calendar to merge in: either a plain ICS subscription (kind
|
||||||
|
"ics", url is the feed itself) or one CalDAV collection (kind
|
||||||
|
"caldav", url is the calendar's own URL, username/password its
|
||||||
|
account credentials) -- see caldav_client.py. owner_display_name
|
||||||
|
tags every event pulled from this source so a merged agenda can show
|
||||||
|
whose event is whose."""
|
||||||
|
|
||||||
|
owner_display_name: str
|
||||||
|
kind: str
|
||||||
|
url: str
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
|
||||||
def merge_events(
|
def merge_events(
|
||||||
sources: list[tuple[str, str]], window_start: date, window_end: date
|
sources: list[CalendarSource], window_start: date, window_end: date
|
||||||
) -> tuple[list[dict], str]:
|
) -> tuple[list[dict], str]:
|
||||||
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
|
"""Fetches each source independently -- one broken feed never blanks
|
||||||
independently -- one broken feed never blanks another's events.
|
another's events. Returns (merged_time_sorted_events, fetch_summary);
|
||||||
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
|
fetch_summary is "" when every source succeeded, else "N of M
|
||||||
"" when every source succeeded, else "N of M calendars unavailable"
|
calendars unavailable" (never *which* source -- naming whose feed is
|
||||||
(never *which* source -- naming whose feed is down to everyone who
|
down to everyone who looks at a shared household display is a bigger
|
||||||
looks at a shared household display is a bigger overshare than the
|
overshare than the outage itself)."""
|
||||||
outage itself)."""
|
|
||||||
merged: list[dict] = []
|
merged: list[dict] = []
|
||||||
failures = 0
|
failures = 0
|
||||||
for owner_display_name, url in sources:
|
for source in sources:
|
||||||
try:
|
try:
|
||||||
events = fetch_source_events(url, window_start, window_end)
|
if source.kind == "caldav":
|
||||||
except CalendarFetchError:
|
events = caldav_client.fetch_calendar_events(
|
||||||
|
source.url, source.username, source.password, window_start, window_end
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
events = fetch_source_events(source.url, window_start, window_end)
|
||||||
|
except (CalendarFetchError, caldav_client.CalDavError):
|
||||||
failures += 1
|
failures += 1
|
||||||
continue
|
continue
|
||||||
for event in events:
|
for event in events:
|
||||||
event["owner_display_name"] = owner_display_name
|
event["owner_display_name"] = source.owner_display_name
|
||||||
merged.append(event)
|
merged.append(event)
|
||||||
|
|
||||||
merged.sort(key=lambda e: e["start"])
|
merged.sort(key=lambda e: e["start"])
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ MARGIN = 20
|
|||||||
BG = (255, 255, 255)
|
BG = (255, 255, 255)
|
||||||
FG = (0, 0, 0)
|
FG = (0, 0, 0)
|
||||||
MUTED = (110, 110, 110)
|
MUTED = (110, 110, 110)
|
||||||
RULE = (200, 200, 200)
|
# Was a light gray, but that dithers away to near-invisible once quantized
|
||||||
|
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
|
||||||
|
RULE = (0, 0, 0)
|
||||||
|
|
||||||
# Cycled per distinct owner_display_name so a merged multi-person calendar
|
# Cycled per distinct owner_display_name so a merged multi-person calendar
|
||||||
# can visually tell whose event is whose -- the panel's own non-black/
|
# can visually tell whose event is whose -- the panel's own non-black/
|
||||||
|
|||||||
@@ -110,6 +110,41 @@ def _migration_8(conn) -> None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_9(conn) -> None:
|
||||||
|
"""CalDAV support alongside the plain ICS subscription (see
|
||||||
|
caldav_client.py), and the frame_calendars table that replaces
|
||||||
|
user_frames.calendar_included now that one account (CalDAV) can
|
||||||
|
expose more than one calendar -- see models.py's FrameCalendar.
|
||||||
|
Existing single-calendar opt-ins are carried forward as "ics" rows
|
||||||
|
before the old column is dropped, so nobody's frame goes silently
|
||||||
|
calendar-less after this migration."""
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||||
|
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE frame_calendars ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
||||||
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
||||||
|
"calendar_key TEXT NOT NULL, "
|
||||||
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
||||||
|
"included INTEGER NOT NULL DEFAULT 1)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
||||||
|
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
|
||||||
|
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
|
||||||
|
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
|
||||||
|
))
|
||||||
|
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -119,6 +154,7 @@ MIGRATIONS = [
|
|||||||
(6, _migration_6),
|
(6, _migration_6),
|
||||||
(7, _migration_7),
|
(7, _migration_7),
|
||||||
(8, _migration_8),
|
(8, _migration_8),
|
||||||
|
(9, _migration_9),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+51
-13
@@ -48,12 +48,26 @@ class User(Base):
|
|||||||
# email -- see routers/device.py's frame_battery) go here; blank = no
|
# email -- see routers/device.py's frame_battery) go here; blank = no
|
||||||
# email configured, both features silently no-op for this user.
|
# email configured, both features silently no-op for this user.
|
||||||
email: Mapped[str] = mapped_column(String, default="")
|
email: Mapped[str] = mapped_column(String, default="")
|
||||||
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
|
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
|
||||||
# frame mode -- see calendar_feed.py. Setting this alone shows up
|
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
|
||||||
# nowhere: a linked frame only pulls this user's events in once
|
# frame only pulls this user's events in once they've also added it
|
||||||
# they've also opted in on that frame's own Configuration -> Calendar
|
# on that frame's own Calendar tab (FrameCalendar below).
|
||||||
# card (UserFrame.calendar_included below).
|
|
||||||
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
|
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
|
||||||
|
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
|
||||||
|
# plain ICS subscription above -- see caldav_client.py. calendar_url
|
||||||
|
# is the server's CalDAV entry point the user pasted in, not any one
|
||||||
|
# calendar's own URL; the individual calendars it exposes are
|
||||||
|
# discovered and cached below.
|
||||||
|
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
|
||||||
|
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
|
||||||
|
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
|
||||||
|
# [{"href", "display_name"}, ...] from the last successful
|
||||||
|
# caldav_client.discover_calendars() call, refreshed by Settings'
|
||||||
|
# "Discover calendars" button -- NULL until discovery has ever
|
||||||
|
# succeeded. This is what a frame's Calendar tab offers the user to
|
||||||
|
# add, without hitting the CalDAV server on every page load.
|
||||||
|
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -234,14 +248,38 @@ class UserFrame(Base):
|
|||||||
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
||||||
)
|
)
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
# Explicit per-(user,frame) opt-in for calendar frame mode -- being
|
|
||||||
# linked to a frame does NOT by itself contribute this user's
|
|
||||||
# calendar to it (deliberate choice, not an oversight: each person's
|
class FrameCalendar(Base):
|
||||||
# calendar is their own data to share or not, not something a
|
"""One calendar included on one frame -- calendar_key is "ics" (the
|
||||||
# frame's controller decides on their behalf). Meaningless if the
|
owner's single calendar_ics_url) or "caldav:<href>" (one of the
|
||||||
# user has no calendar_ics_url set. See routers/api_frames.py's
|
owner's CalDAV collections; href matches an entry in
|
||||||
# api_calendar_included.
|
User.calendar_caldav_calendars). Replaces the old single
|
||||||
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
|
UserFrame.calendar_included boolean now that a CalDAV account can
|
||||||
|
expose more than one calendar.
|
||||||
|
|
||||||
|
A row only ever gets created by its own owner (adding a calendar to
|
||||||
|
a frame is each person's own data-sharing choice, not something a
|
||||||
|
frame's controller decides on their behalf) -- but once it exists,
|
||||||
|
ANY user linked to the frame may flip included back to False, muting
|
||||||
|
a calendar they'd rather not see on a shared display even though
|
||||||
|
they don't own it. Only the owner may flip it back to True. See
|
||||||
|
routers/api_frames.py's api_calendar_select."""
|
||||||
|
|
||||||
|
__tablename__ = "frame_calendars"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
calendar_key: Mapped[str] = mapped_column(String)
|
||||||
|
# Snapshot label for display -- so the list still reads sensibly even
|
||||||
|
# if the owner's CalDAV account later stops offering this calendar.
|
||||||
|
calendar_label: Mapped[str] = mapped_column(String, default="")
|
||||||
|
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PendingClaim(Base):
|
class PendingClaim(Base):
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from ..image_pipeline import (
|
|||||||
render_preview_png,
|
render_preview_png,
|
||||||
)
|
)
|
||||||
from ..firmware import firmware_path, parse_app_version
|
from ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import BatteryLog, Frame, UserFrame
|
from ..models import BatteryLog, Frame, FrameCalendar
|
||||||
from .common import (
|
from .common import (
|
||||||
FRAME_MODES,
|
FRAME_MODES,
|
||||||
OVERDUE_FACTOR,
|
OVERDUE_FACTOR,
|
||||||
@@ -409,33 +409,53 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
|
|||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
class CalendarIncludedRequest(BaseModel):
|
class CalendarSelectRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
calendar_key: str
|
||||||
|
calendar_label: str = ""
|
||||||
included: bool
|
included: bool
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/frames/{frame_id}/calendar-included")
|
@router.post("/api/frames/{frame_id}/calendar-select")
|
||||||
def api_calendar_included(
|
def api_calendar_select(
|
||||||
body: CalendarIncludedRequest,
|
body: CalendarSelectRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""A user's own opt-in into this frame's merged calendar (see
|
"""Include/exclude one calendar (calendar_key "ics" or
|
||||||
UserFrame.calendar_included). Deliberately not require_frame_control:
|
"caldav:<href>", see FrameCalendar) on this frame. Deliberately not
|
||||||
this is the toggling user's own data-sharing preference about their
|
require_frame_control: adding your own calendar, or muting anyone's
|
||||||
own calendar, not a frame setting its controller manages on someone
|
(including your own), is each viewer's own call, not something a
|
||||||
else's behalf -- there's no target user_id in the request body by
|
frame's controller manages on someone else's behalf. The one-sided
|
||||||
design, it always toggles the calling session's own row."""
|
permission split lives here: turning a calendar ON requires being its
|
||||||
|
owner (nobody can add someone else's calendar to a shared frame for
|
||||||
|
them); turning one OFF only requires being linked to the frame at
|
||||||
|
all, so anyone sharing the display can mute a calendar they'd rather
|
||||||
|
not see there even if they don't own it."""
|
||||||
user = require_user_api(request, db)
|
user = require_user_api(request, db)
|
||||||
row = db.get(UserFrame, (user.id, frame.id))
|
if body.included and body.user_id != user.id:
|
||||||
|
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
|
||||||
|
row = db.execute(
|
||||||
|
select(FrameCalendar).where(
|
||||||
|
FrameCalendar.frame_id == frame.id,
|
||||||
|
FrameCalendar.user_id == body.user_id,
|
||||||
|
FrameCalendar.calendar_key == body.calendar_key,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(404, "Not linked to this frame")
|
if not body.included:
|
||||||
row.calendar_included = body.included
|
raise HTTPException(404, "Not currently included on this frame")
|
||||||
|
row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
|
||||||
|
db.add(row)
|
||||||
|
row.included = body.included
|
||||||
|
if body.calendar_label:
|
||||||
|
row.calendar_label = body.calendar_label
|
||||||
# Force this frame's merged cache to pick up the change promptly
|
# Force this frame's merged cache to pick up the change promptly
|
||||||
# rather than waiting out the throttle.
|
# rather than waiting out the throttle.
|
||||||
frame.calendar_checked_at = 0.0
|
frame.calendar_checked_at = 0.0
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "saved", "included": row.calendar_included}
|
return {"status": "saved", "included": row.included}
|
||||||
|
|
||||||
|
|
||||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from .. import calendar_feed, quiet_hours
|
|||||||
from ..db import frame_locked
|
from ..db import frame_locked
|
||||||
from ..image_pipeline import render_frame
|
from ..image_pipeline import render_frame
|
||||||
from ..immich_client import ImmichClient
|
from ..immich_client import ImmichClient
|
||||||
from ..models import BatteryLog, Frame, User, UserFrame
|
from ..models import BatteryLog, Frame, FrameCalendar, User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -373,18 +373,29 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
|
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
|
||||||
"""Every user linked to this frame with BOTH a calendar URL set AND
|
"""Every calendar included on this frame (FrameCalendar.included) --
|
||||||
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
|
the exact set calendar_feed.merge_events needs. A calendar_key of
|
||||||
set calendar_feed.merge_events needs. [(display_name-or-username,
|
"ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
|
||||||
ics_url), ...]."""
|
resolves against the href itself, authenticated with the owner's
|
||||||
|
CalDAV account credentials (see caldav_client.py)."""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(User)
|
select(FrameCalendar, User)
|
||||||
.join(UserFrame, UserFrame.user_id == User.id)
|
.join(User, User.id == FrameCalendar.user_id)
|
||||||
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
|
.where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
|
||||||
User.calendar_ics_url != "")
|
).all()
|
||||||
).scalars().all()
|
sources = []
|
||||||
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
|
for fc, u in rows:
|
||||||
|
name = u.display_name or u.username
|
||||||
|
if fc.calendar_key == "ics":
|
||||||
|
if u.calendar_ics_url:
|
||||||
|
sources.append(calendar_feed.CalendarSource(name, "ics", u.calendar_ics_url))
|
||||||
|
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
|
||||||
|
href = fc.calendar_key[len("caldav:"):]
|
||||||
|
sources.append(calendar_feed.CalendarSource(
|
||||||
|
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password
|
||||||
|
))
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from ..image_pipeline import (
|
|||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
palette_to_hex,
|
palette_to_hex,
|
||||||
)
|
)
|
||||||
from ..models import Frame, User, UserFrame
|
from ..models import Frame, FrameCalendar, User, UserFrame
|
||||||
from ..quiet_hours import ALL_TIMEZONES
|
from ..quiet_hours import ALL_TIMEZONES
|
||||||
from .common import shell_context
|
from .common import shell_context
|
||||||
|
|
||||||
@@ -45,23 +45,54 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
|
|||||||
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
||||||
|
|
||||||
|
|
||||||
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
|
def _user_available_calendars(user: User) -> list[dict]:
|
||||||
"""Every user linked to this frame, their calendar opt-in state, and
|
"""This user's full set of calendars available to add to any frame:
|
||||||
whether they even have a calendar URL set -- what the Configuration
|
the single ICS subscription (if set) plus every CalDAV calendar last
|
||||||
tab's "Included calendars" list needs. Whether a given row is *this*
|
discovered from Settings' "Discover calendars" button. Doesn't hit
|
||||||
viewer's own (and therefore editable) is decided in the template,
|
the network -- reads the cached list a user refreshes themselves."""
|
||||||
using the `user` shell_context already provides."""
|
calendars = []
|
||||||
rows = db.execute(
|
if user.calendar_ics_url:
|
||||||
select(User, UserFrame.calendar_included)
|
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
|
||||||
.join(UserFrame, UserFrame.user_id == User.id)
|
for c in (user.calendar_caldav_calendars or []):
|
||||||
.where(UserFrame.frame_id == frame_id)
|
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
|
||||||
.order_by(User.username)
|
return calendars
|
||||||
).all()
|
|
||||||
return [
|
|
||||||
{"user_id": u.id, "display_name": u.display_name or u.username,
|
def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
|
||||||
"has_url": bool(u.calendar_ics_url), "included": included}
|
"""Per-linked-user calendar list for the Calendar tab's "Included
|
||||||
for u, included in rows
|
calendars" section. The viewer's own row lists EVERY calendar they
|
||||||
|
have available, each with a full add/remove toggle; every other
|
||||||
|
linked user's row lists ONLY the calendars they've already included
|
||||||
|
(mute-only for the viewer -- see api_frames.py's api_calendar_select:
|
||||||
|
only a calendar's owner may turn it on, but anyone linked to the
|
||||||
|
frame may turn one off)."""
|
||||||
|
users = db.execute(
|
||||||
|
select(User).join(UserFrame, UserFrame.user_id == User.id)
|
||||||
|
.where(UserFrame.frame_id == frame_id).order_by(User.username)
|
||||||
|
).scalars().all()
|
||||||
|
included_by_user: dict[int, list[FrameCalendar]] = {}
|
||||||
|
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.frame_id == frame_id)).scalars().all():
|
||||||
|
included_by_user.setdefault(fc.user_id, []).append(fc)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for u in users:
|
||||||
|
is_self = u.id == viewer_id
|
||||||
|
if is_self:
|
||||||
|
included = {fc.calendar_key: fc.included for fc in included_by_user.get(u.id, [])}
|
||||||
|
calendars = [
|
||||||
|
{**c, "included": included.get(c["key"], False)}
|
||||||
|
for c in _user_available_calendars(u)
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
calendars = [
|
||||||
|
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
|
||||||
|
for fc in included_by_user.get(u.id, []) if fc.included
|
||||||
|
]
|
||||||
|
result.append({
|
||||||
|
"user_id": u.id, "display_name": u.display_name or u.username,
|
||||||
|
"is_self": is_self, "calendars": calendars,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||||
@@ -82,10 +113,11 @@ WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
|
|||||||
|
|
||||||
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
|
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
|
||||||
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
|
viewer = current_user(request, db)
|
||||||
return _frame_page(
|
return _frame_page(
|
||||||
request, db, frame_id, "frame_calendar.html", "calendar",
|
request, db, frame_id, "frame_calendar.html", "calendar",
|
||||||
calendar_views=CALENDAR_VIEW_LABELS,
|
calendar_views=CALENDAR_VIEW_LABELS,
|
||||||
calendar_users=_calendar_users_for_frame(db, frame_id),
|
calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
|
||||||
week_start_labels=WEEK_START_LABELS,
|
week_start_labels=WEEK_START_LABELS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import mail
|
from .. import caldav_client, mail
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
SESSION_LIFETIME_S,
|
SESSION_LIFETIME_S,
|
||||||
@@ -30,6 +30,7 @@ from ..auth import (
|
|||||||
destroy_session,
|
destroy_session,
|
||||||
get_server_settings,
|
get_server_settings,
|
||||||
hash_password,
|
hash_password,
|
||||||
|
require_user_api,
|
||||||
users_exist,
|
users_exist,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
@@ -424,6 +425,9 @@ def settings_submit(
|
|||||||
immich_url: str = Form(""),
|
immich_url: str = Form(""),
|
||||||
immich_api_key: str = Form(""),
|
immich_api_key: str = Form(""),
|
||||||
calendar_ics_url: str = Form(""),
|
calendar_ics_url: str = Form(""),
|
||||||
|
calendar_caldav_url: str = Form(""),
|
||||||
|
calendar_caldav_username: str = Form(""),
|
||||||
|
calendar_caldav_password: str = Form(""),
|
||||||
current_password: str = Form(""),
|
current_password: str = Form(""),
|
||||||
new_password: str = Form(""),
|
new_password: str = Form(""),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -452,6 +456,23 @@ def settings_submit(
|
|||||||
else:
|
else:
|
||||||
user.calendar_ics_url = stripped_ics
|
user.calendar_ics_url = stripped_ics
|
||||||
|
|
||||||
|
stripped_caldav_url = calendar_caldav_url.strip()
|
||||||
|
if stripped_caldav_url and not valid_http_url(stripped_caldav_url):
|
||||||
|
error = "CalDAV URL must be a plain http:// or https:// URL."
|
||||||
|
else:
|
||||||
|
if stripped_caldav_url != user.calendar_caldav_url:
|
||||||
|
# Server (and likely account) changed -- last discovery no
|
||||||
|
# longer describes what's actually there.
|
||||||
|
user.calendar_caldav_calendars = None
|
||||||
|
user.calendar_caldav_checked_at = 0.0
|
||||||
|
user.calendar_caldav_url = stripped_caldav_url
|
||||||
|
user.calendar_caldav_username = calendar_caldav_username.strip()
|
||||||
|
# Blank password field = keep the existing one, same idiom as the
|
||||||
|
# Immich API key -- a secret that round-trips through HTML is a
|
||||||
|
# secret in every browser's autofill store.
|
||||||
|
if calendar_caldav_password.strip():
|
||||||
|
user.calendar_caldav_password = calendar_caldav_password.strip()
|
||||||
|
|
||||||
if new_password:
|
if new_password:
|
||||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||||
error = "Current password is wrong -- password not changed."
|
error = "Current password is wrong -- password not changed."
|
||||||
@@ -466,6 +487,28 @@ def settings_submit(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/settings/caldav-discover")
|
||||||
|
def api_caldav_discover(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Lists the calendars in the CalDAV account already saved on this
|
||||||
|
user's Settings (not whatever's currently typed in the form but not
|
||||||
|
yet saved -- same idiom as /api/frames/{id}/albums using the frame's
|
||||||
|
already-saved Immich creds). Caches the result on the user row so
|
||||||
|
every frame's Calendar tab can offer it without a live round-trip."""
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
if not user.calendar_caldav_url or not user.calendar_caldav_username:
|
||||||
|
raise HTTPException(400, "Save a CalDAV URL and username first")
|
||||||
|
try:
|
||||||
|
calendars = caldav_client.discover_calendars(
|
||||||
|
user.calendar_caldav_url, user.calendar_caldav_username, user.calendar_caldav_password
|
||||||
|
)
|
||||||
|
except caldav_client.CalDavError as e:
|
||||||
|
raise HTTPException(502, f"Could not discover calendars: {e}") from e
|
||||||
|
user.calendar_caldav_calendars = calendars
|
||||||
|
user.calendar_caldav_checked_at = time.time()
|
||||||
|
db.commit()
|
||||||
|
return calendars
|
||||||
|
|
||||||
|
|
||||||
def _require_admin_page(request: Request, db: Session) -> User:
|
def _require_admin_page(request: Request, db: Session) -> User:
|
||||||
user = current_user(request, db)
|
user = current_user(request, db)
|
||||||
if user is None or not user.is_admin:
|
if user is None or not user.is_admin:
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ document.getElementById('calendar-config-form').addEventListener('submit', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each person's own opt-in -- auto-saves on toggle, not batched into the
|
// Each calendar's own include/mute toggle -- auto-saves on change, not
|
||||||
// form above, since it's the toggling user's own preference (see
|
// batched into the form above, since it's a data-sharing choice (see
|
||||||
// api_frames.py's /calendar-included), not a frame-wide setting.
|
// api_frames.py's /calendar-select), not a frame-wide setting. Works the
|
||||||
document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
|
// same element for your own calendars (full add/remove) and other
|
||||||
|
// people's (mute only) -- the server enforces which direction is allowed
|
||||||
|
// and this just reverts the checkbox with an error message if rejected.
|
||||||
|
document.querySelectorAll('.calendar-toggle').forEach((el) => {
|
||||||
el.addEventListener('change', async () => {
|
el.addEventListener('change', async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
|
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ included: el.checked }),
|
body: JSON.stringify({
|
||||||
|
user_id: Number(el.dataset.userId),
|
||||||
|
calendar_key: el.dataset.key,
|
||||||
|
calendar_label: el.dataset.label,
|
||||||
|
included: el.checked,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await apiError(resp));
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
|
showStatus(true, el.checked ? 'Calendar included on this frame.' : 'Calendar removed from this frame.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.checked = !el.checked;
|
el.checked = !el.checked;
|
||||||
showStatus(false, e.message);
|
showStatus(false, e.message);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Settings page: "Discover calendars" against the CalDAV account already
|
||||||
|
// saved on this form (same idiom as frame_photos.js's Load Albums using
|
||||||
|
// the frame's already-saved Immich creds) -- so this only works after
|
||||||
|
// the CalDAV URL/username/password have been saved once.
|
||||||
|
|
||||||
|
const discoverBtn = document.getElementById('caldav-discover');
|
||||||
|
if (discoverBtn) {
|
||||||
|
discoverBtn.addEventListener('click', async () => {
|
||||||
|
const list = document.getElementById('caldav-calendar-list');
|
||||||
|
discoverBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings/caldav-discover', { method: 'POST' });
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
const calendars = await resp.json();
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (calendars.length === 0) {
|
||||||
|
list.innerHTML = '<li>No calendars found in this account.</li>';
|
||||||
|
} else {
|
||||||
|
for (const c of calendars) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = c.display_name;
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showStatus(true, `Found ${calendars.length} calendar${calendars.length === 1 ? '' : 's'}.`);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
} finally {
|
||||||
|
discoverBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -261,6 +261,11 @@ input:focus, select:focus {
|
|||||||
.checkbox-row input { width: auto; margin-top: 0; }
|
.checkbox-row input { width: auto; margin-top: 0; }
|
||||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||||
|
|
||||||
|
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||||
|
.calendar-user-list > li { margin-top: 14px; }
|
||||||
|
.calendar-user-list > li:first-child { margin-top: 0; }
|
||||||
|
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -540,6 +545,9 @@ code {
|
|||||||
.device-status-row { column-gap: 16px; }
|
.device-status-row { column-gap: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; }
|
||||||
|
.mode-picker-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
|
||||||
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
||||||
.mobile-bar { display: none; }
|
.mobile-bar { display: none; }
|
||||||
.sidebar-backdrop { display: none; }
|
.sidebar-backdrop { display: none; }
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="mode-picker-row">
|
||||||
|
<span class="mode-picker-label">Mode</span>
|
||||||
|
{% include "_frame_mode_select.html" %}
|
||||||
|
</div>
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% block device_status %}{% endblock %}
|
{% block device_status %}{% endblock %}
|
||||||
|
{% block mode_picker %}{% endblock %}
|
||||||
{% block tabs %}{% endblock %}
|
{% block tabs %}{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
|
||||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
@@ -49,24 +49,29 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
|
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
|
||||||
<p class="sub">Each linked person decides whether their own calendar
|
<p class="sub">Each linked person adds their own calendars (ICS
|
||||||
contributes to this frame -- being linked here doesn't include it
|
subscription or CalDAV, set up in <a href="/settings">Settings</a>)
|
||||||
automatically.</p>
|
-- being linked here doesn't include anything automatically.
|
||||||
|
Anyone linked to this frame can mute a calendar they'd rather
|
||||||
|
not see here, even one they don't own; only its owner can add
|
||||||
|
it back.</p>
|
||||||
<ul class="calendar-user-list">
|
<ul class="calendar-user-list">
|
||||||
{% for u in calendar_users %}
|
{% for u in calendar_users %}
|
||||||
<li>
|
<li>
|
||||||
{% if u.user_id == user.id %}
|
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
|
||||||
{% if u.has_url %}
|
{% if u.calendars %}
|
||||||
|
{% for c in u.calendars %}
|
||||||
<label class="checkbox-row" style="margin-top: 6px;">
|
<label class="checkbox-row" style="margin-top: 6px;">
|
||||||
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
|
<input type="checkbox" class="calendar-toggle"
|
||||||
{{ u.display_name }} (you)
|
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
|
||||||
|
{% if c.included %}checked{% endif %}>
|
||||||
|
{{ c.label }}
|
||||||
</label>
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif u.is_self %}
|
||||||
|
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
|
<p class="sub" style="margin-top: 6px;">No calendars included.</p>
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
|
|
||||||
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
|
|||||||
@@ -33,17 +33,43 @@
|
|||||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||||
|
|
||||||
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
|
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
|
||||||
<label>Calendar URL (iCal/CalDAV .ics feed)
|
<label>Calendar URL (iCal .ics feed)
|
||||||
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
|
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
|
||||||
value="{{ user.calendar_ics_url }}">
|
value="{{ user.calendar_ics_url }}">
|
||||||
</label>
|
</label>
|
||||||
<p class="sub" style="margin-top: 8px;">Your personal calendar
|
<p class="sub" style="margin-top: 8px;">A single subscription link
|
||||||
subscription link (no login needed -- e.g. Google Calendar's
|
(no login needed) -- e.g. Google Calendar's Settings →
|
||||||
Settings → "Secret address in iCal format", or Apple/Outlook/
|
"Secret address in iCal format", or Apple/Outlook's equivalent.</p>
|
||||||
Nextcloud's equivalent). Setting it here doesn't show it anywhere
|
|
||||||
by itself -- include it on any frame you're linked to from that
|
<label style="margin-top: 20px;">CalDAV server URL
|
||||||
frame's Configuration → Calendar card, so a frame only shows
|
<input type="text" name="calendar_caldav_url" placeholder="https://cloud.example.com/remote.php/dav/"
|
||||||
calendars people have actually chosen to share with it.</p>
|
value="{{ user.calendar_caldav_url }}">
|
||||||
|
</label>
|
||||||
|
<label>CalDAV username
|
||||||
|
<input type="text" name="calendar_caldav_username" autocomplete="off"
|
||||||
|
value="{{ user.calendar_caldav_username }}">
|
||||||
|
</label>
|
||||||
|
<label>CalDAV password
|
||||||
|
<input type="password" name="calendar_caldav_password" autocomplete="off"
|
||||||
|
placeholder="{% if user.calendar_caldav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
|
||||||
|
</label>
|
||||||
|
<p class="sub" style="margin-top: 8px;">An account (Nextcloud,
|
||||||
|
Fastmail, iCloud, ...) that can expose more than one calendar --
|
||||||
|
e.g. Nextcloud's is usually
|
||||||
|
<code>https://your-server/remote.php/dav/</code>. Save this
|
||||||
|
section first, then use "Discover calendars" below to list what's
|
||||||
|
in the account.</p>
|
||||||
|
<button type="button" class="secondary" id="caldav-discover" style="margin-top: 12px;">Discover calendars</button>
|
||||||
|
<ul class="sub" id="caldav-calendar-list" style="margin-top: 8px; padding-left: 18px;">
|
||||||
|
{% if user.calendar_caldav_calendars %}
|
||||||
|
{% for c in user.calendar_caldav_calendars %}<li>{{ c.display_name }}</li>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="sub" style="margin-top: 8px;">Neither of these shows up
|
||||||
|
anywhere by itself -- add individual calendars to any frame
|
||||||
|
you're linked to from that frame's Calendar tab, so a frame only
|
||||||
|
shows calendars people have actually chosen to share with it.</p>
|
||||||
|
|
||||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||||
<label>Current password
|
<label>Current password
|
||||||
@@ -58,4 +84,10 @@
|
|||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/settings.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ sqlalchemy==2.0.51
|
|||||||
qrcode==8.2
|
qrcode==8.2
|
||||||
icalendar==7.2.2
|
icalendar==7.2.2
|
||||||
recurring-ical-events==3.8.2
|
recurring-ical-events==3.8.2
|
||||||
|
caldav==3.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user