Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10712db1e9 | ||
|
|
ed76671c48 | ||
|
|
203bd5b96d | ||
|
|
92bf2a115e | ||
|
|
b07b4bd0c1 | ||
|
|
02cd8edd3e | ||
|
|
d720a17e39 | ||
|
|
f80f8e80b0 | ||
|
|
061ddb8b01 | ||
|
|
ef436288e6 | ||
|
|
1817cb50d4 | ||
|
|
bb47aff609 | ||
|
|
415d26c33b | ||
|
|
c6b3d6c0af |
No files matched your search
+1
-1
@@ -4,7 +4,7 @@
|
||||
PUBLIC_URL=https://calendar.example.com
|
||||
|
||||
# Database and photos. Must be a local disk (SQLite WAL doesn't work on network shares).
|
||||
DATA_DIR=./data
|
||||
DATA_DIR=/data
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=8000
|
||||
|
||||
+36
-1
@@ -5,6 +5,38 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `deploy/nginx.conf`, an example nginx site that makes only `/feed/` public and keeps the rest of the app on the LAN or VPN.
|
||||
- `deploy/compose-complete.yml`, an example Compose file that runs ParentCalendar and Ollama together (NVIDIA GPU, with AMD commented out), with every setting from `.env.example` and its default.
|
||||
|
||||
## [0.0.7] - 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- Subscription feeds. The Calendar tab has Subscribe and Copy link for each calendar, so each family member's phone keeps itself up to date and gets the alerts (on iPhone, turn off "Remove Alerts" when subscribing). The feed is `/feed/<token>.ics`, the only URL that needs no login: the past 12 months and everything ahead, deletions as cancelled for 30 days, with ETag and caching headers. "Make a new one" replaces a link that was shared by mistake. The access log hides the token.
|
||||
- Downloads and feeds carry the calendar's color for Apple Calendar, and each event's DTSTAMP is when it last changed.
|
||||
- An About section at the bottom of Settings with the name, version and commit (linked to the repository).
|
||||
|
||||
## [0.0.6] - 2026-11-09
|
||||
|
||||
### Changed
|
||||
|
||||
- "Pack lunch" events are 8:00–8:30 AM instead of all day, with alerts 12 hours before and when they start (instead of the calendar's usual alert). Database migration 003 moves existing ones, and the next "New and changed" download updates them on your device.
|
||||
- Deleting an event takes one tap on Delete, with no second confirm button.
|
||||
- A multi-day event is listed under each of its days in the Calendar list, not only its first.
|
||||
- The same all-day event read on consecutive days (No School on Wed, Thu and Fri) becomes one event from the first day to the last.
|
||||
|
||||
## [0.0.5] - 2026-11-06
|
||||
|
||||
### Added
|
||||
|
||||
- A published scan's page lists its events, and you can edit or delete them there like on the Calendar tab. Events with no child picked are on no calendar, so they're marked "Not on any calendar" there, with a note to pick a child.
|
||||
|
||||
### Changed
|
||||
|
||||
- A new source starts with every child ticked, like a new calendar. Before, it started with none, and its events showed on no calendar.
|
||||
|
||||
## [0.0.4] - 2026-11-06
|
||||
|
||||
### Added
|
||||
@@ -56,7 +88,10 @@ First test release: the Phase 1 MVP.
|
||||
- Runs with plain Python or as a Docker image, with SQLite and a `backup` command.
|
||||
- Release workflow that pushes the image to the Gitea container registry: stable releases tag `X.Y.Z`, `X.Y`, `X` and `latest`; pre-releases tag `X.Y.Z` and `dev`.
|
||||
|
||||
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.4...main
|
||||
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.7...main
|
||||
[0.0.7]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.6...v0.0.7
|
||||
[0.0.6]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.5...v0.0.6
|
||||
[0.0.5]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.4...v0.0.5
|
||||
[0.0.4]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.3...v0.0.4
|
||||
[0.0.3]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.2...v0.0.3
|
||||
[0.0.2]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.1...v0.0.2
|
||||
|
||||
@@ -24,6 +24,8 @@ Then open http://127.0.0.1:8000. Rebuild the front end after changing anything i
|
||||
|
||||
**Docker:** copy `compose.example.yml` to `compose.yml`, then `docker compose up -d --build`.
|
||||
|
||||
**Subscription feeds:** each calendar has a feed at `PUBLIC_URL/feed/<token>.ics` (Calendar tab → Subscribe). It's the one URL that needs no login, so your family's phones can reach it from anywhere: expose `/feed/` publicly on your reverse proxy and keep the rest of the app on your LAN or VPN ([`deploy/nginx.conf`](deploy/nginx.conf) is an example). The token is the secret; "Make a new one" on the Calendar tab replaces it. The access log shows `/feed/***` instead of the token.
|
||||
|
||||
**Backups:** `python main.py backup <folder>` copies the database (safely, while running) and the photos. Schedule it nightly.
|
||||
|
||||
## Develop
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# ParentCalendar and Ollama in one Compose project, with every setting from .env.example.
|
||||
# Copy this file to an empty folder as compose.yml, next to a copy of .env.example named .env (Compose fills
|
||||
# each ${VAR:-default} below from that .env; leave a line out of .env to get the default). Then:
|
||||
# docker compose up -d
|
||||
# docker compose exec ollama ollama pull nemotron-3.5-bolt:e4b # once, and again when you change EXTRACT_MODEL
|
||||
# On Linux, `mkdir data && sudo chown 1000 data` first: the app's container runs as uid 1000.
|
||||
services:
|
||||
parentcalendar:
|
||||
image: git.dou.bet/iamdoubz/parentcalendar:latest # or pin a release, e.g. :0.0.7
|
||||
container_name: parentcalendar
|
||||
mem_limit: 512M
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- ollama
|
||||
environment:
|
||||
# Base URL the app is reached at; its domain goes into new event UIDs.
|
||||
- PUBLIC_URL=${PUBLIC_URL:-https://calendar.example.com}
|
||||
# Fixed for this setup, so a .env made for running without Docker can't break them.
|
||||
- DATA_DIR=/data
|
||||
- HOST=0.0.0.0
|
||||
- OLLAMA_URL=http://ollama:11434
|
||||
- PORT=${PORT:-8000}
|
||||
# Extraction model: an Ollama model, or a claude-* model (then set ANTHROPIC_API_KEY).
|
||||
- EXTRACT_MODEL=${EXTRACT_MODEL:-nemotron-3.5-bolt:e4b}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
# Ollama settings. Empty top P and top K use the model's own.
|
||||
- EXTRACT_TEMPERATURE=${EXTRACT_TEMPERATURE:-0}
|
||||
- EXTRACT_TOP_P=${EXTRACT_TOP_P:-}
|
||||
- EXTRACT_TOP_K=${EXTRACT_TOP_K:-}
|
||||
- EXTRACT_NUM_CTX=${EXTRACT_NUM_CTX:-16384}
|
||||
- EXTRACT_KEEP_ALIVE=${EXTRACT_KEEP_ALIVE:-10m}
|
||||
- EXTRACT_THINK=${EXTRACT_THINK:-0}
|
||||
- EXTRACT_THINK_LEVEL=${EXTRACT_THINK_LEVEL:-}
|
||||
# Only for the command-line eval; in the app each source's "double read" setting decides.
|
||||
- EXTRACT_DOUBLE_READ=${EXTRACT_DOUBLE_READ:-0}
|
||||
# Comma-separated labels that are never food or events, e.g. BHS.
|
||||
- EXTRACT_IGNORE=${EXTRACT_IGNORE:-}
|
||||
# Days to keep photos after a scan is published.
|
||||
- IMAGE_RETENTION_DAYS=${IMAGE_RETENTION_DAYS:-90}
|
||||
# 1 adds a "Scan Stats" button to each scan's page.
|
||||
- SHOW_SCAN_STATS=${SHOW_SCAN_STATS:-0}
|
||||
# Only this machine can reach it; put nginx (HTTPS) in front, see nginx.conf in this folder.
|
||||
ports:
|
||||
- "127.0.0.1:${PORT:-8000}:${PORT:-8000}"
|
||||
volumes:
|
||||
- ./data:/data # must be a local disk, not a network share
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest # AMD: ollama/ollama:rocm
|
||||
container_name: parentcalendar-ollama
|
||||
mem_limit: 8G
|
||||
restart: unless-stopped
|
||||
# No ports: the app reaches it as http://ollama:11434 on the Compose network.
|
||||
# Add "127.0.0.1:11434:11434" here to use it from this machine too (the phase0 eval).
|
||||
volumes:
|
||||
- ollama:/root/.ollama # downloaded models
|
||||
# NVIDIA: needs the NVIDIA Container Toolkit on the host.
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
# AMD: use the rocm image above, delete the deploy block, and uncomment these.
|
||||
# devices:
|
||||
# - /dev/kfd
|
||||
# - /dev/dri
|
||||
# If Ollama can't see the GPU, add the numeric group IDs of /dev/kfd and /dev/dri/* from
|
||||
# `ls -lnd /dev/kfd /dev/dri/*` on the host (often video and render):
|
||||
# group_add:
|
||||
# - "44"
|
||||
# - "993"
|
||||
|
||||
volumes:
|
||||
ollama:
|
||||
@@ -0,0 +1,47 @@
|
||||
# Example nginx site for ParentCalendar: the app stays on your LAN/VPN, only the calendar feeds are public.
|
||||
# Copy to /etc/nginx/sites-available/parentcalendar, change the server_name, certificate paths and allowed
|
||||
# networks, then link it into sites-enabled and run `nginx -t && systemctl reload nginx`.
|
||||
# Assumes the app listens on 127.0.0.1:8000 (compose.example.yml) and PUBLIC_URL=https://calendar.example.com.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name calendar.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name calendar.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/calendar.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/calendar.example.com/privkey.pem;
|
||||
|
||||
# Public: subscription feeds, so family phones refresh from anywhere.
|
||||
# The token in the URL is the only secret, so nginx doesn't log these requests either.
|
||||
location /feed/ {
|
||||
access_log off;
|
||||
limit_except GET { deny all; } # GET also allows HEAD
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Everything else (the app and its API) is private: home network and VPN only.
|
||||
location / {
|
||||
allow 192.168.0.0/16; # home LAN
|
||||
allow 10.0.0.0/8; # VPN, e.g. WireGuard
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
deny all;
|
||||
|
||||
client_max_body_size 260m; # a scan is up to 10 files of 25 MB
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -225,7 +225,7 @@ A menu isn't a list of events. It's something you go through with your kids, and
|
||||
|
||||
1. **Scan.** Capture the menu like any other page, using a source linked to the children who eat from it. Review corrects the items by date. Kids at different schools use separate menu sources.
|
||||
2. **Pick.** The lunch screen shows the month with each day's menu, one child at a time. Each child taps the days to pack.
|
||||
3. **Sync.** Each picked day creates a confirmed event titled "Pack lunch — <child>", with kind `pack_lunch`, tagged with that child. It's an all-day event on the school day, and it reaches every calendar that includes that child, by feed or .ics, like any other event.
|
||||
3. **Sync.** Each picked day creates a confirmed event titled "Pack lunch — <child>", with kind `pack_lunch`, tagged with that child. It's 8:00–8:30 AM on the school day, with alerts 12 hours before and when it starts, and it reaches every calendar that includes that child, by feed or .ics, like any other event.
|
||||
|
||||
**Rules**
|
||||
|
||||
|
||||
@@ -3,14 +3,18 @@ export type Kind = 'no_school' | 'early_release' | 'event' | 'game' | 'practice'
|
||||
export interface Child { id: number; display_name: string; color: string }
|
||||
export interface Calendar {
|
||||
id: number; name: string; color: string; child_ids: number[]; source_ids: number[];
|
||||
alarm_minutes: number | null; last_exported_at: string | null;
|
||||
alarm_minutes: number | null; last_exported_at: string | null; feed_token: string;
|
||||
}
|
||||
export interface Source {
|
||||
id: number; name: string; layout_type: 'month_grid' | 'menu' | 'list'; double_read: boolean;
|
||||
ignore_labels: string[]; applies_filter: string | null; prompt_notes: string | null;
|
||||
default_calendar_id: number | null; child_ids: number[];
|
||||
}
|
||||
export interface Household { timezone: string; show_scan_stats: boolean; children: Child[]; calendars: Calendar[]; sources: Source[] }
|
||||
export interface Household {
|
||||
timezone: string; show_scan_stats: boolean; public_url: string;
|
||||
about: { version: string; commit: string; repo_url: string };
|
||||
children: Child[]; calendars: Calendar[]; sources: Source[];
|
||||
}
|
||||
|
||||
export interface CalEvent {
|
||||
id: number; uid: string; scan_id: number | null; source_id: number | null;
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import CircleAlert from '@lucide/svelte/icons/circle-alert';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
import FileDown from '@lucide/svelte/icons/file-down';
|
||||
import LayoutGrid from '@lucide/svelte/icons/layout-grid';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||
import Rss from '@lucide/svelte/icons/rss';
|
||||
import { api, KINDS, type CalEvent, type EventValues } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { app, setHousehold, toast } from '../lib/state.svelte';
|
||||
import { iso, monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
|
||||
import { KIND_ICONS } from '../lib/icons';
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
@@ -26,6 +28,8 @@
|
||||
let events = $state<CalEvent[] | null>(null);
|
||||
let editing = $state<number | 'new' | null>(null);
|
||||
let newDate = $state('');
|
||||
let editDay = $state(''); // a multi-day event is listed on each day; its editor opens under the one tapped
|
||||
const isOpen = (e: CalEvent, day: string) => editing === e.id && editDay === day;
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
@@ -36,14 +40,39 @@
|
||||
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? calendar?.color ?? 'var(--primary)';
|
||||
const byDay = $derived.by(() => {
|
||||
const days = new Map<string, CalEvent[]>();
|
||||
const last = monthEnd(month);
|
||||
for (const e of events ?? []) {
|
||||
const key = e.date < `${month}-01` ? `${month}-01` : e.date; // multi-day events that began last month
|
||||
if (!days.has(key)) days.set(key, []);
|
||||
days.get(key)!.push(e);
|
||||
// A multi-day event shows on each of its days in this month.
|
||||
for (let d = parse(e.date < `${month}-01` ? `${month}-01` : e.date); iso(d) <= (e.end_date ?? e.date) && iso(d) <= last; d.setDate(d.getDate() + 1)) {
|
||||
const key = iso(d);
|
||||
if (!days.has(key)) days.set(key, []);
|
||||
days.get(key)!.push(e);
|
||||
}
|
||||
}
|
||||
return [...days.entries()].sort();
|
||||
});
|
||||
const today = iso(new Date());
|
||||
// The subscription feed: the one public URL, so it uses PUBLIC_URL. webcal:// opens the phone's subscribe screen.
|
||||
const feedUrl = $derived(calendar ? `${app.household!.public_url}/feed/${calendar.feed_token}.ics` : '');
|
||||
const webcalUrl = $derived(feedUrl.replace(/^https?:/, 'webcal:'));
|
||||
let confirmReset = $state(false);
|
||||
async function copyFeed() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(feedUrl);
|
||||
toast('Link copied');
|
||||
} catch {
|
||||
toast("Couldn't copy. Press and hold Subscribe to copy the link instead.");
|
||||
}
|
||||
}
|
||||
async function resetFeed() {
|
||||
try {
|
||||
setHousehold(await api(`/calendars/${calendarId}/feed_token`, { method: 'POST' }));
|
||||
confirmReset = false;
|
||||
toast('New link made. Send it to everyone who subscribed.');
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const lastDownload = $derived(calendar?.last_exported_at ? new Date(calendar.last_exported_at.replace(' ', 'T') + 'Z') : null);
|
||||
|
||||
async function load() {
|
||||
@@ -98,11 +127,36 @@
|
||||
<Segmented label="View" bind:value={view} options={[{ value: 'list', label: 'List', icon: List }, { value: 'grid', label: 'Month', icon: LayoutGrid }]} />
|
||||
</div>
|
||||
|
||||
<section class="card export">
|
||||
<div class="export-head">
|
||||
<span class="export-icon"><Rss size={22} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>Subscribe, for the whole family</h2>
|
||||
<p class="hint">Each phone that subscribes keeps itself up to date, alerts included. Send the link to your family. On iPhone, tap Subscribe, then turn off “Remove Alerts”.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a class="btn primary" href={webcalUrl}><Rss size={18} aria-hidden="true" />Subscribe</a>
|
||||
<button onclick={copyFeed}><Copy size={18} aria-hidden="true" />Copy link</button>
|
||||
</div>
|
||||
{#if confirmReset}
|
||||
<div class="alert warn" role="alert">
|
||||
<span>The old link stops working, so everyone has to subscribe again with the new one.</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="danger solid sm" onclick={resetFeed}>Make a new link</button>
|
||||
<button class="ghost sm" onclick={() => (confirmReset = false)}>Keep this link</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="ghost sm reset" onclick={() => (confirmReset = true)}>Link shared by mistake? Make a new one…</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card export">
|
||||
<div class="export-head">
|
||||
<span class="export-icon"><FileDown size={22} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>Add to your phone's calendar</h2>
|
||||
<h2>Or download a file</h2>
|
||||
<p class="hint">On iPhone, open the file and tap “Add All”. Each download holds only what changed since the last one, so nothing is duplicated.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,14 +210,14 @@
|
||||
{#each list as e (e.id)}
|
||||
{@const KindIcon = KIND_ICONS[e.kind]}
|
||||
<li class="event" style:--child={colorFor(e)}>
|
||||
<button class="item" onclick={() => (editing = editing === e.id ? null : e.id)} aria-expanded={editing === e.id}>
|
||||
<button class="item" onclick={() => { editing = isOpen(e, day) ? null : e.id; editDay = day; }} aria-expanded={isOpen(e, day)}>
|
||||
<span class="kind"><KindIcon size={18} aria-hidden="true" /></span>
|
||||
<span class="grow">
|
||||
<span class="title">{e.title}</span>
|
||||
<span class="meta">{whenLabel(e)} · {KINDS[e.kind]}{#if e.location} · <MapPin size={12} aria-hidden="true" /> {e.location}{/if}</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if editing === e.id}
|
||||
{#if isOpen(e, day)}
|
||||
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
|
||||
{/if}
|
||||
</li>
|
||||
@@ -184,6 +238,7 @@
|
||||
.export-head { display: flex; gap: var(--s3); align-items: flex-start; }
|
||||
.export-icon { display: grid; place-items: center; width: 44px; height: 44px; flex: none; border-radius: var(--r-md); background: var(--primary-soft); color: var(--primary-text); }
|
||||
.export h2 { font-size: var(--fs-md); margin-bottom: 2px; }
|
||||
.reset { align-self: flex-start; }
|
||||
|
||||
.month-bar { display: flex; align-items: center; gap: var(--s1); }
|
||||
.month-name { font-size: var(--fs-xl); min-width: 10ch; text-align: center; }
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
child_ids: [...(initial.child_ids ?? [])],
|
||||
});
|
||||
let saving = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
const kinds = $derived(Object.entries(KINDS).filter(([k]) => k !== 'pack_lunch' || f.kind === 'pack_lunch'));
|
||||
const uid = untrack(() => event?.id ?? 'new');
|
||||
|
||||
@@ -130,11 +129,7 @@
|
||||
<button type="button" class="ghost" onclick={oncancel}>Cancel</button>
|
||||
</div>
|
||||
{#if ondelete}
|
||||
{#if confirmDelete}
|
||||
<button type="button" class="danger solid" onclick={ondelete}><Trash2 size={18} aria-hidden="true" />Delete event</button>
|
||||
{:else}
|
||||
<button type="button" class="danger" onclick={() => (confirmDelete = true)}><Trash2 size={18} aria-hidden="true" />Delete…</button>
|
||||
{/if}
|
||||
<button type="button" class="danger" onclick={ondelete}><Trash2 size={18} aria-hidden="true" />Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import CalendarDays from '@lucide/svelte/icons/calendar-days';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import { api, KINDS, type EventValues, type Scan } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { whenLabel } from '../lib/dates';
|
||||
import { KIND_ICONS } from '../lib/icons';
|
||||
import EventEditor from './EventEditor.svelte';
|
||||
|
||||
// A published scan's events, editable like on the Calendar tab. An event with no child is on no calendar,
|
||||
// so this is the only place it can be found and fixed.
|
||||
let { scan = $bindable() }: { scan: Scan } = $props();
|
||||
let editing = $state<number | null>(null);
|
||||
const events = $derived(scan.events.filter((e) => e.status === 'confirmed'));
|
||||
const untagged = $derived(events.filter((e) => !e.child_ids.length).length);
|
||||
const children = $derived(new Map(app.household!.children.map((c) => [c.id, c])));
|
||||
|
||||
async function run(work: () => Promise<unknown>, done: string) {
|
||||
try {
|
||||
await work();
|
||||
editing = null;
|
||||
scan = await api<Scan>(`/scans/${scan.id}`);
|
||||
toast(done);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const save = (id: number) => (v: EventValues) => run(() => api(`/events/${id}`, { method: 'PATCH', json: v }), 'Saved');
|
||||
const remove = (id: number) => () => run(() => api(`/events/${id}`, { method: 'DELETE' }), 'Deleted');
|
||||
</script>
|
||||
|
||||
{#if events.length}
|
||||
<section class="section fix">
|
||||
<h2 class="section-title">
|
||||
<CalendarDays size={16} aria-hidden="true" />Fix the events
|
||||
<span class="count">{events.length} {events.length === 1 ? 'event' : 'events'}</span>
|
||||
</h2>
|
||||
{#if untagged}
|
||||
<div class="alert warn" role="status">
|
||||
<TriangleAlert size={18} aria-hidden="true" />
|
||||
<span>{untagged === 1 ? '1 event has' : `${untagged} events have`} no child picked, so {untagged === 1 ? "it isn't" : "they aren't"} on any calendar. Tap one and pick a child.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="hint">Tap an event to change it. Changes show on the Calendar tab as soon as you save.</p>
|
||||
{/if}
|
||||
<ul class="card-list">
|
||||
{#each events as e (e.id)}
|
||||
{@const KindIcon = KIND_ICONS[e.kind]}
|
||||
{@const none = !e.child_ids.length}
|
||||
<li class="event" class:none style:--child={children.get(e.child_ids[0])?.color ?? 'var(--warn)'}>
|
||||
<button class="item" onclick={() => (editing = editing === e.id ? null : e.id)} aria-expanded={editing === e.id}>
|
||||
<span class="kind"><KindIcon size={18} aria-hidden="true" /></span>
|
||||
<span class="grow">
|
||||
<span class="title">{e.title}</span>
|
||||
<span class="meta">{whenLabel(e)} · {KINDS[e.kind]}</span>
|
||||
{#if none}<span class="meta warn"><TriangleAlert size={12} aria-hidden="true" /> Not on any calendar</span>{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if editing === e.id}
|
||||
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fix { margin-top: var(--s6); }
|
||||
/* Same rows as the Calendar list. */
|
||||
.event { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--child); border-radius: var(--r-lg); padding: var(--s1) var(--s2); box-shadow: var(--shadow-1); }
|
||||
.event.none { border-left-style: dashed; }
|
||||
.item { width: 100%; min-height: 56px; justify-content: flex-start; gap: var(--s3); padding: var(--s1); border: 0; background: none; text-align: left; white-space: normal; font-weight: 400; }
|
||||
.item:hover:not(:disabled) { background: none; }
|
||||
.item:active:not(:disabled) { transform: none; }
|
||||
.kind { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: var(--r-md); background: var(--surface-2); color: var(--text-2); }
|
||||
.title { display: block; font-weight: 650; color: var(--text); }
|
||||
.meta { display: block; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
.meta.warn { display: flex; align-items: center; gap: var(--s1); color: var(--warn); font-weight: 600; }
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import MenuDays from './MenuDays.svelte';
|
||||
import ScanEvents from './ScanEvents.svelte';
|
||||
import Review from './Review.svelte';
|
||||
|
||||
let { id }: { id: number } = $props();
|
||||
@@ -163,6 +164,7 @@
|
||||
{:else}
|
||||
<p class="hint photos-gone">The photo was deleted after the retention period.</p>
|
||||
{/if}
|
||||
<ScanEvents bind:scan />
|
||||
{#if scan.menu_items.length}<div class="menu-fix"><MenuDays bind:scan /></div>{/if}
|
||||
{:else}
|
||||
<Review bind:scan />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Globe from '@lucide/svelte/icons/globe';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import Monitor from '@lucide/svelte/icons/monitor';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import Palette from '@lucide/svelte/icons/palette';
|
||||
@@ -56,7 +57,7 @@
|
||||
source = { name: s.name ?? '', layout_type: s.layout_type ?? 'month_grid', double_read: s.double_read ?? false,
|
||||
ignore_text: (s.ignore_labels ?? []).join(', '), applies_filter: s.applies_filter ?? '',
|
||||
prompt_notes: s.prompt_notes ?? '', default_calendar_id: s.default_calendar_id ?? null,
|
||||
child_ids: [...(s.child_ids ?? [])] };
|
||||
child_ids: [...(s.child_ids ?? h.children.map((k) => k.id))] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,10 +304,30 @@
|
||||
</ul>
|
||||
{#if isEditing('source', null)}{@render sourceForm()}{:else}<button class="add" onclick={() => edit('source', null)}><Plus size={18} aria-hidden="true" />Add a source</button>{/if}
|
||||
</section>
|
||||
|
||||
<section class="card section-card">
|
||||
{@render heading(Info, 'About', '')}
|
||||
<dl class="about">
|
||||
<div><dt>Name</dt><dd>ParentCalendar</dd></div>
|
||||
<div><dt>Version</dt><dd class="tabular">{h.about.version}</dd></div>
|
||||
<div>
|
||||
<dt>Commit</dt>
|
||||
<dd class="tabular">
|
||||
{#if h.about.commit !== 'unknown'}
|
||||
<a href={`${h.about.repo_url}/commit/${h.about.commit}`} target="_blank" rel="noopener">{h.about.commit}</a>
|
||||
{:else}{h.about.commit}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-card { display: flex; flex-direction: column; gap: var(--s4); }
|
||||
.about { display: flex; flex-direction: column; gap: var(--s2); margin: 0; }
|
||||
.about div { display: flex; justify-content: space-between; gap: var(--s3); min-height: 32px; align-items: center; }
|
||||
.about dt { color: var(--text-2); }
|
||||
.about dd { margin: 0; font-weight: 600; }
|
||||
.sec-head { display: flex; gap: var(--s3); align-items: flex-start; }
|
||||
.sec-icon { display: grid; place-items: center; width: 40px; height: 40px; flex: none; border-radius: var(--r-md); background: var(--primary-soft); color: var(--primary-text); }
|
||||
.sec-head h2 { font-size: var(--fs-md); }
|
||||
|
||||
@@ -30,6 +30,13 @@ def backup(destination):
|
||||
print(f"Backed up to {target}")
|
||||
|
||||
|
||||
def hide_feed_tokens(record):
|
||||
"""Feed URLs are bearer secrets, so the access log shows /feed/*** instead of the token."""
|
||||
if isinstance(record.args, tuple) and len(record.args) >= 3 and str(record.args[2]).startswith("/feed/"):
|
||||
record.args = (*record.args[:2], "/feed/***", *record.args[3:])
|
||||
return True
|
||||
|
||||
|
||||
def serve():
|
||||
if not config.PUBLIC_URL:
|
||||
sys.exit("Set PUBLIC_URL (for example https://calendar.example.com) in the environment or in .env.")
|
||||
@@ -40,7 +47,7 @@ def serve():
|
||||
from parentcalendar import extraction
|
||||
|
||||
env = os.environ.get
|
||||
print(f"ParentCalendar {env('APP_VERSION', 'dev')} ({env('APP_COMMIT', 'unknown')}, {env('APP_COMMIT_DATE', 'unknown')})")
|
||||
print(f"ParentCalendar {config.APP_VERSION} ({config.APP_COMMIT}, {config.APP_COMMIT_DATE})")
|
||||
settings = { # what the app actually uses, defaults included
|
||||
"PUBLIC_URL": config.PUBLIC_URL, "DATA_DIR": config.DATA_DIR, "HOST": config.HOST, "PORT": config.PORT,
|
||||
"OLLAMA_URL": extraction.OLLAMA_URL, "EXTRACT_MODEL": extraction.MODEL, "EXTRACT_THINK": int(extraction.THINK),
|
||||
@@ -57,6 +64,7 @@ def serve():
|
||||
# The app's own log lines (scan timings, failures), formatted like uvicorn's. Other libraries stay at warnings.
|
||||
logging.basicConfig(format="%(levelname)s: %(message)s")
|
||||
logging.getLogger("parentcalendar").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.access").addFilter(hide_feed_tokens)
|
||||
uvicorn.run("parentcalendar.app:app", host=config.HOST, port=config.PORT, proxy_headers=True)
|
||||
|
||||
|
||||
|
||||
+34
-4
@@ -1,4 +1,5 @@
|
||||
"""HTTP API under /api, and the built front end (static/) for everything else."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
@@ -9,7 +10,7 @@ from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Annotated, Literal
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -74,6 +75,8 @@ def state(conn: DB):
|
||||
return {
|
||||
"timezone": conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0],
|
||||
"show_scan_stats": config.SHOW_SCAN_STATS,
|
||||
"public_url": config.PUBLIC_URL,
|
||||
"about": {"version": config.APP_VERSION, "commit": config.APP_COMMIT, "repo_url": config.REPO_URL},
|
||||
"children": db.all_dicts(conn.execute("SELECT * FROM child ORDER BY id")),
|
||||
"calendars": db.all_dicts(conn.execute(
|
||||
f"SELECT c.*, {CHILD_IDS.format(table='calendar_child', key='calendar_id', alias='c')} FROM calendar c ORDER BY id")),
|
||||
@@ -541,7 +544,7 @@ class PicksIn(BaseModel):
|
||||
|
||||
|
||||
def set_pick(conn, child, day, picked):
|
||||
"""A pick is a 'Pack lunch' event for that child: picking creates or restores it (same UID), unpicking deletes it.
|
||||
"""A pick is a 'Pack lunch' event for that child, 8:00-8:30: picking creates or restores it (same UID), unpicking deletes it.
|
||||
The event's notes are the menu's first line (the main dish), so the calendar shows what's being skipped.
|
||||
Returns False when picking a day with no school lunch (no menu, no school, or no lunch)."""
|
||||
entry = next(iter(lunch_days(conn, child["id"], day, day)), None)
|
||||
@@ -557,8 +560,8 @@ def set_pick(conn, child, day, picked):
|
||||
" updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?", (status, notes if picked else None, existing["id"]))
|
||||
elif not existing and picked:
|
||||
event_id = conn.execute(
|
||||
"INSERT INTO event (uid, source_id, date, all_day, title, notes, kind, status)"
|
||||
" VALUES (?, ?, ?, 1, ?, ?, 'pack_lunch', 'confirmed')",
|
||||
"INSERT INTO event (uid, source_id, date, all_day, start_time, end_time, title, notes, kind, status)"
|
||||
" VALUES (?, ?, ?, 0, '08:00', '08:30', ?, ?, 'pack_lunch', 'confirmed')", # ponytail: fixed time
|
||||
(scans.new_uid(), entry["menus"][0]["source_id"], day.isoformat(), f"Pack lunch — {child['display_name']}", notes)).lastrowid
|
||||
conn.execute("INSERT INTO event_child VALUES (?, ?)", (event_id, child["id"]))
|
||||
return True
|
||||
@@ -606,5 +609,32 @@ def export_ics(calendar_id: int, conn: DB, everything: bool = False):
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}.ics"'})
|
||||
|
||||
|
||||
@app.post("/api/calendars/{calendar_id}/feed_token")
|
||||
def reset_feed_token(calendar_id: int, conn: DB):
|
||||
"""A new feed link. The old one stops working, so everyone subscribed has to subscribe again."""
|
||||
found(db.one(conn, "SELECT id FROM calendar WHERE id = ?", (calendar_id,)), "calendar")
|
||||
with conn:
|
||||
conn.execute("UPDATE calendar SET feed_token = ? WHERE id = ?", (secrets.token_urlsafe(24), calendar_id))
|
||||
return state(conn)
|
||||
|
||||
|
||||
@app.get("/feed/{token}.ics")
|
||||
def feed(token: str, request: Request, conn: DB):
|
||||
"""A calendar's subscription feed, the only URL that's public. The token in the path is the secret.
|
||||
The past 12 months and everything ahead, with deletions as CANCELLED for CANCELLED_DAYS."""
|
||||
calendar = found(db.one(conn, "SELECT * FROM calendar WHERE feed_token = ?", (token,)), "feed")
|
||||
tz = conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0]
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=config.CANCELLED_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
start = (date.today() - timedelta(days=config.FEED_PAST_DAYS)).isoformat()
|
||||
events = [e for e in calendar_events(conn, calendar, ["confirmed", "deleted"], start)
|
||||
if e["status"] == "confirmed" or e["updated_at"] >= cutoff]
|
||||
body = ics.build(calendar["name"], events, tz, calendar["alarm_minutes"], color=calendar["color"], refresh="PT1H")
|
||||
etag = '"' + hashlib.sha256(body).hexdigest()[:32] + '"'
|
||||
headers = {"ETag": etag, "Cache-Control": "private, max-age=900"}
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return Response(body, media_type="text/calendar; charset=utf-8", headers=headers)
|
||||
|
||||
|
||||
if config.STATIC_DIR.exists():
|
||||
app.mount("/", StaticFiles(directory=config.STATIC_DIR, html=True), name="static")
|
||||
@@ -27,6 +27,12 @@ HOST = os.environ.get("HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
IMAGE_RETENTION_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "90"))
|
||||
SHOW_SCAN_STATS = os.environ.get("SHOW_SCAN_STATS", "").strip().lower() in ("1", "true", "yes") # model and time on scan pages
|
||||
# Build info, set by the Docker image (see Dockerfile); shown in Settings → About.
|
||||
APP_VERSION = os.environ.get("APP_VERSION", "dev")
|
||||
APP_COMMIT = os.environ.get("APP_COMMIT", "unknown")
|
||||
APP_COMMIT_DATE = os.environ.get("APP_COMMIT_DATE", "unknown")
|
||||
REPO_URL = "https://git.dou.bet/iamdoubz/parentcalendar"
|
||||
FEED_PAST_DAYS = 365 # feeds carry the past 12 months plus everything ahead
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # per file
|
||||
CANCELLED_DAYS = 30 # deleted events stay in exports as STATUS:CANCELLED this long
|
||||
# Stored in each new event's UID; changing PUBLIC_URL later never changes existing UIDs.
|
||||
|
||||
@@ -242,12 +242,28 @@ def cleanup(result, ignore=None):
|
||||
item[key] = iso(item[key])
|
||||
drop_stray_scripts(result)
|
||||
result["events"] = [e for e in result["events"] if re.sub(r"[^a-z]", "", e["title"].lower()) not in ("", "event")]
|
||||
result["events"] = [e for e in result["events"] if not ignored(e["title"], ignore)]
|
||||
result["events"] = merge_days([e for e in result["events"] if not ignored(e["title"], ignore)])
|
||||
for m in result["menu_items"]:
|
||||
m["items"] = [i for i in m["items"] if not ignored(i, ignore)]
|
||||
return result
|
||||
|
||||
|
||||
def merge_days(events):
|
||||
"""The same all-day event (title and kind) on consecutive days becomes one event with an end date,
|
||||
for example No School on Wed, Thu and Fri, read cell by cell. The first day's crop and text stay."""
|
||||
out, last = [], {}
|
||||
for e in sorted(events, key=lambda e: e.get("date") or ""):
|
||||
key = (e["title"].strip().lower(), e["kind"])
|
||||
prev = last.get(key)
|
||||
start, prev_end = parse_date(e.get("date")), prev and parse_date(prev.get("end_date") or prev["date"])
|
||||
if prev and prev.get("all_day") and e.get("all_day") and start and prev_end and start <= prev_end + timedelta(days=1):
|
||||
prev["end_date"] = max(prev.get("end_date") or prev["date"], e.get("end_date") or e["date"])
|
||||
continue
|
||||
last[key] = e
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def flags_for(item, page_range):
|
||||
"""Code-level checks from the spec. Any flag puts the item in 'Needs a look'."""
|
||||
flags = []
|
||||
@@ -540,6 +556,12 @@ def selftest():
|
||||
"events": [{"date": "2026-11-04", "title": "BHS", "kind": "event"},
|
||||
{"date": "2026-11-04", "title": "No School", "kind": "no_school"}]}, {"bhs"})
|
||||
assert r["menu_items"][0]["items"] == ["Grapes"] and [e["title"] for e in r["events"]] == ["No School"], r
|
||||
ns = lambda d, **kw: {"date": d, "end_date": None, "all_day": True, "title": "No School", "kind": "no_school", **kw}
|
||||
m = merge_days([ns("2026-11-27"), ns("2026-11-25"), ns("2026-11-26"), ns("2026-11-30"),
|
||||
{**ns("2026-11-26"), "title": "Thanksgiving"}, ns("2026-12-01", all_day=False)])
|
||||
assert [(e["title"], e["date"], e["end_date"]) for e in m] == [
|
||||
("No School", "2026-11-25", "2026-11-27"), ("Thanksgiving", "2026-11-26", None),
|
||||
("No School", "2026-11-30", None), ("No School", "2026-12-01", None)], m # a weekend between, or a time: separate
|
||||
# Double read: agreement adds nothing, a moved date and a one-sided item are flagged
|
||||
ev = lambda d, t, **kw: {"date": d, "end_date": None, "all_day": True, "start_time": None,
|
||||
"end_time": None, "kind": "event", "title": t, **kw}
|
||||
|
||||
+24
-7
@@ -3,8 +3,10 @@ from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from icalendar import Alarm, Calendar, Event
|
||||
from icalendar.prop import vDuration, vInline
|
||||
|
||||
PRODID = "-//ParentCalendar//ParentCalendar//EN"
|
||||
PACK_LUNCH_ALARMS = (12 * 60, 0) # minutes before: the evening before, and when it starts
|
||||
|
||||
|
||||
def utc(stamp):
|
||||
@@ -12,20 +14,26 @@ def utc(stamp):
|
||||
return datetime.fromisoformat(stamp).replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def build(name, events, tz_name, alarm_minutes=None):
|
||||
"""events: dicts from the event table (status confirmed or deleted). Returns .ics bytes."""
|
||||
def build(name, events, tz_name, alarm_minutes=None, color=None, refresh=None):
|
||||
"""events: dicts from the event table (status confirmed or deleted). Returns .ics bytes.
|
||||
color: the calendar's hex color, which Apple uses when subscribing. refresh: how often a feed asks to be fetched."""
|
||||
cal = Calendar()
|
||||
cal.add("prodid", PRODID)
|
||||
cal.add("version", "2.0")
|
||||
cal.add("calscale", "GREGORIAN")
|
||||
cal.add("x-wr-calname", name)
|
||||
cal.add("x-wr-timezone", tz_name)
|
||||
if color:
|
||||
cal.add("x-apple-calendar-color", color)
|
||||
if refresh:
|
||||
cal["REFRESH-INTERVAL"] = vInline(refresh)
|
||||
cal["REFRESH-INTERVAL"].params["VALUE"] = "DURATION"
|
||||
cal.add("x-published-ttl", refresh)
|
||||
zone = ZoneInfo(tz_name)
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
for e in events:
|
||||
ev = Event()
|
||||
ev.add("uid", e["uid"])
|
||||
ev.add("dtstamp", now)
|
||||
ev.add("dtstamp", utc(e["updated_at"])) # with no METHOD, DTSTAMP is when the event last changed
|
||||
ev.add("summary", e["title"])
|
||||
start = date.fromisoformat(e["date"])
|
||||
end = date.fromisoformat(e["end_date"]) if e.get("end_date") else start
|
||||
@@ -44,11 +52,13 @@ def build(name, events, tz_name, alarm_minutes=None):
|
||||
ev.add("description", e["notes"])
|
||||
if e.get("location"):
|
||||
ev.add("location", e["location"])
|
||||
if alarm_minutes and e["status"] != "deleted":
|
||||
alarms = PACK_LUNCH_ALARMS if e["kind"] == "pack_lunch" else (alarm_minutes,) if alarm_minutes else ()
|
||||
for minutes in alarms if e["status"] != "deleted" else ():
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", e["title"])
|
||||
alarm.add("trigger", timedelta(minutes=-alarm_minutes))
|
||||
# icalendar writes zero as P0D; PT0S is what Apple calendars write for "at time of event"
|
||||
alarm["TRIGGER"] = vInline("PT0S") if minutes == 0 else vDuration(timedelta(minutes=-minutes))
|
||||
ev.add_component(alarm)
|
||||
cal.add_component(ev)
|
||||
cal.add_missing_timezones()
|
||||
@@ -66,7 +76,12 @@ if __name__ == "__main__":
|
||||
ev(uid="c@x", title="Trunk or Treat", all_day=False, start_time="17:00", end_time="18:30", date="2026-10-29"),
|
||||
ev(uid="d@x", title="PTO", all_day=False, start_time="16:00", date="2026-10-20", location="Library"),
|
||||
ev(uid="e@x", title="Spirit Day", status="deleted", sequence=2),
|
||||
ev(uid="f@x", title="Pack lunch", kind="pack_lunch", all_day=False, start_time="08:00", end_time="08:30", date="2026-11-11"),
|
||||
], "America/Chicago", alarm_minutes=60).decode()
|
||||
feed = build("Kids", [ev()], "America/Chicago", color="#2c0977", refresh="PT1H")
|
||||
assert feed == build("Kids", [ev()], "America/Chicago", color="#2c0977", refresh="PT1H") # same events, same bytes (ETag)
|
||||
feed = feed.decode()
|
||||
assert "X-APPLE-CALENDAR-COLOR:#2c0977" in feed and "REFRESH-INTERVAL;VALUE=DURATION:PT1H" in feed and "X-PUBLISHED-TTL:PT1H" in feed
|
||||
assert "\r\n" in out and "VERSION:2.0" in out and "X-WR-TIMEZONE:America/Chicago" in out
|
||||
assert "DTSTART;VALUE=DATE:20261030" in out and "DTEND;VALUE=DATE:20261031" in out # exclusive end
|
||||
assert "DTSTART;VALUE=DATE:20261221" in out and "DTEND;VALUE=DATE:20270102" in out # break runs through Jan 1
|
||||
@@ -78,5 +93,7 @@ if __name__ == "__main__":
|
||||
assert "SUMMARY:No School\\, Fall Break" in out # commas escaped
|
||||
spirit = block("e@x")
|
||||
assert "STATUS:CANCELLED" in spirit and "SEQUENCE:2" in spirit and "VALARM" not in spirit
|
||||
assert out.count("BEGIN:VALARM") == 4 and "TRIGGER:-PT1H" in out
|
||||
assert out.count("BEGIN:VALARM") == 6 and "TRIGGER:-PT1H" in out
|
||||
lunch = block("f@x")
|
||||
assert "TRIGGER:-PT12H" in lunch and "TRIGGER:PT0S" in lunch and "PT1H" not in lunch, lunch
|
||||
print("ics selftest ok")
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Pack lunch events move from all day to 8:00-8:30. The new SEQUENCE and updated_at make the next
|
||||
-- "New and changed" download carry the change to calendar apps.
|
||||
UPDATE event SET all_day = 0, start_time = '08:00', end_time = '08:30', sequence = sequence + 1,
|
||||
updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')
|
||||
WHERE kind = 'pack_lunch' AND all_day = 1;
|
||||
+10
-1
@@ -122,6 +122,15 @@ def test():
|
||||
assert "STATUS:CANCELLED" in c.get(f"/api/calendars/{cal}/export.ics").text
|
||||
assert c.get(f"/api/calendars/{cal}/export.ics?everything=true").text.count("BEGIN:VEVENT") == 4
|
||||
|
||||
# Feed: everything plus the deletion, with an ETag; a reset link retires the old one
|
||||
token = c.get("/api/state").json()["calendars"][0]["feed_token"]
|
||||
r = c.get(f"/feed/{token}.ics")
|
||||
assert r.status_code == 200 and r.text.count("BEGIN:VEVENT") == 5 and "STATUS:CANCELLED" in r.text
|
||||
assert c.get(f"/feed/{token}.ics", headers={"If-None-Match": r.headers["etag"]}).status_code == 304
|
||||
assert c.get("/feed/nope.ics").status_code == 404
|
||||
new = c.post(f"/api/calendars/{cal}/feed_token").json()["calendars"][0]["feed_token"]
|
||||
assert new != token and c.get(f"/feed/{token}.ics").status_code == 404 and c.get(f"/feed/{new}.ics").status_code == 200
|
||||
|
||||
# A revised calendar: the matching event keeps its UID and is updated, not duplicated
|
||||
spirit = next(e for e in scan["events"] if e["title"] == "Spirit Day" and e["date"] == "2026-10-02")
|
||||
rescan = upload(c, district, png("gray"), {"events": [
|
||||
@@ -158,7 +167,7 @@ def test():
|
||||
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
|
||||
assert next(d for d in days if d["date"] == "2026-10-05")["picked"] is True
|
||||
pack = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
||||
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is True and pack[0]["notes"] == "Pork Chopette"
|
||||
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is False and pack[0]["start_time"] == "08:00" and pack[0]["notes"] == "Pork Chopette"
|
||||
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
|
||||
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True})
|
||||
pack2 = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
||||
|
||||
Reference in new issue
Block a user