Compare commits

..

56 Commits

Author SHA1 Message Date
Scarriffle
a45bf6ea73 Web: eight more UI languages behind a single language registry
Adds Swiss German, French, Italian, Dutch, Danish, Norwegian, Swedish and
Finnish — ten selectable languages, each complete at 418/418 keys with
matching placeholders and date arrays.

A language used to be registered in four unrelated places (dictionary,
settings dropdown, date-picker locale ternary, resolver fallback), so
adding one meant touching all of them. LANGUAGES in i18n.js is now the
single source of truth: adding a language is one entry plus one
dictionary, and the dropdown derives from it.

The registry also separates the stored code from the BCP-47 tag used for
formatting. That distinction is what makes 'ch' safe — "ch" is a country,
not a language, and would throw if handed to Intl; it maps to de-CH.

Fixes locale handling along the way: fmtTime/fmtDatetime in calendar.js,
month.js and week.js hardcoded 'de', so English users saw German-formatted
times, and two toLocaleDateString() calls silently used the browser locale
instead of the app language. All formatting now goes through getLocale().
Missing keys fall back language → English → German rather than straight to
German, and the dead per-dictionary `locale` key is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:58:03 +02:00
Scarriffle
c632dabc31 feat(web): branded green PWA icons + maskable, installable app polish
Replace the stale blue calendar icon with the real green Calendarr app
icon (matching iOS/Android) for the installable web app:
- icon-192/512.png regenerated from the branded 1024 app icon
- add dedicated maskable icons (192/512) with brand-green safe-zone
  padding so Android/Chrome render a proper adaptive icon (no white box)
