10 Commits
Author SHA1 Message Date
iamdoubz bb47aff609 Merge pull request 'fix/calendar-no-child' (#7) from published-events into main
Release image / image (release) Successful in 5s
Reviewed-on: #7
2026-11-06 14:42:59 -06:00
iamdoubz 415d26c33b Update changelog for 0.0.5 2026-11-06 14:41:08 -06:00
iamdoubz c6b3d6c0af List and edit a published scan's events; new sources start with every child 2026-11-06 14:40:21 -06:00
iamdoubz b3ac884707 Merge pull request 'feat/ui-today-stats' (#6) from published-photo into main
Release image / image (release) Successful in 4s
Reviewed-on: #6
2026-11-06 11:51:48 -06:00
iamdoubz 3a7d796f30 Show scan stats behind a Scan Stats button 2026-11-06 11:50:06 -06:00
iamdoubz cb9a611aaa Log each scan's processing time and add SHOW_SCAN_STATS 2026-11-06 11:44:14 -06:00
iamdoubz ad6cd4bbfd Update changelog for 0.0.4 2026-11-06 11:37:19 -06:00
iamdoubz a953b1bcf4 Highlight today in the Calendar list 2026-11-06 11:36:43 -06:00
iamdoubz 2c3867b1c8 Highlight today on the Lunch screen 2026-11-06 11:30:10 -06:00
iamdoubz d600332af4 Show the scanned photo on a published scan 2026-11-06 11:25:00 -06:00
13 changed files with 204 additions and 11 deletions

No files matched your search

+3
View File
@@ -39,3 +39,6 @@ EXTRACT_MODEL=nemotron-3.5-bolt:e4b
# Days to keep photos after a scan is published.
IMAGE_RETENTION_DAYS=90
# 1 adds a "Scan Stats" button to each scan's page (model, tokens, pages, events, scan time), for comparing models. Every scan's time is logged either way.
# SHOW_SCAN_STATS=0
+21 -1
View File
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
## [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
- A published scan's photo can still be viewed from its scan page, under "Scanned photo", until it's deleted after `IMAGE_RETENTION_DAYS`.
- A log line for every scan with how long it took, the model and what it found. `SHOW_SCAN_STATS=1` adds a "Scan Stats" button next to a scan's status that shows the model, tokens used, pages, events and scan time.
- Today stands out on the Lunch screen and in the Calendar list: a filled day number like the month view, a "Today" label and a tinted card.
## [0.0.3] - 2026-11-06
### Added
@@ -48,7 +66,9 @@ 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.3...main
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.5...main
[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
[0.0.1]: https://git.dou.bet/iamdoubz/parentcalendar/src/tag/v0.0.1
+1
View File
@@ -105,6 +105,7 @@ Both include `python main.py backup <destination>`, which runs SQLite's online b
| `EXTRACT_IGNORE` | Comma-separated labels dropped from every source's menu items and event titles. Each source can add its own in `ignore_labels` | Not set; for example `BHS,Milk` |
| `HOST`, `PORT` | Bind address | `127.0.0.1`, `8000` |
| `IMAGE_RETENTION_DAYS` | Days to keep images after publish | `90` |
| `SHOW_SCAN_STATS` | `1` adds a "Scan Stats" button to each scan's page: model, tokens used, pages, events and scan time. Every scan's time is logged either way | `0` |
**Deployment on the home server**
+2 -2
View File
@@ -10,7 +10,7 @@ export interface Source {
ignore_labels: string[]; applies_filter: string | null; prompt_notes: string | null;
default_calendar_id: number | null; child_ids: number[];
}
export interface Household { timezone: string; children: Child[]; calendars: Calendar[]; sources: Source[] }
export interface Household { timezone: string; show_scan_stats: boolean; children: Child[]; calendars: Calendar[]; sources: Source[] }
export interface CalEvent {
id: number; uid: string; scan_id: number | null; source_id: number | null;
@@ -32,7 +32,7 @@ export interface Scan {
id: number; source_id: number; source_name: string; layout_type: Source['layout_type']; hint_month: string;
date_start: string | null; date_end: string | null; image_keys: string[];
status: 'queued' | 'parsed' | 'failed' | 'abandoned' | 'reviewed' | 'published';
error: string | null; seconds: number | null; created_at: string; published_at: string | null;
error: string | null; seconds: number | null; model: string | null; cost_tokens: number | null; created_at: string; published_at: string | null;
events: CalEvent[]; menu_items: MenuItem[]; can_undo: boolean;
}
export interface ScanSummary {
+10 -2
View File
@@ -11,7 +11,7 @@
import MapPin from '@lucide/svelte/icons/map-pin';
import { api, KINDS, type CalEvent, type EventValues } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
import { iso, monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
import { KIND_ICONS } from '../lib/icons';
import PageHeader from '../lib/ui/PageHeader.svelte';
import EmptyState from '../lib/ui/EmptyState.svelte';
@@ -43,6 +43,7 @@
}
return [...days.entries()].sort();
});
const today = iso(new Date());
const lastDownload = $derived(calendar?.last_exported_at ? new Date(calendar.last_exported_at.replace(' ', 'T') + 'Z') : null);
async function load() {
@@ -143,11 +144,14 @@
<ol class="agenda">
{#each byDay as [day, list] (day)}
{@const d = parse(day)}
<li class="day">
{@const isToday = day === today}
<li class="day" class:today={isToday} aria-current={isToday ? 'date' : undefined}>
<div class="date" aria-hidden="true">
<span class="dow">{d.toLocaleDateString(undefined, { weekday: 'short' })}</span>
<span class="dnum tabular">{d.getDate()}</span>
{#if isToday}<span class="today-tag">Today</span>{/if}
</div>
{#if isToday}<span class="visually-hidden">Today</span>{/if}
<ul class="card-list grow">
{#each list as e (e.id)}
{@const KindIcon = KIND_ICONS[e.kind]}
@@ -189,6 +193,10 @@
.date { flex: none; width: 52px; display: flex; flex-direction: column; align-items: center; padding-top: var(--s2); }
.dow { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
.dnum { font-size: var(--fs-xl); font-weight: 750; line-height: 1.1; }
/* Today: same filled day number and label as the Lunch screen, and its events get the same tint. */
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; margin-top: 2px; }
.today-tag { margin-top: var(--s1); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
.today .event { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); border-left-color: var(--child); }
.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) var(--s1) var(--s2); box-shadow: var(--shadow-1); }
.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; }
+11 -3
View File
@@ -10,7 +10,7 @@
import Utensils from '@lucide/svelte/icons/utensils';
import { api, type LunchDay } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { monthLabel, parse, shiftMonth, thisMonth } from '../lib/dates';
import { iso, monthLabel, parse, shiftMonth, thisMonth } from '../lib/dates';
import { foodEmoji } from '../lib/foodEmoji';
import PageHeader from '../lib/ui/PageHeader.svelte';
import EmptyState from '../lib/ui/EmptyState.svelte';
@@ -31,6 +31,7 @@
const open = $derived(days?.filter((d) => !d.greyed) ?? []); // days that can be picked
const allPicked = $derived(open.length > 0 && open.every((d) => d.picked));
let bulkBusy = $state(false);
const today = iso(new Date());
async function load() {
if (childId === null) return;
@@ -127,13 +128,15 @@
<ul class="days">
{#each days as d (d.date)}
{@const date = parse(d.date)}
<li class="day card" class:closed={d.greyed} class:picked={d.picked}>
{@const isToday = d.date === today}
<li class="day card" class:closed={d.greyed} class:picked={d.picked} class:today={isToday} aria-current={isToday ? 'date' : undefined}>
<div class="day-head">
<span class="date-tile" aria-hidden="true">
<span class="dow">{date.toLocaleDateString(undefined, { weekday: 'short' })}</span>
<span class="dnum tabular">{date.getDate()}</span>
{#if isToday}<span class="today-tag">Today</span>{/if}
</span>
<span class="visually-hidden">{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
<span class="visually-hidden">{isToday ? 'Today, ' : ''}{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
<span class="head-end">
{#if d.warning}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{d.warning}</span>{/if}
<!-- Decoration only: the foods are listed below, so screen readers skip this. -->
@@ -177,6 +180,11 @@
.day { display: flex; flex-direction: column; gap: var(--s3); transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.day.picked { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary), var(--shadow-1); }
.day.closed { background: var(--day-closed); box-shadow: none; }
/* Today: the same filled day number as the month view, plus a label so it isn't color alone. */
.day.today:not(.closed) { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); }
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; }
.today .date-tile { align-items: center; }
.today-tag { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
.day-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); }
.date-tile { display: flex; align-items: baseline; gap: 6px; }
.head-end { display: flex; align-items: center; gap: var(--s2); min-width: 0; }
+80
View File
@@ -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>
+60 -1
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import CalendarDays from '@lucide/svelte/icons/calendar-days';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChartColumn from '@lucide/svelte/icons/chart-column';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import CircleCheck from '@lucide/svelte/icons/circle-check';
import Download from '@lucide/svelte/icons/download';
import ImageIcon from '@lucide/svelte/icons/image';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Sandwich from '@lucide/svelte/icons/sandwich';
@@ -13,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();
@@ -20,6 +24,9 @@
let error = $state('');
let confirmDiscard = $state(false);
let retrying = $state(false);
let statsOpen = $state(false);
// SHOW_SCAN_STATS: model, tokens and time, for comparing models. Shown once the scan has been read.
const hasStats = $derived(app.household!.show_scan_stats && scan?.model != null && scan?.seconds != null);
async function load() {
try {
@@ -75,8 +82,26 @@
</div>
{:else}
<PageHeader title={scan.source_name} subtitle={monthLabel(scan.hint_month)} back="#/">
{#snippet actions()}<StatusBadge status={scan!.status} />{/snippet}
{#snippet actions()}
<StatusBadge status={scan!.status} />
{#if hasStats}
<button class="sm" aria-expanded={statsOpen} aria-controls="scan-stats" onclick={() => (statsOpen = !statsOpen)}>
<ChartColumn size={16} aria-hidden="true" />Scan Stats
</button>
{/if}
{/snippet}
</PageHeader>
{#if hasStats && statsOpen}
<dl id="scan-stats" class="card stats tabular">
<div class="wide"><dt>Model</dt><dd>{scan.model}</dd></div>
<div><dt>Tokens used</dt><dd>{scan.cost_tokens?.toLocaleString() ?? '—'}</dd></div>
<!-- Photos are deleted after IMAGE_RETENTION_DAYS, and the page count with them. -->
<div><dt>Pages</dt><dd>{scan.image_keys.length || '—'}</dd></div>
<div><dt>Events</dt><dd>{scan.events.length}</dd></div>
{#if scan.menu_items.length}<div><dt>Menu days</dt><dd>{scan.menu_items.length}</dd></div>{/if}
<div><dt>Scan time</dt><dd>{scan.seconds!.toFixed(1)} s</dd></div>
</dl>
{/if}
{#if scan.status === 'queued'}
<section class="card state" aria-live="polite">
@@ -119,6 +144,27 @@
<a class="btn ghost" href="#/calendar"><CalendarDays size={18} aria-hidden="true" /> See the calendar</a>
</div>
</section>
{#if scan.image_keys.length}
<!-- Closed by default; the lazy images only load once it's opened. -->
<details class="card photos">
<summary>
<ImageIcon size={18} aria-hidden="true" />
<span class="grow">{scan.image_keys.length === 1 ? 'Scanned photo' : `Scanned photos (${scan.image_keys.length})`}</span>
<ChevronDown size={20} class="chev" aria-hidden="true" />
</summary>
<div class="photo-list">
{#each scan.image_keys as _, i}
<a href={`/api/scans/${scan.id}/pages/${i + 1}`} target="_blank" rel="noopener">
<img loading="lazy" src={`/api/scans/${scan.id}/pages/${i + 1}`} alt={`Page ${i + 1} of the scanned ${scan.source_name}`} />
</a>
{/each}
<p class="hint">Tap the photo to open it full size and zoom in.</p>
</div>
</details>
{: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 />
@@ -141,6 +187,19 @@
.state-icon.danger { background: var(--danger-soft); color: var(--danger); }
.state p { max-width: 48ch; }
.actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s2); margin-top: var(--s2); }
.photos { margin-top: var(--s4); padding: 0; overflow: hidden; }
.photos summary { display: flex; align-items: center; gap: var(--s2); min-height: 48px; padding: 0 var(--s4); cursor: pointer; font-weight: 600; list-style: none; }
.photos summary::-webkit-details-marker { display: none; }
.photos :global(.chev) { color: var(--text-2); transition: transform var(--dur) var(--ease); }
.photos[open] :global(.chev) { transform: rotate(180deg); }
.photo-list { display: flex; flex-direction: column; gap: var(--s3); padding: 0 var(--s4) var(--s4); }
.photo-list a { display: block; border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; background: var(--surface-2); }
.photo-list img { display: block; width: 100%; height: auto; }
.photos-gone { margin-top: var(--s4); text-align: center; }
.menu-fix { margin-top: var(--s6); }
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--s3) var(--s4); margin: calc(-1 * var(--s2)) 0 var(--s4); }
.stats dt { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
.stats dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
.stats .wide { grid-column: 1 / -1; }
.discard { margin-top: var(--s6); padding-top: var(--s4); border-top: 1px solid var(--border); display: flex; gap: var(--s2); }
</style>
+1 -1
View File
@@ -56,7 +56,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))] };
}
}
+5
View File
@@ -4,6 +4,7 @@ Usage:
python main.py start the server (settings: see .env.example)
python main.py backup <destination> copy the database (SQLite online backup) and the photos
"""
import logging
import os
import shutil
import sqlite3
@@ -48,10 +49,14 @@ def serve():
"EXTRACT_TOP_K": "" if extraction.TOP_K is None else extraction.TOP_K,
"EXTRACT_NUM_CTX": extraction.NUM_CTX, "EXTRACT_KEEP_ALIVE": extraction.KEEP_ALIVE, "EXTRACT_DOUBLE_READ": int(extraction.DOUBLE_READ),
"EXTRACT_IGNORE": ",".join(sorted(extraction.IGNORE)), "IMAGE_RETENTION_DAYS": config.IMAGE_RETENTION_DAYS,
"SHOW_SCAN_STATS": int(config.SHOW_SCAN_STATS),
"ANTHROPIC_API_KEY": "set" if env("ANTHROPIC_API_KEY") else "unset", # never print the key
}
print("Settings: " + " ".join(f"{k}={v}" for k, v in settings.items()))
# 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)
uvicorn.run("parentcalendar.app:app", host=config.HOST, port=config.PORT, proxy_headers=True)
+1
View File
@@ -73,6 +73,7 @@ CHILD_IDS = "(SELECT json_group_array(child_id) FROM {table} WHERE {key} = {alia
def state(conn: DB):
return {
"timezone": conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0],
"show_scan_stats": config.SHOW_SCAN_STATS,
"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")),
+1
View File
@@ -26,6 +26,7 @@ STATIC_DIR = ROOT / "static"
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
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.
+8 -1
View File
@@ -88,6 +88,7 @@ def process_scan(conn, scan_id):
hint = {"year": year, "month": month, "layout": source["layout_type"], "filter": source["applies_filter"]}
ignore = extraction.IGNORE | {w.strip().lower() for w in source["ignore_labels"] if w.strip()}
runs = []
started = time.time()
for key in scan["image_keys"]:
img = extraction.load_image(config.IMAGES_DIR / key)
run = extraction.extract_image(img, hint, double_read=source["double_read"], ignore=ignore)
@@ -124,6 +125,11 @@ def process_scan(conn, scan_id):
(dates[0] if dates else None, dates[-1] if dates else None, runs[0]["model"] if runs else None,
tokens, round(sum(r["seconds"] for r in runs), 1), scan_id))
match_drafts(conn, scan_id)
events = sum(len(r["result"]["events"]) for r in runs)
menus = sum(len(r["result"]["menu_items"]) for r in runs)
log.info("scan %s (%s, %s) read in %.1fs: %d page(s), %d events, %d menu days, model %s (%s), %d tokens",
scan_id, source["name"], scan["hint_month"], time.time() - started, len(runs), events, menus,
runs[0]["model"] if runs else "-", ", ".join(dict.fromkeys(r["method"] for r in runs)) or "-", tokens)
def overlaps(a, b):
@@ -215,11 +221,12 @@ def run_worker(stop):
if job is None:
stop.wait(2)
continue
started = time.time()
try:
process_scan(conn, job["scan_id"])
status, error = "done", None
except Exception as e: # any failure marks this scan failed; the worker keeps serving the queue
log.exception("scan %s failed", job["scan_id"])
log.exception("scan %s failed after %.1fs", job["scan_id"], time.time() - started)
status, error = "failed", f"{type(e).__name__}: {e}"
with conn:
conn.execute("UPDATE job SET status = ?, error = ? WHERE id = ?", (status, error, job["id"]))