- manifest: maskable purpose entries, id, description, lang/dir,
  categories; theme_color -> brand green (#16713d)
- index.html: theme-color -> green, add modern mobile-web-app-capable
- favicon.svg + icon.svg recolored to brand green
Bump APP_VERSION v89 -> v90.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:31:58 +02:00
Scarriffle
995d9bb5b4 feat(web): every sidebar calendar can be hidden (shared, birthday, ical too)
Equal rights for all calendar types: the sidebar remove button is now a uniform
"hide" (eye) for every calendar instead of delete-for-some / nothing-for-shared.

- Owned local (incl. birthday) and iCal now hide via the server sidebar_hidden
  flag (delete stays available in Settings' calendar table).
- Calendars shared with me get a per-device sidebar hide (they can't carry a
  server flag); their events are filtered out while hidden, and the Settings
  calendar table's "shared with me" rows gain an un-hide eye toggle.
- External caldav/google/ha unchanged.

Group calendars intentionally left as-is for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:46:53 +02:00
Scarriffle
4c7cdc362a feat(web): link Impressum version to Gitea source repo
The version line in the Impressum footer ("Calendarr vNN") is now a
subtle link to the Calendarr Gitea repo (git.scarriffle.com), opening
in a new tab. Styled to stay muted at rest and reveal as a link on
hover. Bump APP_VERSION v87 -> v88.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:01:20 +02:00
Scarriffle
37e5507261 feat(web): admin theme import + branding dimension hints
- Admin default-theme editor: "Theme importieren" button loads a .theme.json
  into the editor (colours only, live preview) so an imported theme can be set
  as the instance default (still confirmed via "Standard speichern").
- Branding: drop the "Standard" placeholder; show pixel-dimension hints instead
  (logo shown at max 150×40, favicon 128×128). Logo preview box now mirrors its
  real topbar footprint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:33:59 +02:00
Scarriffle
22a58e0d9f feat(web): set built-in DEFAULT_COLORS to the green brand palette
Replaces the blue defaults with the provided green theme (primary #58B900,
accent #45A148, today #6FB669, greens for month/selected/today-bg, etc.). These
apply when no admin instance theme is set. THEME.md defaults table updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:21:03 +02:00
Scarriffle
cea96660d9 feat(web): admin area with instance default theme, custom logo/favicon; .theme.json export
- Admin settings tab (admin-only) now holds user management plus a server-wide
  default theme editor (live preview + discard) and logo/favicon branding.
- New singleton InstanceSettings model + /api/instance router (public GET for
  the login screen; admin-gated theme PUT and logo/favicon upload/delete,
  reusing the avatar PIL/validation pattern).
- Instance default theme is the base users inherit and the target a per-colour
  "Reset" returns to (baseColor: instance default -> built-in).
- Custom favicon overrides the primary-colour tinting; custom logo replaces the
  top-left glyph+text, hard-capped so it only scales down and never breaks layout.
  Branding + defaults load before login (public endpoint).
- Theme export now uses the recognised .theme.json extension (old .theme still
  imports); import stays partial/unknown-key aware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:19:15 +02:00
Scarriffle
6316ed3a6b feat(web): theme import/export, more themable colors, hideable local/ical calendars
Theme:
- Export/import themes via UI as <date>_<time>.theme (JSON, readable keys,
  link to THEME.md). Import is partial-aware: only present params are written
  (server accepts partial); prompts before importing files with unknown params.
- Dynamic favicon + theme-color tinted to the primary colour on load/save.
- New themable colours: general hover-highlight, day hover/selected/bg,
  today background, plus two unified sidebar action-icon colours
  (inactive/active) covering bell, hide, delete and read-only icons.
- All new colours are per-setting syncable; documented in THEME.md.

UX:
- Styled confirm dialog (#modal-confirm) replaces window.confirm() for
  calendar delete and account disconnect.
- Birthday/local calendars and iCal subscriptions can now be hidden from the
  sidebar via Settings (new sidebar_hidden column + hide toggle).

Backend: additive nullable columns + idempotent migrations for user_settings
colours and local_calendars/ical_subscriptions.sidebar_hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:20:30 +02:00
Scarriffle
12c869451b Never leak a shared calendar's real name to recipients
_cal_dict now labels shared (non-owned) calendars by the sharer/group name
(the value already computed for shared_by) instead of the owner's real calendar
name. Fixes recipients seeing the true name in calendar-management lists (and
anywhere else the name is shown); all clients pick it up automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:15:35 +02:00
Scarriffle
a7802778b6 Web: expose month-navigation mode as a named dropdown
New 'Monatswechsel' setting (synced flag already existed as month_view_paged):
- Continuous scroll (week): current behaviour — wheel scrolls by a week,
  buttons/swipe jump 4 weeks.
- Page by page (month): wheel navigation is disabled; buttons and horizontal
  swipe jump a whole calendar month.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:26:02 +02:00
Scarriffle
7a89cc44f8 Web: settings page nav/header follow the surface colour too
The settings modal's own left nav and header were on --bg-app; point them at
--bg-sidebar / --bg-topbar so they track surface_color like the main sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:08:22 +02:00
Scarriffle
131f6b496d Add configurable surface/sidebar colour + clearer sync-icon state
- new user_settings.surface_color (nullable, device-local default, not synced):
  drives the web sidebar / top bar / surfaces; NULL derives from bg_color as
  before. Added to schema, migration, GET/PUT, NULLABLE_OVERRIDES, DEFAULT_SYNC.
- web: surface colour row in the settings table; applyTheme derives the whole
  surface family from it
- web: per-row sync icon now has real styling — ON shows the primary-tinted
  glyph on a pill, OFF shows a slashed, muted glyph (was previously unstyled,
  so synced/not-synced looked identical)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:01:16 +02:00
Scarriffle
4ab07ddcc3 Web polish from testing feedback + admin role toggle
- style the settings-table dropdowns for the dark theme (were white)
- share-icon picker on one even row (no lopsided wrap)
- more breathing room between colour hex and swatch
- user management: promote/demote admin (new PUT /users/{id}/admin,
  guarded against self-change and removing the last admin)
- calendar management table: clip/ellipsis cells so narrow columns no
  longer overlap (full-width detail rows still wrap)
- centralise default colours: utils.applyTheme now derives every fallback
  from settings-sync.DEFAULT_COLORS (single place for coders to edit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:25:01 +02:00
Scarriffle
137694ed98 Web: unified settings sync table with per-setting sync toggles
Replace the ad-hoc appearance panel with a uniform table (sync icon | name |
value): dropdowns instead of button groups, every colour with a reset to a
canonical default, a per-row sync toggle, and a global "sync all" master
switch.

- new settings-sync.js: canonical default colours, declarative table
  definition, browser-local value copy, effective-value resolution
- calendar.js: generalised sync engine (pull applies only synced keys from the
  server, save pushes only synced keys + the flag map), unified the two save
  buttons into one, dropped the dead text/line-contrast code, moved default
  duration into the appearance table, effective UI language on load
- account settings (private visibility, group calendar, hide profile) stay in a
  dedicated profile block, not the sync table
- app.css: settings table / sync toggle / sync icon styles
- i18n: sync-all / sync-this / share-icon strings; sw cache bump

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:07:26 +02:00
Scarriffle
3d58e8fef8 Document the cross-device settings-sync contract
Shared reference for Web/iOS/Android: canonical keys, default flags, the
GET/PUT sync_flags API, and the pull/push rules each client implements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:46:23 +02:00
Scarriffle
91ae434e7c Add per-setting cross-device sync flags to user settings
Introduce an account-wide sync-flag map that is the single server-side
authority for which settings each client sends/fetches, plus value columns
for two newly-syncable device-local prefs.

- models.UserSettings: sync_flags (JSON), cache_months, month_view_paged
- main._migrate(): idempotent ALTER TABLE for the three new columns
- settings_router: DEFAULT_SYNC map + _resolve_sync_flags(); GET returns
  fully-resolved sync_flags (+ cache_months, month_view_paged); PUT accepts
  and merges a partial sync_flags map and the two new value fields

Rollout defaults: settings that already lived on the server sync ON; the
four newly-syncable prefs (language, share icon, cache range, month paging)
sync OFF until the user opts in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:44:44 +02:00
Scarriffle
1f1eb582ed Birthdays: explicit "Birthday calendar" add type + tidier table defaults (web)
- add "Geburtstagskalender" to the sidebar +calendar dropdown and a "+ Geburtstage"
  button to Settings > Calendars; both call ensureBirthdayCalendar (server enforces
  one per account) and toast "already exists" if present
- Settings birthday section: when none exists, just a hint pointing to "+ Geburtstage"
  (creation lives in the add area now, not a separate activate button)
- resizable calendar table: sensible default column widths instead of raw measured
- i18n de/en

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:34:38 +02:00
Scarriffle
29ddc5acbb Enforce a single birthday calendar per account (server-side)
- POST /local/calendars with is_birthday returns the existing birthday calendar
  if one exists (idempotent, no second created)
- PUT /local/calendars rejects (422) marking a second calendar as is_birthday

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:22:10 +02:00
Scarriffle
0ce77ccdf8 Add drag-resizable columns to the calendar settings table
Column widths are draggable via a handle on each header's right edge and
persisted in localStorage (re-applied on re-render). Uses fixed table layout
locked to measured px widths so resizing one column doesn't reflow the others.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:06:36 +02:00
Scarriffle
16ff434bef Make imported-event dedup bulletproof (server-side, id-based)
Repeated Contacts syncs must never create duplicate birthdays. Enforce it on
the server, independent of the client's reconcile:

- POST /local/events is now idempotent on external_uid: if an event with the
  same (calendar_id, external_uid) exists, update it in place instead of
  inserting a new row
- startup cleanup removes existing duplicates sharing the same
  (calendar_id, external_uid), keeping the earliest — auto-heals old data
- unique DB index on (calendar_id, external_uid) as a hard guarantee

external_uid is the stable "contact:<deviceId>:<contactId>", so the same contact
from the same device always maps to exactly one event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:53:11 +02:00
Scarriffle
1a30b4066a Fix: don't squeeze the Kalender settings table into the 680px column
The reading-column max-width added for the profile panel also constrained the
wide calendar table, forcing a half-width horizontal scroll. Exempt
#settings-panel-accounts so the table uses the full width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:49:26 +02:00
Scarriffle
aa12b83302 Birthday sync-device list + guard group-visible against birthday calendar
- new BirthdaySyncDevice table + /api/birthdays/sync-report & /devices so the
  web can show "birthdays come from these devices" (iOS reports its device on
  each Contacts sync)
- settings: reject setting a birthday calendar as the group-visible calendar
  (it can still be shared directly)
- web: Settings > Calendars > Birthdays shows the device list; exclude birthday
  calendars from the group-visible picker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:52:02 +02:00
Scarriffle
19258096e2 Tidy settings layout (web)
- constrain settings panels to a 680px reading column so fields no longer
  stretch edge-to-edge on wide screens (main "looks bad" cause)
- fix the app-password "Erstellen" button rendering centred below the input:
  .app-pw-create inherited flex-direction:column from .form-group; force row so
  it sits inline to the right of the field

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 04:31:12 +02:00
Scarriffle
6baa07379f Rework birthdays to a single calendar + UI fixes (web)
- one dedicated birthday calendar per user; enable it in Settings > Calendars
  (or from the New-birthday dialog) — it then shows in the sidebar like any
  calendar (colour/visibility)
- New-birthday dialog: no target picker; day/month selects + a year field that
  hides when "year unknown"; fixed the year-unknown checkbox layout
- create split-button is now one seamless pill; the caret opens the menu
- Settings > Calendars gains a Birthdays section (enable + notify-days-before)
- removed the birthday toggle from the generic new-calendar modal
- profile settings: right-align the section save button for consistency
- bump service-worker cache to v25 so assets refresh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 04:20:49 +02:00
Scarriffle
eb0684b99c Add birthday feature (web)
- views render display_title (age) and a cake icon for is_birthday events
  (month/week/agenda + quarter tooltip) via shared eventTitle/birthdayIconSvg
- create split-button caret opens a menu: new event / new birthday
- new birthday modal: name + date + "year unknown" + target birthday calendar,
  saved as an all-day FREQ=YEARLY local event with birth_year
- local-calendar modal gains a "birthday calendar" toggle + "notify N days before"
- i18n de/en strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:10:52 +02:00
Scarriffle
ca09538971 Add birthday calendar backend support
Birthday calendars are ordinary local calendars flagged is_birthday, so they
flow to all clients via the merge read and inherit sharing/colors/reminders.

- models: LocalCalendar.is_birthday + birthday_notify_days_before;
  LocalEvent.external_uid (Contacts dedup) + birth_year
- build_local_event_dict: server-computed display_title "Name (age)" per
  occurrence, is_birthday flag for the client cake icon, and a reminder injected
  from birthday_notify_days_before so mobile schedulers fire it
- groups combined view keeps the birthday display_title instead of overwriting it
- local_router: calendar flags + event fields on create/update, plus
  GET /calendars/{id}/birthdays for importer reconcile by external_uid
- additive SQLite migrations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:38:37 +02:00
Scarriffle
d426f8985c style(web): dark-mode the share modal's add-user search + permission select
The user-search input and permission dropdown in the share modal were raw white
browser controls. Style them like the rest of the app (dark bg, border, radius,
focus ring) and give the user checkboxes the primary accent colour. v80.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:25:03 +02:00
Scarriffle
89a1355149 fix(web): inverted condition broke hiding shared calendars
The per-device hide branch was gated on owned!==false (my own calendars) instead
of owned===false (shared with me), so unchecking a shared calendar hit the
owner-only enabled PUT and 404'd. Swap to owned===false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:32:34 +02:00
Scarriffle
f844ded57d fix(web): hide edit/delete for others' events; persistent hide for shared calendars
Bug 1 — a calendar shared with me stayed visible after unchecking it: the hide
was a one-shot cache filter the server undid on refetch. Add a per-device
hidden set (localStorage 'hiddenLocalCalendars'), honoured in filterEvents
(normal view) and used to drive the checkbox state, so it survives refetch/reload.

Bug 2 — in the group combined view, other members' events showed edit/delete and
403'd on save. The combined endpoint now emits read_only (editable = the group
calendar OR my own events), via a read_only param threaded through
build_local_event_dict/expand_recurring_local. The event popup and edit modal now
treat read_only events as read-only (copy still allowed). Test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:11:46 +02:00
Scarriffle
ec85a5b5f3 fix(web): show dialogs opened from settings on top of the settings page
The settings modal is a full-screen OPAQUE overlay at z-index 500. Dialogs
opened from within it (share, add-account, color picker) share z-index 500 but
sit earlier in the DOM, so they rendered BEHIND the opaque settings page — the
Share button appeared to "do nothing". Drop the settings page to z-index 400 so
real modals stack above it. v79.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:31:03 +02:00
Scarriffle
af64e191ec fix(web): reset checkbox size inside .form-group so the label isn't squeezed
The global ".form-group input" rule (width:100% + padding + border) also hit the
directory-hidden checkbox, blowing it up to a full-width field that pushed the
label text into a narrow wrapping column. Reset the checkbox to a small native
box (16px, no padding/border/background) so the label reads normally beside it.
v78.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:23:28 +02:00
Scarriffle
52bc7066db fix(web): settings modal — table crash, tab restore, checkbox layout
Three separate settings bugs (all pre-existing, exposed once the modal actually
opened):

1. renderCalendarTable() referenced a bare `owned` variable (undefined in that
   scope; should be cal.owned) → ReferenceError on the first owned local calendar
   → renderAllAccounts threw → the calendar table never rendered. This was the
   ROOT cause of "settings won't open" (it threw out of openSettingsModal); the
   earlier try/catch only masked it. Fixed to cal.owned.

2. On reload, writeUrlState() (via fetchAndRender) ran before openSettingsModal
   activated the saved tab and wrote the HTML-default (Profile) tab into the URL,
   so every reload landed on Profile. Now the stab is only rewritten once the
   modal is actually shown; otherwise the saved one is preserved.

3. The directory-hidden checkbox label inherited the global ".form-group label"
   uppercase/spaced/12px styling, stretching the text into a broken column. A
   higher-specificity .checkbox-row rule restores normal inline layout.

v77.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:16:10 +02:00
Scarriffle
7d5530e51c fix(web): open settings modal before populating it
openSettingsModal() populated every field and ran render helpers
(renderGroupVisibleList, renderAllAccounts, initAppPasswords) BEFORE the final
openModal() call, so an error in any of them left the modal unopened — the
"settings button does nothing" report. Now the modal opens first and the
populate step runs in try/catch (errors logged, not fatal). v76.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:51:56 +02:00
Scarriffle
90bc4b1531 fix(web): bind UI handlers before first fetch so a data error can't kill the UI
initCalendar() awaited fetchAndRender() BEFORE binding the topbar/settings/menu
handlers, so any error from /caldav/events (e.g. a transient 500) threw out of
init and left the buttons dead — the reported "settings won't open". Same root
cause as the reload-logout bug: app wiring must not depend on event data loading.

Bind all handlers first, then fetch inside try/catch (errors are logged + shown
as a toast, no longer fatal). v75.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:44:32 +02:00
Scarriffle
1eecf834a3 fix(sharing): per-user calendar colour works for every sharing path
The recipient colour was stored on calendar_shares, so it only worked for
DIRECT shares. A calendar made visible through a group (a co-member's
group_visible_calendar_id) has no CalendarShare row, so the colour endpoint
returned 403 and nothing was saved — the reported "colour picker opens but the
colour stays the same" for a group-shared calendar.

Replace the share-scoped colour with a general per-user override table
(calendar_color_prefs, keyed by user+calendar). PUT /calendars/{id}/color now
accepts any calendar the user can read (readable_local_calendar_ids covers
direct shares, group calendars AND group-visible), and the merge read + calendar
list apply the override for all of them. Owners still set the shared colour.
The new table is created by create_all; the old calendar_shares.color is unused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:38:16 +02:00
Scarriffle
b8a578cf53 refactor(web): use server per-user colour for shared calendars
Replace the per-device localStorage colour override with the server-backed
per-user colour: the colour dot on a shared calendar now PUTs /calendars/{id}/color
(stores the recipient's own colour server-side, synced across devices) and the
list/events already carry share.color from the server, so the localStorage
re-apply layer is removed. Owners and recipients share one code path. v74.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:22:33 +02:00
Scarriffle
572172c424 fix(web): stay logged in when app initialisation fails after reload
boot() ran token validation and launchApp() inside the SAME try/catch, so ANY
error during app init (a failed calendar/event fetch, a render error) cleared
the token and bounced a validly-authenticated user to the login screen — the
"logged out on every F5" bug. Now /auth/me validates the token alone; launchApp()
runs outside that catch, so a data/render error can no longer log the user out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:22:33 +02:00
Scarriffle
343f7a5e7b feat(sharing): recipients can recolour a shared calendar (per-user, no rename)
A share recipient couldn't change anything on a shared calendar (update_calendar
is owner-only → 404), yet the clients still showed a colour picker for it.

Add a per-recipient colour: new nullable calendar_shares.color column (+ migration).
New PUT /calendars/{id}/color endpoint sets the calendar colour for the owner
(global) or, for a recipient, only their own share colour — never the name, so
recipients can recolour but not rename. The merge read and the calendar list now
prefer the recipient's share colour over the owner's (NULL = owner's colour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:43:10 +02:00
Scarriffle
cad48efcc6 feat(web): recolour calendars shared with me (per-device local override)
Shared calendars belong to someone else, so a server colour PUT would 403.
Instead store the recipient's chosen colour locally (localStorage 'sharedCalColors',
keyed by calendar id) and re-apply it to events on every fetch. The colour dot
on a shared calendar now opens the picker instead of showing an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:49:07 +02:00
Scarriffle
2ae8499247 fix(web): can't edit calendars shared with me; struck-pencil read-only marker
- Sidebar: don't start an inline rename on a calendar I don't own (owned=false)
  — the save would only 403. Colour picker was already gated.
- Sidebar: show a struck-through pencil icon on read-only shared calendars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:01 +02:00
Scarriffle
79fdf4f54a feat(sharing): group-shared calendars in every member's sidebar, person share picker, hidden profiles
- Backend: co-member group_visible calendars now surface in /local/calendars
  (owned=false, shared_by=owner, read-only, group_shared) and in the normal
  /caldav/events merge (via readable_local_calendar_ids), deduped against
  direct shares / group calendars so nothing appears twice.
- Backend: new User.directory_hidden — a user can hide from sharing/group
  pickers (/users/directory), while admin user management (/users/) still lists
  them. Migration + profile GET/PUT.
- Backend: /groups/{id} members carry shares_calendar so clients can drop
  phantom rows for members who share nothing.
- Frontend: reachable "Teilen" button on owned local calendars; share modal is
  now a checkbox multi-select of users (checked = shared). Hidden-profile toggle
  in Settings → Profile. Group member filter only lists members who actually
  share (phantom fix). Calendars shared with me moved to a dedicated read-only
  "shared with me" section in the manage table.
- Tests: group_visible propagation, no-share absence, dedup, directory_hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:35:11 +02:00
Scarriffle
f76d2783d9 feat(web): show shared calendars under the owner's name
A calendar shared with another person now appears to the recipient under the
OWNER's name (Guido's "Persönlich" shows as "Guido"), matching the iOS filter
sheet. The original calendar name moves to the sub-label / settings source
column so it stays identifiable. Sidebar list + settings table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:41:31 +02:00
Scarriffle
e539508bec fix(security): scope CalDAV PUT to its calendar, block iCal SSRF, auth avatar endpoint
- dav_router: PUT now looks up the event within the authenticated calendar only
  (local_events.uid is globally unique), so a CalDAV client can no longer
  overwrite another user's/calendar's event; a cross-calendar UID clash returns
  409 instead of a 500 from the UNIQUE constraint.
- ical_router: _fetch_ics validates the URL (http/https only), resolves the host
  and rejects private/loopback/link-local/reserved targets, follows redirects
  manually re-validating each hop, and caps the response size — closing an
  authenticated SSRF into internal services / cloud metadata.
- profile_router: GET /profile/avatar/{user_id} now requires authentication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:17:24 +02:00
Scarriffle
784c9013eb feat(web): mark read-only shared calendars and hide them from the event editor
- Sidebar: a calendar shared with read access shows "· Nur lesen" next to the
  "shared with me · <owner>" label.
- Event editor calendar picker: exclude read-only shared calendars (own +
  read_write, incl. group calendars, stay) so a save can't 403.

Parity with the server/iOS/Android sharing changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:02:12 +02:00
Scarriffle
e3844294ae feat(sharing): label shared personal calendars by owner + drop group title prefix
Two group/sharing display fixes, both server-side so every client benefits:

1. A personal calendar shared WITH a user showed only its raw name
   ("Persönlich"), indistinguishable from the user's own. The merge read now
   relabels a shared *personal* calendar under the owner's display name (so
   Guido's "Persönlich" reads as "Guido" for recipients) and adds read_only:true
   when the share isn't read_write. Group calendars are excluded — they keep
   their own name and stay writable for members.

2. The combined group view prefixed every foreign event with the owner's first
   name ("Guido: …"). Each member already has a distinct display_color, so the
   prefix was redundant. _decorate_title now returns the raw title; display_title
   stays non-empty so clients' legacy prefix fallback never triggers. The change
   takes effect on already-installed clients with no app update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:42:26 +02:00
Scarriffle
444772c959 fix: attribute account-level token failures to each calendar
When a Home Assistant or Google token refresh fails, the whole account fetch
aborts. Previously this raised, so the outer handler in caldav_router emitted
a single sync error WITH NO calendar_id. Clients that preserve cached events
per calendar (iOS) couldn't attribute it and wiped the affected calendars.

Now both get_ha_events and get_google_events catch the token failure and emit
one error per enabled calendar, each carrying its calendar_id — so every
client can pin the failure to a specific calendar and keep its cached data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:31:26 +02:00
Scarriffle
e0ea16f2ef fix: include calendar_id in per-calendar sync errors
Events already carry calendar_id, but the corresponding sync-error
entries didn't, forcing clients to match errors to calendars by
fragile name-suffix comparison. Account-level failures (whole account
unreachable) still omit calendar_id since no single calendar is at fault.
2026-07-02 18:36:46 +02:00
Scarriffle
9fb350eb29 fix: surface all calendar sync failures, not just Google
CalDAV and Home Assistant sync failures were previously only logged
server-side, leaving clients unable to distinguish an empty calendar
from a broken sync. Unify error reporting across CalDAV, Home
Assistant, and Google into a single errors list on GET
/api/caldav/events, shaped as {source, name, message}. Messages are
fixed generic strings, never raw exception text, to avoid leaking
URLs or credential fragments. get_ha_events and get_google_events now
return (events, errors) tuples so per-calendar failures propagate to
the caller in addition to account-level failures. Frontend toast now
picks its label from err.source instead of assuming Google/err.email.
2026-07-02 18:22:11 +02:00
Scarriffle
94655ce7c5 fix(caldav): match Basic Auth username case-insensitively
Login names are stored lowercase and the web login already compares with
func.lower(); CalDAV Basic Auth used an exact match, so "Scarriffle" failed to
authenticate while "scarriffle" worked. Compare case-insensitively too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:38:21 +02:00
Scarriffle
9306d638ad fix(caldav): move app-password section to Settings → Profile where users look
The app-password UI was in the user-menu profile modal, but users manage CalDAV
in Settings, so it went unnoticed. Move the section into the Settings → Profile
panel (next to account/privacy) and drive it from openSettingsModal via a
top-level initAppPasswords().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:17:25 +02:00
Scarriffle
f662163185 feat(caldav): app-specific passwords so MFA accounts can use CalDAV
CalDAV clients send only user+password over Basic Auth and can't provide a TOTP
code, so account passwords would bypass 2FA. Add revocable app passwords:

- models: AppPassword table (bcrypt hash, label, last_used); auto-created via
  create_all
- profile_router: GET/POST/DELETE /profile/app-passwords (plaintext shown once)
- dav_router: Basic Auth accepts any app password; the account password is
  accepted only when 2FA is disabled
- frontend: "App-Passwörter (CalDAV)" section in the profile modal (create/show-
  once/copy/revoke) + i18n (de/en); login hint now says app password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:15:35 +02:00
Scarriffle
fb32f0424f feat(caldav): username/password (Basic Auth) access with discovery + https URLs
- dav_router: add Basic-Auth principal-discovery tree at /caldav/ (and
  /.well-known/caldav) so clients can add a CalDAV account with server URL +
  username + password; lists all published calendars. Token URL /dav/{token}/
  still works without login. Handlers generalised over a base href.
- dav_util: derive the public origin from X-Forwarded-Proto/-Host (or
  PUBLIC_BASE_URL) so published URLs are https, not internal http:8080.
- local_router: expose caldav_login_url alongside caldav_url.
- frontend/i18n: show both the no-login token URL and the login URL + hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:44:16 +02:00
Scarriffle
34e701fa78 feat(pwa): auto-update service worker so new releases take effect without manual cache clearing
- app.js: reload once on SW controllerchange (guarded against loops / first
  install) and poll reg.update() hourly for long-open tabs
- sw.js: bump cache to v24 so the new (network-first) worker replaces any stale
  cache-first worker and cleans old caches on activate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:24:05 +02:00
Scarriffle
661a8ea579 chore: bump APP_VERSION to v65 (cache-bust / deploy verification)
Fresh commit so a git pull visibly advances the deployed version and the PWA
service worker refreshes its cached assets. No functional change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:12:21 +02:00
Scarriffle
47944ed952 feat: publish local calendars via two-way CalDAV (opt-in, secret token URL)
Backend:
- LocalCalendar gains caldav_published + dav_token + dav_ctag; LocalEvent gains
  etag (migrations in main.py). bump_dav() refreshes ctag/etag on every local
  event write (create/update/delete/import).
- local_router: PUT /calendars/{id} accepts caldav_published (mints/revokes
  token), new POST /calendars/{id}/dav-token/rotate, _cal_dict exposes
  caldav_published + caldav_url.
- New dav_router mounted at root (/dav/{token}/...): a minimal two-way CalDAV
  server (OPTIONS/PROPFIND/REPORT/GET/PUT/DELETE) reusing ical_io build/parse,
  ctag-based change detection. Secret token = auth, no login.

Frontend:
- Settings calendar table: per-local-calendar publish toggle + subscribe URL
  with copy and token-rotate; i18n (de/en) and styling.

Note: reverse proxy must allow WebDAV methods (PROPFIND/REPORT/PUT/DELETE).
VALARM/reminders are not round-tripped via CalDAV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:19:01 +02:00
Scarriffle
a591eb51d3 chore: bump APP_VERSION to v63 to ship mini-cal + week-view fixes
The multi-day mini-calendar and short-event week-view fixes were already
committed but not visible because the PWA served cached assets. Bump the
version so clients pick up the new JS/CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:15:41 +02:00
42 changed files with 7472 additions and 570 deletions

66
THEME.md Normal file
View File

@@ -0,0 +1,66 @@
# Theme parameters (Web)
Calendarr's web client lets you customise the colour theme under
**Settings → Darstellung → Farben**. Every colour has its own sync toggle (share
it across your devices or keep it device-local — see
[backend/SETTINGS_SYNC.md](backend/SETTINGS_SYNC.md)).
You can also **export** the current theme to a `<date>_<time>.theme` file and
**import** one later. A `.theme` file is plain JSON:
```json
{
"_format": "calendarr-theme",
"_version": 1,
"_docs": "https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md",
"exported_at": "2026-07-20T14:33:00.000Z",
"settings": {
"primary_color": "#4285F4",
"hover_highlight_color": "#2A2A38",
"...": "..."
}
}
```
A theme may be **partial** — delete any keys you don't want and only the
remaining ones are applied. Importing writes **only the parameters present in
the file** (keys whose sync toggle is on are also pushed to the server; the rest
update this browser only); omitted parameters are left untouched. If the file
contains parameters this version doesn't recognise, you're asked whether to
import the rest anyway.
## Colour parameters
Source of truth for the defaults: `DEFAULT_COLORS` in
[frontend/js/settings-sync.js](frontend/js/settings-sync.js). Each value is a
`#RRGGBB` hex string. Where a default is listed as "derived", leaving the value
untouched reproduces the previous automatic look; setting it overrides that.
| Key | What it colours | Default |
|---|---|---|
| `primary_color` | Primary/brand colour — buttons, links, active states, and the browser favicon/tab colour | `#58B900` |
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#45A148` |
| `today_color` | "Today" accent: the day-number circle and today's labels | `#6FB669` |
| `text_color` | Base text colour (secondary/tertiary text is derived from it) | `#FFFFFF` |
| `bg_color` | App background | `#000000` |
| `surface_color` | Sidebar / top bar / card surfaces (derived from `bg_color` when unset) | `#2B2B2B` |
| `line_color` | Borders and grid lines | `#3B3B3D` |
| `month_divider_color` | The line marking a month change in the scrolling month view | `#95D25E` |
| `month_label_color` | The month abbreviation shown at a month change | `#95D25E` |
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` |
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#90AA91` |
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` |
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2B382A` |
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#88EF9A` |
| `day_bg_color` | Normal (unselected, non-today) day background. Defaults to the app background so days look transparent | `#000000` |
| `today_bg_color` | Today's day-cell background — applied as a subtle tint of this colour | `#477650` |
Notes:
- `day_selected_color` and `today_bg_color` are applied as a low-opacity tint of
the chosen colour (so content stays readable). The colour you pick is the base
hue; the swatch shows the full colour.
- `day_bg_color` defaults to the app background. If you set a custom
`bg_color`, also set `day_bg_color` to match if you want fully transparent days.
- The other clients (iOS / Android) currently ignore the fine-grained element
colours; they are stored and synced by the server but only the web client
renders them.

63
backend/SETTINGS_SYNC.md Normal file
View File

@@ -0,0 +1,63 @@
# Settings sync contract (Web / iOS / Android)
> For a human-facing description of each theme colour parameter (and the
> `.theme` import/export format), see [../THEME.md](../THEME.md).
Per-setting, cross-device synchronisation of user settings. The **server is the
sole authority** for *which* settings sync; clients must not duplicate that logic.
This document is the shared contract all three clients implement identically.
## Canonical keys & default flags
Source of truth: `DEFAULT_SYNC` in `backend/routers/settings_router.py`. Keys use
the server's snake_case field names.
| Key | Kind | Default sync |
|---|---|---|
| `default_view` | enum | ON |
| `week_start_day` | enum | ON |
| `dim_past_events` | bool | ON |
| `hour_height` | enum(int) | ON |
| `primary_color` `accent_color` `today_color` `text_color` `line_color` `bg_color` `month_divider_color` `month_label_color` | color hex | ON |
| `default_event_duration_minutes` | enum(int) | ON |
| `default_reminder_minutes` | enum(int, null=off) | ON |
| `language` | enum | **OFF** |
| `share_calendar_icon` | icon key | **OFF** |
| `cache_months` | enum(int) | **OFF** |
| `month_view_paged` | bool | **OFF** |
Settings **not** in this list are never synced by this mechanism:
- Account-wide settings (`private_event_visibility`, `group_visible_calendar_id`,
`directory_hidden`) — one value per account, always identical everywhere; they
keep their existing dedicated endpoints/UI, not a sync toggle.
- Platform-exclusive device prefs (e.g. iOS `liquid_glass`) — stay device-local.
- Identity/security, calendar/account management, admin.
## API
- `GET /api/settings/` returns every value **plus** `sync_flags`: a fully-resolved
`{key: bool}` map covering exactly the keys above (stored overrides on top of
`DEFAULT_SYNC`). Clients read this map verbatim — no client-side defaults.
- `PUT /api/settings/` accepts a partial `sync_flags` map (merged account-wide,
unknown keys ignored, untouched flags preserved) and partial value fields
(`exclude_unset`; `text_color`/`line_color`/`bg_color`/… treated as
nullable-reset per `NULLABLE_OVERRIDES`).
## Client rules
Each client keeps a **local copy** of every syncable value (UserDefaults /
DataStore-SharedPreferences / localStorage) so that "not synced" works per device.
1. **On login / launch / foreground:** `GET /api/settings/` → values + `sync_flags`.
2. **Pull:** for each syncable key, if `sync_flags[key]` is ON, adopt the server
value into the local copy; if OFF, keep the local value.
3. **Push (debounced, read-modify-write):** start from the current server snapshot,
overwrite only keys whose flag is ON with the local value, `PUT`. Never push a
key whose flag is OFF.
4. **Toggle a flag ON:** set the flag true **and** push this device's current local
value (it becomes the shared value). **OFF:** set false; keep the local value.
5. **Global "share everything":** set all syncable flags true and push all local
values. Global off: set all false.
The flag map itself is always account-wide and always fetched fresh; it is what a
client consults to decide what to send/receive.

63
backend/dav_util.py Normal file
View File

@@ -0,0 +1,63 @@
"""Shared helpers for CalDAV publishing of local calendars.
Publishing is opt-in per calendar: a published calendar gets a secret
``dav_token`` and is reachable as a two-way CalDAV collection at
``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect
changes; each event carries an ``etag`` that changes on write. Rotating the
token revokes existing subscriptions.
"""
from __future__ import annotations
import os
import secrets
import uuid
def new_token() -> str:
"""A URL-safe, unguessable token used as the CalDAV collection path."""
return secrets.token_urlsafe(24)
def new_tag() -> str:
"""A fresh ctag/etag value."""
return uuid.uuid4().hex
def bump_dav(cal, event=None) -> None:
"""Mark a calendar (and optionally an event) as changed for CalDAV clients.
Safe to call unconditionally on every local-event write — it only refreshes
opaque change tags, so unpublished calendars are unaffected.
"""
if cal is not None:
cal.dav_ctag = new_tag()
if event is not None:
event.etag = new_tag()
def public_base(request) -> str:
"""Public origin (scheme://host) as clients actually reach us.
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
ones.
"""
env = os.environ.get("PUBLIC_BASE_URL")
if env:
return env.rstrip("/")
h = request.headers
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
return f"{proto}://{host}"
def caldav_url(request, token: str) -> str:
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
return f"{public_base(request)}/dav/{token}/"
def caldav_login_url(request) -> str:
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
return f"{public_base(request)}/caldav/"

View File

@@ -54,7 +54,7 @@ def private_visibility_for(db: Session, user_id: int) -> str:
# field (title/location/description/creator/calendar name/recurrence) can leak. # field (title/location/description/creator/calendar name/recurrence) can leak.
_BUSY_KEEP = { _BUSY_KEEP = {
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor", "id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
"source", "type", "owner", "is_group_event", "display_color", "source", "type", "owner", "is_group_event", "display_color", "read_only",
} }
@@ -102,12 +102,14 @@ def build_local_event_dict(
creator: Optional[dict] = None, creator: Optional[dict] = None,
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False,
) -> dict: ) -> dict:
"""Build the unified dict for a single local event (or occurrence). """Build the unified dict for a single local event (or occurrence).
``start``/``end``/``all_day`` override the stored values (used when emitting ``start``/``end``/``all_day`` override the stored values (used when emitting
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
by the group combined view. by the group combined view. ``read_only`` marks events the requester may not
edit (someone else's calendar), so clients can hide edit/delete.
""" """
d = { d = {
"id": ev.uid, "id": ev.uid,
@@ -130,10 +132,31 @@ def build_local_event_dict(
"private": bool(ev.is_private), "private": bool(ev.is_private),
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], "reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
} }
# Birthday calendars: the server owns the presentation. It flags the event so
# clients can show a cake icon, appends the age computed for THIS occurrence
# (so it stays correct as years pass), and — when the calendar defines a
# "notify N days before" — injects a reminder so the mobile schedulers fire
# it. Web has no notification delivery, so the reminder is display-only there.
if getattr(cal, "is_birthday", False):
d["is_birthday"] = True
display = ev.title
if ev.birth_year:
try:
occ_year = int(str(d["start"])[:4])
age = occ_year - int(ev.birth_year)
if age >= 0:
display = f"{ev.title} ({age})"
except (ValueError, TypeError):
pass
d["display_title"] = display
if not d["reminders"] and cal.birthday_notify_days_before is not None:
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
if owner is not None: if owner is not None:
d["owner"] = owner d["owner"] = owner
if is_group_event: if is_group_event:
d["is_group_event"] = True d["is_group_event"] = True
if read_only:
d["read_only"] = True
return d return d
@@ -146,6 +169,7 @@ def expand_recurring_local(
creator: Optional[dict] = None, creator: Optional[dict] = None,
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False,
) -> list: ) -> list:
"""Expand a recurring LocalEvent into individual occurrences in the range.""" """Expand a recurring LocalEvent into individual occurrences in the range."""
results = [] results = []
@@ -177,6 +201,7 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
else: else:
ev_start = dt_datetime.fromisoformat(ev_start_str) ev_start = dt_datetime.fromisoformat(ev_start_str)
@@ -203,11 +228,13 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
except Exception as exc: except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to a single event. # Fall back to a single event.
results.append(build_local_event_dict( results.append(build_local_event_dict(
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
return results return results

View File

@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -225,6 +225,128 @@ def _migrate():
except Exception: except Exception:
pass pass
# Per-setting cross-device sync: value columns for the two newly syncable
# device-local prefs, plus the JSON map of which settings sync.
for col, ddl in (
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
("surface_color", "ALTER TABLE user_settings ADD COLUMN surface_color VARCHAR(7)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Fine-grained web element theme colours (all optional overrides).
for col in (
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color",
"day_bg_color", "today_bg_color",
):
try:
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
# from the sidebar, matching caldav/google/ha.
for tbl in ("local_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to %s", tbl)
except Exception:
pass
# CalDAV publishing of local calendars (opt-in, secret token URL).
for col, ddl in (
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
conn.commit()
logging.info("Migration: added etag to local_events")
except Exception:
pass
# Per-recipient colour override for a shared calendar (NULL = owner's colour).
try:
conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)"))
conn.commit()
logging.info("Migration: added color to calendar_shares")
except Exception:
pass
# Hide a user from sharing/group pickers (admin management still shows them).
try:
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added directory_hidden to users")
except Exception:
pass
# Birthday calendars: flag + per-calendar "notify N days before".
for col, ddl in (
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
# Birthday events: stable external id (Contacts dedup) + birth year.
for col, ddl in (
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_events", col)
except Exception:
pass
# One-time cleanup of duplicate imported events sharing the same
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
# older build without idempotent upsert. Keep the earliest row.
# Idempotent: after cleanup there is nothing left to delete.
try:
conn.execute(text(
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
"GROUP BY calendar_id, external_uid)"
))
conn.commit()
logging.info("Migration: de-duplicated local_events by external_uid")
except Exception:
pass
# Hard guarantee: the DB itself forbids two events with the same
# external_uid in one calendar (imported entries only; NULLs unconstrained).
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
))
conn.commit()
logging.info("Migration: unique index on (calendar_id, external_uid)")
except Exception:
pass
_migrate() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@@ -268,10 +390,15 @@ app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
app.include_router(dav_router.router, tags=["dav"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")

View File

@@ -17,6 +17,9 @@ class User(Base):
avatar_filename = Column(String(255), nullable=True) avatar_filename = Column(String(255), nullable=True)
totp_secret = Column(String(32), nullable=True) totp_secret = Column(String(32), nullable=True)
totp_enabled = Column(Boolean, default=False) totp_enabled = Column(Boolean, default=False)
# When true, the user is hidden from sharing/group picker directories
# (/users/directory). Admin user management (/users/) still shows them.
directory_hidden = Column(Boolean, default=False, nullable=False)
caldav_accounts = relationship( caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan" "CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
@@ -97,6 +100,18 @@ class UserSettings(Base):
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast) line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default) bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
# NULL = derive from bg_color. Device-local by default (not synced).
surface_color = Column(String(7), nullable=True)
# Fine-grained web element colours (all optional overrides; NULL = derive the
# previous default). See frontend THEME.md for what each one paints.
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
day_selected_color = Column(String(7), nullable=True) # selected day background
day_bg_color = Column(String(7), nullable=True) # normal day background
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
# How this user's private events appear to other group members: # How this user's private events appear to other group members:
# 'hidden' = invisible, 'busy' = anonymous busy block (default). # 'hidden' = invisible, 'busy' = anonymous busy block (default).
private_event_visibility = Column(String(10), default="busy") private_event_visibility = Column(String(10), default="busy")
@@ -110,10 +125,54 @@ class UserSettings(Base):
default_event_duration_minutes = Column(Integer, default=60) default_event_duration_minutes = Column(Integer, default=60)
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups. # Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
share_calendar_icon = Column(String(16), nullable=True) share_calendar_icon = Column(String(16), nullable=True)
# How many months around the visible range clients preload/cache. Device-local
# by default (only shared when its sync flag is on).
cache_months = Column(Integer, default=3)
# Whether the month view uses horizontal paging (swipe) instead of a vertical
# scroll feed. Device-local by default (only shared when its sync flag is on).
month_view_paged = Column(Boolean, default=False)
# Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall
# back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is
# the single authority for which settings each client sends/fetches.
sync_flags = Column(Text, nullable=True)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
class InstanceSettings(Base):
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
Applies to everyone; a user's own settings still override the default theme."""
__tablename__ = "instance_settings"
id = Column(Integer, primary_key=True) # always 1
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
# client's built-in defaults. A user's own colour wins over this.
default_theme = Column(Text, nullable=True)
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
logo_filename = Column(String(255), nullable=True)
favicon_filename = Column(String(255), nullable=True)
class AppPassword(Base):
"""Per-device app-specific password for CalDAV (Basic Auth).
Keeps MFA intact: accounts with 2FA can't use their normal password over
CalDAV (clients can't send a TOTP code), so they authenticate with one of
these revocable app passwords instead. Only the bcrypt hash is stored.
"""
__tablename__ = "app_passwords"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String(100), nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(String(50), nullable=True)
last_used_at = Column(String(50), nullable=True)
user = relationship("User")
class LocalCalendar(Base): class LocalCalendar(Base):
__tablename__ = "local_calendars" __tablename__ = "local_calendars"
@@ -122,8 +181,22 @@ class LocalCalendar(Base):
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
color = Column(String(7), default="#34a853") color = Column(String(7), default="#34a853")
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this calendar generate reminders/notifications on clients. # Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False) reminders_enabled = Column(Boolean, default=True, nullable=False)
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
# collection reachable via a secret token URL. dav_ctag changes on every
# event write so clients detect changes; rotating the token revokes access.
caldav_published = Column(Boolean, default=False, nullable=False)
dav_token = Column(String(64), nullable=True, unique=True)
dav_ctag = Column(String(32), nullable=True)
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
is_birthday = Column(Boolean, default=False, nullable=False)
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
# Injected as an event reminder on read so the mobile schedulers fire it.
birthday_notify_days_before = Column(Integer, nullable=True)
user = relationship("User", back_populates="local_calendars") user = relationship("User", back_populates="local_calendars")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -152,6 +225,13 @@ class LocalEvent(Base):
creator_name_external = Column(Text, nullable=True) creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting. # Private events are filtered for other group members per their visibility setting.
is_private = Column(Boolean, default=False) is_private = Column(Boolean, default=False)
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
etag = Column(String(32), nullable=True)
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
external_uid = Column(String(255), nullable=True, index=True)
# Birth year for birthday events; NULL = year unknown (no age shown).
birth_year = Column(Integer, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User") creator = relationship("User")
@@ -166,6 +246,8 @@ class ICalSubscription(Base):
url = Column(String(1000), nullable=False) url = Column(String(1000), nullable=False)
color = Column(String(7), default="#46bdc6") color = Column(String(7), default="#46bdc6")
enabled = Column(Boolean, default=True) enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still subscribed; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this subscription generate reminders/notifications on clients. # Whether events of this subscription generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False) reminders_enabled = Column(Boolean, default=True, nullable=False)
refresh_minutes = Column(Integer, default=60) refresh_minutes = Column(Integer, default=60)
@@ -282,6 +364,22 @@ class CalendarShare(Base):
user = relationship("User") user = relationship("User")
class CalendarColorPref(Base):
"""A user's personal colour for a calendar they don't own — works for any
way a foreign calendar becomes visible (direct share, group calendar, or a
co-member's group-visible calendar). NULL/absent = the owner's colour."""
__tablename__ = "calendar_color_prefs"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_pref"),
)
id = Column(Integer, primary_key=True, index=True)
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
color = Column(String(16), nullable=False)
class Group(Base): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
@@ -317,6 +415,26 @@ class GroupMember(Base):
user = relationship("User") user = relationship("User")
class BirthdaySyncDevice(Base):
"""A device that has synced Contacts birthdays into the user's birthday
calendar. Powers the web "birthdays come from these devices" list. One row
per (user, device); the client sends a stable device_id + human name."""
__tablename__ = "birthday_sync_devices"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
device_id = Column(String(64), nullable=False)
device_name = Column(String(120), nullable=False)
last_sync = Column(String(50), nullable=True) # ISO 8601
count = Column(Integer, default=0)
user = relationship("User")
class GroupCalendar(Base): class GroupCalendar(Base):
"""1:1 link between a group and its shared local calendar.""" """1:1 link between a group and its shared local calendar."""

View File

@@ -97,8 +97,44 @@ def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> model
return cal return cal
def co_member_group_visible_calendars(db: Session, user: models.User) -> list[models.LocalCalendar]:
"""Calendars that co-members of the user's groups share into the group.
Each user designates ONE of their own calendars via
UserSettings.group_visible_calendar_id. This returns those calendars for
every co-member of any group the user belongs to (excluding the user's own).
The calendar must be owned by the designating member. Deduped (each calendar
appears once even across multiple shared groups).
"""
my_group_ids = (
db.query(models.GroupMember.group_id)
.filter(models.GroupMember.user_id == user.id)
)
co_member_ids = (
db.query(models.GroupMember.user_id)
.filter(
models.GroupMember.group_id.in_(my_group_ids),
models.GroupMember.user_id != user.id,
)
.distinct()
)
return (
db.query(models.LocalCalendar)
.join(
models.UserSettings,
models.UserSettings.group_visible_calendar_id == models.LocalCalendar.id,
)
.filter(
models.UserSettings.user_id.in_(co_member_ids),
# the designating member must own the calendar they share
models.LocalCalendar.user_id == models.UserSettings.user_id,
)
.all()
)
def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
"""All local calendar ids the user may read: own + shared + group calendars.""" """All local calendar ids the user may read: own + shared + group + co-member group-visible."""
ids: set[int] = set() ids: set[int] = set()
own = ( own = (
@@ -123,4 +159,18 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
) )
ids.update(r[0] for r in group_cals) ids.update(r[0] for r in group_cals)
# Calendars co-members share into shared groups (group-visible).
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
return list(ids) return list(ids)
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
"""Map calendar_id -> the user's personal colour for calendars they don't
own (any sharing path). Empty when the user set no overrides."""
return {
p.calendar_id: p.color
for p in db.query(models.CalendarColorPref).filter(
models.CalendarColorPref.user_id == user_id
)
}

View File

@@ -0,0 +1,194 @@
"""Instance-wide (singleton) settings: an admin-defined default theme plus a
custom logo and favicon. The GET endpoint is public (needed on the login screen,
before auth); all writes require admin. Branding files are stored under
DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern."""
import io
import json
import re
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_admin
from database import DATA_DIR, get_db
router = APIRouter()
BRANDING_DIR = DATA_DIR / "branding"
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# Colour keys an admin may set as the instance default theme. Keep in sync with
# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js).
THEME_COLOR_KEYS = {
"primary_color", "accent_color", "today_color", "text_color", "bg_color",
"line_color", "surface_color", "month_divider_color", "month_label_color",
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color",
}
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _get_or_create(db: Session) -> models.InstanceSettings:
inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first()
if not inst:
inst = models.InstanceSettings(id=1)
db.add(inst)
db.commit()
db.refresh(inst)
return inst
def _mtime(filename: Optional[str]) -> int:
if not filename:
return 0
p = BRANDING_DIR / filename
try:
return int(p.stat().st_mtime)
except OSError:
return 0
def _public_dict(inst: models.InstanceSettings) -> dict:
theme = {}
if inst.default_theme:
try:
theme = json.loads(inst.default_theme) or {}
except (ValueError, TypeError):
theme = {}
has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists()
has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists()
return {
"default_theme": theme,
"has_logo": has_logo,
"has_favicon": has_favicon,
# Cache-busted URLs so a freshly uploaded asset is fetched immediately.
"logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None,
"favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None,
}
# ── Public read ───────────────────────────────────────────
@router.get("/")
def get_instance(db: Session = Depends(get_db)):
return _public_dict(_get_or_create(db))
@router.get("/logo")
def get_logo(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.logo_filename:
raise HTTPException(404, "No logo")
path = BRANDING_DIR / inst.logo_filename
if not path.exists():
raise HTTPException(404, "No logo")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
@router.get("/favicon")
def get_favicon(db: Session = Depends(get_db)):
inst = _get_or_create(db)
if not inst.favicon_filename:
raise HTTPException(404, "No favicon")
path = BRANDING_DIR / inst.favicon_filename
if not path.exists():
raise HTTPException(404, "No favicon")
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
# ── Admin writes ──────────────────────────────────────────
class ThemeUpdate(BaseModel):
default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in
@router.put("/theme")
def set_default_theme(
data: ThemeUpdate,
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
# Keep only known colour keys with valid hex values.
clean = {
k: v.upper()
for k, v in (data.default_theme or {}).items()
if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v)
}
inst = _get_or_create(db)
inst.default_theme = json.dumps(clean) if clean else None
db.commit()
return {"ok": True, "default_theme": clean}
async def _save_branding(file: UploadFile, kind: str) -> str:
"""Validate + normalise an uploaded image and store it. Returns the filename."""
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Only JPEG, PNG or WebP allowed")
raw = await file.read()
if len(raw) > MAX_BRANDING_SIZE:
raise HTTPException(400, "File too large (max 5 MB)")
try:
img = Image.open(io.BytesIO(raw)).convert("RGBA")
except Exception:
raise HTTPException(400, "Invalid image")
if kind == "favicon":
img = img.resize((128, 128), Image.LANCZOS)
else: # logo: keep aspect ratio, cap the longest edge at 256px
img.thumbnail((256, 256), Image.LANCZOS)
filename = f"{kind}.png"
img.save(str(BRANDING_DIR / filename), "PNG")
return filename
@router.post("/logo")
async def upload_logo(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.logo_filename = await _save_branding(file, "logo")
db.commit()
return {"ok": True}
@router.delete("/logo")
def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.logo_filename:
p = BRANDING_DIR / inst.logo_filename
if p.exists():
p.unlink()
inst.logo_filename = None
db.commit()
return {"ok": True}
@router.post("/favicon")
async def upload_favicon(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: models.User = Depends(get_current_admin),
):
inst = _get_or_create(db)
inst.favicon_filename = await _save_branding(file, "favicon")
db.commit()
return {"ok": True}
@router.delete("/favicon")
def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
inst = _get_or_create(db)
if inst.favicon_filename:
p = BRANDING_DIR / inst.favicon_filename
if p.exists():
p.unlink()
inst.favicon_filename = None
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,77 @@
"""Birthday sync device tracking.
The iOS app reports, after each Contacts birthday sync, which device it was and
how many birthdays it manages. The web shows this as a "birthdays come from
these devices" list. Birthday events themselves are ordinary local events
(see local_router); this router only tracks the sync sources.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
router = APIRouter()
class SyncReport(BaseModel):
device_id: str
device_name: str
count: int = 0
@router.post("/sync-report")
def report_sync(
data: SyncReport,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Upsert the (user, device) sync record after a Contacts birthday sync."""
row = (
db.query(models.BirthdaySyncDevice)
.filter(
models.BirthdaySyncDevice.user_id == current_user.id,
models.BirthdaySyncDevice.device_id == data.device_id,
)
.first()
)
now = datetime.now(timezone.utc).isoformat()
name = (data.device_name or "Gerät")[:120]
if row is None:
db.add(models.BirthdaySyncDevice(
user_id=current_user.id, device_id=data.device_id,
device_name=name, last_sync=now, count=data.count,
))
else:
row.device_name = name
row.last_sync = now
row.count = data.count
db.commit()
return {"ok": True}
@router.get("/devices")
def list_devices(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.BirthdaySyncDevice)
.filter(models.BirthdaySyncDevice.user_id == current_user.id)
.order_by(models.BirthdaySyncDevice.last_sync.desc())
.all()
)
return [
{
"device_id": r.device_id,
"device_name": r.device_name,
"last_sync": r.last_sync,
"count": r.count,
}
for r in rows
]

View File

@@ -300,6 +300,7 @@ def get_events(
end_dt = end_dt.replace(tzinfo=timezone.utc) end_dt = end_dt.replace(tzinfo=timezone.utc)
all_events = [] all_events = []
sync_errors = []
accounts = ( accounts = (
db.query(models.CalDAVAccount) db.query(models.CalDAVAccount)
.filter( .filter(
@@ -333,6 +334,12 @@ def get_events(
logger.error( logger.error(
"Error fetching calendar %s: %s", calendar.id, exc "Error fetching calendar %s: %s", calendar.id, exc
) )
sync_errors.append({
"source": "caldav",
"name": f"{account.username} {calendar.name}",
"calendar_id": calendar.id,
"message": "Sync fehlgeschlagen",
})
# ── Local calendar events (own + shared + group calendars) ───────────── # ── Local calendar events (own + shared + group calendars) ─────────────
readable_ids = permissions.readable_local_calendar_ids(db, current_user) readable_ids = permissions.readable_local_calendar_ids(db, current_user)
@@ -345,6 +352,23 @@ def get_events(
.all() .all()
) if readable_ids else [] ) if readable_ids else []
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()} name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
# A personal calendar shared WITH the current user is relabelled under the
# owner's name (so Guido's "Persönlich" reads as "Guido" for his mum) and
# flagged read_only unless the share grants write. Group calendars are
# excluded — they keep their own name and stay writable for members.
shares_by_cal = {
s.calendar_id: s
for s in db.query(models.CalendarShare).filter(
models.CalendarShare.user_id == current_user.id
)
}
group_cal_ids = {
r[0] for r in db.query(models.GroupCalendar.calendar_id).filter(
models.GroupCalendar.calendar_id.in_(readable_ids)
)
} if readable_ids else set()
# Per-user colour overrides for calendars the user doesn't own (any share path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
# Cache each owner's private-event visibility (one lookup per owner, not per event). # Cache each owner's private-event visibility (one lookup per owner, not per event).
vis_cache: dict = {} vis_cache: dict = {}
@@ -354,6 +378,15 @@ def get_events(
return vis_cache[uid] return vis_cache[uid]
for local_cal in local_calendars: for local_cal in local_calendars:
# Decoration for a personal calendar shared with (not owned by) me.
is_shared_personal = (
local_cal.user_id != current_user.id
and local_cal.id not in group_cal_ids
)
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None
shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write"
shared_color = color_prefs.get(local_cal.id)
local_events = ( local_events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -384,6 +417,12 @@ def get_events(
else: else:
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)] built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
for b in built: for b in built:
if shared_owner_name:
b["calendar_name"] = shared_owner_name
if shared_read_only:
b["read_only"] = True
if shared_color:
b["calendarColor"] = shared_color
b = apply_event_privacy( b = apply_event_privacy(
b, owner_id=owner_id, is_private=is_priv, b, owner_id=owner_id, is_private=is_priv,
requester_id=current_user.id, visibility=visibility, requester_id=current_user.id, visibility=visibility,
@@ -414,13 +453,18 @@ def get_events(
.filter(models.GoogleAccount.user_id == current_user.id) .filter(models.GoogleAccount.user_id == current_user.id)
.all() .all()
) )
google_errors = []
for g_acc in google_accounts: for g_acc in google_accounts:
try: try:
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db)) g_events, g_errors = get_google_events(g_acc, start_dt, end_dt, db)
all_events.extend(g_events)
sync_errors.extend(g_errors)
except Exception as exc: except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
google_errors.append({"email": g_acc.email}) sync_errors.append({
"source": "google",
"name": g_acc.email,
"message": "Sync fehlgeschlagen",
})
# ── Home Assistant events ───────────────────────────── # ── Home Assistant events ─────────────────────────────
from routers.homeassistant_router import get_ha_events from routers.homeassistant_router import get_ha_events
@@ -431,11 +475,18 @@ def get_events(
) )
for ha_acc in ha_accounts: for ha_acc in ha_accounts:
try: try:
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db)) ha_events, ha_errors = get_ha_events(ha_acc, start_dt, end_dt, db)
all_events.extend(ha_events)
sync_errors.extend(ha_errors)
except Exception as exc: except Exception as exc:
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
sync_errors.append({
"source": "homeassistant",
"name": ha_acc.name,
"message": "Sync fehlgeschlagen",
})
return {"events": all_events, "errors": google_errors} return {"events": all_events, "errors": sync_errors}
@router.post("/events") @router.post("/events")

View File

@@ -0,0 +1,491 @@
"""Minimal two-way CalDAV server for published local calendars.
Two ways to reach a published local calendar:
1. Secret token URL (no login) — ``/dav/{token}/``.
2. Username + password (HTTP Basic Auth) with principal discovery —
``/caldav/`` advertises the user's calendar-home-set and lists every
published calendar as ``/caldav/{id}/``. This is what account-based clients
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
getetag per event), avoiding deletion tombstones.
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
round-tripped (parse_ics ignores them).
"""
from __future__ import annotations
import base64
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from urllib.parse import quote, unquote
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import func
from sqlalchemy.orm import Session
import dav_util
import ical_io
import models
from auth import verify_password
from database import get_db
router = APIRouter()
# XML namespaces used across WebDAV / CalDAV.
NS_DAV = "DAV:"
NS_CAL = "urn:ietf:params:xml:ns:caldav"
NS_CS = "http://calendarserver.org/ns/"
NS_ICAL = "http://apple.com/ns/ical/"
_NS_DECL = (
'xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav" '
'xmlns:CS="http://calendarserver.org/ns/" '
'xmlns:ICAL="http://apple.com/ns/ical/"'
)
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
_MULTISTATUS_CT = "application/xml; charset=utf-8"
_LOGIN_HREF = "/caldav/"
# ── Helpers ───────────────────────────────────────────────
def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
if not token:
return None
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.dav_token == token,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
"""Validate an HTTP Basic Authorization header against a Calendarr account.
Accepts an app-specific password (always) or the account password (only when
MFA is off — otherwise the account password would bypass 2FA, which CalDAV
clients can't satisfy).
"""
hdr = request.headers.get("Authorization", "")
if not hdr.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
except Exception:
return None
username, sep, password = raw.partition(":")
if not sep:
return None
# Login names are stored lowercase; match case-insensitively like the web login.
user = (
db.query(models.User)
.filter(func.lower(models.User.username) == username.lower())
.first()
)
if not user:
return None
# 1) App-specific passwords — always allowed, MFA-safe.
for ap in db.query(models.AppPassword).filter(models.AppPassword.user_id == user.id).all():
try:
if verify_password(password, ap.password_hash):
ap.last_used_at = datetime.now(timezone.utc).isoformat()
db.commit()
return user
except Exception:
continue
# 2) Account password — only when 2FA is disabled.
if not user.totp_enabled:
try:
if verify_password(password, user.password_hash):
return user
except Exception:
return None
return None
def _unauthorized() -> Response:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.all()
)
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
return (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
def _etag(ev: models.LocalEvent) -> str:
return ev.etag or "0"
def _resource_name(ev: models.LocalEvent) -> str:
return f"{quote(ev.uid, safe='')}.ics"
def _event_href(base: str, ev: models.LocalEvent) -> str:
return f"{base}{_resource_name(ev)}"
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
if owner:
return {owner.id: (owner.display_name or owner.username)}
return {}
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
# ── XML builders ──────────────────────────────────────────
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
principal_href: str | None = None,
home_href: str | None = None) -> str:
principal_href = principal_href or base
home_href = home_href or base
return f""" <D:response>
<D:href>{base}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
<D:supported-report-set>
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
</D:supported-report-set>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _principal_propstat(user: models.User) -> str:
href = _LOGIN_HREF
name = user.display_name or user.username
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
<D:displayname>{xml_escape(name)}</D:displayname>
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
ics: str | None = None) -> str:
href = _event_href(base, ev)
data = ""
if with_data and ics is not None:
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _multistatus(body: str) -> Response:
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
# ── Method handlers (shared by token and Basic-Auth paths) ──
def _handle_options() -> Response:
return Response(status_code=200, headers={
"DAV": "1, 2, 3, calendar-access",
"Allow": _ALLOW,
})
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
name = resource.rsplit("/", 1)[-1]
if name.endswith(".ics"):
name = name[:-4]
uid = unquote(name)
return (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
models.LocalEvent.uid == uid,
)
.first()
)
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
db: Session, *, principal_href: str | None = None,
home_href: str | None = None) -> Response:
# PROPFIND on a single event resource.
if resource:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
return _multistatus(_event_propstat(base, ev))
# Collection: always include collection props; Depth:1 adds each event.
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
if depth != "0":
for ev in _events(cal, db):
parts.append(_event_propstat(base, ev))
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
report_type = None
hrefs: list[str] = []
if body:
try:
root = ET.fromstring(body)
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
except ET.ParseError:
pass
if report_type == "calendar-multiget" and hrefs:
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
else:
# calendar-query (or unknown) → return the whole calendar.
evs = _events(cal, db)
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
*, head: bool = False) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
ics = _build_ics(cal, [ev], db)
headers = {"ETag": f'"{_etag(ev)}"'}
return Response(
content=b"" if head else ics,
media_type="text/calendar; charset=utf-8",
headers=headers,
)
def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response:
try:
parsed = ical_io.parse_ics(body)
except ValueError:
return Response(status_code=400)
items = parsed.get("events") or []
if not items:
return Response(status_code=400)
item = items[0]
# Key by the VEVENT UID; fall back to the resource name.
uid = item.get("uid")
if not uid:
name = resource.rsplit("/", 1)[-1]
uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4())
# Scope to THIS calendar — never touch another calendar's/user's event that
# happens to share the UID (local_events.uid is globally unique).
ev = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == cal.id,
models.LocalEvent.uid == uid,
)
.first()
)
created = ev is None
if created:
# If the UID already exists elsewhere, the global UNIQUE constraint would
# 500 on commit — reject cleanly with 409 instead.
if db.query(models.LocalEvent.id).filter(models.LocalEvent.uid == uid).first():
return Response(status_code=409)
ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id)
db.add(ev)
ev.title = item.get("title") or "(ohne Titel)"
ev.start = item["start"]
ev.end = item["end"]
ev.all_day = item.get("all_day", False)
ev.location = item.get("location")
ev.description = item.get("description")
ev.rrule = item.get("rrule")
ev.exdate = item.get("exdate")
dav_util.bump_dav(cal, ev)
db.commit()
db.refresh(ev)
return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'})
def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
dav_util.bump_dav(cal)
db.delete(ev)
db.commit()
return Response(status_code=204)
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
resource: str, db: Session, *,
principal_href: str | None = None,
home_href: str | None = None) -> Response:
"""Serve a single calendar collection; auth/ownership already checked."""
method = request.method.upper()
if method == "PROPFIND":
depth = request.headers.get("Depth", "0")
return _handle_propfind(cal, base, resource, depth, db,
principal_href=principal_href, home_href=home_href)
if method == "REPORT":
return _handle_report(cal, base, await request.body(), db)
if method in ("GET", "HEAD"):
if not resource:
ics = _build_ics(cal, _events(cal, db), db)
return Response(content=ics, media_type="text/calendar; charset=utf-8")
return _handle_get(cal, resource, db, head=(method == "HEAD"))
if method == "PUT":
return _handle_put(cal, resource, await request.body(), db)
if method == "DELETE":
if not resource:
return Response(status_code=403) # don't delete the collection itself
return _handle_delete(cal, resource, db)
return Response(status_code=405, headers={"Allow": _ALLOW})
# ── Token path (no login): /dav/{token}/… ─────────────────
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
cal = _resolve(token, db)
if not cal:
return Response(status_code=404)
base = f"/dav/{token}/"
return await _dispatch_collection(request, cal, base, resource, db)
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_token(request, token, "", db)
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_token(request, token, resource, db)
# ── Basic-Auth path (username/password): /caldav/… ────────
async def _dispatch_home(request: Request, db: Session) -> Response:
"""Principal + calendar-home-set: lists the user's published calendars."""
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
if request.method.upper() != "PROPFIND":
return Response(status_code=405, headers={"Allow": _ALLOW})
depth = request.headers.get("Depth", "0")
parts = [_principal_propstat(user)]
if depth != "0":
for cal in _published_calendars(user, db):
parts.append(_collection_propstat(
cal, f"/caldav/{cal.id}/",
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
return _multistatus("\n".join(parts))
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == cal_id,
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
if not cal:
return Response(status_code=404)
base = f"/caldav/{cal.id}/"
return await _dispatch_collection(request, cal, base, resource, db,
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
async def wellknown_caldav(request: Request):
if request.method.upper() == "OPTIONS":
return _handle_options()
# Point discovery at the principal/home collection.
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
async def caldav_home(request: Request, db: Session = Depends(get_db)):
return await _dispatch_home(request, db)
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, "", db)
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, resource, db)

View File

@@ -382,15 +382,35 @@ def update_calendar(
# ── Events ─────────────────────────────────────────────── # ── Events ───────────────────────────────────────────────
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list: def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
"""Fetch events from all enabled Google calendars for an account.""" """Fetch events from all enabled Google calendars for an account.
Returns (events, errors) — errors is a list of
{"source": "google", "name": ..., "message": ...} dicts for any
calendar that failed to sync. Never includes raw exception text.
"""
all_events = []
errors = []
try: try:
token = _refresh_access_token(account, db) token = _refresh_access_token(account, db)
except Exception as exc: except Exception as exc:
# A token failure aborts the whole account, but attribute it to each
# enabled calendar so clients can pin the error to a specific calendar
# (and preserve that calendar's cached events instead of wiping it).
logger.error("Token refresh failed for Google account %s: %s", account.email, exc) logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
raise for gcal in account.calendars:
if not gcal.enabled or gcal.sidebar_hidden:
continue
if _is_system_calendar(gcal.cal_id):
continue
errors.append({
"source": "google",
"name": f"{account.email} {gcal.name}",
"calendar_id": gcal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
all_events = []
for gcal in account.calendars: for gcal in account.calendars:
if not gcal.enabled or gcal.sidebar_hidden: if not gcal.enabled or gcal.sidebar_hidden:
continue continue
@@ -409,8 +429,14 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4")) all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
except Exception as exc: except Exception as exc:
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc) logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
errors.append({
"source": "google",
"name": f"{account.email} {gcal.name}",
"calendar_id": gcal.id,
"message": "Sync fehlgeschlagen",
})
return all_events return all_events, errors
class GoogleEventCreate(BaseModel): class GoogleEventCreate(BaseModel):

View File

@@ -176,11 +176,22 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
member_dicts = [] member_dicts = []
for i, m in enumerate(members): for i, m in enumerate(members):
u = db.query(models.User).filter(models.User.id == m.user_id).first() u = db.query(models.User).filter(models.User.id == m.user_id).first()
# Whether this member actually shares a calendar into the group (owns a
# calendar designated as their group_visible). Lets clients hide phantom
# empty rows for members who share nothing.
s = db.query(models.UserSettings).filter(models.UserSettings.user_id == m.user_id).first()
shares_calendar = False
if s and s.group_visible_calendar_id is not None:
shares_calendar = db.query(models.LocalCalendar.id).filter(
models.LocalCalendar.id == s.group_visible_calendar_id,
models.LocalCalendar.user_id == m.user_id,
).first() is not None
member_dicts.append({ member_dicts.append({
"id": m.user_id, "id": m.user_id,
"display_name": (u.display_name or u.username) if u else None, "display_name": (u.display_name or u.username) if u else None,
"role": m.role, "role": m.role,
"color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)], "color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
"shares_calendar": shares_calendar,
}) })
gcal_id = _group_calendar_id(db, group.id) gcal_id = _group_calendar_id(db, group.id)
return { return {
@@ -317,17 +328,12 @@ def _first_name(name: Optional[str]) -> str:
def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict], def _decorate_title(title: str, *, is_group: bool, creator: Optional[dict],
owner: Optional[dict], me_id: int) -> str: owner: Optional[dict], me_id: int) -> str:
"""Server-side display title for the combined view so every client (web, """Server-side display title for the combined view. The former owner/creator
iOS, Android) renders identically: another member's / creator's first name first-name prefix ("Guido: …") was dropped: each member already has a
is prefixed. No icon glyph is embedded — group icons are semantic keys the distinct colour, so the prefix was redundant noise. We still return a
clients render as native vector icons, and group-calendar events are non-empty `display_title` (== raw title) so the clients' legacy fallback —
distinguished by their (group) colour. The raw `title` stays for editing.""" which rebuilds a prefix when `display_title` is empty — never kicks in.
if is_group: `display_color` continues to carry the per-person colour."""
if creator and creator.get("id") is not None and creator.get("id") != me_id:
return f"{_first_name(creator.get('display_name'))}: {title}"
return title
if owner and owner.get("id") is not None and owner.get("id") != me_id:
return f"{_first_name(owner.get('display_name'))}: {title}"
return title return title
@@ -374,6 +380,9 @@ def combined_events(
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
owner_user = name_cache.get(owner_id) owner_user = name_cache.get(owner_id)
owner = {"id": owner_id, "display_name": owner_user} owner = {"id": owner_id, "display_name": owner_user}
# Editable by the requester iff it's the shared group calendar (all members
# may write) or the requester's own calendar; everyone else's is read-only.
read_only = not (is_group or owner_id == current_user.id)
events = ( events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -399,9 +408,9 @@ def combined_events(
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule: if ev.rrule:
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group) built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)
else: else:
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)] built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)]
for b in built: for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
@@ -411,10 +420,13 @@ def combined_events(
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id) b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
# Decorated title (group icon / owner name) computed server-side # Decorated title (group icon / owner name) computed server-side
# so all clients render identically; raw `title` kept for editing. # so all clients render identically; raw `title` kept for editing.
b["display_title"] = _decorate_title( # A birthday event already carries an age display_title from
b.get("title", ""), is_group=is_group, creator=b.get("creator"), # build_local_event_dict — keep it rather than clobber the age.
owner=owner, me_id=current_user.id, if not b.get("is_birthday"):
) b["display_title"] = _decorate_title(
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
owner=owner, me_id=current_user.id,
)
all_events.append(b) all_events.append(b)
# Each member shares exactly one calendar into their groups, chosen in their # Each member shares exactly one calendar into their groups, chosen in their

View File

@@ -313,13 +313,32 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) ->
} }
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list: def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> tuple:
"""Fetch events from all enabled HA calendars for an account.
Returns (events, errors) — errors is a list of
{"source": "homeassistant", "name": ..., "calendar_id": ..., "message": ...}
dicts for any calendar that failed to sync. Never includes raw exception text.
"""
all_events = [] all_events = []
errors = []
try: try:
token = _get_valid_token(account, db) token = _get_valid_token(account, db)
except Exception as exc: except Exception as exc:
# A token failure aborts the whole account, but attribute it to each
# enabled calendar so clients can pin the error to a specific calendar
# (and preserve that calendar's cached events instead of wiping it).
logger.error("HA token error for %s: %s", account.name, exc) logger.error("HA token error for %s: %s", account.name, exc)
raise for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden:
continue
errors.append({
"source": "homeassistant",
"name": f"{account.name} {cal.name}",
"calendar_id": cal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
for cal in account.calendars: for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden: if not cal.enabled or cal.sidebar_hidden:
continue continue
@@ -330,7 +349,13 @@ def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
except Exception as exc: except Exception as exc:
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc) logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
return all_events errors.append({
"source": "homeassistant",
"name": f"{account.name} {cal.name}",
"calendar_id": cal.id,
"message": "Sync fehlgeschlagen",
})
return all_events, errors
# ── Serialization ───────────────────────────────────────── # ── Serialization ─────────────────────────────────────────

View File

@@ -1,6 +1,9 @@
import ipaddress
import logging import logging
import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import urljoin, urlparse
import requests as http_requests import requests as http_requests
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -28,6 +31,7 @@ class SubscriptionUpdate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
refresh_minutes: Optional[int] = None refresh_minutes: Optional[int] = None
reminders_enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None
@@ -39,19 +43,74 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
"url": sub.url, "url": sub.url,
"color": sub.color, "color": sub.color,
"enabled": sub.enabled, "enabled": sub.enabled,
"sidebar_hidden": bool(sub.sidebar_hidden),
"reminders_enabled": bool(sub.reminders_enabled), "reminders_enabled": bool(sub.reminders_enabled),
"refresh_minutes": sub.refresh_minutes, "refresh_minutes": sub.refresh_minutes,
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None, "last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
} }
def _fetch_ics(url: str) -> str: _MAX_ICS_BYTES = 5 * 1024 * 1024
"""Download .ics content from a URL.""" _MAX_REDIRECTS = 5
def _host_is_public(host: str) -> bool:
"""False if the host resolves to any private/loopback/link-local address."""
try: try:
resp = http_requests.get(url, timeout=30, allow_redirects=True) infos = socket.getaddrinfo(host, None)
resp.raise_for_status() except socket.gaierror:
resp.encoding = 'utf-8' return False
return resp.text for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return False
return True
def _validate_public_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Nur http(s)-URLs sind erlaubt.")
if not parsed.hostname:
raise ValueError("Ungültige URL.")
if not _host_is_public(parsed.hostname):
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
def _fetch_ics(url: str) -> str:
"""Download .ics content, blocking SSRF to internal/private hosts.
Redirects are followed manually so every hop is re-validated (a public URL
can otherwise 30x-redirect into the internal network). Response size is
capped. (Residual risk: DNS rebinding between check and connect.)
"""
if url.startswith("webcal://"):
url = "https://" + url[len("webcal://"):]
try:
for _ in range(_MAX_REDIRECTS + 1):
_validate_public_url(url)
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True)
if resp.is_redirect and resp.headers.get("location"):
url = urljoin(url, resp.headers["location"])
resp.close()
continue
resp.raise_for_status()
resp.encoding = "utf-8"
chunks, total = [], 0
for chunk in resp.iter_content(8192, decode_unicode=True):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total > _MAX_ICS_BYTES:
resp.close()
raise ValueError("Datei zu groß (max. 5 MB).")
return "".join(chunks)
raise ValueError("Zu viele Weiterleitungen.")
except http_requests.RequestException as e: except http_requests.RequestException as e:
raise ValueError(f"Fehler beim Abrufen der URL: {e}") raise ValueError(f"Fehler beim Abrufen der URL: {e}")
@@ -216,6 +275,8 @@ def update_subscription(
sub.color = data.color sub.color = data.color
if data.enabled is not None: if data.enabled is not None:
sub.enabled = data.enabled sub.enabled = data.enabled
if data.sidebar_hidden is not None:
sub.sidebar_hidden = data.sidebar_hidden
if data.refresh_minutes is not None: if data.refresh_minutes is not None:
sub.refresh_minutes = data.refresh_minutes sub.refresh_minutes = data.refresh_minutes
if data.reminders_enabled is not None: if data.reminders_enabled is not None:

View File

@@ -2,11 +2,12 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import dav_util
import ical_io import ical_io
import models import models
import permissions import permissions
@@ -24,13 +25,19 @@ def _now_iso() -> str:
class CalendarCreate(BaseModel): class CalendarCreate(BaseModel):
name: str name: str
color: str = "#34a853" color: str = "#34a853"
is_birthday: bool = False
birthday_notify_days_before: Optional[int] = None
class CalendarUpdate(BaseModel): class CalendarUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None
caldav_published: Optional[bool] = None
is_birthday: Optional[bool] = None
birthday_notify_days_before: Optional[int] = None
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -45,6 +52,8 @@ class EventCreate(BaseModel):
rrule: Optional[str] = None rrule: Optional[str] = None
private: bool = False private: bool = False
reminders: Optional[List[int]] = None # minutes before start (0 = at start) reminders: Optional[List[int]] = None # minutes before start (0 = at start)
external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup)
birth_year: Optional[int] = None # birthday events; NULL = year unknown
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -59,6 +68,8 @@ class EventUpdate(BaseModel):
exdate: Optional[str] = None exdate: Optional[str] = None
private: Optional[bool] = None private: Optional[bool] = None
reminders: Optional[List[int]] = None reminders: Optional[List[int]] = None
external_uid: Optional[str] = None
birth_year: Optional[int] = None
class ShareCreate(BaseModel): class ShareCreate(BaseModel):
@@ -67,16 +78,29 @@ class ShareCreate(BaseModel):
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict: shared_by: Optional[str] = None, permission: Optional[str] = None,
color_override: Optional[str] = None,
request: Optional[Request] = None) -> dict:
d = { d = {
"id": cal.id, "id": cal.id,
"name": cal.name, # A shared calendar is labelled by the person/group it comes from — the
"color": cal.color, # owner's real calendar name must never reach recipients.
"name": shared_by if (not owned and shared_by is not None) else cal.name,
# A recipient's own colour for a shared calendar wins over the owner's.
"color": color_override or cal.color,
"enabled": cal.enabled, "enabled": cal.enabled,
"sidebar_hidden": bool(cal.sidebar_hidden),
"reminders_enabled": bool(cal.reminders_enabled), "reminders_enabled": bool(cal.reminders_enabled),
"caldav_published": bool(cal.caldav_published),
"is_birthday": bool(cal.is_birthday),
"birthday_notify_days_before": cal.birthday_notify_days_before,
"type": "local", "type": "local",
"owned": owned, "owned": owned,
} }
# Only the owner may publish; expose the subscribe URLs only when active.
if owned and cal.caldav_published and cal.dav_token:
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
if shared_by is not None: if shared_by is not None:
d["shared_by"] = shared_by d["shared_by"] = shared_by
if permission is not None: if permission is not None:
@@ -92,6 +116,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -
@router.get("/calendars") @router.get("/calendars")
def list_calendars( def list_calendars(
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -109,6 +134,9 @@ def list_calendars(
) )
} }
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
color_prefs = permissions.color_prefs_for(db, current_user.id)
# Own calendars # Own calendars
own = ( own = (
db.query(models.LocalCalendar) db.query(models.LocalCalendar)
@@ -117,7 +145,7 @@ def list_calendars(
) )
result = [] result = []
for c in own: for c in own:
d = _cal_dict(c, owned=True) d = _cal_dict(c, owned=True, request=request)
if c.id in group_cal_map: if c.id in group_cal_map:
d["group"] = True d["group"] = True
d["shared_by"] = group_cal_map[c.id] # group name, for labelling d["shared_by"] = group_cal_map[c.id] # group name, for labelling
@@ -141,6 +169,7 @@ def list_calendars(
cal, owned=False, cal, owned=False,
shared_by=(owner.display_name or owner.username) if owner else None, shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission, permission=share.permission,
color_override=color_prefs.get(cal.id),
) )
if cal.id in group_cal_map: if cal.id in group_cal_map:
d["group"] = True d["group"] = True
@@ -155,9 +184,28 @@ def list_calendars(
if not cal: if not cal:
continue continue
seen_ids.add(cal_id) seen_ids.add(cal_id)
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write") d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
color_override=color_prefs.get(cal.id))
d["group"] = True d["group"] = True
result.append(d) result.append(d)
# Calendars co-members share into shared groups (group_visible_calendar_id).
# Read-only, shown under the owner's name. Deduped against everything above
# so a calendar already shared directly / as a group calendar isn't doubled.
for cal in permissions.co_member_group_visible_calendars(db, current_user):
if cal.id in seen_ids:
continue
seen_ids.add(cal.id)
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
d = _cal_dict(
cal, owned=False,
shared_by=(owner.display_name or owner.username) if owner else None,
permission="read",
color_override=color_prefs.get(cal.id),
request=request,
)
d["group_shared"] = True
result.append(d)
return result return result
@@ -167,10 +215,27 @@ def create_calendar(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
# Only ONE birthday calendar per account — hard server guarantee. If the
# user already has one, "create birthday calendar" is idempotent: return the
# existing one instead of creating a second.
if data.is_birthday:
existing = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.is_birthday == True,
)
.first()
)
if existing is not None:
return _cal_dict(existing)
cal = models.LocalCalendar( cal = models.LocalCalendar(
user_id=current_user.id, user_id=current_user.id,
name=data.name, name=data.name,
color=data.color, color=data.color,
is_birthday=data.is_birthday,
birthday_notify_days_before=data.birthday_notify_days_before,
) )
db.add(cal) db.add(cal)
db.commit() db.commit()
@@ -182,6 +247,7 @@ def create_calendar(
def update_calendar( def update_calendar(
calendar_id: int, calendar_id: int,
data: CalendarUpdate, data: CalendarUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -201,10 +267,112 @@ def update_calendar(
cal.color = data.color cal.color = data.color
if data.enabled is not None: if data.enabled is not None:
cal.enabled = data.enabled cal.enabled = data.enabled
if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None: if data.reminders_enabled is not None:
cal.reminders_enabled = data.reminders_enabled cal.reminders_enabled = data.reminders_enabled
if data.is_birthday is not None:
# Never let a second calendar be marked as the birthday calendar.
if data.is_birthday and not cal.is_birthday:
other = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == current_user.id,
models.LocalCalendar.is_birthday == True,
models.LocalCalendar.id != cal.id,
)
.first()
)
if other is not None:
raise HTTPException(422, "A birthday calendar already exists")
cal.is_birthday = data.is_birthday
if data.birthday_notify_days_before is not None:
# -1 is the client's sentinel for "clear the reminder" (JSON has no way to
# send SQL NULL through an Optional that also means "unchanged").
cal.birthday_notify_days_before = (
None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before
)
if data.caldav_published is not None:
cal.caldav_published = data.caldav_published
if data.caldav_published:
# First publish: mint a token + initial ctag so clients can sync.
if not cal.dav_token:
cal.dav_token = dav_util.new_token()
if not cal.dav_ctag:
cal.dav_ctag = dav_util.new_tag()
else:
# Unpublishing revokes access: drop the token so the URL 404s.
cal.dav_token = None
db.commit() db.commit()
return {"ok": True} db.refresh(cal)
return _cal_dict(cal, owned=True, request=request)
class CalendarColorUpdate(BaseModel):
color: str
@router.put("/calendars/{calendar_id}/color")
def set_calendar_color(
calendar_id: int,
data: CalendarColorUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Set a calendar's colour. The owner changes the calendar's colour for
everyone; anyone else who can see the calendar (direct share, group calendar,
or a co-member's group-visible calendar) sets only their OWN per-user colour.
Recipients may recolour but never rename a shared calendar."""
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
if cal is None:
raise HTTPException(404, "Calendar not found")
if cal.user_id == current_user.id:
cal.color = data.color
else:
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
raise HTTPException(403, "You cannot access this calendar")
pref = (
db.query(models.CalendarColorPref)
.filter(
models.CalendarColorPref.calendar_id == calendar_id,
models.CalendarColorPref.user_id == current_user.id,
)
.first()
)
if pref:
pref.color = data.color
else:
db.add(models.CalendarColorPref(
calendar_id=calendar_id, user_id=current_user.id, color=data.color))
db.commit()
return {"ok": True, "color": data.color}
@router.post("/calendars/{calendar_id}/dav-token/rotate")
def rotate_dav_token(
calendar_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Issue a fresh CalDAV token — the old subscribe URL stops working."""
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if not cal.caldav_published:
raise HTTPException(422, "Calendar is not published")
cal.dav_token = dav_util.new_token()
cal.dav_ctag = dav_util.new_tag()
db.commit()
db.refresh(cal)
return _cal_dict(cal, owned=True, request=request)
@router.delete("/calendars/{calendar_id}") @router.delete("/calendars/{calendar_id}")
@@ -241,22 +409,56 @@ def create_event(
db, current_user, data.calendar_id, require_write=True db, current_user, data.calendar_id, require_write=True
) )
ev = models.LocalEvent( # Idempotent on external_uid: repeated syncs of the same contact (stable
calendar_id=cal.id, # "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row
uid=str(uuid.uuid4()), # with this external_uid already exists in this calendar, update it in place
title=data.title, # instead of inserting a new one. Enforced server-side, so the client can
start=data.start, # never duplicate — even without the reconcile read.
end=data.end, existing = None
all_day=data.allDay, if data.external_uid:
location=data.location, existing = (
description=data.description, db.query(models.LocalEvent)
color=data.color, .filter(
rrule=data.rrule, models.LocalEvent.calendar_id == cal.id,
is_private=data.private, models.LocalEvent.external_uid == data.external_uid,
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None), )
creator_id=current_user.id, # server-side, never from the client .first()
) )
db.add(ev)
reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
if existing is not None:
ev = existing
ev.title = data.title
ev.start = data.start
ev.end = data.end
ev.all_day = data.allDay
ev.location = data.location
ev.description = data.description
ev.color = data.color
ev.rrule = data.rrule
ev.is_private = data.private
ev.reminders = reminders
ev.birth_year = data.birth_year
else:
ev = models.LocalEvent(
calendar_id=cal.id,
uid=str(uuid.uuid4()),
title=data.title,
start=data.start,
end=data.end,
all_day=data.allDay,
location=data.location,
description=data.description,
color=data.color,
rrule=data.rrule,
is_private=data.private,
reminders=reminders,
external_uid=data.external_uid,
birth_year=data.birth_year,
creator_id=current_user.id, # server-side, never from the client
)
db.add(ev)
dav_util.bump_dav(cal, ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)
return _event_dict(ev, cal, db) return _event_dict(ev, cal, db)
@@ -305,6 +507,12 @@ def update_event(
ev.exdate = ",".join(dates) ev.exdate = ",".join(dates)
if data.reminders is not None: if data.reminders is not None:
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
if data.external_uid is not None:
ev.external_uid = data.external_uid
if data.birth_year is not None:
# -1 is the client's sentinel for "clear" (year became unknown).
ev.birth_year = None if data.birth_year < 0 else data.birth_year
dav_util.bump_dav(ev.calendar, ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -316,11 +524,48 @@ def delete_event(
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
ev = _writable_event(db, current_user, uid) ev = _writable_event(db, current_user, uid)
dav_util.bump_dav(ev.calendar)
db.delete(ev) db.delete(ev)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.get("/calendars/{calendar_id}/birthdays")
def list_birthday_entries(
calendar_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Raw (unexpanded) rows of a birthday calendar so a client importer can
reconcile against the address book by ``external_uid``. Contact-sourced rows
carry an ``external_uid``; manually added birthdays have ``None`` and must be
left untouched by the importer."""
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
events = (
db.query(models.LocalEvent)
.filter(models.LocalEvent.calendar_id == cal.id)
.all()
)
out = []
for ev in events:
month = day = None
try:
parts = (ev.start or "")[:10].split("-")
if len(parts) == 3:
month, day = int(parts[1]), int(parts[2])
except (ValueError, IndexError):
pass
out.append({
"uid": ev.uid,
"external_uid": ev.external_uid,
"title": ev.title,
"month": month,
"day": day,
"birth_year": ev.birth_year,
})
return out
# ── Sharing (owner only) ────────────────────────────────── # ── Sharing (owner only) ──────────────────────────────────
@router.get("/calendars/{calendar_id}/shares") @router.get("/calendars/{calendar_id}/shares")
@@ -441,9 +686,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
rrule=item.get("rrule"), rrule=item.get("rrule"),
exdate=item.get("exdate"), exdate=item.get("exdate"),
creator_name_external=item.get("organizer"), creator_name_external=item.get("organizer"),
etag=dav_util.new_tag(),
) )
db.add(ev) db.add(ev)
imported += 1 imported += 1
if imported:
dav_util.bump_dav(cal)
try: try:
db.commit() db.commit()
except Exception as exc: except Exception as exc:

View File

@@ -1,6 +1,8 @@
import io import io
import re import re
import base64 import base64
import secrets
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -32,6 +34,7 @@ class ProfileUpdate(BaseModel):
email: Optional[str] = Field(default=None, max_length=120) email: Optional[str] = Field(default=None, max_length=120)
display_name: Optional[str] = Field(default=None, max_length=80) display_name: Optional[str] = Field(default=None, max_length=80)
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase) username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
directory_hidden: Optional[bool] = None # hide from sharing/group pickers
def _strip_controls(s: str) -> str: def _strip_controls(s: str) -> str:
@@ -64,6 +67,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
"is_admin": current_user.is_admin, "is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None, "has_avatar": current_user.avatar_filename is not None,
"totp_enabled": current_user.totp_enabled, "totp_enabled": current_user.totp_enabled,
"directory_hidden": bool(current_user.directory_hidden),
} }
@@ -107,11 +111,15 @@ def update_profile(
if taken: if taken:
raise HTTPException(400, "Username already taken") raise HTTPException(400, "Username already taken")
current_user.username = new_login current_user.username = new_login
if data.directory_hidden is not None:
current_user.directory_hidden = data.directory_hidden
db.commit() db.commit()
# The JWT 'sub' is the login name — renaming it invalidates the old # The JWT 'sub' is the login name — renaming it invalidates the old
# token, so hand back a fresh one for the client to store. # token, so hand back a fresh one for the client to store.
result["access_token"] = create_access_token({"sub": new_login}) result["access_token"] = create_access_token({"sub": new_login})
return result return result
if data.directory_hidden is not None:
current_user.directory_hidden = data.directory_hidden
db.commit() db.commit()
return result return result
@@ -168,7 +176,11 @@ def get_avatar(current_user: models.User = Depends(get_current_user)):
@router.get("/avatar/{user_id}") @router.get("/avatar/{user_id}")
def get_user_avatar(user_id: int, db: Session = Depends(get_db)): def get_user_avatar(
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if not user or not user.avatar_filename: if not user or not user.avatar_filename:
raise HTTPException(404, "Kein Profilbild") raise HTTPException(404, "Kein Profilbild")
@@ -264,3 +276,74 @@ def disable_totp(
current_user.totp_enabled = False current_user.totp_enabled = False
db.commit() db.commit()
return {"ok": True} return {"ok": True}
# ── App passwords (for CalDAV Basic Auth) ────────────────
class AppPasswordCreate(BaseModel):
label: str = Field(default="CalDAV", max_length=100)
def _app_pw_dict(ap: models.AppPassword) -> dict:
return {
"id": ap.id,
"label": ap.label,
"created_at": ap.created_at,
"last_used_at": ap.last_used_at,
}
@router.get("/app-passwords")
def list_app_passwords(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
rows = (
db.query(models.AppPassword)
.filter(models.AppPassword.user_id == current_user.id)
.order_by(models.AppPassword.id.desc())
.all()
)
return [_app_pw_dict(r) for r in rows]
@router.post("/app-passwords")
def create_app_password(
data: AppPasswordCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Show the plaintext exactly once; only the hash is stored.
password = secrets.token_urlsafe(18)
ap = models.AppPassword(
user_id=current_user.id,
label=(data.label or "CalDAV")[:100],
password_hash=get_password_hash(password),
created_at=datetime.now(timezone.utc).isoformat(),
)
db.add(ap)
db.commit()
db.refresh(ap)
out = _app_pw_dict(ap)
out["password"] = password
return out
@router.delete("/app-passwords/{ap_id}")
def delete_app_password(
ap_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ap = (
db.query(models.AppPassword)
.filter(
models.AppPassword.id == ap_id,
models.AppPassword.user_id == current_user.id,
)
.first()
)
if not ap:
raise HTTPException(404, "App password not found")
db.delete(ap)
db.commit()
return {"ok": True}

View File

@@ -1,3 +1,4 @@
import json
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -11,6 +12,56 @@ from database import get_db
router = APIRouter() router = APIRouter()
# Which settings can sync across a user's devices, and the default flag applied to
# an existing/new account until the user overrides it. This map is the single
# authority: GET returns the fully-resolved flags so no client duplicates default
# logic. Rollout rule: settings that already lived on the server default to True;
# the four newly-syncable device-local prefs default to False.
DEFAULT_SYNC = {
"default_view": True,
"week_start_day": True,
"dim_past_events": True,
"hour_height": True,
"primary_color": True,
"accent_color": True,
"today_color": True,
"text_color": True,
"line_color": True,
"bg_color": True,
"month_divider_color": True,
"month_label_color": True,
"default_event_duration_minutes": True,
"default_reminder_minutes": True,
"language": False,
"share_calendar_icon": False,
"cache_months": False,
"month_view_paged": False,
"surface_color": False,
"hover_highlight_color": True,
"icon_inactive_color": True,
"icon_active_color": True,
"day_hover_color": True,
"day_selected_color": True,
"day_bg_color": True,
"today_bg_color": True,
}
def _resolve_sync_flags(s: models.UserSettings) -> dict:
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
top of DEFAULT_SYNC, junk keys dropped."""
stored = {}
if s.sync_flags:
try:
stored = json.loads(s.sync_flags) or {}
except (ValueError, TypeError):
stored = {}
return {
key: bool(stored[key]) if key in stored else default
for key, default in DEFAULT_SYNC.items()
}
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
default_view: Optional[str] = None default_view: Optional[str] = None
week_start_day: Optional[str] = None week_start_day: Optional[str] = None
@@ -27,11 +78,22 @@ class SettingsUpdate(BaseModel):
text_color: Optional[str] = None text_color: Optional[str] = None
line_color: Optional[str] = None line_color: Optional[str] = None
bg_color: Optional[str] = None bg_color: Optional[str] = None
surface_color: Optional[str] = None
hover_highlight_color: Optional[str] = None
icon_inactive_color: Optional[str] = None
icon_active_color: Optional[str] = None
day_hover_color: Optional[str] = None
day_selected_color: Optional[str] = None
day_bg_color: Optional[str] = None
today_bg_color: Optional[str] = None
private_event_visibility: Optional[str] = None private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off default_reminder_minutes: Optional[int] = None # null = off
default_event_duration_minutes: Optional[int] = None default_event_duration_minutes: Optional[int] = None
share_calendar_icon: Optional[str] = None share_calendar_icon: Optional[str] = None
cache_months: Optional[int] = None
month_view_paged: Optional[bool] = None
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -51,11 +113,22 @@ def _settings_dict(s: models.UserSettings) -> dict:
"text_color": s.text_color, "text_color": s.text_color,
"line_color": s.line_color, "line_color": s.line_color,
"bg_color": s.bg_color, "bg_color": s.bg_color,
"surface_color": s.surface_color,
"hover_highlight_color": s.hover_highlight_color,
"icon_inactive_color": s.icon_inactive_color,
"icon_active_color": s.icon_active_color,
"day_hover_color": s.day_hover_color,
"day_selected_color": s.day_selected_color,
"day_bg_color": s.day_bg_color,
"today_bg_color": s.today_bg_color,
"private_event_visibility": s.private_event_visibility or "busy", "private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id, "group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes, "default_reminder_minutes": s.default_reminder_minutes,
"default_event_duration_minutes": s.default_event_duration_minutes or 60, "default_event_duration_minutes": s.default_event_duration_minutes or 60,
"share_calendar_icon": s.share_calendar_icon, "share_calendar_icon": s.share_calendar_icon,
"cache_months": s.cache_months or 3,
"month_view_paged": bool(s.month_view_paged),
"sync_flags": _resolve_sync_flags(s),
} }
@@ -95,11 +168,42 @@ def update_settings(
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"): if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'") raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
# A birthday calendar must never become the group-visible ("personal")
# calendar — it may be shared directly, but not stand in as your calendar in
# group views. Clients filter it out of the picker; this is the safety net.
if data.group_visible_calendar_id:
bcal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == data.group_visible_calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if bcal is not None and bcal.is_birthday:
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
# For these three override colours, an explicit null is meaningful # For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields # ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored. # keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"} NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
# syncable keys are kept; a partial map leaves untouched flags as they were.
if "sync_flags" in update_data:
incoming = update_data.pop("sync_flags") or {}
current = {}
if settings.sync_flags:
try:
current = json.loads(settings.sync_flags) or {}
except (ValueError, TypeError):
current = {}
for key, val in incoming.items():
if key in DEFAULT_SYNC:
current[key] = bool(val)
settings.sync_flags = json.dumps(current)
for field, value in update_data.items(): for field, value in update_data.items():
if field in NULLABLE_OVERRIDES: if field in NULLABLE_OVERRIDES:
setattr(settings, field, value or None) setattr(settings, field, value or None)

View File

@@ -23,6 +23,10 @@ class ChangePasswordRequest(BaseModel):
password: str password: str
class SetAdminRequest(BaseModel):
is_admin: bool
def _user_dict(u: models.User) -> dict: def _user_dict(u: models.User) -> dict:
return { return {
"id": u.id, "id": u.id,
@@ -53,7 +57,10 @@ def user_directory(
""" """
users = ( users = (
db.query(models.User) db.query(models.User)
.filter(models.User.id != current_user.id) .filter(
models.User.id != current_user.id,
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
)
.order_by(models.User.username) .order_by(models.User.username)
.all() .all()
) )
@@ -99,6 +106,28 @@ def delete_user(
return {"ok": True} return {"ok": True}
@router.put("/{user_id}/admin")
def set_admin(
user_id: int,
req: SetAdminRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_admin),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot change your own admin status")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
# Never leave the instance without an admin.
if user.is_admin and not req.is_admin:
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
if admin_count <= 1:
raise HTTPException(400, "At least one admin must remain")
user.is_admin = req.is_admin
db.commit()
return {"ok": True}
@router.put("/{user_id}/password") @router.put("/{user_id}/password")
def change_password( def change_password(
user_id: int, user_id: int,

View File

@@ -409,3 +409,106 @@ def test_import_export_only_local(client):
cal_id = _make_calendar(client, admin, "Privat") cal_id = _make_calendar(client, admin, "Privat")
# Bob has no access -> 404 on export. # Bob has no access -> 404 on export.
assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404 assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
# ── Group-visible calendars propagate to co-members' sidebars ─────────────
def test_group_visible_propagates_to_member_sidebar(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
# Bob designates a calendar as group-visible; admin (co-member) should see it.
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Bobs Termin")
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
shared = [c for c in cals if c["id"] == b_cal]
assert len(shared) == 1, shared
assert shared[0]["owned"] is False
assert shared[0]["shared_by"] == "bob"
assert shared[0]["permission"] == "read"
assert shared[0].get("group_shared") is True
# Its events appear in the normal merged read, read-only.
events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
bob_ev = [e for e in events if e["title"] == "Bobs Termin"]
assert bob_ev and bob_ev[0].get("read_only") is True
def test_group_visible_absent_when_not_designated(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
b_cal = _make_calendar(client, b_tok, "Bobs Kalender") # never designated
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
assert not any(c["id"] == b_cal for c in cals)
def test_group_visible_not_duplicated_with_direct_share(client):
admin = register_admin(client)
admin_id = client.get("/api/profile/", headers=auth(admin)).json()["id"]
b_id, b_tok = create_user(client, admin, "bob")
client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]})
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
# Also directly shared with admin (read_write) — must not double-list.
client.post(f"/api/local/calendars/{b_cal}/shares", headers=auth(b_tok),
json={"user_id": admin_id, "permission": "read_write"})
cals = client.get("/api/local/calendars", headers=auth(admin)).json()
matches = [c for c in cals if c["id"] == b_cal]
assert len(matches) == 1, matches
# The direct share wins (read_write, not flagged group_shared).
assert matches[0]["permission"] == "read_write"
assert matches[0].get("group_shared") is not True
# ── Hidden profile (directory opt-out) ────────────────────────────────────
def test_directory_hidden_excludes_from_picker_but_not_admin(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
assert any(u["id"] == b_id for u in
client.get("/api/users/directory", headers=auth(admin)).json())
r = client.put("/api/profile/", headers=auth(b_tok), json={"directory_hidden": True})
assert r.status_code == 200, r.text
assert not any(u["id"] == b_id for u in
client.get("/api/users/directory", headers=auth(admin)).json())
# Admin user management still lists the hidden user.
assert any(u["id"] == b_id for u in
client.get("/api/users/", headers=auth(admin)).json())
def test_combined_view_read_only_for_other_members(client):
"""In the group combined view, events I may not edit carry read_only=True:
other members' calendars are read-only; the group calendar + my own aren't."""
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
group = client.post("/api/groups/", headers=auth(admin),
json={"name": "Team", "member_ids": [b_id]}).json()
gid = group["id"]
gcal = group["group_calendar_id"]
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
_make_event(client, b_tok, b_cal, "Bobs Termin")
_make_event(client, admin, gcal, "Gruppentermin")
# As admin: bob's event is read-only; the group calendar is editable.
by = {e["title"]: e for e in
client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]}
assert by["Bobs Termin"].get("read_only") is True
assert by["Gruppentermin"].get("read_only") is not True
# As bob: his own event and the group calendar are both editable.
by_b = {e["title"]: e for e in
client.get(f"/api/groups/{gid}/combined", headers=auth(b_tok), params=RANGE).json()["events"]}
assert by_b["Bobs Termin"].get("read_only") is not True
assert by_b["Gruppentermin"].get("read_only") is not True

View File

@@ -23,6 +23,17 @@
--border-light: #242438; --border-light: #242438;
--scrollbar: #30303c; --scrollbar: #30303c;
/* Fine-grained element colours (customisable via Settings → Farben).
Defaults reference the derived values so the look is unchanged until the
user overrides them; applyTheme() writes concrete overrides on top. */
--hover-highlight: var(--bg-hover); /* general interactive hover */
--icon-inactive-color: var(--text-2); /* sidebar action icons: resting/off/not-hovered */
--icon-active-color: var(--text-1); /* sidebar action icons: hovered / on / pressed */
--day-hover-color: var(--bg-hover); /* calendar day-cell hover */
--day-selected-base: var(--primary); /* selected day (tinted via color-mix) */
--day-bg: transparent; /* normal day background */
--today-bg-base: var(--today-color); /* today's day (tinted via color-mix) */
--topbar-h: 64px; --topbar-h: 64px;
--sidebar-w: 256px; --sidebar-w: 256px;
--shadow: 0 2px 12px rgba(0,0,0,.45); --shadow: 0 2px 12px rgba(0,0,0,.45);
@@ -52,6 +63,9 @@ input, textarea, [contenteditable="true"], .selectable {
-webkit-user-select: text; user-select: text; -webkit-user-select: text; user-select: text;
} }
a { color: var(--primary); text-decoration: none; } a { color: var(--primary); text-decoration: none; }
/* Version im Impressum verlinkt aufs Gitea-Repo — dezent, erst beim Hover als Link erkennbar */
.impressum-version-link { color: var(--text-3); text-decoration: none; transition: color .15s ease; }
.impressum-version-link:hover { color: var(--primary); text-decoration: underline; }
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; }
@@ -165,6 +179,28 @@ a { color: var(--primary); text-decoration: none; }
} }
.btn-fab:active { transform: translateY(0) scale(.985); } .btn-fab:active { transform: translateY(0) scale(.985); }
/* Split create button: one seamless pill — main "Erstellen" action on the left,
a caret section on the right that opens a small menu (new event / new birthday). */
.create-split {
display: flex; align-items: stretch; margin: 16px 12px 8px;
border-radius: 999px;
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
}
.create-split .btn-fab { margin: 0; box-shadow: none; border-radius: 0; }
.create-split .btn-fab:hover { transform: none; box-shadow: none; filter: brightness(1.08); }
.create-split .create-main { flex: 1; justify-content: center; border-radius: 999px 0 0 999px; }
.create-split .create-caret {
flex: 0 0 auto; padding: 12px 14px; border-radius: 0 999px 999px 0;
border-left: 1px solid color-mix(in srgb, #fff 25%, transparent);
}
.create-split .create-caret svg { width: 18px; height: 18px; }
.create-menu { left: 0; right: auto; min-width: 200px; }
/* Birthday modal: day / month / year row */
.birthday-date-row { display: flex; gap: 8px; }
.birthday-date-row select { flex: 1; }
.birthday-date-row #birthday-year { width: 90px; flex: 0 0 auto; }
/* Circular icon buttons (topbar nav, modal close, etc.) */ /* Circular icon buttons (topbar nav, modal close, etc.) */
.icon-btn { .icon-btn {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
@@ -179,7 +215,7 @@ a { color: var(--primary); text-decoration: none; }
transform .1s ease; transform .1s ease;
} }
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; } .icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); } .icon-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
.icon-btn:active { transform: scale(.92); } .icon-btn:active { transform: scale(.92); }
.icon-btn:focus-visible { .icon-btn:focus-visible {
outline: 2px solid var(--primary); outline: 2px solid var(--primary);
@@ -288,7 +324,7 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 9px 10px; border-radius: 8px; cursor: pointer; padding: 9px 10px; border-radius: 8px; cursor: pointer;
} }
.cal-select-option:hover { background: var(--bg-hover); } .cal-select-option:hover { background: var(--hover-highlight); }
.cal-select-option.selected { background: var(--primary-dim); } .cal-select-option.selected { background: var(--primary-dim); }
/* ── Date/time input dark mode ──────────────────────────── */ /* ── Date/time input dark mode ──────────────────────────── */
@@ -459,7 +495,7 @@ a { color: var(--primary); text-decoration: none; }
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
border-radius: 20px; border-radius: 20px;
} }
.view-btn:hover { background: var(--bg-hover); color: var(--text-1); } .view-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
.view-btn.active { background: var(--primary-dim); color: var(--primary); } .view-btn.active { background: var(--primary-dim); color: var(--primary); }
.user-menu-wrapper { position: relative; } .user-menu-wrapper { position: relative; }
@@ -488,7 +524,7 @@ a { color: var(--primary); text-decoration: none; }
background: none; color: var(--text-2); font-size: 13px; background: none; color: var(--text-2); font-size: 13px;
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
} }
.dropdown-item:hover { background: var(--bg-hover); color: var(--text-1); } .dropdown-item:hover { background: var(--hover-highlight); color: var(--text-1); }
.dropdown-item svg { flex-shrink: 0; .dropdown-item svg { flex-shrink: 0;
} }
@@ -533,7 +569,7 @@ a { color: var(--primary); text-decoration: none; }
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); } .mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); }
.mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); } .mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); }
.mini-btn:hover { color: var(--text-1); background: var(--bg-hover); } .mini-btn:hover { color: var(--text-1); background: var(--hover-highlight); }
.mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; } .mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
.mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; } .mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; }
.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; } .mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
@@ -544,13 +580,13 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
margin: 1px auto; position: relative; margin: 1px auto; position: relative;
} }
.mini-day:hover { background: var(--bg-hover); color: var(--text-1); } .mini-day:hover { background: var(--day-hover-color); color: var(--text-1); }
.mini-day.other-month { color: var(--text-3); } .mini-day.other-month { color: var(--text-3); }
.mini-day.today { .mini-day.today {
background: var(--today-color); background: var(--today-color);
color: #fff; font-weight: 700; color: #fff; font-weight: 700;
} }
.mini-day.selected:not(.today) { background: var(--primary-dim); color: var(--primary); font-weight: 600; } .mini-day.selected:not(.today) { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); color: var(--primary); font-weight: 600; }
.mini-day.has-events::after { .mini-day.has-events::after {
content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
width: 4px; height: 4px; border-radius: 50%; background: var(--primary); width: 4px; height: 4px; border-radius: 50%; background: var(--primary);
@@ -577,7 +613,7 @@ a { color: var(--primary); text-decoration: none; }
text-align: left; font-size: 13px; color: var(--text-1); text-align: left; font-size: 13px; color: var(--text-1);
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
} }
.add-cal-dropdown button:hover { background: var(--bg-hover); } .add-cal-dropdown button:hover { background: var(--hover-highlight); }
.cal-item { .cal-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 6px 16px; cursor: pointer; padding: 6px 16px; cursor: pointer;
@@ -585,7 +621,7 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 0 20px 20px 0; border-radius: 0 20px 20px 0;
margin-right: 12px; margin-right: 12px;
} }
.cal-item:hover { background: var(--bg-hover); } .cal-item:hover { background: var(--hover-highlight); }
.cal-item-dot { .cal-item-dot {
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer; width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
} }
@@ -602,11 +638,16 @@ a { color: var(--primary); text-decoration: none; }
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; } .cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
/* Fixed min-height so 28px mini-btns never change row height when they appear. */ /* Fixed min-height so 28px mini-btns never change row height when they appear. */
.cal-item { position: relative; min-height: 40px; } .cal-item { position: relative; min-height: 40px; }
.cal-item-bell { display: none; flex-shrink: 0; } /* Sidebar action icons (bell / hide / delete / read-only flag) share two theme
colours: "inactive" (resting/off) and "active" (hovered/on/pressed). */
.cal-item-bell { display: none; flex-shrink: 0; color: var(--icon-active-color); } /* bell shown = reminders on = active */
.cal-item:hover .cal-item-remove, .cal-item:hover .cal-item-remove,
.cal-item:hover .cal-item-bell { display: inline-flex; } .cal-item:hover .cal-item-bell { display: inline-flex; }
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--text-3); } .cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--icon-inactive-color); }
.cal-item:hover .cal-item-bell.off { opacity: 1; color: inherit; } .cal-item:hover .cal-item-bell.off { opacity: 1; color: var(--icon-inactive-color); }
.cal-item-bell:hover, .cal-item-bell.off:hover { color: var(--icon-active-color); }
.cal-item-remove { color: var(--icon-inactive-color); }
.cal-item-remove:hover { color: var(--icon-active-color); }
/* ── Month View ─────────────────────────────────────────── */ /* ── Month View ─────────────────────────────────────────── */
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
@@ -639,11 +680,12 @@ a { color: var(--primary); text-decoration: none; }
flex: 1; border-right: 1px solid var(--border); flex: 1; border-right: 1px solid var(--border);
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
padding: 4px 4px 0; min-width: 0; padding: 4px 4px 0; min-width: 0;
background: var(--day-bg);
} }
.month-col:last-child { border-right: none; } .month-col:last-child { border-right: none; }
.month-col:hover { background: var(--bg-hover); } .month-col:hover { background: var(--day-hover-color); }
.month-col.today { background: color-mix(in srgb, var(--today-color) 10%, transparent); } .month-col.today { background: color-mix(in srgb, var(--today-bg-base) 10%, transparent); }
.month-col.month-selected { background: var(--primary-dim); } .month-col.month-selected { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); }
.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; } .month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; }
.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; } .month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; }
.month-col.other-month .cell-day { color: var(--text-3); } .month-col.other-month .cell-day { color: var(--text-3); }
@@ -764,7 +806,7 @@ a { color: var(--primary); text-decoration: none; }
padding: 4px 12px; cursor: pointer; padding: 4px 12px; cursor: pointer;
border-radius: 6px; margin: 0 4px; border-radius: 6px; margin: 0 4px;
} }
.mop-row:hover { background: var(--bg-hover); } .mop-row:hover { background: var(--hover-highlight); }
.mop-dot { .mop-dot {
width: 8px; height: 8px; border-radius: 50%; width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
@@ -825,7 +867,7 @@ a { color: var(--primary); text-decoration: none; }
border-left: 1px solid var(--border); cursor: pointer; border-left: 1px solid var(--border); cursor: pointer;
transition: background var(--transition); transition: background var(--transition);
} }
.week-day-header:hover { background: var(--bg-hover); } .week-day-header:hover { background: var(--day-hover-color); }
.week-day-header .day-name { .week-day-header .day-name {
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-2); letter-spacing: .5px; color: var(--text-2);
@@ -977,7 +1019,7 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 4px; border-radius: 4px;
min-height: 36px; min-height: 36px;
} }
.qtr-cell:hover { background: var(--bg-hover); } .qtr-cell:hover { background: var(--day-hover-color); }
.qtr-cell.today .qtr-day-num { .qtr-cell.today .qtr-day-num {
background: var(--today-color, var(--primary)); background: var(--today-color, var(--primary));
color: #fff; color: #fff;
@@ -1040,7 +1082,7 @@ a { color: var(--primary); text-decoration: none; }
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
margin-left: 54px; margin-bottom: 4px; margin-left: 54px; margin-bottom: 4px;
} }
.agenda-event:hover { background: var(--bg-hover); } .agenda-event:hover { background: var(--day-hover-color); }
.agenda-event.past { opacity: .45; } .agenda-event.past { opacity: .45; }
.agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; } .agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
.agenda-ev-info { flex: 1; } .agenda-ev-info { flex: 1; }
@@ -1092,7 +1134,7 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
} }
.rec-day-btn:hover { background: var(--bg-hover); } .rec-day-btn:hover { background: var(--hover-highlight); }
.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); } .rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
/* ── Day Context Menu ──────────────────────────────────── */ /* ── Day Context Menu ──────────────────────────────────── */
@@ -1105,7 +1147,7 @@ a { color: var(--primary); text-decoration: none; }
.ctx-item { .ctx-item {
padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer; padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer;
} }
.ctx-item:hover { background: var(--bg-hover); } .ctx-item:hover { background: var(--hover-highlight); }
/* ── Event Popup ────────────────────────────────────────── /* ── Event Popup ──────────────────────────────────────────
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben. Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
@@ -1229,7 +1271,7 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 10px; border-radius: 10px;
transition: background var(--transition); transition: background var(--transition);
} }
.popup-copy-item:hover { background: var(--bg-hover); } .popup-copy-item:hover { background: var(--hover-highlight); }
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.popup-copy-edit-toggle { .popup-copy-edit-toggle {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -1244,6 +1286,10 @@ a { color: var(--primary); text-decoration: none; }
/* ── Settings Page ──────────────────────────────────────── */ /* ── Settings Page ──────────────────────────────────────── */
#modal-settings.modal-overlay { #modal-settings.modal-overlay {
align-items: stretch; justify-content: stretch; padding: 0; background: var(--bg-app); align-items: stretch; justify-content: stretch; padding: 0; background: var(--bg-app);
/* Full-screen opaque "page". Sit BELOW real modals (z-index 500) so dialogs
opened from inside settings — share, add-account, color picker — appear on
top instead of behind this page (that's why the Share button "did nothing"). */
z-index: 400;
} }
.settings-page-card { .settings-page-card {
width: 100%; height: 100%; width: 100%; height: 100%;
@@ -1254,6 +1300,7 @@ a { color: var(--primary); text-decoration: none; }
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 14px 20px; border-bottom: 1px solid var(--border); padding: 14px 20px; border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-topbar);
} }
.settings-page-header h3 { font-size: 16px; font-weight: 600; color: var(--text-1); margin: 0; } .settings-page-header h3 { font-size: 16px; font-weight: 600; color: var(--text-1); margin: 0; }
.settings-page-body { .settings-page-body {
@@ -1264,6 +1311,7 @@ a { color: var(--primary); text-decoration: none; }
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 12px 8px; padding: 12px 8px;
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
background: var(--bg-sidebar);
} }
.settings-nav-btn { .settings-nav-btn {
display: block; width: 100%; text-align: left; display: block; width: 100%; text-align: left;
@@ -1272,13 +1320,18 @@ a { color: var(--primary); text-decoration: none; }
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
transition: background .15s, color .15s; transition: background .15s, color .15s;
} }
.settings-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); } .settings-nav-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
.settings-nav-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; } .settings-nav-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; }
.settings-panels { .settings-panels {
flex: 1; overflow-y: auto; padding: 24px 28px; flex: 1; overflow-y: auto; padding: 24px 28px;
} }
.settings-panel { display: none; } .settings-panel { display: none; }
.settings-panel.active { display: block; } /* Constrain content to a comfortable reading column so fields don't stretch
edge-to-edge on wide screens. */
.settings-panel.active { display: block; max-width: 680px; }
/* …except the Kalender panel, whose wide multi-column table needs the full
width (otherwise it's squeezed into a horizontal scroll). */
#settings-panel-accounts { max-width: none; }
/* Panel typography */ /* Panel typography */
.panel-title { .panel-title {
@@ -1305,7 +1358,7 @@ a { color: var(--primary); text-decoration: none; }
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
} }
.contrast-btn:last-child { border-right: none; } .contrast-btn:last-child { border-right: none; }
.contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); } .contrast-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
.contrast-btn.active { background: var(--primary); color: #fff; } .contrast-btn.active { background: var(--primary); color: #fff; }
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; } .contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
.contrast-btn.active span { color: #fff !important; } .contrast-btn.active span { color: #fff !important; }
@@ -1321,6 +1374,131 @@ a { color: var(--primary); text-decoration: none; }
} }
.contrast-btn.active .hour-preview { color: #fff; } .contrast-btn.active .hour-preview { color: #fff; }
/* ── Settings sync table (Sync-Icon | Name | Wert) ─────────── */
.sync-global-row {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 12px 14px; margin-bottom: 12px;
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
}
.sync-global-text { display: flex; flex-direction: column; gap: 2px; }
.sync-global-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.sync-global-row .panel-desc { margin: 0; }
.settings-table { display: flex; flex-direction: column; }
.theme-io-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.theme-io-help {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
background: var(--bg-hover); color: var(--text-2);
font-size: 12px; font-weight: 700; text-decoration: none;
}
.theme-io-help:hover { background: var(--hover-highlight); color: var(--text-1); }
/* Custom instance logo (replaces the glyph+text). Hard-capped so it can only
scale DOWN and never pushes the topbar/auth layout. */
.topbar-logo-img { max-height: 40px; max-width: 150px; width: auto; height: auto; object-fit: contain; display: block; }
.auth-logo-img { max-height: 56px; max-width: 220px; width: auto; height: auto; object-fit: contain; display: block; }
/* Admin panel: default-theme editor + branding */
.admin-theme-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.admin-theme-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
.admin-theme-name { font-size: 13px; color: var(--text-2); }
.admin-theme-actions { display: flex; gap: 8px; margin-top: 14px; }
.admin-brand-row { display: flex; align-items: center; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
.admin-brand-label { width: 70px; font-size: 13px; color: var(--text-2); flex-shrink: 0; }
.admin-brand-preview {
width: 48px; height: 48px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-hover); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); overflow: hidden;
}
/* Logo preview mirrors its real topbar footprint (max 150×40). */
.admin-brand-preview-logo { width: 150px; height: 40px; }
.admin-brand-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.admin-brand-controls { display: flex; flex-direction: column; gap: 4px; }
.admin-brand-btns { display: flex; gap: 8px; }
.admin-brand-dims { font-size: 11px; color: var(--text-3); }
.settings-table-section {
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--text-3); margin: 18px 0 6px; padding: 0 2px;
}
.settings-table-section:first-child { margin-top: 0; }
.settings-row {
display: grid; grid-template-columns: 40px 1fr minmax(140px, auto);
align-items: center; gap: 12px;
padding: 10px 4px; border-bottom: 1px solid var(--border-light);
}
.settings-row:last-child { border-bottom: none; }
.settings-row-name { font-size: 14px; color: var(--text-1); }
.settings-row-value { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.settings-row-value select {
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
color: var(--text-1);
outline: none;
cursor: pointer;
color-scheme: dark;
min-width: 130px; max-width: 100%;
transition: border-color var(--transition);
}
.settings-row-value select:hover:not(:focus) { border-color: var(--text-3); }
.settings-row-value select:focus { border-color: var(--primary); }
/* Sync toggle switch — used per row and (larger) globally */
.sync-toggle {
position: relative; flex: 0 0 auto;
width: 34px; height: 20px; padding: 0;
border: none; border-radius: 999px; cursor: pointer;
background: var(--bg-active); transition: background var(--transition);
}
.sync-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: transform var(--transition);
}
.sync-toggle.on { background: var(--primary); }
.sync-toggle.on::after { transform: translateX(14px); }
.sync-toggle-lg { width: 44px; height: 26px; }
.sync-toggle-lg::after { width: 22px; height: 22px; }
.sync-toggle-lg.on::after { transform: translateX(18px); }
.settings-row .sync-toggle { justify-self: center; }
/* Per-row sync toggle icon: ON = primary + pill, OFF = muted + slashed icon */
.sync-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 28px; padding: 0;
border: none; border-radius: 8px; cursor: pointer;
background: transparent; color: var(--text-3);
transition: color var(--transition), background var(--transition);
justify-self: center;
}
.sync-icon:hover { background: var(--hover-highlight); color: var(--text-2); }
.sync-icon.on { color: var(--primary); background: var(--primary-dim); }
.sync-icon .si-on, .sync-icon .si-off { align-items: center; }
.sync-icon .si-on { display: none; }
.sync-icon .si-off { display: inline-flex; }
.sync-icon.on .si-off { display: none; }
.sync-icon.on .si-on { display: inline-flex; }
/* Per-row colour control: swatch + hex + reset, right-aligned */
.settings-color-ctl { display: flex; align-items: center; gap: 12px; }
.settings-color-ctl .ev-color-hex { width: 92px; }
.settings-color-ctl .ev-color-preview { margin-left: 2px; }
.settings-color-ctl .ev-color-reset {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; color: var(--text-3);
}
/* Inline share-icon picker inside a value cell — one even row */
.settings-icon-ctl { display: flex; flex-wrap: nowrap; gap: 6px; justify-content: flex-end; }
@media (max-width: 640px) {
.settings-row { grid-template-columns: 34px 1fr; grid-auto-rows: auto; }
.settings-row-value { grid-column: 1 / -1; justify-content: flex-start; padding-left: 46px; }
}
/* ── Settings (legacy) ──────────────────────────────────── */ /* ── Settings (legacy) ──────────────────────────────────── */
.settings-section { margin-bottom: 28px; } .settings-section { margin-bottom: 28px; }
.settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } .settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
@@ -1447,7 +1625,18 @@ a { color: var(--primary); text-decoration: none; }
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
} }
.cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; } .cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
/* Fixed layout + narrow drags must clip, not overlap the next column (Excel-like). */
.cal-manage-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Full-width detail/section rows (account headers, CalDAV boxes, empty state) wrap normally. */
.cal-manage-table td[colspan] { overflow: visible; white-space: normal; }
.cal-manage-table tbody tr:last-child td { border-bottom: none; } .cal-manage-table tbody tr:last-child td { border-bottom: none; }
/* Drag-resizable columns: a grab handle on each header's right edge. */
.cal-manage-table th { position: relative; }
.cal-manage-table th .col-resizer {
position: absolute; top: 0; right: 0; width: 8px; height: 100%;
cursor: col-resize; user-select: none; touch-action: none;
}
.cal-manage-table th .col-resizer:hover { background: var(--border); }
.ct-acc-row td { padding-top: 14px; background: none; } .ct-acc-row td { padding-top: 14px; background: none; }
.ct-acc-row td:first-child { font-size: 13px; } .ct-acc-row td:first-child { font-size: 13px; }
.ct-cal-row .ct-indent { padding-left: 16px; } .ct-cal-row .ct-indent { padding-left: 16px; }
@@ -1462,6 +1651,28 @@ a { color: var(--primary); text-decoration: none; }
.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; } .ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; }
.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; } .ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
.ct-toggle { cursor: pointer; } .ct-toggle { cursor: pointer; }
.ct-dav-toggle.on { color: var(--primary); }
.ct-dav-row td { padding-top: 4px; padding-bottom: 12px; }
.ct-dav-box { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ct-dav-label { font-size: 12px; color: var(--text-3); }
.ct-dav-url {
flex: 1; min-width: 220px; font-size: 12px; padding: 5px 8px;
border: 1px solid var(--border); border-radius: 6px;
background: var(--surface-2); color: var(--text-1);
}
.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; }
/* Row (not the column inherited from .form-group) so the "Erstellen" button
sits inline to the right of the name field instead of centred below it. */
.app-pw-create { display: flex; flex-direction: row; gap: 8px; align-items: center; }
.app-pw-create input { flex: 1; }
/* Consistent placement for a section's action button: right-aligned, matching
the sticky header save and the app-password create row. */
.settings-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
.app-pw-new { margin: 8px 0; }
.app-pw-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid var(--border); }
.app-pw-name { font-weight: 500; }
.app-pw-meta { font-size: 11px; color: var(--text-3); margin-left: auto; }
.ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; } .ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; }
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; } .ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
.ct-eye:hover, .ct-bell:hover { opacity: 1; } .ct-eye:hover, .ct-bell:hover { opacity: 1; }
@@ -1555,7 +1766,7 @@ a { color: var(--primary); text-decoration: none; }
padding: 2px 6px; border-radius: var(--radius-sm); padding: 2px 6px; border-radius: var(--radius-sm);
transition: background var(--transition), color var(--transition); transition: background var(--transition), color var(--transition);
} }
.dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); } .dtp-nav-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
/* Day-of-week headers */ /* Day-of-week headers */
.dtp-grid { .dtp-grid {
display: grid; grid-template-columns: repeat(7, 1fr); display: grid; grid-template-columns: repeat(7, 1fr);
@@ -1572,9 +1783,9 @@ a { color: var(--primary); text-decoration: none; }
font-size: 13px; font-weight: 500; color: var(--text-1); font-size: 13px; font-weight: 500; color: var(--text-1);
cursor: pointer; transition: background var(--transition); cursor: pointer; transition: background var(--transition);
} }
.dtp-day:hover { background: var(--bg-hover); } .dtp-day:hover { background: var(--day-hover-color); }
.dtp-day.other { color: var(--text-3); } .dtp-day.other { color: var(--text-3); }
.dtp-day.other:hover { background: var(--bg-hover); } .dtp-day.other:hover { background: var(--day-hover-color); }
.dtp-day.today { color: var(--primary); font-weight: 700; } .dtp-day.today { color: var(--primary); font-weight: 700; }
.dtp-day.selected { .dtp-day.selected {
background: var(--primary) !important; background: var(--primary) !important;
@@ -1986,12 +2197,57 @@ a { color: var(--primary); text-decoration: none; }
border-radius: 10px; border-radius: 10px;
} }
.share-user-item { .share-user-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; padding: 10px 14px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.share-user-item:last-child { border-bottom: none; } .share-user-item:last-child { border-bottom: none; }
.share-user-item:hover { background: var(--bg-surface); } .share-user-item:hover { background: var(--bg-surface); }
.share-user-item input[type=checkbox] {
flex-shrink: 0; width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer;
}
/* Add-user row: match the app's dark inputs instead of raw white browser
controls, with a subtle focus ring for a modern feel. */
#share-user-search,
#share-permission {
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
color: var(--text-1);
font-size: 14px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
#share-user-search::placeholder { color: var(--text-3); }
#share-permission { cursor: pointer; }
#share-user-search:focus,
#share-permission:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15);
}
/* "Shared with me" section header row inside the calendar-management table */
.ct-section-row td {
padding-top: 14px; font-size: 12px; font-weight: 600;
color: var(--text-3); text-transform: uppercase; letter-spacing: .04em;
}
/* Higher specificity than the global ".form-group label" (uppercase / spaced /
12px) so a checkbox label reads as normal inline text next to its box, not a
stretched uppercase column. */
.form-group label.checkbox-row,
.checkbox-row {
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
cursor: pointer; text-transform: none; letter-spacing: normal;
font-size: 14px; font-weight: 400; color: var(--text-1);
}
/* Undo the global ".form-group input { width:100%; padding; border }" so the
checkbox stays a small native box instead of a full-width field that shoves
the label text into a narrow, wrapping column. */
.checkbox-row input[type=checkbox] {
flex: none; width: 16px; height: 16px; margin: 0; padding: 0;
border: 0; border-radius: 0; background: none; accent-color: var(--primary);
}
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */ /* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
/* ── Groups ─────────────────────────────────────────────────── */ /* ── Groups ─────────────────────────────────────────────────── */
@@ -2123,6 +2379,8 @@ a { color: var(--primary); text-decoration: none; }
/* Group emoji + icon picker */ /* Group emoji + icon picker */
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; } .group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; } .cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
/* Read-only (shared with me) indicator: a struck-through pencil. */
.cal-readonly-flag { color: var(--icon-inactive-color); opacity: .7; margin-left: 2px; }
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; } .group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
.group-icon-opt { .group-icon-opt {
width: 38px; height: 38px; width: 38px; height: 38px;

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2ea05a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/> <rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/> <line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/> <line x1="8" y1="2" x2="8" y2="6"/>

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#4285f4"/> <rect width="512" height="512" fill="#16713d"/>
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
<rect x="102" y="154" width="307" height="266" rx="26"/> <rect x="102" y="154" width="307" height="266" rx="26"/>
<line x1="102" y1="234" x2="409" y2="234"/> <line x1="102" y1="234" x2="409" y2="234"/>

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -7,7 +7,8 @@
<title>Calendarr</title> <title>Calendarr</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4285f4" /> <meta name="theme-color" content="#16713d" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Calendarr" /> <meta name="apple-mobile-web-app-title" content="Calendarr" />
@@ -158,10 +159,19 @@
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-inner"> <div class="sidebar-inner">
<button class="btn btn-fab" id="btn-create-event"> <div class="create-split add-cal-dropdown-wrap">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> <button class="btn btn-fab create-main" id="btn-create-event">
<span data-i18n="btn_create">Erstellen</span> <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button> <span data-i18n="btn_create">Erstellen</span>
</button>
<button class="btn btn-fab create-caret" id="btn-create-menu" data-i18n-title="birthday_new" title="Neuer Geburtstag" aria-haspopup="menu" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div class="add-cal-dropdown create-menu hidden" id="create-menu" role="menu">
<button data-action="event" data-i18n="create_event_option">Neuer Termin</button>
<button data-action="birthday" data-i18n="birthday_new">Neuer Geburtstag</button>
</div>
</div>
<!-- Mini Calendar --> <!-- Mini Calendar -->
<div class="mini-cal" id="mini-cal"> <div class="mini-cal" id="mini-cal">
@@ -197,6 +207,7 @@
</button> </button>
<div class="add-cal-dropdown hidden" id="add-cal-dropdown"> <div class="add-cal-dropdown hidden" id="add-cal-dropdown">
<button data-action="local">Lokaler Kalender</button> <button data-action="local">Lokaler Kalender</button>
<button data-action="birthday" data-i18n="birthday_calendar_type">Geburtstagskalender</button>
<button data-action="caldav">CalDAV-Konto</button> <button data-action="caldav">CalDAV-Konto</button>
<button data-action="ical">iCal-URL abonnieren</button> <button data-action="ical">iCal-URL abonnieren</button>
<button data-action="google">Google Kalender</button> <button data-action="google">Google Kalender</button>
@@ -477,6 +488,24 @@
</div> </div>
</div> </div>
<!-- Generic Confirm Dialog (styled replacement for window.confirm) -->
<div id="modal-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 id="confirm-title">Bestätigen</h3>
<button class="icon-btn modal-close" data-modal="modal-confirm">&times;</button>
</div>
<div class="modal-body">
<p id="confirm-text"></p>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-ghost" id="confirm-cancel" data-modal="modal-confirm" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-danger" id="confirm-ok" data-i18n="delete">Löschen</button>
</div>
</div>
</div>
<!-- Event Detail Popup --> <!-- Event Detail Popup -->
<div id="popup-event" class="event-popup hidden"> <div id="popup-event" class="event-popup hidden">
<div class="popup-header"> <div class="popup-header">
@@ -589,6 +618,44 @@
</div> </div>
</div> </div>
<!-- Birthday modal -->
<div id="modal-birthday" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 data-i18n="birthday_modal_title">Neuen Geburtstag hinzufügen</h3>
<button class="icon-btn modal-close" data-modal="modal-birthday">&times;</button>
</div>
<div class="modal-body">
<div id="birthday-no-cal" class="hidden">
<div class="form-hint" data-i18n="birthday_activate_hint">Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.</div>
<button class="btn btn-primary btn-sm" id="birthday-activate" data-i18n="birthday_activate">Geburtstagskalender aktivieren</button>
</div>
<div id="birthday-form">
<div class="form-group">
<label data-i18n="birthday_person">Name</label>
<input type="text" id="birthday-name" data-i18n-ph="birthday_person_ph" placeholder="Name der Person" />
</div>
<div class="form-group">
<label data-i18n="birthday_date">Geburtstag</label>
<div class="birthday-date-row">
<select id="birthday-day"></select>
<select id="birthday-month"></select>
<input type="number" id="birthday-year" min="1900" max="2100" data-i18n-ph="birthday_year_ph" placeholder="Jahr" />
</div>
<label class="checkbox-row" style="margin-top:10px;display:flex;align-items:center;gap:8px;text-transform:none;letter-spacing:normal;font-weight:400;cursor:pointer">
<input type="checkbox" id="birthday-year-unknown" style="width:16px;height:16px;flex:none;margin:0" />
<span data-i18n="birthday_year_unknown">Jahr unbekannt</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-birthday" data-i18n="cancel">Abbrechen</button>
<button class="btn btn-primary" id="birthday-save" data-i18n="save">Speichern</button>
</div>
</div>
</div>
<!-- iCal Subscription Modal --> <!-- iCal Subscription Modal -->
<div id="modal-ical-sub" class="modal-overlay hidden"> <div id="modal-ical-sub" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px"> <div class="modal-card" style="max-width:480px">
@@ -693,7 +760,7 @@
<button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button> <button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button> <button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button> <button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button> <button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_admin">Admin</button>
</nav> </nav>
<div class="settings-panels"> <div class="settings-panels">
@@ -714,7 +781,23 @@
<label>E-Mail</label> <label>E-Mail</label>
<input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" /> <input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" />
</div> </div>
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p>
<div class="form-group app-pw-create">
<input type="text" id="app-pw-label" data-i18n-placeholder="app_pw_label_ph" placeholder="Name (z.B. iPhone)" maxlength="100" />
<button class="btn btn-primary btn-sm" id="app-pw-create-btn" data-i18n="app_pw_create">Erstellen</button>
</div>
<div id="app-pw-new" class="app-pw-new hidden">
<label data-i18n="app_pw_new_label">Neues App-Passwort (nur jetzt sichtbar):</label>
<div class="totp-secret-row">
<code id="app-pw-new-value"></code>
<button class="btn btn-ghost btn-sm" id="app-pw-copy" title="Kopieren">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</button>
</div>
</div>
<div id="app-pw-list"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p> <p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
@@ -725,15 +808,12 @@
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option> <option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
</select> </select>
</div> </div>
<div class="form-group">
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4> <label class="checkbox-row">
<div class="contrast-selector" id="cfg-event-duration"> <input type="checkbox" id="cfg-directory-hidden" />
<button class="contrast-btn" data-val="15">15 min</button> <span data-i18n="settings_directory_hidden">Profil verbergen (nicht in Teilen-/Gruppen-Auswahl anzeigen)</span>
<button class="contrast-btn" data-val="30">30 min</button> </label>
<button class="contrast-btn" data-val="45">45 min</button> <p class="panel-desc" data-i18n="settings_directory_hidden_desc">Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.</p>
<button class="contrast-btn" data-val="60">1 h</button>
<button class="contrast-btn" data-val="90">1,5 h</button>
<button class="contrast-btn" data-val="120">2 h</button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
@@ -744,115 +824,21 @@
</div> </div>
</div> </div>
<!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben --> <!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) -->
<div class="settings-panel" id="settings-panel-general"> <div class="settings-panel" id="settings-panel-general">
<div class="sync-global-row">
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4> <div class="sync-global-text">
<div class="form-group"> <span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span>
<label data-i18n="settings_default_view">Standardansicht</label> <span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span>
<select id="cfg-default-view">
<option value="month" data-i18n="view_month">Monat</option>
<option value="week" data-i18n="view_week">Woche</option>
<option value="day" data-i18n="view_day">Tag</option>
<option value="quarter" data-i18n="view_quarter">Quartal</option>
<option value="agenda" data-i18n="view_agenda">Termine</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings_week_start">Erster Wochentag</label>
<select id="cfg-week-start">
<option value="monday" data-i18n="week_start_monday">Montag</option>
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
</select>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="cfg-dim-past" />
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
</label>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_language">Sprache</h4>
<div class="form-group">
<select id="cfg-language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px">Teilen-Symbol</h4>
<p class="panel-desc">Symbol neben deinem geteilten Kalender in der Seitenleiste</p>
<div class="group-icon-picker" id="cfg-share-icon"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
<div class="form-group">
<label data-i18n="settings_primary_color">Primärfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div> </div>
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
</div> </div>
<div class="form-group"> <div class="settings-table" id="settings-appearance-table"></div>
<label data-i18n="settings_accent_color">Akzentfarbe</label> <div class="theme-io-row">
<div class="ev-color-row"> <button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button>
<input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" /> <button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button>
<div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div> <a class="theme-io-help" id="cfg-theme-help" href="https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md" target="_blank" rel="noopener noreferrer" data-i18n-title="theme_docs_hint" title="Erklärung der Theme-Parameter">?</a>
</div> <input type="file" id="cfg-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
</div>
<div class="form-group">
<label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_label_color">Monatskürzel</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_text_color">Schriftfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_line_color">Linienfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div> </div>
</div> </div>
@@ -861,15 +847,19 @@
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4> <h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
<div class="accounts-add-row"> <div class="accounts-add-row">
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-birthday" data-i18n="birthday_add_btn">+ Geburtstage</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button> <button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button>
</div> </div>
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div> <div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="birthday_settings_title">Geburtstage</h4>
<div id="birthday-settings"></div>
</div> </div>
<!-- Benutzerverwaltung --> <!-- Admin: Benutzerverwaltung + Standard-Theme + Branding -->
<div class="settings-panel" id="settings-panel-users"> <div class="settings-panel" id="settings-panel-users">
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4> <h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
<div id="users-list"></div> <div id="users-list"></div>
@@ -888,6 +878,45 @@
</label> </label>
<button class="btn btn-primary" id="new-user-save">Erstellen</button> <button class="btn btn-primary" id="new-user-save">Erstellen</button>
</div> </div>
<!-- Instanz-Standardtheme -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
<div id="admin-theme-grid" class="admin-theme-grid"></div>
<div class="admin-theme-actions">
<button class="btn btn-primary btn-sm" id="admin-theme-save" data-i18n="admin_theme_save">Standard speichern</button>
<button class="btn btn-secondary btn-sm" id="admin-theme-import" data-i18n="admin_theme_import">Theme importieren</button>
<button class="btn btn-ghost btn-sm" id="admin-theme-discard" data-i18n="admin_theme_discard">Verwerfen</button>
<input type="file" id="admin-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
</div>
<!-- Branding: Logo & Favicon -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_branding">Branding</h4>
<p class="panel-desc" data-i18n="admin_branding_desc">Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.</p>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_logo">Logo</span>
<div class="admin-brand-preview admin-brand-preview-logo" id="admin-logo-preview"></div>
<div class="admin-brand-controls">
<div class="admin-brand-btns">
<button class="btn btn-secondary btn-sm" id="admin-logo-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-logo-remove" data-i18n="admin_remove">Entfernen</button>
</div>
<span class="admin-brand-dims" data-i18n="admin_logo_dims">PNG/JPEG/WebP · Anzeige max. 150 × 40 px</span>
</div>
<input type="file" id="admin-logo-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_favicon">Favicon</span>
<div class="admin-brand-preview" id="admin-favicon-preview"></div>
<div class="admin-brand-controls">
<div class="admin-brand-btns">
<button class="btn btn-secondary btn-sm" id="admin-favicon-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-favicon-remove" data-i18n="admin_remove">Entfernen</button>
</div>
<span class="admin-brand-dims" data-i18n="admin_favicon_dims">PNG/JPEG/WebP · 128 × 128 px (quadratisch)</span>
</div>
<input type="file" id="admin-favicon-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
</div> </div>
</div><!-- settings-panels --> </div><!-- settings-panels -->

View File

@@ -1,9 +1,13 @@
import { api } from './api.js'; import { api } from './api.js';
import { initCalendar, showToast, openProfileModal } from './calendar.js'; import { initCalendar, showToast, openProfileModal } from './calendar.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { loadInstance } from './instance.js';
// ── Bootstrap ───────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────
async function boot() { async function boot() {
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
// setup screens are already branded. Public endpoint — no token needed.
loadInstance();
// Check if setup is required // Check if setup is required
let setupRequired = false; let setupRequired = false;
try { try {
@@ -23,14 +27,23 @@ async function boot() {
// Check if already logged in // Check if already logged in
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
let authed = false;
try { try {
await api.get('/auth/me'); // validate token await api.get('/auth/me'); // validate the TOKEN only
await launchApp(); authed = true;
return;
} catch (_) { } catch (_) {
// The token is genuinely invalid/expired — clear it and show login.
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
} }
if (authed) {
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
// try/catch on purpose: a later data/render error inside app init must
// never bounce a validly-authenticated user back to the login screen
// (that was the "logged out on every reload" bug).
await launchApp();
return;
}
} }
showScreen('login'); showScreen('login');
@@ -192,8 +205,23 @@ boot();
// ── Service Worker registration (PWA) ───────────────────── // ── Service Worker registration (PWA) ─────────────────────
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
// Auto-update: when a new service worker takes control, reload once so the
// page runs the fresh assets. Guarded so it never loops and never fires on
// the very first install (when there was no previous controller).
let refreshing = false;
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing || !hadController) return;
refreshing = true;
window.location.reload();
});
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => { navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => {
// Check for a new SW now and hourly, so long-open tabs pick up releases.
reg.update();
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => {
console.warn('SW registration failed:', err); console.warn('SW registration failed:', err);
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or "" * value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
* mode : 'datetime' | 'date' * mode : 'datetime' | 'date'
*/ */
import { t } from './i18n.js'; import { t, getLocale } from './i18n.js';
const ITEM_H = 40; // px per scroll item const ITEM_H = 40; // px per scroll item
const VISIBLE = 3; // visible items in time scroller const VISIBLE = 3; // visible items in time scroller
@@ -250,16 +250,16 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
/** /**
* Format an ISO value for display in the UI * Format an ISO value for display in the UI
* mode: 'datetime' | 'date' * mode: 'datetime' | 'date'
* lang: 'de' | 'en' * Formatting follows the active UI language via the i18n locale registry.
*/ */
export function formatDtDisplay(isoStr, mode, lang = 'de') { export function formatDtDisplay(isoStr, mode) {
if (!isoStr) return '—'; if (!isoStr) return '—';
try { try {
const d = mode === 'datetime' const d = mode === 'datetime'
? new Date(isoStr.replace(' ', 'T')) ? new Date(isoStr.replace(' ', 'T'))
: new Date(isoStr + 'T00:00:00'); : new Date(isoStr + 'T00:00:00');
if (isNaN(d)) return isoStr; if (isNaN(d)) return isoStr;
const locale = lang === 'en' ? 'en-GB' : 'de-CH'; const locale = getLocale();
if (mode === 'datetime') { if (mode === 'datetime') {
return d.toLocaleString(locale, { return d.toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',

File diff suppressed because it is too large Load Diff

56
frontend/js/instance.js Normal file
View File

@@ -0,0 +1,56 @@
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
// early as possible so the login screen is already branded. See
// backend/routers/admin_router.py.
import { setInstanceDefaults } from './settings-sync.js';
import { setCustomFavicon, applyFavicon } from './utils.js';
export let instanceConfig = {};
// Cache the bundled default logo markup so "remove logo" can restore it.
const originalLogoHtml = {};
function setBrandLogo(url) {
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
if (url) {
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
} else {
el.innerHTML = originalLogoHtml[key];
}
});
}
export function applyInstanceBranding(cfg) {
cfg = cfg || {};
setInstanceDefaults(cfg.default_theme || {});
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
applyFavicon(); // no arg → uses instance/built-in primary as fallback
}
// Memoised so boot() and initCalendar() share a single fetch; callers can await
// it to guarantee instance defaults are set before the first applyTheme().
let loadPromise = null;
export function loadInstance() {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
instanceConfig = res.ok ? await res.json() : {};
} catch (_) {
instanceConfig = {};
}
applyInstanceBranding(instanceConfig);
return instanceConfig;
})();
return loadPromise;
}
// Force a re-fetch (after an admin changes branding/theme).
export function reloadInstance() {
loadPromise = null;
return loadInstance();
}

View File

@@ -0,0 +1,137 @@
// Per-setting cross-device sync for the web client.
//
// The server is the sole authority for WHICH settings sync (it returns a fully
// resolved `sync_flags` map). This module owns the browser-local copy of every
// syncable value so that "not synced" works per browser, plus the declarative
// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
import { LANGUAGES } from './i18n.js';
// Canonical default colours — the single source for the web. Reset writes these.
export const DEFAULT_COLORS = {
primary_color: '#58B900',
accent_color: '#45A148',
today_color: '#6FB669',
text_color: '#FFFFFF',
bg_color: '#000000',
line_color: '#3B3B3D',
surface_color: '#2B2B2B',
month_divider_color: '#95D25E',
month_label_color: '#95D25E',
// Fine-grained element colours (see THEME.md). day_selected/today_bg are
// applied as a subtle tint of the chosen colour; the rest are applied solid.
hover_highlight_color: '#2A2A38',
icon_inactive_color: '#90AA91',
icon_active_color: '#E8E8F0',
day_hover_color: '#2B382A',
day_selected_color: '#88EF9A',
day_bg_color: '#000000',
today_bg_color: '#477650',
};
// The syncable settings the web client exposes, grouped into table sections.
// `type`: 'select' | 'toggle' | 'color' | 'icon'.
// Option labels use i18n keys via `tk`, or a literal `label`.
export const SETTING_GROUPS = [
{
titleKey: 'settings_calendar_view',
rows: [
{ key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [
{ v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' },
{ v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' },
{ v: 'agenda', tk: 'view_agenda' },
] },
{ key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [
{ v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' },
] },
{ key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' },
{ key: 'month_view_paged', labelKey: 'settings_month_mode', type: 'select', opts: [
{ v: false, tk: 'settings_month_mode_scroll' }, { v: true, tk: 'settings_month_mode_paged' },
] },
{ key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [
{ v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' },
{ v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' },
] },
{ key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [
{ v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' },
{ v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' },
] },
],
},
{
titleKey: 'settings_language',
rows: [
// Derived from the i18n registry: adding a language there adds it here.
// Each language is listed under its own endonym, so the labels are never
// translated — a Finn looks for "Suomi", not "Finnisch".
{ key: 'language', labelKey: 'settings_language', type: 'select',
opts: LANGUAGES.map(l => ({ v: l.code, label: l.label })) },
{ key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
],
},
{
titleKey: 'settings_colors',
rows: [
{ key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' },
{ key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' },
{ key: 'today_color', labelKey: 'settings_today_color', type: 'color' },
{ key: 'text_color', labelKey: 'settings_text_color', type: 'color' },
{ key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' },
{ key: 'surface_color', labelKey: 'settings_surface_color', type: 'color' },
{ key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
{ key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
{ key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
{ key: 'hover_highlight_color', labelKey: 'settings_hover_highlight_color', type: 'color' },
{ key: 'icon_inactive_color', labelKey: 'settings_icon_inactive_color', type: 'color' },
{ key: 'icon_active_color', labelKey: 'settings_icon_active_color', type: 'color' },
{ key: 'day_hover_color', labelKey: 'settings_day_hover_color', type: 'color' },
{ key: 'day_selected_color', labelKey: 'settings_day_selected_color', type: 'color' },
{ key: 'day_bg_color', labelKey: 'settings_day_bg_color', type: 'color' },
{ key: 'today_bg_color', labelKey: 'settings_today_bg_color', type: 'color' },
],
},
];
// Flat list of every syncable key the web manages (table order).
export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
// the base a user inherits and the target that a per-colour "Reset" returns to.
// A user's own value always overrides it. See backend/routers/admin_router.py.
export let INSTANCE_DEFAULTS = {};
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
// The effective default for a colour key: admin instance default → built-in.
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
const LOCAL_KEY = 'settingsLocal';
export function loadLocal() {
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
export function saveLocal(obj) {
try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {}
}
// Effective value of a syncable key: synced → server value; otherwise the
// browser-local value (falling back to the server value if we have none yet).
export function effectiveValue(key, server, flags, local) {
if (flags[key]) return server[key];
return (key in local && local[key] != null) ? local[key] : server[key];
}
// Build the effective settings object: a copy of the raw server settings with
// each syncable key resolved to its effective value. As a side effect, mirror
// every effective value into the local copy so that flipping a flag OFF later
// retains the currently-visible value.
export function mergeEffective(server, flags, local) {
const eff = { ...server };
for (const key of SYNCABLE_KEYS) {
const val = effectiveValue(key, server, flags, local);
eff[key] = val;
if (val != null) local[key] = val;
}
saveLocal(local);
return eff;
}

View File

@@ -1,3 +1,6 @@
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
import { getLocale } from './i18n.js';
export function isToday(d) { export function isToday(d) {
const now = new Date(); const now = new Date();
return d.getFullYear() === now.getFullYear() && return d.getFullYear() === now.getFullYear() &&
@@ -16,8 +19,28 @@ export function isPast(ev) {
return end < new Date(); return end < new Date();
} }
// Title to render for an event: the server-decorated one (birthday age, group
// prefix) wins over the raw title, which stays untouched for editing.
export function eventTitle(ev) {
return ev.display_title || ev.title || '';
}
// Small inline cake icon for birthday events, sized to the surrounding text and
// tinted with the current text colour so it matches every bar it's dropped into.
export function birthdayIconSvg() {
return '<svg viewBox="0 0 24 24" aria-hidden="true" '
+ 'style="width:0.85em;height:0.85em;vertical-align:-0.1em;margin-right:2px;flex:0 0 auto">'
+ '<path fill="currentColor" d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97'
+ 'c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0'
+ 'l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16'
+ 'c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6'
+ 'c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13'
+ 'c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12'
+ 'c.01-1.66-1.33-3-2.99-3z"/></svg>';
}
export function formatDate(d, opts = {}) { export function formatDate(d, opts = {}) {
return d.toLocaleDateString('de', opts); return d.toLocaleDateString(getLocale(), opts);
} }
export function dateKey(d) { export function dateKey(d) {
@@ -76,23 +99,26 @@ const LINE_CONTRAST = {
4: { border: '#5a5a78', light: '#484860' }, 4: { border: '#5a5a78', light: '#484860' },
}; };
// Defaults wenn kein Custom-Override gesetzt ist. // Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js.
// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet. // Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig.
export const DEFAULT_TEXT_COLOR = '#FFFFFF'; export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color;
export const DEFAULT_LINE_COLOR = '#3A3A52'; export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color;
export const DEFAULT_BG_COLOR = '#000000'; export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
export function applyTheme(settings) { export function applyTheme(settings) {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--primary', settings.primary_color || '#4285f4'); // Fallback chain for a colour that has no per-user value: admin instance
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15)); // default → built-in default (baseColor()).
root.style.setProperty('--accent', settings.accent_color || '#ea4335'); const primary = settings.primary_color || baseColor('primary_color');
root.style.setProperty('--today-color', settings.today_color || '#4285f4'); root.style.setProperty('--primary', primary);
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
// Effektive Farben bestimmen (Override > Default). // Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
let textColor = settings.text_color || DEFAULT_TEXT_COLOR; let textColor = settings.text_color || baseColor('text_color');
let lineColor = settings.line_color || DEFAULT_LINE_COLOR; let lineColor = settings.line_color || baseColor('line_color');
let bgColor = settings.bg_color || DEFAULT_BG_COLOR; let bgColor = settings.bg_color || baseColor('bg_color');
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug // Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt), // Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
@@ -110,18 +136,73 @@ export function applyTheme(settings) {
root.style.setProperty('--border', lineColor); root.style.setProperty('--border', lineColor);
root.style.setProperty('--border-light', shadeHex(lineColor, -0.25)); root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
// Surface family (sidebar / top bar / cards). Explicit surface_color drives
// it; otherwise derive from the app background as before.
const surfaceBase = settings.surface_color || shadeHex(bgColor, 0.10);
root.style.setProperty('--bg-app', bgColor); root.style.setProperty('--bg-app', bgColor);
root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10)); root.style.setProperty('--bg-topbar', surfaceBase);
root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10)); root.style.setProperty('--bg-sidebar', surfaceBase);
root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18)); root.style.setProperty('--bg-surface', shadeHex(surfaceBase, 0.10));
root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26)); root.style.setProperty('--bg-hover', shadeHex(surfaceBase, 0.20));
root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40)); root.style.setProperty('--bg-active', shadeHex(surfaceBase, 0.34));
const hh = settings.hour_height || 44; const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px'); root.style.setProperty('--hour-h', hh + 'px');
root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0'); root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0'); root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('month_label_color'));
// Fine-grained element colours. Each is applied ONLY when the user set an
// explicit value; otherwise the :root default (which references a derived
// variable) stays in effect, so the look is unchanged until customised.
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
// into a subtle tint via color-mix; the rest are applied as-is.
// User value → admin instance default (if any). When neither is set the
// property stays unset so the derived :root default keeps the current look.
const setIf = (varName, key) => {
const value = settings[key] || INSTANCE_DEFAULTS[key];
if (value) root.style.setProperty(varName, value);
};
setIf('--hover-highlight', 'hover_highlight_color');
setIf('--icon-inactive-color', 'icon_inactive_color');
setIf('--icon-active-color', 'icon_active_color');
setIf('--day-hover-color', 'day_hover_color');
setIf('--day-selected-base', 'day_selected_color');
setIf('--day-bg', 'day_bg_color');
setIf('--today-bg-base', 'today_bg_color');
}
// Instance custom favicon URL (set by the branding loader). When present it
// overrides the primary-colour tinting.
let customFaviconUrl = null;
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
// Tint the favicon (and browser theme-colour) to the current primary colour so
// the tab icon reflects the user's theme. Called at load and after saving —
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
export function applyFavicon(primaryColor) {
const color = primaryColor || baseColor('primary_color');
let link = document.querySelector('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
if (customFaviconUrl) {
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
link.type = 'image/png';
link.href = customFaviconUrl;
} else {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
link.type = 'image/svg+xml';
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', color);
} }
function luminance(hex) { function luminance(hex) {

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v62'; export const APP_VERSION = 'v90';

View File

@@ -1,5 +1,5 @@
import { isPast } from '../utils.js'; import { isPast, eventTitle, birthdayIconSvg } from '../utils.js';
import { t, getLang } from '../i18n.js'; import { t, getLocale } from '../i18n.js';
export function renderAgenda(container, currentDate, events, onEventClick) { export function renderAgenda(container, currentDate, events, onEventClick) {
if (!events.length) { if (!events.length) {
@@ -45,7 +45,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"> return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
<div class="agenda-ev-color" style="background:${color}"></div> <div class="agenda-ev-color" style="background:${color}"></div>
<div class="agenda-ev-info"> <div class="agenda-ev-info">
<div class="agenda-ev-title">${escHtml(ev.title)}</div> <div class="agenda-ev-title">${ev.is_birthday ? birthdayIconSvg() : ''}${escHtml(eventTitle(ev))}</div>
<div class="agenda-ev-meta">${timeStr}${locHtml}</div> <div class="agenda-ev-meta">${timeStr}${locHtml}</div>
</div> </div>
</div>`; </div>`;
@@ -94,7 +94,7 @@ function isTodayDate(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {

View File

@@ -1,5 +1,5 @@
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
import { t } from '../i18n.js'; import { t, getLocale } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap) const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const DAY_H = 30; // day-number row height const DAY_H = 30; // day-number row height
@@ -110,14 +110,15 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
const cL = continuesLeft ? 'continues-left' : ''; const cL = continuesLeft ? 'continues-left' : '';
const cR = continuesRight ? 'continues-right' : ''; const cR = continuesRight ? 'continues-right' : '';
const titleEsc = escHtml(ev.title); const titleEsc = escHtml(eventTitle(ev));
const icon = ev.is_birthday ? birthdayIconSvg() : '';
const labelHtml = ev.allDay const labelHtml = ev.allDay
? titleEsc ? icon + titleEsc
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`; : `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${icon}${titleEsc}`;
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}" eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}" style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
title="${escAttr(ev.title)}">${labelHtml}</div>`; title="${escAttr(eventTitle(ev))}">${labelHtml}</div>`;
}); });
// "+N more" per column // "+N more" per column
@@ -250,7 +251,7 @@ function dateKey(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {
@@ -292,7 +293,7 @@ function showOverflowPopup(anchor, date, events, onEventClick) {
+ (continuesLeft ? ' continues-left' : '') + (continuesLeft ? ' continues-left' : '')
+ (continuesRight ? ' continues-right' : ''); + (continuesRight ? ' continues-right' : '');
bar.style.background = color; bar.style.background = color;
bar.textContent = ev.title; bar.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
bar.addEventListener('click', e => { bar.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
popup.remove(); popup.remove();
@@ -314,7 +315,7 @@ function showOverflowPopup(anchor, date, events, onEventClick) {
const title = document.createElement('span'); const title = document.createElement('span');
title.className = 'mop-title'; title.className = 'mop-title';
title.textContent = ev.title; title.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
row.append(dot, time, title); row.append(dot, time, title);
row.addEventListener('click', e => { row.addEventListener('click', e => {

View File

@@ -1,4 +1,4 @@
import { isToday, isPast, dayOfWeek } from '../utils.js'; import { isToday, isPast, dayOfWeek, eventTitle } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
@@ -64,7 +64,7 @@ export function renderQuarter(container, currentDate, events, onDayClick, onEven
const dots = cellEvs.slice(0, 3).map(ev => { const dots = cellEvs.slice(0, 3).map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`; return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(eventTitle(ev))}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
}).join(''); }).join('');
const moreDot = cellEvs.length > 3 const moreDot = cellEvs.length > 3
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>` ? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`

View File

@@ -1,5 +1,5 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
import { t } from '../i18n.js'; import { t, getLocale } from '../i18n.js';
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) { export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
// Build the days array (7 days for week, 1 for day) // Build the days array (7 days for week, 1 for day)
@@ -77,11 +77,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const cL = evStart < firstDay ? 'continues-left' : ''; const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : ''; const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${ev.title}` ? `${fmtTime(new Date(ev.start))} ${eventTitle(ev)}`
: ev.title; : eventTitle(ev);
const icon = ev.is_birthday ? birthdayIconSvg() : '';
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}" return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff" style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`; data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">${icon}${escHtml(label)}</div>`;
}).join(''); }).join('');
const alldayBgCols = days.map(day => const alldayBgCols = days.map(day =>
@@ -129,11 +130,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const isShort = height < 34; const isShort = height < 34;
const shortCls = isShort ? 'short' : ''; const shortCls = isShort ? 'short' : '';
const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : ''; const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
const icon = ev.is_birthday ? birthdayIconSvg() : '';
return `<div class="timed-event ${pastCls} ${shortCls}" return `<div class="timed-event ${pastCls} ${shortCls}"
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff" style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}"> data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">
<div class="ev-time">${startStr}</div> <div class="ev-time">${startStr}</div>
<div class="ev-title">${escHtml(ev.title)}</div> <div class="ev-title">${icon}${escHtml(eventTitle(ev))}</div>
${locHtml} ${locHtml}
</div>`; </div>`;
}).join(''); }).join('');
@@ -350,7 +352,7 @@ function isSameDay(a, b) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {

View File

@@ -1,12 +1,17 @@
{ {
"id": "/",
"name": "Calendarr", "name": "Calendarr",
"short_name": "Calendarr", "short_name": "Calendarr",
"description": "Dein privater, selbst gehosteter Kalender.",
"lang": "de",
"dir": "ltr",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#0e0e14", "background_color": "#0e0e14",
"theme_color": "#4285f4", "theme_color": "#16713d",
"categories": ["productivity", "utilities"],
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",
@@ -21,10 +26,16 @@
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/icons/icon.svg", "src": "/icons/icon-maskable-192.png",
"sizes": "any", "sizes": "192x192",
"type": "image/svg+xml", "type": "image/png",
"purpose": "any" "purpose": "maskable"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }

View File

@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next // the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required. // reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v23'; const CACHE_VERSION = 'calendarr-v36';
const OFFLINE_SHELL = ['/', '/index.html']; const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => { self.addEventListener('install', event => {