2 Commits

27 changed files with 2093 additions and 2630 deletions

View File

@@ -11,6 +11,7 @@ export default defineConfig({
schema: './src/lib/server/db/schema/index.ts', schema: './src/lib/server/db/schema/index.ts',
out: './drizzle', out: './drizzle',
schemaFilter: [ schemaFilter: [
'books',
'auth', 'auth',
'money', 'money',
'house', 'house',
@@ -19,8 +20,7 @@ export default defineConfig({
'cs', 'cs',
'animaldex', 'animaldex',
'site', 'site',
'meta', 'meta'
'health'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

View File

@@ -0,0 +1,146 @@
CREATE SCHEMA "books";
--> statement-breakpoint
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
CREATE TABLE "books"."files" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"path" text NOT NULL,
"format" text NOT NULL,
"size_bytes" bigint NOT NULL,
"sha256" text NOT NULL,
"md5" text,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."notes" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"locator" text,
"page" integer,
"highlight" text,
"note" text,
"color" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."progress" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"locator" text,
"page" integer,
"percent" real DEFAULT 0 NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "progress_percent_check" CHECK ("percent" between 0 and 100)
);
--> statement-breakpoint
CREATE TABLE "books"."shelf_works" (
"shelf_id" integer,
"work_id" integer,
"sort_order" integer DEFAULT 0 NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "shelf_works_pkey" PRIMARY KEY("shelf_id","work_id")
);
--> statement-breakpoint
CREATE TABLE "books"."shelves" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."works" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"subtitle" text,
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
"isbn10" text,
"isbn13" text,
"publisher" text,
"year" integer,
"language" text,
"page_count" integer,
"description" text,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"cover_url" text,
"cover_path" text,
"openlibrary_work_id" text,
"openlibrary_edition_id" text,
"google_books_id" text,
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
"source_refs" jsonb DEFAULT '{}' NOT NULL,
"metadata_status" "books"."metadata_status" DEFAULT 'pending'::"books"."metadata_status" NOT NULL,
"metadata_source" text,
"metadata_fetched_at" timestamp with time zone,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "works_year_check" CHECK ("year" is null or "year" between 0 and 2100)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "files_path_key" ON "books"."files" ("path");--> statement-breakpoint
CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> statement-breakpoint
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> statement-breakpoint
CREATE INDEX "notes_work_id_idx" ON "books"."notes" ("work_id");--> statement-breakpoint
CREATE UNIQUE INDEX "progress_work_id_key" ON "books"."progress" ("work_id");--> statement-breakpoint
CREATE INDEX "shelf_works_work_id_idx" ON "books"."shelf_works" ("work_id");--> statement-breakpoint
CREATE UNIQUE INDEX "shelves_slug_key" ON "books"."shelves" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
CREATE INDEX "works_title_idx" ON "books"."works" (lower("title"));--> statement-breakpoint
ALTER TABLE "books"."files" ADD CONSTRAINT "files_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_shelf_id_shelves_id_fkey" FOREIGN KEY ("shelf_id") REFERENCES "books"."shelves"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, default shelves, GUI registration.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['works','files','shelves','progress','notes'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA books TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA books TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA books TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA books TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA books TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
INSERT INTO books.shelves (slug, name, sort_order) VALUES
('to-read', 'To read', 10), ('reading', 'Reading', 20), ('done', 'Done', 30), ('reference', 'Reference', 40)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('books','works','books-works','Books','core',true,false,1000),
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
('books','files','books-files','Book files','core',false,false,1000)
ON CONFLICT DO NOTHING;
PERFORM meta.sync();
END IF;
END $$;

View File

@@ -1,148 +0,0 @@
CREATE SCHEMA "health";
--> statement-breakpoint
CREATE TABLE "health"."appointments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL,
"type" text DEFAULT 'appointment' NOT NULL,
"provider" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "appointments_type_check" CHECK ("type" IN ('appointment','checkup','condition','test','procedure','other')),
CONSTRAINT "appointments_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."body_measurements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL,
"weight_kg" numeric(5,2),
"waist_cm" numeric(5,1),
"body_fat_pct" numeric(4,1),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "body_measurements_any_check" CHECK ("weight_kg" IS NOT NULL OR "waist_cm" IS NOT NULL OR "body_fat_pct" IS NOT NULL),
CONSTRAINT "body_measurements_weight_check" CHECK ("weight_kg" IS NULL OR "weight_kg" BETWEEN 20 AND 400),
CONSTRAINT "body_measurements_waist_check" CHECK ("waist_cm" IS NULL OR "waist_cm" BETWEEN 30 AND 250),
CONSTRAINT "body_measurements_fat_check" CHECK ("body_fat_pct" IS NULL OR "body_fat_pct" BETWEEN 1 AND 75),
CONSTRAINT "body_measurements_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."medications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" text NOT NULL,
"kind" text DEFAULT 'medication' NOT NULL,
"dose" text,
"frequency" text,
"start_date" date,
"stop_date" date,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "medications_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "medications_kind_check" CHECK ("kind" IN ('medication','supplement')),
CONSTRAINT "medications_dates_check" CHECK ("stop_date" IS NULL OR "start_date" IS NULL OR "stop_date" >= "start_date")
);
--> statement-breakpoint
CREATE TABLE "health"."nutrition_daily" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL CONSTRAINT "nutrition_daily_date_key" UNIQUE,
"kcal" integer,
"protein_g" numeric(5,1),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "nutrition_daily_kcal_check" CHECK ("kcal" IS NULL OR "kcal" BETWEEN 0 AND 20000),
CONSTRAINT "nutrition_daily_protein_check" CHECK ("protein_g" IS NULL OR "protein_g" BETWEEN 0 AND 1000),
CONSTRAINT "nutrition_daily_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."sleep" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL CONSTRAINT "sleep_date_key" UNIQUE,
"bedtime" timestamp with time zone NOT NULL,
"wake_time" timestamp with time zone NOT NULL,
"hours" numeric(4,2) GENERATED ALWAYS AS (round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)) STORED,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sleep_order_check" CHECK ("wake_time" > "bedtime"),
CONSTRAINT "sleep_length_check" CHECK ("wake_time" - "bedtime" <= interval '24 hours'),
CONSTRAINT "sleep_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."vaccinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"vaccine" text NOT NULL,
"target" text,
"dose_no" integer,
"date" date NOT NULL,
"batch_lot" text,
"provider" text,
"country" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "vaccinations_vaccine_check" CHECK (length(btrim("vaccine")) > 0),
CONSTRAINT "vaccinations_dose_no_check" CHECK ("dose_no" IS NULL OR "dose_no" BETWEEN 1 AND 20),
CONSTRAINT "vaccinations_country_check" CHECK ("country" IS NULL OR "country" ~ '^[A-Z]{2}$'),
CONSTRAINT "vaccinations_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."workouts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL,
"session_id" uuid,
"exercise" text NOT NULL,
"sets" integer,
"reps" integer,
"load_kg" numeric(6,2),
"rpe" numeric(3,1),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "workouts_exercise_check" CHECK (length(btrim("exercise")) > 0),
CONSTRAINT "workouts_sets_check" CHECK ("sets" IS NULL OR "sets" BETWEEN 1 AND 100),
CONSTRAINT "workouts_reps_check" CHECK ("reps" IS NULL OR "reps" BETWEEN 0 AND 1000),
CONSTRAINT "workouts_load_check" CHECK ("load_kg" IS NULL OR "load_kg" BETWEEN 0 AND 1000),
CONSTRAINT "workouts_rpe_check" CHECK ("rpe" IS NULL OR "rpe" BETWEEN 1 AND 10),
CONSTRAINT "workouts_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE INDEX "appointments_date_idx" ON "health"."appointments" ("date");--> statement-breakpoint
CREATE INDEX "body_measurements_date_idx" ON "health"."body_measurements" ("date");--> statement-breakpoint
CREATE INDEX "medications_start_date_idx" ON "health"."medications" ("start_date");--> statement-breakpoint
CREATE INDEX "medications_stop_date_idx" ON "health"."medications" ("stop_date");--> statement-breakpoint
CREATE INDEX "vaccinations_date_idx" ON "health"."vaccinations" ("date");--> statement-breakpoint
CREATE INDEX "workouts_date_idx" ON "health"."workouts" ("date");--> statement-breakpoint
CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id");
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table (07_triggers_meta_grants.sql).
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','appointments'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON health.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Privacy: health data is readable/writable by the app role only. Unlike the other core
-- schemas it is deliberately NOT granted to personal_ro, and it is not registered in
-- meta.tables (so the generic list/table GUI never shows it).
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh.
REVOKE ALL ON SCHEMA health FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA health TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA health TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;

945
scripts/books-ingest.py Normal file
View File

@@ -0,0 +1,945 @@
#!/usr/bin/env python3
"""Books ingest + metadata enrichment.
Builds books.works / books.files from:
* lists.books rows (the old GUI list) -> placeholders
* Obsidian "Book links.md" md5s resolved via the libgen JSON API -> placeholders
* ebook files dropped in $BOOKS_ROOT/inbox (sha256-deduped) -> works + files
Metadata: embedded (EPUB OPF / PDF info + ISBN scan) -> Open Library -> Google Books
(-> arXiv for anything tagged paper). Covers go to $BOOKS_ROOT/covers/<work_id>.jpg.
Files are moved to $BOOKS_ROOT/library/<Author>/<Title>/.
Usage:
DB_URL=postgresql://... BOOKS_ROOT=~/books \
python scripts/books-ingest.py --obsidian-json resolved.json [--reset] [--dry-run]
Network calls are cached in $BOOKS_ROOT/.cache/http so reruns are cheap and polite.
Refuses to run against the prod database name ('personal') unless --allow-prod.
"""
from __future__ import annotations
import argparse, difflib, hashlib, html, json, os, re, shutil, sys, time, unicodedata, urllib.parse, urllib.request, zipfile
from dataclasses import dataclass, field
from pathlib import Path
from xml.etree import ElementTree as ET
UA = "stan-books-ingest/0.1 (personal library catalogue; low volume)"
LANG = {"eng": "en", "dut": "nl", "nld": "nl", "ger": "de", "deu": "de", "fre": "fr", "fra": "fr", "spa": "es", "ita": "it",
"por": "pt", "rus": "ru", "jpn": "ja", "kor": "ko", "chi": "zh", "zho": "zh", "lat": "la", "gre": "el", "swe": "sv"}
BOOK_EXT = {".pdf", ".epub", ".mobi", ".azw3", ".azw", ".lit", ".djvu", ".docx", ".fb2", ".cbz"}
# ---------------------------------------------------------------- helpers
def strip_accents(s: str) -> str:
s = (s or "").replace("'", "").replace("’", "")
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c))
def norm_title(t: str) -> str:
t = strip_accents(html.unescape(t or "")).lower().replace("&", " and ")
t = re.split(r"\s*[:(\[]\s*|\s+[-–—]\s+|_\s", t, maxsplit=1)[0] # main title only
t = re.sub(r"[^a-z0-9]+", " ", t).strip()
t = re.sub(r"^(the|a|an|de|het|een)\s+", "", t)
t = re.sub(r"\s+(\d+(st|nd|rd|th)?\s+edition|ed)$", "", t)
return t
def full_norm(t: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", strip_accents(t or "").lower()).strip()
def sim(a: str, b: str) -> float:
na, nb = norm_title(a), norm_title(b)
a, b = full_norm(a), full_norm(b)
if not a or not b:
return 0.0
if a == b:
return 1.0
# volume / part / edition markers must agree ("Finance I" vs "Finance II", "Part 1" vs "Part 2")
marks = lambda x: {t for t in x.split() if re.fullmatch(r"\d{1,2}|[ivx]{1,4}", t)}
if marks(a) != marks(b) and marks(a) and marks(b):
return 0.5
r = difflib.SequenceMatcher(None, a, b).ratio()
if na and na == nb and len(na) >= 15:
r = max(r, 0.93)
return r
def surnames(authors: list[str]) -> set[str]:
out = set()
for a in authors or []:
a = strip_accents(a).lower()
if "," in a: # "King, Michael"
a = a.split(",")[0]
parts = re.findall(r"[a-z]{3,}", a)
if parts:
out.add(parts[-1] if "," not in a else parts[0])
out.update(parts)
return out
JUNK_AUTHORS = {"admin", "administrator", "user", "owner", "unknown", "oreily", "oreilly", "o'reilly", "author", "john", "mr2"}
def clean_authors(names) -> list[str]:
out = []
for n in names or []:
n = re.sub(r"\s+", " ", (n or "").strip(" ,;"))
low = n.lower()
if not n or low in JUNK_AUTHORS or re.search(r"\d|@|\.(com|org|net)\b", n) or " " not in n and "," not in n and "." not in n:
continue
if n.isupper():
n = n.title()
out.append(n)
return out
def authors_compatible(a: list[str], b: list[str]) -> bool:
if not a or not b:
return True
return bool(surnames(a) & surnames(b))
def isbn_ok(s: str) -> str | None:
s = re.sub(r"[^0-9Xx]", "", s or "").upper()
if len(s) == 13 and s.isdigit() and s[:3] in ("978", "979"):
tot = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(s[:12]))
return s if (10 - tot % 10) % 10 == int(s[12]) else None
if len(s) == 10 and s[:9].isdigit():
tot = sum((10 - i) * int(c) for i, c in enumerate(s[:9]))
chk = (11 - tot % 11) % 11
return s if (("X" if chk == 10 else str(chk)) == s[9]) else None
return None
def to13(s: str) -> str | None:
s = isbn_ok(s)
if not s:
return None
if len(s) == 13:
return s
core = "978" + s[:9]
tot = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(core))
return core + str((10 - tot % 10) % 10)
def to10(s13: str) -> str | None:
if not s13 or not s13.startswith("978"):
return None
core = s13[3:12]
tot = sum((10 - i) * int(c) for i, c in enumerate(core))
chk = (11 - tot % 11) % 11
return core + ("X" if chk == 10 else str(chk))
ISBN_RE = re.compile(r"ISBN(?:-1[03])?[:\s]*((?:97[89][\s-]?)?(?:\d[\s-]?){9}[\dXx])", re.I)
def find_isbns(text: str) -> list[str]:
out = []
for m in ISBN_RE.finditer(text or ""):
i = to13(m.group(1))
if i and i not in out:
out.append(i)
return out
def safe_name(s: str, n: int = 80) -> str:
s = strip_accents(s or "").replace("/", "-").replace("\\", "-")
s = re.sub(r'[<>:"|?*\x00-\x1f]', "", s)
s = re.sub(r"\s+", " ", s).strip(" .")
return (s[:n].rstrip(" .") or "Untitled")
def year_of(v) -> int | None:
m = re.search(r"(1[5-9]\d\d|20\d\d)", str(v or ""))
return int(m.group(1)) if m else None
# ---------------------------------------------------------------- http with cache
class Http:
def __init__(self, cache: Path, delay: float = 1.0):
self.cache, self.delay, self.last = cache, delay, 0.0
cache.mkdir(parents=True, exist_ok=True)
self.calls = 0
self.dead_hosts: set[str] = set()
def _get(self, url: str, binary=False):
host = urllib.parse.urlparse(url).netloc
if host in self.dead_hosts: # daily quota hit (Google Books): stop hammering it this run
return 429, b""
wait = self.delay - (time.time() - self.last)
if wait > 0:
time.sleep(wait)
self.last = time.time()
self.calls += 1
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
if e.code == 429 and "googleapis" in host:
self.dead_hosts.add(host)
log(f" {host}: quota exhausted (429), skipping it for the rest of this run")
return 429, b""
if e.code in (429, 503) and attempt < 2:
time.sleep(5 * (attempt + 1))
continue
return e.code, b""
except Exception:
if attempt < 2:
time.sleep(3)
continue
return 0, b""
return 0, b""
def json(self, url: str):
key = self.cache / (hashlib.sha1(url.encode()).hexdigest() + ".json")
if key.exists():
return json.loads(key.read_text())
status, body = self._get(url)
data = None
if status == 200:
try:
data = json.loads(body)
except Exception:
data = None
if status in (200, 404): # don't cache transient errors
key.write_text(json.dumps(data))
return data
def text(self, url: str):
key = self.cache / (hashlib.sha1(url.encode()).hexdigest() + ".txt")
if key.exists():
return key.read_text()
status, body = self._get(url)
if status == 200:
key.write_text(body.decode("utf-8", "replace"))
return body.decode("utf-8", "replace")
return None
def download(self, url: str, dest: Path) -> bool:
status, body = self._get(url, binary=True)
if status == 200 and len(body) > 1500: # skip 1x1 placeholders
dest.write_bytes(body)
return True
return False
# ---------------------------------------------------------------- model
@dataclass
class Work:
title: str
authors: list[str] = field(default_factory=list)
subtitle: str | None = None
isbns: list[str] = field(default_factory=list) # isbn13s, best first
publisher: str | None = None
year: int | None = None
language: str | None = None
page_count: int | None = None
description: str | None = None
tags: list[str] = field(default_factory=list)
cover_url: str | None = None
cover_path: str | None = None
ol_work: str | None = None
ol_edition: str | None = None
gb_id: str | None = None
md5s: list[str] = field(default_factory=list)
refs: dict = field(default_factory=dict)
status: str = "pending"
source: str | None = None
notes: str | None = None
files: list = field(default_factory=list)
shelves: set = field(default_factory=set)
origins: set = field(default_factory=set)
db_id: int | None = None
def add_ref(self, k, v):
self.refs.setdefault(k, [])
if v not in self.refs[k]:
self.refs[k].append(v)
@dataclass
class FileRec:
path: Path
rel_inbox: str
sha256: str
md5: str
size: int
fmt: str
title: str | None = None
authors: list[str] = field(default_factory=list)
isbns: list[str] = field(default_factory=list)
year: int | None = None
publisher: str | None = None
language: str | None = None
pages: int | None = None
is_paper: bool = False
broken: str | None = None
dest_rel: str | None = None
# ---------------------------------------------------------------- embedded metadata
def epub_meta(p: Path) -> dict:
out: dict = {}
try:
with zipfile.ZipFile(p) as z:
cont = ET.fromstring(z.read("META-INF/container.xml"))
opf_path = next(e.get("full-path") for e in cont.iter() if e.tag.endswith("rootfile"))
opf = ET.fromstring(z.read(opf_path))
md = next((e for e in opf.iter() if e.tag.endswith("metadata")), None)
if md is not None:
for e in md:
tag = e.tag.split("}")[-1]
txt = (e.text or "").strip()
if not txt:
continue
if tag == "title" and "title" not in out:
out["title"] = txt
elif tag == "creator":
out.setdefault("authors", []).append(txt)
elif tag == "identifier":
i = to13(txt.replace("urn:isbn:", ""))
if i:
out.setdefault("isbns", []).append(i)
elif tag == "publisher":
out.setdefault("publisher", txt)
elif tag == "date":
out.setdefault("year", year_of(txt))
elif tag == "language":
out.setdefault("language", txt[:2].lower())
# scan the first few content docs (copyright page) for ISBNs
names = [n for n in z.namelist() if n.lower().endswith((".xhtml", ".html", ".htm"))][:12]
for n in names:
for i in find_isbns(re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore"))):
out.setdefault("isbns", [])
if i not in out["isbns"]:
out["isbns"].append(i)
except Exception as e: # noqa
out["error"] = f"epub: {e.__class__.__name__}"
return out
def pdf_meta(p: Path) -> dict:
out: dict = {}
try:
from pypdf import PdfReader
import logging
logging.getLogger("pypdf").setLevel(logging.ERROR)
r = PdfReader(str(p), strict=False)
out["pages"] = len(r.pages)
info = r.metadata or {}
t = (info.get("/Title") or "").strip() if info else ""
a = (info.get("/Author") or "").strip() if info else ""
if t:
out["title"] = t
if a:
out["authors"] = clean_authors(re.split(r";|\band\b|&", a))
text = ""
for i in range(min(len(r.pages), 10)):
try:
text += (r.pages[i].extract_text() or "") + "\n"
except Exception:
pass
out["isbns"] = find_isbns(text)
if re.search(r"arXiv:\d{4}\.\d{4,5}", text):
out["arxiv"] = re.search(r"arXiv:(\d{4}\.\d{4,5})", text).group(1)
except Exception as e: # noqa
out["error"] = f"pdf: {e.__class__.__name__}"
return out
ANNAS_RE = re.compile(r"^(?P<title>.+?) -- (?P<author>.+?) -- (?P<rest>.+?) -- (?P<md5>[0-9a-f]{32}) -- Anna’s Archive$")
def filename_meta(stem: str) -> dict:
out: dict = {}
m = ANNAS_RE.match(stem)
if m:
out["title"] = m["title"].replace("_ ", ": ").replace("_", " ")
out["authors"] = [a.strip().replace("_", ".") for a in m["author"].split(",") if a.strip()]
out["md5"] = m["md5"]
for part in m["rest"].split(" -- "):
i = to13(part)
if i:
out.setdefault("isbns", []).append(i)
elif year_of(part) and "year" not in out:
out["year"] = year_of(part)
return out
s = re.sub(r"\s*-\s*libgen\.(li|rs|is)$", "", stem)
s = re.sub(r"\s*\((z-library|z-lib\.org|1lib\.\w+)\)", "", s, flags=re.I)
s = re.sub(r"\s+(penguin (modern )?classics)$", "", s, flags=re.I)
s = re.sub(r"\s+0$", "", s)
mm = re.match(r"^([^-]+?) : ([^-]+?) - (.+)$", s) # "A : B - Title" (libgen multi-author)
if mm:
out["authors"] = [mm[1].strip(), mm[2].strip()]
s = mm[3]
pm = re.search(r"\s*\((\d{4}),\s*([^)]+)\)\s*$", s)
if pm:
out["year"], out["publisher"] = int(pm[1]), pm[2].strip()
s = s[: pm.start()]
# "Author - Title" (only when the left part looks like a person name)
mm = re.match(r"^([A-Z][a-z]+(?: [A-Z][a-z]+){1,2}) - (.+)$", s)
if mm:
out["authors"] = [mm[1]]
s = mm[2]
mm = re.match(r"^(.+?) by ([A-Z][\w.]+(?: [A-Z][\w.]+){1,2})$", s)
if mm:
s, out["authors"] = mm[1], [mm[2]]
if s.count("-") >= 4 and " " not in s: # The-Lean-Startup_-How-...-Eric-Ries-2011-Crown-Business-030788791X-
for tok in s.split("-"):
i = to13(tok)
if i:
out.setdefault("isbns", []).append(i)
s = s.split("_")[0].replace("-", " ")
s = s.replace("_ ", ": ").replace("_", " ")
if s.count(".") >= 3 and s.count(" ") <= 1: # Sipser_Introduction.to.the.Theory.of.Computation.3E
s = s.replace(".", " ")
s = re.sub(r"\s+", " ", s).strip(" -")
out["title"] = s
return out
def good_embedded_title(t: str | None, fname_title: str) -> bool:
if not t or len(t) < 4:
return False
bad = ("untitled", "microsoft word", ".doc", ".pdf", ".indd", "layout 1", "unknown", "title", "cover")
tl = t.lower()
if any(b in tl for b in bad) or re.fullmatch(r"[\w-]+\.\w+", t) or re.fullmatch(r"[0-9a-f\-_ ]+", tl):
return False
return sim(t, fname_title) > 0.55
# ---------------------------------------------------------------- enrichment
class Enricher:
def __init__(self, http: Http):
self.h = http
def ol_isbn(self, isbn: str):
ed = self.h.json(f"https://openlibrary.org/isbn/{isbn}.json")
if not ed or "title" not in ed:
return None
rec = {"title": ed.get("title"), "subtitle": ed.get("subtitle"), "publisher": (ed.get("publishers") or [None])[0],
"year": year_of(ed.get("publish_date")), "pages": ed.get("number_of_pages"),
"ol_edition": ed.get("key", "").split("/")[-1] or None, "isbn": isbn,
"cover": f"https://covers.openlibrary.org/b/id/{ed['covers'][0]}-L.jpg" if ed.get("covers") and ed["covers"][0] > 0 else None,
"lang": LANG.get(((ed.get("languages") or [{}])[0].get("key") or "").split("/")[-1])}
wk = (ed.get("works") or [{}])[0].get("key")
if wk:
w = self.h.json(f"https://openlibrary.org{wk}.json") or {}
rec["ol_work"] = wk.split("/")[-1]
d = w.get("description")
rec["description"] = d.get("value") if isinstance(d, dict) else d
rec["subjects"] = (w.get("subjects") or [])[:8]
names = []
for a in (w.get("authors") or [])[:4]:
k = (a.get("author") or {}).get("key")
if k:
aj = self.h.json(f"https://openlibrary.org{k}.json") or {}
if aj.get("name"):
names.append(aj["name"])
rec["authors"] = names
if not rec["cover"] and w.get("covers") and w["covers"][0] > 0:
rec["cover"] = f"https://covers.openlibrary.org/b/id/{w['covers'][0]}-L.jpg"
return rec
def ol_search(self, title: str, authors: list[str]):
q = {"title": norm_title(title) if len(norm_title(title)) > 3 else title, "limit": 8,
"fields": "key,title,subtitle,author_name,first_publish_year,isbn,cover_i,cover_edition_key,language,number_of_pages_median,publisher,subject"}
if authors:
q["author"] = re.sub(r"[^\w\s.-]", " ", authors[0])
d = self.h.json("https://openlibrary.org/search.json?" + urllib.parse.urlencode(q))
docs = (d or {}).get("docs") or []
if not docs and authors:
q.pop("author")
d = self.h.json("https://openlibrary.org/search.json?" + urllib.parse.urlencode(q))
docs = (d or {}).get("docs") or []
best, bs = None, 0.0
for doc in docs:
t = doc.get("title", "")
s = max(sim(title, t), sim(title, f"{t}: {doc.get('subtitle', '')}"))
if norm_title(title) and norm_title(title) == norm_title(t) and (
authors and doc.get("author_name") and authors_compatible(authors, doc["author_name"]) or len(norm_title(t)) >= 15):
s = max(s, 0.92)
if authors and doc.get("author_name"):
s += 0.05 if authors_compatible(authors, doc["author_name"]) else -0.25
if s > bs:
best, bs = doc, s
if not best:
return None, 0.0
isbns = [to13(i) for i in (best.get("isbn") or [])]
isbns = [i for i in isbns if i]
rec = {"title": best.get("title"), "subtitle": best.get("subtitle"), "authors": best.get("author_name") or [],
"year": best.get("first_publish_year"), "ol_work": best.get("key", "").split("/")[-1] or None,
"ol_edition": best.get("cover_edition_key"), "pages": best.get("number_of_pages_median"),
"publisher": (best.get("publisher") or [None])[0],
"cover": f"https://covers.openlibrary.org/b/id/{best['cover_i']}-L.jpg" if best.get("cover_i") else None,
"lang": LANG.get((best.get("language") or [""])[0]), "subjects": (best.get("subject") or [])[:8],
"isbn": next((i for i in isbns if i.startswith("978")), isbns[0] if isbns else None)}
if rec["ol_work"]:
w = self.h.json(f"https://openlibrary.org/works/{rec['ol_work']}.json") or {}
dsc = w.get("description")
rec["description"] = dsc.get("value") if isinstance(dsc, dict) else dsc
return rec, min(bs, 1.0)
def gb(self, title: str | None, authors: list[str], isbn: str | None = None):
if isbn:
q = f"isbn:{isbn}"
else:
q = f'intitle:"{norm_title(title) or title}"' + (f" inauthor:{surnames(authors[:1]).pop()}" if authors and surnames(authors[:1]) else "")
d = self.h.json("https://www.googleapis.com/books/v1/volumes?" + urllib.parse.urlencode({"q": q, "maxResults": 8, "printType": "books"}))
items = (d or {}).get("items") or []
best, bs = None, 0.0
for it in items:
v = it.get("volumeInfo", {})
if isbn:
s = 1.0
else:
s = max(sim(title, v.get("title", "")), sim(title, f"{v.get('title', '')}: {v.get('subtitle', '')}"))
if norm_title(title) and norm_title(title) == norm_title(v.get("title", "")) and (
authors and v.get("authors") and authors_compatible(authors, v["authors"]) or len(norm_title(title)) >= 15):
s = max(s, 0.92)
if authors and v.get("authors"):
s += 0.05 if authors_compatible(authors, v["authors"]) else -0.25
if s > bs:
best, bs = it, s
if isbn:
break
if not best:
return None, 0.0
v = best["volumeInfo"]
ids = {x.get("type"): x.get("identifier") for x in v.get("industryIdentifiers", [])}
img = (v.get("imageLinks") or {})
cover = img.get("extraLarge") or img.get("large") or img.get("medium") or img.get("thumbnail")
if cover:
cover = cover.replace("http://", "https://").replace("&edge=curl", "")
return {"title": v.get("title"), "subtitle": v.get("subtitle"), "authors": v.get("authors") or [],
"publisher": v.get("publisher"), "year": year_of(v.get("publishedDate")), "pages": v.get("pageCount"),
"description": re.sub(r"<[^>]+>", "", v.get("description") or "") or None,
"lang": (v.get("language") or "")[:2] or None, "gb_id": best.get("id"), "cover": cover,
"isbn": to13(ids.get("ISBN_13") or ids.get("ISBN_10") or ""), "subjects": v.get("categories") or []}, min(bs, 1.0)
def arxiv(self, title: str, arxiv_id: str | None = None):
if arxiv_id:
url = f"https://export.arxiv.org/api/query?id_list={arxiv_id}"
else:
t = re.sub(r"[^\w\s]", " ", title)
url = "https://export.arxiv.org/api/query?" + urllib.parse.urlencode({"search_query": f'ti:"{t}"', "max_results": 5})
x = self.h.text(url)
if not x:
return None, 0.0
ns = {"a": "http://www.w3.org/2005/Atom"}
try:
root = ET.fromstring(x)
except ET.ParseError:
return None, 0.0
best, bs = None, 0.0
for e in root.findall("a:entry", ns):
t = re.sub(r"\s+", " ", e.findtext("a:title", "", ns)).strip()
if not t or t == "Error":
continue
s = 1.0 if arxiv_id else sim(title, t)
if s > bs:
best, bs = (e, t), s
if not best:
return None, 0.0
e, t = best
return {"title": t, "authors": [a.findtext("a:name", "", ns) for a in e.findall("a:author", ns)],
"year": year_of(e.findtext("a:published", "", ns)),
"description": re.sub(r"\s+", " ", e.findtext("a:summary", "", ns)).strip(),
"arxiv": e.findtext("a:id", "", ns).split("/abs/")[-1]}, bs
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--obsidian-json", help="md5 -> libgen record map from the resolver")
ap.add_argument("--extra", action="append", default=[], help='extra placeholder "Title|Author|isbn13|origin"')
ap.add_argument("--reset", action="store_true", help="truncate books.works/files first (scratch DBs only)")
ap.add_argument("--dry-run", action="store_true", help="no DB writes, no file moves; write plan json")
ap.add_argument("--no-network", action="store_true")
ap.add_argument("--allow-prod", action="store_true")
ap.add_argument("--source", default="gdrive")
args = ap.parse_args()
root = Path(os.path.expanduser(os.environ.get("BOOKS_ROOT", "/srv/books")))
inbox, library, covers = root / "inbox", root / "library", root / "covers"
for d in (inbox, library, covers):
d.mkdir(parents=True, exist_ok=True)
db_url = os.environ.get("DB_URL")
if not db_url:
sys.exit("DB_URL required")
dbname = urllib.parse.urlparse(db_url).path.lstrip("/")
if dbname == "personal" and not args.allow_prod:
sys.exit("refusing to run against prod DB 'personal' (use --allow-prod)")
import psycopg
conn = psycopg.connect(db_url)
http = Http(root / ".cache" / "http", delay=0.0 if args.no_network else 1.0)
en = Enricher(http)
works: list[Work] = []
log = lambda *a: print(*a, flush=True) # noqa
# ---- 1. placeholders: lists.books
with conn.cursor() as cur:
cur.execute("select to_regclass('lists.books')")
has_lists = cur.fetchone()[0] is not None
rows = []
if has_lists:
cur.execute("select id, name, author, notes, done_at from lists.books order by sort_order nulls last, id")
rows = cur.fetchall()
for bid, name, author, notes, done_at in rows:
w = Work(title=name.strip(), authors=[author.strip()] if author else [], notes=notes or None)
w.add_ref("lists_books_id", bid)
w.origins.add("lists")
w.shelves.add("done" if done_at else "to-read")
if done_at:
w.refs["done_at"] = done_at.isoformat()
works.append(w)
n_lists = len(rows)
def find_work(title, authors, isbns, md5=None):
for w in works:
if md5 and md5 in w.md5s:
return w
if isbns and set(isbns) & set(w.isbns) and (not title or not w.title or sim(title, w.title) >= 0.85):
return w
best, bs = None, 0.0
for w in works:
s = sim(title, w.title)
if s >= 0.9 and authors_compatible(authors, w.authors) and s > bs:
best, bs = w, s
return best
def merge(w: Work, title, authors, isbns, **kw):
if authors and not w.authors:
w.authors = authors
for i in isbns or []:
if i not in w.isbns:
w.isbns.append(i)
for k in ("year", "publisher", "language"):
if kw.get(k) and not getattr(w, k):
setattr(w, k, kw[k])
# prefer the longer, properly-cased title when the list name is a lowercase shorthand
if title and (w.title.islower() or len(norm_title(w.title)) < len(norm_title(title)) and sim(w.title, title) > 0.9 and w.title == w.title.lower()):
w.title = title
# ---- 2. placeholders: Obsidian md5s
obs_total = obs_resolved = obs_overlap = 0
if args.obsidian_json:
data = json.load(open(args.obsidian_json))
for md5, rec in data.items():
obs_total += 1
eds = (rec or {}).get("editions") or []
if not eds:
continue
obs_resolved += 1
ed = next((e for e in eds if e.get("isbns")), eds[0])
isbns = []
for e in eds:
for i in e.get("isbns") or []:
i13 = to13(i)
if i13 and i13 not in isbns:
isbns.append(i13)
title = (ed.get("title") or rec.get("locator") or md5).strip()
authors = [a.strip() for a in re.split(r";|, (?=[A-Z][a-z]+ [A-Z])", ed.get("author") or "") if a.strip()]
lang = LANG.get((ed.get("lang") or [""])[0], None) if isinstance(ed.get("lang"), list) else None
w = find_work(title, authors, isbns, md5)
if w:
if "obsidian" not in w.origins:
obs_overlap += 1
merge(w, title, authors, isbns, year=year_of(ed.get("year")), publisher=ed.get("publisher") or None, language=lang)
else:
w = Work(title=title, authors=authors, isbns=isbns, year=year_of(ed.get("year")), publisher=ed.get("publisher") or None, language=lang)
works.append(w)
w.shelves.add("to-read")
w.origins.add("obsidian")
if md5 not in w.md5s:
w.md5s.append(md5)
w.add_ref("obsidian", {"md5": md5, "ext": rec.get("ext"), "filesize": rec.get("filesize")})
for ex in args.extra:
t, a, i, origin = (ex.split("|") + ["", "", "", ""])[:4]
isbns = [to13(i)] if to13(i) else []
authors = [a] if a else []
w = find_work(t, authors, isbns)
if w:
merge(w, t, authors, isbns)
else:
w = Work(title=t, authors=authors, isbns=isbns)
works.append(w)
w.shelves.add("to-read")
w.origins.add("obsidian")
w.add_ref("obsidian_link", origin)
n_placeholders = len(works)
# ---- 3. files in inbox
files: list[FileRec] = []
for p in sorted(inbox.rglob("*")):
if not p.is_file() or p.name.startswith("."):
continue
h256, h5 = hashlib.sha256(), hashlib.md5()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h256.update(chunk)
h5.update(chunk)
rel = str(p.relative_to(inbox))
ext = p.suffix.lower()
with open(p, "rb") as hf:
head = hf.read(8)
fmt = ext.lstrip(".") or "bin"
if head.startswith(b"%PDF"):
fmt = "pdf"
elif head.startswith(b"PK") and ext in (".epub", ""):
fmt = "epub"
fr = FileRec(path=p, rel_inbox=rel, sha256=h256.hexdigest(), md5=h5.hexdigest(), size=p.stat().st_size, fmt=fmt)
fr.is_paper = rel.split("/")[0].lower() == "papers"
if ext == ".crdownload":
fr.broken = "partial Chrome download (.crdownload)"
fr.fmt = "pdf" if head.startswith(b"%PDF") else "partial"
files.append(fr)
by_sha: dict[str, FileRec] = {}
dups: list[tuple[FileRec, FileRec]] = []
uniq: list[FileRec] = []
for fr in files:
if fr.sha256 in by_sha:
dups.append((fr, by_sha[fr.sha256]))
else:
by_sha[fr.sha256] = fr
uniq.append(fr)
log(f"files: {len(files)} total, {len(uniq)} unique, {len(dups)} sha256 duplicates")
# already-ingested shas (rerun without --reset)
with conn.cursor() as cur:
cur.execute("select sha256 from books.files")
known = {r[0] for r in cur.fetchall()} if not args.reset else set()
matched_existing = 0
for fr in uniq:
if fr.sha256 in known:
continue
stem = Path(fr.rel_inbox).name
stem = stem[: -len(Path(stem).suffix)] if Path(stem).suffix.lower() in BOOK_EXT | {".crdownload"} else stem
fm = filename_meta(stem)
em = epub_meta(fr.path) if fr.fmt == "epub" else pdf_meta(fr.path) if fr.fmt == "pdf" and not fr.broken else {}
fr.title = fm.get("title") or stem
if fr.fmt == "epub" and good_embedded_title(em.get("title"), fr.title):
fr.title = em["title"]
fr.authors = fm.get("authors") or [a for a in em.get("authors", []) if len(a) > 2 and "unknown" not in a.lower()][:4]
fr.isbns = list(dict.fromkeys((fm.get("isbns") or []) + (em.get("isbns") or [])))[:6]
fr.year = fm.get("year") or em.get("year")
fr.publisher = fm.get("publisher") or em.get("publisher")
fr.language = em.get("language")
fr.pages = em.get("pages")
md5 = fm.get("md5") or fr.md5
w = find_work(fr.title, fr.authors, fr.isbns[:2], md5) or (find_work(fr.title, fr.authors, [], fr.md5))
if w and w.files == [] and w.origins & {"lists", "obsidian"} and "drive" not in w.origins:
matched_existing += 1
if w:
merge(w, fr.title, fr.authors, fr.isbns[:2], year=fr.year, publisher=fr.publisher, language=fr.language)
else:
w = Work(title=fr.title, authors=fr.authors, isbns=fr.isbns[:2], year=fr.year, publisher=fr.publisher, language=fr.language, page_count=fr.pages)
works.append(w)
if fr.is_paper and "paper" not in w.tags:
w.tags.append("paper")
if fr.md5 not in w.md5s and fm.get("md5"):
w.md5s.append(fm["md5"])
w.origins.add("drive")
w.add_ref("drive", fr.rel_inbox)
if em.get("arxiv"):
w.refs["arxiv"] = em["arxiv"]
if fr.broken:
w.notes = ((w.notes + "\n") if w.notes else "") + f"Drive copy is broken: {fr.broken}; needs a fresh copy."
w.add_ref("broken_files", fr.rel_inbox)
else:
w.files.append(fr)
for d, orig in dups:
for w in works:
if orig in w.files:
w.add_ref("drive_duplicates", d.rel_inbox)
log(f"works: {len(works)} ({n_placeholders} placeholders before files; {matched_existing} placeholders got files)")
# ---- 4. enrichment
stats = {"matched": 0, "low_confidence": 0, "not_found": 0}
for n, w in enumerate(works, 1):
if args.no_network:
break
rec, score, src = None, 0.0, None
if "paper" in w.tags:
rec, score = en.arxiv(w.title, w.refs.get("arxiv"))
src = "arxiv"
else:
for isbn in w.isbns[:3]:
rec = en.ol_isbn(isbn)
if rec and (sim(w.title, rec["title"]) > 0.6 or sim(w.title, f"{rec['title']}: {rec.get('subtitle') or ''}") > 0.6 or "drive" not in w.origins):
score, src = 1.0, "openlibrary"
break
rec = None
if not rec:
rec, score = en.ol_search(w.title, w.authors)
src = "openlibrary"
if score < 0.9:
g, gs = en.gb(None, [], w.isbns[0]) if w.isbns else (None, 0.0)
if g and sim(w.title, g["title"] or "") < 0.6 and sim(w.title, f"{g['title']}: {g.get('subtitle') or ''}") < 0.6:
g, gs = None, 0.0
if not g:
g, gs = en.gb(w.title, w.authors)
if g and gs > score:
rec, score, src = g, gs, "googlebooks"
if rec and score >= 0.9:
w.status = "matched"
elif rec and score >= 0.75:
w.status = "low_confidence"
else:
w.status = "not_found"
stats[w.status] += 1
if rec:
w.refs["match"] = {"source": src, "score": round(score, 3), "title": rec.get("title"), "authors": rec.get("authors")}
if w.status == "matched":
w.source = src
if src == "openlibrary" or src == "googlebooks":
# keep Stan's/the file's title wording unless it was a lowercase list shorthand or a filename
# keep Stan's own list wording; otherwise take the catalogue's properly-cased title
if rec.get("title") and ("lists" not in w.origins or w.title.islower()):
w.title = rec["title"]
w.subtitle = w.subtitle or rec.get("subtitle")
elif src == "arxiv":
w.title = rec["title"]
if rec.get("authors"):
w.authors = rec["authors"][:6]
w.year = w.year or rec.get("year")
if src == "openlibrary" and rec.get("year") and w.year and rec["year"] < w.year:
w.year = w.year # edition year stays; first-publish year recorded in refs
w.refs["first_publish_year"] = rec["year"]
w.publisher = w.publisher or rec.get("publisher")
w.page_count = w.page_count or rec.get("pages")
w.language = w.language or rec.get("lang")
w.description = w.description or rec.get("description")
if rec.get("isbn") and rec["isbn"] not in w.isbns:
w.isbns.insert(0 if not w.isbns else len(w.isbns), rec["isbn"])
w.ol_work = w.ol_work or rec.get("ol_work")
w.ol_edition = w.ol_edition or rec.get("ol_edition")
w.gb_id = w.gb_id or rec.get("gb_id")
w.cover_url = w.cover_url or rec.get("cover")
if rec.get("arxiv"):
w.refs["arxiv"] = rec["arxiv"]
for s in (rec.get("subjects") or [])[:5]:
s = s.lower().strip()
if 2 < len(s) < 40 and s not in w.tags:
w.tags.append(s)
if n % 25 == 0:
log(f" enriched {n}/{len(works)} ({http.calls} http calls)")
if args.no_network:
stats = {"skipped": len(works)}
log("metadata:", stats)
# cover fallback for matched works lacking one: Google Books by isbn
if not args.no_network:
for w in works:
if not w.cover_url and w.status == "matched" and "paper" not in w.tags and w.isbns:
g, _ = en.gb(None, [], w.isbns[0])
if g and g.get("cover"):
w.cover_url = g["cover"]
w.gb_id = w.gb_id or g.get("gb_id")
# ---- 5. plan destinations
used: set[str] = set()
for w in works:
author_dir = safe_name(w.authors[0] if w.authors else "Unknown", 60)
title_dir = safe_name(w.title, 90)
for fr in w.files:
base = f"{author_dir}/{title_dir}/{title_dir}"
rel = f"{base}.{fr.fmt}"
k = 2
while rel in used or (library / rel).exists() and not args.reset:
rel = f"{base} ({k}).{fr.fmt}"
k += 1
used.add(rel)
fr.dest_rel = rel
plan = [{"title": w.title, "authors": w.authors, "isbns": w.isbns, "status": w.status, "origins": sorted(w.origins),
"files": [{"from": f.rel_inbox, "to": f.dest_rel, "sha256": f.sha256} for f in w.files], "refs": w.refs,
"cover": w.cover_url} for w in works]
(root / ".cache").mkdir(exist_ok=True)
(root / ".cache" / "ingest-plan.json").write_text(json.dumps(plan, indent=1, default=str))
if args.dry_run:
log("dry run: plan written to", root / ".cache" / "ingest-plan.json")
return
# ---- 6. write DB
def isbn13(w):
return next((i for i in w.isbns if i.startswith("978") or i.startswith("979")), None)
seen13 = set()
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
with conn.transaction():
cur = conn.cursor()
if args.reset:
cur.execute("truncate books.notes, books.progress, books.shelf_works, books.files, books.works restart identity cascade")
cur.execute("select slug, id from books.shelves")
shelves = dict(cur.fetchall())
for w in works:
i13 = isbn13(w)
if i13 in seen13:
w.refs["isbn13_shared_with_other_work"] = i13
i13 = None
if i13:
seen13.add(i13)
refs = dict(w.refs)
refs["origins"] = sorted(w.origins)
if len(w.isbns) > 1:
refs["all_isbns"] = w.isbns
cur.execute(
"""insert into books.works (title, subtitle, authors, isbn10, isbn13, publisher, year, language, page_count,
description, tags, cover_url, openlibrary_work_id, openlibrary_edition_id, google_books_id, annas_md5,
source_refs, metadata_status, metadata_source, metadata_fetched_at, notes)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) returning id""",
(w.title, w.subtitle, w.authors, to10(i13) if i13 else None, i13, w.publisher,
w.year if w.year and 1400 < w.year < 2100 else None, w.language, w.page_count, w.description, w.tags,
w.cover_url, w.ol_work, w.ol_edition, w.gb_id, w.md5s, json.dumps(refs, default=str), w.status,
w.source, now if w.status != "pending" else None, w.notes))
w.db_id = cur.fetchone()[0]
for fr in w.files:
cur.execute("insert into books.files (work_id, path, format, size_bytes, sha256, md5, source) values (%s,%s,%s,%s,%s,%s,%s)",
(w.db_id, fr.dest_rel, fr.fmt, fr.size, fr.sha256, fr.md5, args.source))
for s in sorted(w.shelves):
if s in shelves:
cur.execute("insert into books.shelf_works (shelf_id, work_id) values (%s,%s) on conflict do nothing", (shelves[s], w.db_id))
if "done" in w.shelves and w.refs.get("done_at"):
cur.execute("insert into books.progress (work_id, percent, finished_at) values (%s, 100, %s) on conflict do nothing", (w.db_id, w.refs["done_at"]))
# ---- 7. covers
got = 0
if not args.no_network:
for w in works:
if not w.cover_url:
continue
dest = covers / f"{w.db_id}.jpg"
if dest.exists() or http.download(w.cover_url, dest):
got += 1
with conn.cursor() as cur:
cur.execute("update books.works set cover_path=%s where id=%s", (dest.name, w.db_id))
conn.commit()
# ---- 8. move files (inbox -> library); write undo manifest
manifest = root / ".cache" / f"moves-{time.strftime('%Y%m%d-%H%M%S')}.tsv"
dupdir = root / "duplicates"
qdir = root / "quarantine"
with open(manifest, "w") as mf:
for w in works:
for fr in w.files:
dest = library / fr.dest_rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(fr.path), dest)
mf.write(f"{fr.path}\t{dest}\n")
for d, _ in dups:
dest = dupdir / d.rel_inbox
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(d.path), dest)
mf.write(f"{d.path}\t{dest}\n")
for fr in uniq:
if fr.broken and fr.path.exists():
dest = qdir / fr.rel_inbox
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(fr.path), dest)
mf.write(f"{fr.path}\t{dest}\n")
for d in sorted(inbox.rglob("*"), reverse=True):
if d.is_dir() and not any(d.iterdir()):
d.rmdir()
summary = {
"lists_rows": n_lists, "obsidian_md5s": obs_total, "obsidian_resolved": obs_resolved,
"obsidian_overlapping_lists": obs_overlap, "placeholders": n_placeholders,
"files_total": len(files), "files_unique": len(uniq), "sha256_duplicates": len(dups),
"placeholders_with_files": matched_existing, "works_total": len(works),
"works_with_files": sum(1 for w in works if w.files), "metadata": stats, "covers": got,
"papers": sum(1 for w in works if "paper" in w.tags), "http_calls": http.calls, "manifest": str(manifest),
}
(root / ".cache" / "ingest-summary.json").write_text(json.dumps(summary, indent=1))
print(json.dumps(summary, indent=1))
if __name__ == "__main__":
main()

View File

@@ -1,72 +0,0 @@
/**
* Import health data (JSON or CSV) into the private `health` schema.
*
* bun scripts/health-import.ts data.json # {"vaccinations":[...],"sleep":[...]}
* bun scripts/health-import.ts --table vaccinations rivm.csv # CSV (header row) or a JSON array
* bun scripts/health-import.ts --table sleep - < sleep.csv # stdin
* add --dry-run to validate without writing
*
* Column names are the snake_case DB columns (see src/lib/health/tables.ts). Rows with an
* `id` update that row; nutrition_daily and sleep upsert on `date`; everything else is
* inserted (importing the same file twice duplicates those rows). All-or-nothing: if any
* row is invalid, nothing is written. Uses DB_URL (the app role on prod, personal_dev on dev).
*/
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { parseCsv, toPayload, validatePayload, writeRows } from '../src/lib/server/health-import';
let dryRun = false;
let table: string | undefined;
const files: string[] = [];
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === '--dry-run') dryRun = true;
else if (arg === '--table') table = process.argv[++i];
else if (arg.startsWith('--table=')) table = arg.slice(8);
else files.push(arg);
}
if (!files.length) {
console.error(
'usage: bun scripts/health-import.ts [--table <name>] [--dry-run] <file.json|file.csv|->...'
);
process.exit(2);
}
const read = (file: string) => (file === '-' ? Bun.stdin.text() : readFile(file, 'utf8'));
const merged: Record<string, unknown[]> = {};
try {
for (const file of files) {
const text = await read(file);
const csv = file.toLowerCase().endsWith('.csv') || (file === '-' && !/^\s*[[{]/.test(text));
const payload = toPayload(csv ? parseCsv(text) : JSON.parse(text), table);
for (const [t, rows] of Object.entries(payload)) (merged[t] ??= []).push(...(rows ?? []));
}
} catch (e) {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
}
const { clean, errors } = validatePayload(toPayload(merged));
if (errors.length) {
for (const e of errors.slice(0, 50)) console.error(`${e.table} row ${e.row}: ${e.error}`);
console.error(`${errors.length} invalid row(s); nothing imported.`);
process.exit(1);
}
const summary = Object.entries(clean)
.map(([t, r]) => `${t}: ${r.length}`)
.join(', ');
if (dryRun) {
console.log(`Valid (dry run, nothing written): ${summary}`);
process.exit(0);
}
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const counts = await writeRows(drizzle({ client }), clean);
for (const [t, c] of Object.entries(counts))
console.log(`${t}: ${c.inserted} inserted, ${c.updated} updated`);
} finally {
await client.end();
}

View File

@@ -1,8 +1,6 @@
import type { Handle } from '@sveltejs/kit/hooks'; import type { Handle } from '@sveltejs/kit/hooks';
import { building } from '$app/env'; import { building } from '$app/env';
import { auth } from '#lib/server/auth.js'; import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath } from '#lib/server/owner.js';
import { error } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite. // Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
@@ -11,22 +9,5 @@ export const handle: Handle = async ({ event, resolve }) => {
const session = building ? null : await auth.api.getSession({ headers: event.request.headers }); const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
event.locals.user = session?.user ?? null; event.locals.user = session?.user ?? null;
event.locals.session = session?.session ?? null; event.locals.session = session?.session ?? null;
// Owner-only areas (health tracker). Page loads, __data.json and the import endpoint return resolve(event);
// 404 for non-owners in the route itself (layout load / endpoint), which renders the
// same 404 page as an unknown URL. As a backstop, any other method (form actions,
// which skip layout loads) is refused here before a route runs.
const ownerOnly = isOwnerOnlyPath(event.url.pathname);
if (
ownerOnly &&
!isOwner(event) &&
event.request.method !== 'GET' &&
event.request.method !== 'HEAD'
)
error(404, 'Not Found');
const response = await resolve(event);
if (ownerOnly) {
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
response.headers.set('cache-control', 'private, no-store');
}
return response;
}; };

View File

@@ -12,7 +12,6 @@
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined'; import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import FolderKanban from 'lucide-svelte/icons/folder-kanban'; import FolderKanban from 'lucide-svelte/icons/folder-kanban';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2'; import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
import House from 'lucide-svelte/icons/house'; import House from 'lucide-svelte/icons/house';
import Globe2 from 'lucide-svelte/icons/globe-2'; import Globe2 from 'lucide-svelte/icons/globe-2';
import Languages from 'lucide-svelte/icons/languages'; import Languages from 'lucide-svelte/icons/languages';
@@ -35,8 +34,7 @@
'money tracker': Wallet, 'money tracker': Wallet,
'cs prices': CandlestickChart, 'cs prices': CandlestickChart,
'house tracker': House, 'house tracker': House,
lists: ListChecks, lists: ListChecks
health: HeartPulse
}; };
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban); const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);

View File

@@ -1,118 +0,0 @@
<script lang="ts">
// Weight + waist trend. Same lazy-loaded ECharts setup as the CS prices chart.
import { onMount } from 'svelte';
import type { ECharts } from 'echarts/core';
export type BodyPoint = { date: string; weightKg: number | null; waistCm: number | null };
let { points }: { points: BodyPoint[] } = $props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
const series = $derived([
{
name: 'Weight (kg)',
yAxisIndex: 0,
data: points.filter((p) => p.weightKg !== null).map((p) => [Date.parse(p.date), p.weightKg])
},
{
name: 'Waist (cm)',
yAxisIndex: 1,
data: points.filter((p) => p.waistCm !== null).map((p) => [Date.parse(p.date), p.waistCm])
}
]);
function update() {
if (!chart) return;
const style = getComputedStyle(document.documentElement);
const foreground = style.getPropertyValue('--site-text').trim();
const muted = style.getPropertyValue('--site-muted').trim();
const border = style.getPropertyValue('--site-border').trim();
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
const axis = (name: string) => ({
type: 'value' as const,
name,
scale: true,
nameTextStyle: { color: muted },
axisLabel: { color: muted },
splitLine: { lineStyle: { color: border } }
});
chart.setOption(
{
animation: false,
backgroundColor: 'transparent',
grid: { left: 48, right: 48, top: 36, bottom: 28 },
legend: { top: 0, textStyle: { color: muted } },
xAxis: {
type: 'time',
axisLine: { lineStyle: { color: border } },
axisLabel: { color: muted },
splitLine: { show: false }
},
yAxis: [axis('kg'), { ...axis('cm'), splitLine: { show: false } }],
tooltip: {
trigger: 'axis',
backgroundColor: style.getPropertyValue('--site-surface').trim(),
borderColor: border,
textStyle: { color: foreground }
},
series: series.map((s, i) => ({
...s,
type: 'line',
showSymbol: s.data.length < 60,
symbolSize: 5,
itemStyle: { color: `hsl(${i ? 200 : 25} 70% ${dark ? '65%' : '40%'})` },
lineStyle: { width: 2 }
}))
},
true
);
}
onMount(() => {
let disposed = false;
let resize: ResizeObserver | undefined;
let theme: MutationObserver | undefined;
import('echarts/core').then(async (echarts) => {
const [
{ LineChart },
{ GridComponent, TooltipComponent, LegendComponent },
{ CanvasRenderer }
] = await Promise.all([
import('echarts/charts'),
import('echarts/components'),
import('echarts/renderers')
]);
if (disposed) return;
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
chart = echarts.init(element, undefined, { renderer: 'canvas' });
ready = true;
update();
resize = new ResizeObserver(() => chart?.resize());
resize.observe(element);
theme = new MutationObserver(update);
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
});
return () => {
disposed = true;
resize?.disconnect();
theme?.disconnect();
chart?.dispose();
};
});
$effect(() => {
if (ready) update();
});
</script>
<div class="chart" bind:this={element} role="img" aria-label="Weight and waist trend"></div>
<style>
.chart {
width: 100%;
height: 240px;
}
</style>

View File

@@ -1,129 +0,0 @@
// Client-safe description of the health tables: used by the forms on /trackers/health
// and by the server-side validator in #lib/server/health-import.ts. Keys are the
// snake_case column names, which are also the import (JSON/CSV) field names.
export type FieldType =
'date' | 'text' | 'textarea' | 'integer' | 'number' | 'datetime' | 'select' | 'uuid';
export type Field = {
key: string;
label: string;
type: FieldType;
required?: boolean;
options?: readonly string[];
min?: number;
max?: number;
step?: number;
maxLength?: number;
placeholder?: string;
};
export const medicationKinds = ['medication', 'supplement'] as const;
export const appointmentTypes = [
'appointment',
'checkup',
'condition',
'test',
'procedure',
'other'
] as const;
const notes: Field = { key: 'notes', label: 'Notes', type: 'textarea', maxLength: 4000 };
const date: Field = { key: 'date', label: 'Date', type: 'date', required: true };
export const healthTables = {
vaccinations: {
label: 'Vaccinations',
fields: [
{ key: 'vaccine', label: 'Vaccine', type: 'text', required: true, maxLength: 200 },
{ key: 'target', label: 'Disease / target', type: 'text', maxLength: 200 },
{ key: 'dose_no', label: 'Dose no.', type: 'integer', min: 1, max: 20 },
date,
{ key: 'batch_lot', label: 'Batch / lot', type: 'text', maxLength: 100 },
{ key: 'provider', label: 'Provider', type: 'text', maxLength: 200 },
{
key: 'country',
label: 'Country (ISO code)',
type: 'text',
maxLength: 2,
placeholder: 'NL'
},
notes
]
},
body_measurements: {
label: 'Body measurements',
fields: [
date,
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number', min: 20, max: 400, step: 0.01 },
{
key: 'waist_cm',
label: 'Waist at navel (cm)',
type: 'number',
min: 30,
max: 250,
step: 0.1
},
{ key: 'body_fat_pct', label: 'Body fat (%)', type: 'number', min: 1, max: 75, step: 0.1 },
notes
]
},
workouts: {
label: 'Workouts',
fields: [
date,
{ key: 'exercise', label: 'Exercise', type: 'text', required: true, maxLength: 200 },
{ key: 'sets', label: 'Sets', type: 'integer', min: 1, max: 100 },
{ key: 'reps', label: 'Reps (per set)', type: 'integer', min: 0, max: 1000 },
{ key: 'load_kg', label: 'Load (kg)', type: 'number', min: 0, max: 1000, step: 0.25 },
{ key: 'rpe', label: 'RPE', type: 'number', min: 1, max: 10, step: 0.5 },
{ key: 'session_id', label: 'Session id (optional)', type: 'uuid' },
notes
]
},
nutrition_daily: {
label: 'Nutrition',
fields: [
date,
{ key: 'kcal', label: 'kcal', type: 'integer', min: 0, max: 20000 },
{ key: 'protein_g', label: 'Protein (g)', type: 'number', min: 0, max: 1000, step: 0.1 },
notes
]
},
sleep: {
label: 'Sleep',
fields: [
{ ...date, label: 'Date (wake-up day)' },
{ key: 'bedtime', label: 'Bedtime', type: 'datetime', required: true },
{ key: 'wake_time', label: 'Wake time', type: 'datetime', required: true },
notes
]
},
medications: {
label: 'Medications & supplements',
fields: [
{ key: 'name', label: 'Name', type: 'text', required: true, maxLength: 200 },
{ key: 'kind', label: 'Kind', type: 'select', options: medicationKinds },
{ key: 'dose', label: 'Dose', type: 'text', maxLength: 200, placeholder: '1 mg' },
{ key: 'frequency', label: 'Frequency', type: 'text', maxLength: 200, placeholder: 'daily' },
{ key: 'start_date', label: 'Start date', type: 'date' },
{ key: 'stop_date', label: 'Stop date', type: 'date' },
notes
]
},
appointments: {
label: 'Appointments & conditions',
fields: [
date,
{ key: 'type', label: 'Type', type: 'select', options: appointmentTypes },
{ key: 'provider', label: 'Provider', type: 'text', maxLength: 200 },
notes
]
}
} as const satisfies Record<string, { label: string; fields: readonly Field[] }>;
export type HealthTable = keyof typeof healthTables;
export const healthTableNames = Object.keys(healthTables) as HealthTable[];
export const isHealthTable = (value: unknown): value is HealthTable =>
typeof value === 'string' && Object.hasOwn(healthTables, value);

View File

@@ -71,19 +71,3 @@ export const trackers = [
external: false external: false
} }
] as const; ] as const;
// Owner-only trackers: shown in the header, home page and /trackers only to the logged-in
// owner (layout data `isOwner`), never in the sitemap. Their routes 404 for everyone else.
export const privateTrackers = [
{
name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
href: '/trackers/health',
external: false
}
] as const;
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
return isOwner ? [...trackers, ...privateTrackers] : trackers;
}

View File

@@ -0,0 +1,162 @@
import { sql } from 'drizzle-orm';
import {
bigint,
check,
index,
integer,
jsonb,
primaryKey,
real,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { booksSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Book tracker + reader. A work is one book (any edition we care about); a work can
// exist without files (placeholder for a book Stan wants but doesn't have yet).
// Files live on stan-vm under $BOOKS_ROOT/library (target /srv/books/library); covers under $BOOKS_ROOT/covers.
export const bookMetadataStatus = booksSchema.enum('metadata_status', [
'pending', // not looked up yet
'matched', // confident match (ISBN or exact title+author)
'low_confidence', // fuzzy match, needs a human look
'not_found',
'manual' // edited by hand; enrichment never overwrites it
]);
export const bookWorks = booksSchema.table(
'works',
{
id: id(),
title: text('title').notNull(),
subtitle: text('subtitle'),
authors: text('authors').array().notNull().default([]),
isbn10: text('isbn10'),
isbn13: text('isbn13'),
publisher: text('publisher'),
year: integer('year'),
language: text('language'), // ISO 639-1 where known, e.g. 'en', 'nl'
pageCount: integer('page_count'),
description: text('description'),
tags: text('tags').array().notNull().default([]),
coverUrl: text('cover_url'), // remote source of the cover
coverPath: text('cover_path'), // local file, relative to /srv/books/covers
// External ids
openlibraryWorkId: text('openlibrary_work_id'), // e.g. OL45883W
openlibraryEditionId: text('openlibrary_edition_id'), // e.g. OL7353617M
googleBooksId: text('google_books_id'),
annasMd5: text('annas_md5').array().notNull().default([]), // Anna's Archive / libgen file md5s
sourceRefs: jsonb('source_refs').$type<Record<string, unknown>>().notNull().default({}),
metadataStatus: bookMetadataStatus('metadata_status').notNull().default('pending'),
metadataSource: text('metadata_source'), // 'openlibrary' | 'googlebooks' | 'libgen' | 'manual'
metadataFetchedAt: timestamptz('metadata_fetched_at'),
notes: text('notes'),
...timestamps
},
(t) => [
uniqueIndex('works_isbn13_key')
.on(t.isbn13)
.where(sql`${t.isbn13} is not null`),
index('works_title_idx').on(sql`lower(${t.title})`),
check('works_year_check', sql`${t.year} is null or ${t.year} between 0 and 2100`)
]
);
export const bookFiles = booksSchema.table(
'files',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
path: text('path').notNull(), // relative to /srv/books/library
format: text('format').notNull(), // epub | pdf | mobi | azw3 | djvu
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
sha256: text('sha256').notNull(),
md5: text('md5'),
source: text('source'), // 'gdrive' | 'textbooks-repo' | 'annas' | 'upload'
...timestamps
},
(t) => [
uniqueIndex('files_path_key').on(t.path),
uniqueIndex('files_sha256_key').on(t.sha256),
index('files_work_id_idx').on(t.workId)
]
);
export const bookShelves = booksSchema.table(
'shelves',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [uniqueIndex('shelves_slug_key').on(t.slug)]
);
export const bookShelfWorks = booksSchema.table(
'shelf_works',
{
shelfId: integer('shelf_id')
.notNull()
.references(() => bookShelves.id, { onDelete: 'cascade' }),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
sortOrder: integer('sort_order').notNull().default(0),
addedAt: timestamptz('added_at').notNull().defaultNow()
},
(t) => [
primaryKey({ columns: [t.shelfId, t.workId] }),
index('shelf_works_work_id_idx').on(t.workId)
]
);
export const bookProgress = booksSchema.table(
'progress',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
locator: text('locator'), // EPUB CFI or 'page:123' for PDFs
page: integer('page'),
percent: real('percent').notNull().default(0),
startedAt: timestamptz('started_at'),
finishedAt: timestamptz('finished_at'),
...timestamps
},
(t) => [
uniqueIndex('progress_work_id_key').on(t.workId),
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
]
);
export const bookNotes = booksSchema.table(
'notes',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
locator: text('locator'),
page: integer('page'),
highlight: text('highlight'), // the selected text, if any
note: text('note'),
color: text('color'),
...timestamps
},
(t) => [index('notes_work_id_idx').on(t.workId)]
);
export type BookWork = typeof bookWorks.$inferSelect;
export type NewBookWork = typeof bookWorks.$inferInsert;
export type BookFile = typeof bookFiles.$inferSelect;
export type BookShelf = typeof bookShelves.$inferSelect;
export type BookProgress = typeof bookProgress.$inferSelect;
export type BookNote = typeof bookNotes.$inferSelect;

View File

@@ -1,179 +0,0 @@
import { check, date, index, integer, numeric, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { healthSchema } from './schemas';
import { timestamps, timestamptz } from './helpers';
// Private health log (owner only, see requireOwner). No user_id: there is exactly one
// owner, and the whole schema is readable by personal_app only (no personal_ro grant).
// Numerics use mode 'number' so the app gets plain numbers instead of strings.
const pk = () => uuid('id').primaryKey().defaultRandom();
const day = (name = 'date') => date(name, { mode: 'string' });
const sane = (column: string) => sql.raw(`"${column}" >= DATE '1900-01-01'`);
export const healthVaccinations = healthSchema.table(
'vaccinations',
{
id: pk(),
vaccine: text('vaccine').notNull(),
target: text('target'), // disease(s) it protects against, e.g. "DTP", "COVID-19"
doseNo: integer('dose_no'),
date: day().notNull(),
batchLot: text('batch_lot'),
provider: text('provider'), // GGD, GP, travel clinic...
country: text('country'), // ISO 3166-1 alpha-2, e.g. NL
notes: text('notes'),
...timestamps
},
(t) => [
index('vaccinations_date_idx').on(t.date),
check('vaccinations_vaccine_check', sql`length(btrim(${t.vaccine})) > 0`),
check('vaccinations_dose_no_check', sql`${t.doseNo} IS NULL OR ${t.doseNo} BETWEEN 1 AND 20`),
check('vaccinations_country_check', sql`${t.country} IS NULL OR ${t.country} ~ '^[A-Z]{2}$'`),
check('vaccinations_date_check', sane('date'))
]
);
export const healthBodyMeasurements = healthSchema.table(
'body_measurements',
{
id: pk(),
date: day().notNull(),
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
bodyFatPct: numeric('body_fat_pct', { precision: 4, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
index('body_measurements_date_idx').on(t.date),
check(
'body_measurements_any_check',
sql`${t.weightKg} IS NOT NULL OR ${t.waistCm} IS NOT NULL OR ${t.bodyFatPct} IS NOT NULL`
),
check(
'body_measurements_weight_check',
sql`${t.weightKg} IS NULL OR ${t.weightKg} BETWEEN 20 AND 400`
),
check(
'body_measurements_waist_check',
sql`${t.waistCm} IS NULL OR ${t.waistCm} BETWEEN 30 AND 250`
),
check(
'body_measurements_fat_check',
sql`${t.bodyFatPct} IS NULL OR ${t.bodyFatPct} BETWEEN 1 AND 75`
),
check('body_measurements_date_check', sane('date'))
]
);
export const healthWorkouts = healthSchema.table(
'workouts',
{
id: pk(),
date: day().notNull(),
sessionId: uuid('session_id'), // optional: groups the exercises of one session
exercise: text('exercise').notNull(),
sets: integer('sets'),
reps: integer('reps'), // per set
loadKg: numeric('load_kg', { precision: 6, scale: 2, mode: 'number' }),
rpe: numeric('rpe', { precision: 3, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
index('workouts_date_idx').on(t.date),
index('workouts_session_idx').on(t.sessionId),
check('workouts_exercise_check', sql`length(btrim(${t.exercise})) > 0`),
check('workouts_sets_check', sql`${t.sets} IS NULL OR ${t.sets} BETWEEN 1 AND 100`),
check('workouts_reps_check', sql`${t.reps} IS NULL OR ${t.reps} BETWEEN 0 AND 1000`),
check('workouts_load_check', sql`${t.loadKg} IS NULL OR ${t.loadKg} BETWEEN 0 AND 1000`),
check('workouts_rpe_check', sql`${t.rpe} IS NULL OR ${t.rpe} BETWEEN 1 AND 10`),
check('workouts_date_check', sane('date'))
]
);
export const healthNutritionDaily = healthSchema.table(
'nutrition_daily',
{
id: pk(),
date: day().notNull().unique('nutrition_daily_date_key'),
kcal: integer('kcal'),
proteinG: numeric('protein_g', { precision: 5, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
check('nutrition_daily_kcal_check', sql`${t.kcal} IS NULL OR ${t.kcal} BETWEEN 0 AND 20000`),
check(
'nutrition_daily_protein_check',
sql`${t.proteinG} IS NULL OR ${t.proteinG} BETWEEN 0 AND 1000`
),
check('nutrition_daily_date_check', sane('date'))
]
);
export const healthSleep = healthSchema.table(
'sleep',
{
id: pk(),
date: day().notNull().unique('sleep_date_key'), // the wake-up date of the night
bedtime: timestamptz('bedtime').notNull(),
wakeTime: timestamp('wake_time', { withTimezone: true }).notNull(),
hours: numeric('hours', { precision: 4, scale: 2, mode: 'number' }).generatedAlwaysAs(
sql`round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)`
),
notes: text('notes'),
...timestamps
},
(t) => [
check('sleep_order_check', sql`${t.wakeTime} > ${t.bedtime}`),
check('sleep_length_check', sql`${t.wakeTime} - ${t.bedtime} <= interval '24 hours'`),
check('sleep_date_check', sane('date'))
]
);
export const healthMedications = healthSchema.table(
'medications',
{
id: pk(),
name: text('name').notNull(),
kind: text('kind').notNull().default('medication'),
dose: text('dose'), // free text: "1 mg", "5% solution, 1 ml"
frequency: text('frequency'), // free text: "daily", "2x daily", "as needed"
startDate: day('start_date'),
stopDate: day('stop_date'), // null = still taking it
notes: text('notes'),
...timestamps
},
(t) => [
index('medications_start_date_idx').on(t.startDate),
index('medications_stop_date_idx').on(t.stopDate),
check('medications_name_check', sql`length(btrim(${t.name})) > 0`),
check('medications_kind_check', sql`${t.kind} IN ('medication','supplement')`),
check(
'medications_dates_check',
sql`${t.stopDate} IS NULL OR ${t.startDate} IS NULL OR ${t.stopDate} >= ${t.startDate}`
)
]
);
export const healthAppointments = healthSchema.table(
'appointments',
{
id: pk(),
date: day().notNull(),
type: text('type').notNull().default('appointment'),
provider: text('provider'),
notes: text('notes'),
...timestamps
},
(t) => [
index('appointments_date_idx').on(t.date),
check(
'appointments_type_check',
sql`${t.type} IN ('appointment','checkup','condition','test','procedure','other')`
),
check('appointments_date_check', sane('date'))
]
);

View File

@@ -12,4 +12,4 @@ export * from './schemas';
export * from './money-tracker'; export * from './money-tracker';
export * from './cs-prices'; export * from './cs-prices';
export * from './house-tracker'; export * from './house-tracker';
export * from './health'; export * from './books';

View File

@@ -12,4 +12,4 @@ export const csSchema = pgSchema('cs');
export const animaldexSchema = pgSchema('animaldex'); export const animaldexSchema = pgSchema('animaldex');
export const siteSchema = pgSchema('site'); export const siteSchema = pgSchema('site');
export const metaSchema = pgSchema('meta'); export const metaSchema = pgSchema('meta');
export const healthSchema = pgSchema('health'); export const booksSchema = pgSchema('books');

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest';
import { amsterdamTime, cleanRow, parseCsv, toPayload, validatePayload } from './health-import';
describe('health import', () => {
it('parses CSV with quotes and semicolons', () => {
expect(parseCsv('date;kcal;notes\r\n2026-01-02;2500;"a; ""b"""\n')).toEqual([
{ date: '2026-01-02', kcal: '2500', notes: 'a; "b"' }
]);
});
it('validates and camel-cases rows', () => {
const r = cleanRow('body_measurements', {
date: '2026-01-02',
weight_kg: '80,5',
waist_cm: ''
});
expect(r).toEqual({
ok: true,
row: {
id: undefined,
values: { date: '2026-01-02', weightKg: 80.5, waistCm: null, bodyFatPct: null, notes: null }
}
});
expect(cleanRow('body_measurements', { date: '2026-01-02' }).ok).toBe(false);
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', rpe: 11 }).ok).toBe(false);
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', bogus: 1 }).ok).toBe(
false
);
expect(
cleanRow('vaccinations', { vaccine: 'X', date: '2026-01-02', country: 'nl' })
).toMatchObject({ ok: true, row: { values: { country: 'NL' } } });
});
it('resolves bare sleep times in Amsterdam time', () => {
const r = cleanRow('sleep', { date: '2026-07-02', bedtime: '23:30', wake_time: '07:15' });
expect(r.ok && (r.row.values.bedtime as Date).toISOString()).toBe('2026-07-01T21:30:00.000Z');
expect(r.ok && (r.row.values.wakeTime as Date).toISOString()).toBe('2026-07-02T05:15:00.000Z');
expect(amsterdamTime('2026-01-15', '12:00')?.toISOString()).toBe('2026-01-15T11:00:00.000Z');
const after = cleanRow('sleep', { date: '2026-07-02', bedtime: '00:40', wake_time: '08:00' });
expect(after.ok && (after.row.values.bedtime as Date).toISOString()).toBe(
'2026-07-01T22:40:00.000Z'
);
});
it('reports errors per row and table', () => {
const { errors } = validatePayload(
toPayload({ nutrition_daily: [{ date: 'x' }, { date: '2026-01-01', kcal: 2000 }] })
);
expect(errors).toEqual([
{ table: 'nutrition_daily', row: 1, error: expect.stringContaining('date') }
]);
expect(() => toPayload([{}])).toThrow();
expect(() => toPayload({ nope: [] })).toThrow();
});
});

View File

@@ -1,349 +0,0 @@
// Validation + import for the private health tables. Shared by the owner-only import
// endpoint (/trackers/health/import), the page's add/edit forms and
// scripts/health-import.ts, so it must not import anything from SvelteKit.
import { sql } from 'drizzle-orm';
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import {
healthAppointments,
healthBodyMeasurements,
healthMedications,
healthNutritionDaily,
healthSleep,
healthVaccinations,
healthWorkouts
} from './db/schema/health';
import { healthTables, isHealthTable, type Field, type HealthTable } from '../health/tables';
export const drizzleTables = {
vaccinations: healthVaccinations,
body_measurements: healthBodyMeasurements,
workouts: healthWorkouts,
nutrition_daily: healthNutritionDaily,
sleep: healthSleep,
medications: healthMedications,
appointments: healthAppointments
} as const;
/** Tables with one row per day: importing the same date again updates that day. */
const uniqueByDate = new Set<HealthTable>(['nutrition_daily', 'sleep']);
/** Columns that exports may contain but imports ignore. */
const ignored = new Set(['hours', 'created_at', 'updated_at']);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const TIME_ZONE = 'Europe/Amsterdam';
export const MAX_ROWS = 5000;
type Row = Record<string, unknown>;
export type CleanRow = { id?: string; values: Record<string, unknown> };
export type RowResult = { ok: true; row: CleanRow } | { ok: false; error: string };
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
export function validDate(raw: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const d = new Date(`${raw}T12:00:00Z`);
return !Number.isNaN(d.valueOf()) && d.toISOString().slice(0, 10) === raw && raw >= '1900-01-01'
? raw
: null;
}
/** Offset of Europe/Amsterdam (ms) at a given UTC instant. */
function zoneOffset(utc: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: TIME_ZONE,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(new Date(utc));
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);
return (
Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')) -
utc
);
}
/** Wall-clock time in Amsterdam ("YYYY-MM-DD", "HH:MM[:SS]") to a Date. */
export function amsterdamTime(day: string, time: string): Date | null {
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time);
if (!m || !validDate(day)) return null;
const [h, min, s] = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
if (h > 23 || min > 59 || s > 59) return null;
const [y, mo, d] = day.split('-').map(Number);
const wall = Date.UTC(y, mo - 1, d, h, min, s);
let utc = wall - zoneOffset(wall);
utc = wall - zoneOffset(utc); // second pass settles DST edges
return new Date(utc);
}
/**
* Datetimes: ISO with offset ("2026-10-24T07:10:00+02:00", "...Z"), local Amsterdam
* ("2026-10-24T07:10" / "2026-10-24 07:10", which is what <input type=datetime-local> sends),
* or a bare "HH:MM" resolved against the row's date by the caller.
*/
function parseDateTime(raw: string): Date | 'time' | null {
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(raw)) return 'time';
const local = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)(?:\.\d+)?$/.exec(raw);
if (local) return amsterdamTime(local[1], local[2]);
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i.test(raw)) {
const d = new Date(raw);
return Number.isNaN(d.valueOf()) ? null : d;
}
return null;
}
function cleanField(field: Field, value: unknown): { value: unknown } | { error: string } {
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
return field.required ? { error: `${field.key} is required` } : { value: null };
}
const raw = typeof value === 'string' ? value.trim() : value;
switch (field.type) {
case 'text':
case 'textarea': {
if (typeof raw !== 'string' && typeof raw !== 'number')
return { error: `${field.key} must be text` };
const text = String(raw);
if (field.maxLength && text.length > field.maxLength)
return { error: `${field.key} is longer than ${field.maxLength} characters` };
return { value: text };
}
case 'integer':
case 'number': {
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(',', '.'));
if (!Number.isFinite(n)) return { error: `${field.key} must be a number` };
if (field.type === 'integer' && !Number.isInteger(n))
return { error: `${field.key} must be a whole number` };
if ((field.min !== undefined && n < field.min) || (field.max !== undefined && n > field.max))
return { error: `${field.key} must be between ${field.min} and ${field.max}` };
return { value: n };
}
case 'date': {
const d = typeof raw === 'string' ? validDate(raw) : null;
return d
? { value: d }
: { error: `${field.key} must be a date (YYYY-MM-DD, 1900 or later)` };
}
case 'datetime': {
const d = typeof raw === 'string' ? parseDateTime(raw) : null;
return d ? { value: d } : { error: `${field.key} must be an ISO datetime or HH:MM` };
}
case 'select':
return field.options?.includes(String(raw))
? { value: String(raw) }
: { error: `${field.key} must be one of ${field.options?.join(', ')}` };
case 'uuid':
return typeof raw === 'string' && UUID.test(raw)
? { value: raw.toLowerCase() }
: { error: `${field.key} must be a UUID` };
}
}
/** Validate one row (snake_case keys) for a table. Unknown keys are rejected. */
export function cleanRow(table: HealthTable, input: Row): RowResult {
if (!input || typeof input !== 'object' || Array.isArray(input))
return { ok: false, error: 'row must be an object' };
const fields: readonly Field[] = healthTables[table].fields;
const known = new Set(fields.map((f) => f.key));
for (const key of Object.keys(input))
if (key !== 'id' && !known.has(key) && !ignored.has(key))
return { ok: false, error: `unknown column "${key}"` };
let id: string | undefined;
if (input.id !== undefined && input.id !== null && input.id !== '') {
if (typeof input.id !== 'string' || !UUID.test(input.id))
return { ok: false, error: 'id must be a UUID' };
id = input.id.toLowerCase();
}
const v: Record<string, unknown> = {};
for (const field of fields) {
const result = cleanField(field, input[field.key]);
if ('error' in result) return { ok: false, error: result.error };
v[field.key] = result.value;
}
// Table-specific rules (mirrors the CHECK constraints, with friendlier messages).
if (table === 'vaccinations' && typeof v.country === 'string') {
const country = v.country.toUpperCase();
v.country = country;
if (!/^[A-Z]{2}$/.test(country))
return { ok: false, error: 'country must be a 2-letter ISO code, e.g. NL' };
}
if (
table === 'body_measurements' &&
v.weight_kg === null &&
v.waist_cm === null &&
v.body_fat_pct === null
)
return { ok: false, error: 'give at least one of weight_kg, waist_cm, body_fat_pct' };
if (table === 'medications') {
v.kind ??= 'medication';
if (v.start_date && v.stop_date && String(v.stop_date) < String(v.start_date))
return { ok: false, error: 'stop_date is before start_date' };
}
if (table === 'appointments') v.type ??= 'appointment';
if (table === 'sleep') {
const day = String(v.date);
const wakeRaw = String(input.wake_time).trim();
const bedRaw = String(input.bedtime).trim();
if (v.wake_time === 'time') v.wake_time = amsterdamTime(day, wakeRaw);
if (v.bedtime === 'time') {
// A bare bedtime is on the wake-up day if it is before the wake time, else the evening before.
const sameDay = amsterdamTime(day, bedRaw);
const wake = v.wake_time as Date | null;
if (sameDay && wake && sameDay < wake) v.bedtime = sameDay;
else {
const prev = new Date(`${day}T12:00:00Z`);
prev.setUTCDate(prev.getUTCDate() - 1);
v.bedtime = amsterdamTime(prev.toISOString().slice(0, 10), bedRaw);
}
}
const bed = v.bedtime as Date | null;
const wake = v.wake_time as Date | null;
if (!bed || !wake) return { ok: false, error: 'invalid bedtime / wake_time' };
if (wake <= bed) return { ok: false, error: 'wake_time must be after bedtime' };
if (wake.getTime() - bed.getTime() > 24 * 3600e3)
return { ok: false, error: 'sleep longer than 24 hours' };
} else {
for (const key of Object.keys(v))
if (v[key] === 'time') return { ok: false, error: `${key} needs a full datetime` };
}
const values: Record<string, unknown> = {};
for (const [key, value] of Object.entries(v)) values[camel(key)] = value;
return { ok: true, row: { id, values } };
}
/** Minimal RFC 4180 CSV parser: header row, quoted fields, CRLF, BOM. Semicolons allowed as delimiter. */
export function parseCsv(text: string): Row[] {
const src = text.replace(/^\uFEFF/, '');
const firstLine = src.split(/\r?\n/, 1)[0] ?? '';
const delimiter =
(firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ';' : ',';
const records: string[][] = [];
let record: string[] = [];
let field = '';
let quoted = false;
const endField = () => {
record.push(field);
field = '';
};
const endRecord = () => {
endField();
records.push(record);
record = [];
};
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (quoted) {
if (c === '"' && src[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"' && field === '') quoted = true;
else if (c === delimiter) endField();
else if (c === '\n' || c === '\r') {
if (c === '\r' && src[i + 1] === '\n') i++;
endRecord();
} else field += c;
}
if (quoted) throw new Error('CSV has an unterminated quoted field');
if (field !== '' || record.length) endRecord();
const rows = records.filter((r) => r.some((cell) => cell.trim() !== ''));
if (!rows.length) return [];
const header = rows[0].map((h) => h.trim().toLowerCase());
if (new Set(header).size !== header.length) throw new Error('CSV header has duplicate columns');
return rows.slice(1).map((cells, line) => {
if (cells.length > header.length)
throw new Error(`CSV row ${line + 2} has more cells than the header`);
return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? '']));
});
}
export type Payload = Partial<Record<HealthTable, Row[]>>;
/**
* Accepted shapes:
* JSON object keyed by table: { "vaccinations": [ {...}, ... ], "sleep": [ ... ] }
* JSON array of rows + table: [ {...}, ... ] (table given separately)
* CSV with a header row + table
*/
export function toPayload(body: unknown, table?: string | null): Payload {
if (Array.isArray(body)) {
if (!isHealthTable(table))
throw new Error('a plain list of rows needs a table (?table= or --table)');
return { [table]: body };
}
if (!body || typeof body !== 'object')
throw new Error('expected a JSON object keyed by table, or an array of rows');
const payload: Payload = {};
for (const [key, rows] of Object.entries(body)) {
if (!isHealthTable(key)) throw new Error(`unknown table "${key}"`);
if (!Array.isArray(rows)) throw new Error(`"${key}" must be an array of rows`);
payload[key] = rows as Row[];
}
return payload;
}
export type ValidationError = { table: HealthTable; row: number; error: string };
export function validatePayload(payload: Payload) {
const clean: Partial<Record<HealthTable, CleanRow[]>> = {};
const errors: ValidationError[] = [];
let total = 0;
for (const [table, rows] of Object.entries(payload) as [HealthTable, Row[]][]) {
total += rows.length;
clean[table] = [];
rows.forEach((row, i) => {
const result = cleanRow(table, row);
if (result.ok) clean[table]!.push(result.row);
else errors.push({ table, row: i + 1, error: result.error });
});
}
if (total > MAX_ROWS)
errors.push({
table: Object.keys(payload)[0] as HealthTable,
row: 0,
error: `more than ${MAX_ROWS} rows`
});
return { clean, errors };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Db = PgAsyncDatabase<any, any>;
export type ImportCounts = Partial<Record<HealthTable, { inserted: number; updated: number }>>;
/** Insert/update rows; all-or-nothing in one transaction. Rows with an id update that row;
* nutrition_daily and sleep upsert on date. */
export async function writeRows(
db: Db,
clean: Partial<Record<HealthTable, CleanRow[]>>
): Promise<ImportCounts> {
return db.transaction(async (tx) => {
const counts: ImportCounts = {};
for (const [table, rows] of Object.entries(clean) as [HealthTable, CleanRow[]][]) {
const target = drizzleTables[table];
const count = { inserted: 0, updated: 0 };
for (const { id, values } of rows) {
const data = { ...values, ...(id ? { id } : {}) };
const set = { ...values, updatedAt: new Date() };
const conflictTarget = id
? target.id
: uniqueByDate.has(table)
? (target as typeof healthSleep).date
: null;
const query = tx.insert(target).values(data as never);
const [result] = await (
conflictTarget
? query.onConflictDoUpdate({ target: conflictTarget, set: set as never })
: query
).returning({ inserted: sql<boolean>`(xmax = 0)` });
if (result?.inserted) count.inserted++;
else count.updated++;
}
counts[table] = count;
}
return counts;
});
}

View File

@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath } from './owner';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
describe('owner gate', () => {
it('is only the owner account', () => {
expect(isOwner(event(null))).toBe(false);
expect(isOwner(event({ role: 'admin', email: 'someone@example.com' }))).toBe(false);
expect(isOwner(event({ role: 'user', email: 'Stan@StanRunge.dev' }))).toBe(true);
});
it('matches owner-only paths', () => {
expect(isOwnerOnlyPath('/trackers/health')).toBe(true);
expect(isOwnerOnlyPath('/trackers/health/')).toBe(true);
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
expect(isOwnerOnlyPath('/trackers')).toBe(false);
});
});

View File

@@ -1,27 +0,0 @@
import { error } from '@sveltejs/kit';
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
// Owner-only data, such as the health tracker, is gated on that account's email, not on
// a role, so other users (now or later, e.g. on a public tracking project) never see it.
// Override with OWNER_EMAIL if the address ever changes.
const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').trim().toLowerCase();
type OwnerEvent = { locals: App.Locals };
export function isOwner(event: OwnerEvent): boolean {
return event.locals.user?.email?.trim().toLowerCase() === OWNER_EMAIL;
}
/** Owner-only routes answer 404 to everyone else, so they don't reveal that they exist. */
export function requireOwner(event: OwnerEvent) {
if (!isOwner(event)) error(404, 'Not Found');
return event.locals.user!;
}
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
export const ownerOnlyPrefixes = ['/trackers/health'] as const;
export function isOwnerOnlyPath(pathname: string): boolean {
const path = pathname.toLowerCase().replace(/\/+$/, '');
return ownerOnlyPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}

View File

@@ -1,5 +1,4 @@
import { isAdmin } from '#lib/server/authorization.js'; import { isAdmin } from '#lib/server/authorization.js';
import { isOwner } from '#lib/server/owner.js';
import { dev } from '$app/env'; import { dev } from '$app/env';
import { siteEnvironment } from '#lib/title.js'; import { siteEnvironment } from '#lib/title.js';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
@@ -9,7 +8,6 @@ export const load: LayoutServerLoad = (event) => ({
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image } ? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
: null, : null,
isAdmin: isAdmin(event), isAdmin: isAdmin(event),
isOwner: isOwner(event),
editable: isAdmin(event), editable: isAdmin(event),
version: process.env.APP_VERSION || 'dev', version: process.env.APP_VERSION || 'dev',
environment environment

View File

@@ -7,7 +7,7 @@
import { getSeo } from '#lib/seo.js'; import { getSeo } from '#lib/seo.js';
import CopyButton from '#lib/components/CopyButton.svelte'; import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import { projects, visibleTrackers } from '#lib/projects.js'; import { projects, trackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js'; import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js'; import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
@@ -42,11 +42,13 @@
{ label: 'blog', href: '/blog' }, { label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' } { label: 'lists', href: '/lists' }
] as const; ] as const;
type MenuItem = { name: string; subtitle: string; href: string; external: boolean }; const menus: Record<
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({ string,
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
> = {
'/projects': { label: 'projects', items: projects }, '/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) } '/trackers': { label: 'trackers', items: trackers }
}); };
const socialLinks = [ const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' }, { icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' }, { icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },

View File

@@ -3,7 +3,7 @@
import portrait from '#lib/assets/stan.jpg?url'; import portrait from '#lib/assets/stan.jpg?url';
import ProgressBar from '#lib/components/ProgressBar.svelte'; import ProgressBar from '#lib/components/ProgressBar.svelte';
import CatalogGrid from '#lib/components/CatalogGrid.svelte'; import CatalogGrid from '#lib/components/CatalogGrid.svelte';
import { projects, visibleTrackers } from '#lib/projects.js'; import { projects, trackers } from '#lib/projects.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
import ExternalLink from 'lucide-svelte/icons/external-link'; import ExternalLink from 'lucide-svelte/icons/external-link';
import Mail from 'lucide-svelte/icons/mail'; import Mail from 'lucide-svelte/icons/mail';
@@ -114,10 +114,7 @@
>trackers <ArrowRight size={20} aria-hidden="true" /></a >trackers <ArrowRight size={20} aria-hidden="true" /></a
> >
</h2> </h2>
<CatalogGrid <CatalogGrid items={trackers} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
items={visibleTrackers(data.isOwner)}
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/>
</section> </section>
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20"> <section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">

View File

@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import CatalogGrid from '#lib/components/CatalogGrid.svelte'; import CatalogGrid from '#lib/components/CatalogGrid.svelte';
import { visibleTrackers } from '#lib/projects.js'; import { trackers } from '#lib/projects.js';
let { data } = $props();
</script> </script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16"> <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -10,5 +8,5 @@
<p class="mb-10 text-[var(--site-muted)]"> <p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, prices, animals and lists. Personal data I keep track of: games, money, houses, prices, animals and lists.
</p> </p>
<CatalogGrid items={visibleTrackers(data.isOwner)} /> <CatalogGrid items={trackers} />
</div> </div>

View File

@@ -1,11 +0,0 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only: everyone else (logged out, or any other account) gets a plain 404.
// Form actions don't run this load, so +page.server.ts checks again in every action,
// and hooks.server.ts blocks the whole /trackers/health prefix as a backstop.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -1,110 +0,0 @@
import { db } from '#lib/server/db/index.js';
import {
healthAppointments,
healthBodyMeasurements,
healthMedications,
healthNutritionDaily,
healthSleep,
healthVaccinations,
healthWorkouts
} from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { cleanRow, drizzleTables, TIME_ZONE, writeRows } from '#lib/server/health-import.js';
import { healthTables, isHealthTable } from '#lib/health/tables.js';
import { asc, desc, eq, gte, lt } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function today(): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE }).format(new Date());
}
function daysAgo(day: string, n: number): string {
const d = new Date(`${day}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
}
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const now = today();
const [measurements, workouts, nutrition, sleep, medications, vaccinations, upcoming, past] =
await Promise.all([
db
.select()
.from(healthBodyMeasurements)
.orderBy(asc(healthBodyMeasurements.date), asc(healthBodyMeasurements.createdAt)),
db
.select()
.from(healthWorkouts)
.orderBy(desc(healthWorkouts.date), asc(healthWorkouts.createdAt))
.limit(30),
db
.select()
.from(healthNutritionDaily)
.where(gte(healthNutritionDaily.date, daysAgo(now, 6)))
.orderBy(desc(healthNutritionDaily.date)),
db
.select()
.from(healthSleep)
.where(gte(healthSleep.date, daysAgo(now, 6)))
.orderBy(desc(healthSleep.date)),
db.select().from(healthMedications).orderBy(asc(healthMedications.name)),
db
.select()
.from(healthVaccinations)
.orderBy(desc(healthVaccinations.date), asc(healthVaccinations.vaccine)),
db
.select()
.from(healthAppointments)
.where(gte(healthAppointments.date, now))
.orderBy(asc(healthAppointments.date)),
db
.select()
.from(healthAppointments)
.where(lt(healthAppointments.date, now))
.orderBy(desc(healthAppointments.date))
.limit(10)
]);
return {
today: now,
measurements,
workouts,
nutrition,
sleep,
medications,
vaccinations,
appointments: { upcoming, past },
seo: { title: 'Health', description: 'Private health log.', index: false }
};
};
export const actions: Actions = {
save: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const table = form.get('table');
if (!isHealthTable(table)) return fail(400, { message: 'Unknown table' });
const row: Record<string, unknown> = { id: form.get('id') || undefined };
for (const field of healthTables[table].fields) row[field.key] = form.get(field.key) ?? '';
const result = cleanRow(table, row);
if (!result.ok) return fail(400, { message: result.error, table });
try {
await writeRows(db, { [table]: [result.row] });
} catch (e) {
return fail(400, { message: e instanceof Error ? e.message : 'Could not save', table });
}
return { saved: table };
},
delete: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const table = form.get('table');
const id = String(form.get('id') ?? '');
if (!isHealthTable(table) || !UUID.test(id)) return fail(400, { message: 'Invalid entry' });
const target = drizzleTables[table];
await db.delete(target).where(eq(target.id, id));
return { deleted: table };
}
};

View File

@@ -1,486 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import BodyChart from '#lib/health/BodyChart.svelte';
import {
healthTables,
healthTableNames,
type Field,
type HealthTable
} from '#lib/health/tables.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Lock from 'lucide-svelte/icons/lock';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
type AnyRow = { id: string } & Record<string, unknown>;
let dialogOpen = $state(false);
let editor = $state<{ table: HealthTable; row: AnyRow | null; confirmDelete: boolean }>({
table: 'body_measurements',
row: null,
confirmDelete: false
});
let error = $state<string | null>(null);
const fields = $derived<readonly Field[]>(healthTables[editor.table].fields);
const latest = $derived(data.measurements.at(-1));
const latestWeight = $derived(data.measurements.findLast((m) => m.weightKg !== null));
const latestWaist = $derived(data.measurements.findLast((m) => m.waistCm !== null));
const todayNutrition = $derived(data.nutrition.find((n) => n.date === data.today));
const activeMeds = $derived(
data.medications.filter(
(m) =>
(!m.startDate || m.startDate <= data.today) && (!m.stopDate || m.stopDate >= data.today)
)
);
const otherMeds = $derived(data.medications.filter((m) => !activeMeds.includes(m)));
const sleepAverage = $derived.by(() => {
const hours = data.sleep.map((s) => s.hours).filter((h): h is number => h !== null);
return hours.length ? hours.reduce((a, b) => a + b, 0) / hours.length : null;
});
const workoutDays = $derived.by(() => {
const days: [string, typeof data.workouts][] = [];
for (const w of data.workouts) {
const last = days.at(-1);
if (last?.[0] === w.date) last[1].push(w);
else days.push([w.date, [w]]);
}
return days;
});
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
const localTime = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Amsterdam',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const clock = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Amsterdam',
hour: '2-digit',
minute: '2-digit'
});
const dayLabel = (day: string) =>
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: day.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric'
});
const num = (value: number | null | undefined, digits = 1) =>
value === null || value === undefined ? '–' : value.toFixed(digits).replace(/\.0+$/, '');
function fieldValue(field: Field): string {
const value = editor.row?.[camel(field.key)];
if (value === null || value === undefined) {
if (!editor.row && field.type === 'date' && field.key === 'date') return data.today;
return '';
}
if (value instanceof Date) return localTime.format(value).replace(' ', 'T');
return String(value);
}
function open(table: HealthTable, row: AnyRow | null = null, confirmDelete = false) {
editor = { table, row, confirmDelete };
error = null;
dialogOpen = true;
}
const submit: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') dialogOpen = false;
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
};
</script>
{#snippet rowActions(table: HealthTable, row: AnyRow)}
<span class="flex shrink-0 gap-1">
<button class="icon" type="button" aria-label="Edit" onclick={() => open(table, row)}
><Pencil size={14} /></button
>
<button class="icon" type="button" aria-label="Delete" onclick={() => open(table, row, true)}
><Trash2 size={14} /></button
>
</span>
{/snippet}
{#snippet sectionHeader(title: string, table: HealthTable)}
<header class="mb-3 flex items-center justify-between gap-2">
<h2 class="text-lg font-semibold tracking-tight">{title}</h2>
<button class="icon" type="button" aria-label={`Add to ${title}`} onclick={() => open(table)}
><Plus size={16} /></button
>
</header>
{/snippet}
{#snippet empty(text: string)}
<p class="text-sm text-[var(--site-muted)]">{text}</p>
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">health</h1>
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
</p>
</div>
<NativeSelect
aria-label="Add an entry"
value=""
onchange={(e: Event) => {
const select = e.currentTarget as HTMLSelectElement;
if (select.value) open(select.value as HealthTable);
select.value = '';
}}
>
<option value="">+ add entry…</option>
{#each healthTableNames as table (table)}<option value={table}
>{healthTables[table].label}</option
>{/each}
</NativeSelect>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<section class="panel lg:col-span-2">
{@render sectionHeader('Body', 'body_measurements')}
{#if latest}
<div class="mb-3 flex flex-wrap gap-6">
<p>
<span class="stat">{num(latestWeight?.weightKg, 2)}</span> kg
<small>{latestWeight ? dayLabel(latestWeight.date) : ''}</small>
</p>
<p>
<span class="stat">{num(latestWaist?.waistCm)}</span> cm waist
<small>{latestWaist ? dayLabel(latestWaist.date) : ''}</small>
</p>
{#if latest.bodyFatPct !== null}<p>
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
</p>{/if}
</div>
{#if data.measurements.length > 1}
<BodyChart points={data.measurements} />
{:else}
{@render empty('Add a second measurement to see a trend.')}
{/if}
<details class="mt-3 text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]"
>All measurements ({data.measurements.length})</summary
>
<ul class="mt-2 divide-y divide-[var(--site-border)]">
{#each data.measurements.toReversed() as m (m.id)}
<li class="flex items-center justify-between py-1.5">
<span
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
null
? ` · ${num(m.bodyFatPct)}%`
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
>
{@render rowActions('body_measurements', m)}
</li>
{/each}
</ul>
</details>
{:else}
{@render empty('No measurements yet.')}
{/if}
</section>
<section class="panel">
{@render sectionHeader('Nutrition today', 'nutrition_daily')}
{#if todayNutrition}
<p>
<span class="stat">{todayNutrition.kcal ?? '–'}</span> kcal ·
<span class="stat">{num(todayNutrition.proteinG)}</span> g protein
</p>
{:else}
{@render empty('Nothing logged today.')}
{/if}
{#if data.nutrition.length}
<ul class="mt-3 divide-y divide-[var(--site-border)] text-sm">
{#each data.nutrition as n (n.id)}
<li class="flex items-center justify-between py-1.5">
<span>{dayLabel(n.date)} · {n.kcal ?? '–'} kcal · {num(n.proteinG)} g</span>
{@render rowActions('nutrition_daily', n)}
</li>
{/each}
</ul>
{/if}
</section>
<section class="panel">
{@render sectionHeader('Sleep, last 7 days', 'sleep')}
{#if data.sleep.length}
{#if sleepAverage !== null}<p class="mb-2">
<span class="stat">{num(sleepAverage, 1)}</span> h average
</p>{/if}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each data.sleep as s (s.id)}
<li class="flex items-center justify-between py-1.5">
<span
>{dayLabel(s.date)} · {clock.format(s.bedtime)}–{clock.format(s.wakeTime)} · {num(
s.hours,
2
)} h</span
>
{@render rowActions('sleep', s)}
</li>
{/each}
</ul>
{:else}
{@render empty('No sleep logged this week.')}
{/if}
</section>
<section class="panel">
{@render sectionHeader('Medications & supplements', 'medications')}
{#if activeMeds.length}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each activeMeds as m (m.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
><strong>{m.name}</strong>{m.dose ? ` · ${m.dose}` : ''}{m.frequency
? ` · ${m.frequency}`
: ''}
<small>{m.kind}{m.startDate ? `, since ${dayLabel(m.startDate)}` : ''}</small></span
>
{@render rowActions('medications', m)}
</li>
{/each}
</ul>
{:else}
{@render empty('No active medications or supplements.')}
{/if}
{#if otherMeds.length}
<details class="mt-2 text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]"
>Stopped or not started ({otherMeds.length})</summary
>
<ul class="mt-1">
{#each otherMeds as m (m.id)}
<li class="flex items-center justify-between py-1">
<span>{m.name} <small>{m.startDate ?? '?'} → {m.stopDate ?? 'now'}</small></span>
{@render rowActions('medications', m)}
</li>
{/each}
</ul>
</details>
{/if}
</section>
<section class="panel">
{@render sectionHeader('Appointments', 'appointments')}
{#if data.appointments.upcoming.length}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each data.appointments.upcoming as a (a.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
><strong>{dayLabel(a.date)}</strong> · {a.type}{a.provider
? ` · ${a.provider}`
: ''}{a.notes ? ` · ${a.notes}` : ''}</span
>
{@render rowActions('appointments', a)}
</li>
{/each}
</ul>
{:else}
{@render empty('Nothing upcoming.')}
{/if}
{#if data.appointments.past.length}
<details class="mt-2 text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]"
>Recent ({data.appointments.past.length})</summary
>
<ul class="mt-1">
{#each data.appointments.past as a (a.id)}
<li class="flex items-center justify-between py-1">
<span>{dayLabel(a.date)} · {a.type}{a.provider ? ` · ${a.provider}` : ''}</span>
{@render rowActions('appointments', a)}
</li>
{/each}
</ul>
</details>
{/if}
</section>
<section class="panel lg:col-span-2">
{@render sectionHeader('Recent workouts', 'workouts')}
{#if workoutDays.length}
{#each workoutDays as [day, entries] (day)}
<h3 class="mt-2 text-sm font-medium text-[var(--site-muted)]">{dayLabel(day)}</h3>
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each entries as w (w.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
><strong>{w.exercise}</strong>{w.sets
? ` · ${w.sets}×${w.reps ?? '?'}`
: ''}{w.loadKg !== null ? ` @ ${num(w.loadKg, 2)} kg` : ''}{w.rpe !== null
? ` · RPE ${num(w.rpe)}`
: ''}{w.notes ? ` · ${w.notes}` : ''}</span
>
{@render rowActions('workouts', w)}
</li>
{/each}
</ul>
{/each}
{:else}
{@render empty('No workouts yet.')}
{/if}
</section>
<section class="panel lg:col-span-3">
{@render sectionHeader('Vaccinations', 'vaccinations')}
{#if data.vaccinations.length}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="text-[var(--site-muted)]">
<tr
><th>Date</th><th>Vaccine</th><th>Against</th><th>Dose</th><th>Batch</th><th
>Provider</th
><th>Country</th><th></th></tr
>
</thead>
<tbody>
{#each data.vaccinations as v (v.id)}
<tr class="border-t border-[var(--site-border)]">
<td>{v.date}</td><td>{v.vaccine}</td><td>{v.target ?? ''}</td><td
>{v.doseNo ?? ''}</td
>
<td>{v.batchLot ?? ''}</td><td>{v.provider ?? ''}</td><td>{v.country ?? ''}</td>
<td>{@render rowActions('vaccinations', v)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
{@render empty('No vaccinations yet. Import your Mijn RIVM record to fill this in.')}
{/if}
</section>
</div>
</div>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-4 pr-8">
<Dialog.Title>
{editor.confirmDelete ? 'Delete entry' : editor.row ? 'Edit' : 'Add'} · {healthTables[
editor.table
].label}
</Dialog.Title>
<Dialog.Description class="sr-only">Manage a private health record.</Dialog.Description>
</Dialog.Header>
{#if editor.confirmDelete && editor.row}
<form method="POST" action="?/delete" use:enhance={submit}>
<input type="hidden" name="table" value={editor.table} />
<input type="hidden" name="id" value={editor.row.id} />
<p class="mb-4 text-sm">Delete this entry? This cannot be undone.</p>
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
>Cancel</Button
>
<Button type="submit" variant="destructive">Delete</Button>
</div>
</form>
{:else}
<form method="POST" action="?/save" use:enhance={submit} class="grid gap-3">
<input type="hidden" name="table" value={editor.table} />
{#if editor.row}<input type="hidden" name="id" value={editor.row.id} />{/if}
{#each fields as field (field.key)}
<label class="grid gap-1 text-sm">
<span>{field.label}{field.required ? ' *' : ''}</span>
{#if field.type === 'textarea'}
<Textarea
name={field.key}
value={fieldValue(field)}
maxlength={field.maxLength}
rows={2}
/>
{:else if field.type === 'select'}
<NativeSelect name={field.key} value={fieldValue(field) || field.options?.[0]}>
{#each field.options ?? [] as option (option)}<option value={option}
>{option}</option
>{/each}
</NativeSelect>
{:else}
<Input
name={field.key}
value={fieldValue(field)}
required={field.required}
type={field.type === 'date'
? 'date'
: field.type === 'datetime'
? 'datetime-local'
: field.type === 'integer' || field.type === 'number'
? 'number'
: 'text'}
min={field.min}
max={field.max}
step={field.type === 'integer' ? 1 : field.step}
maxlength={field.maxLength}
placeholder={field.placeholder}
inputmode={field.type === 'number' ? 'decimal' : undefined}
/>
{/if}
</label>
{/each}
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="mt-2 flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
>Cancel</Button
>
<Button type="submit">Save</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
{#if form?.message && !dialogOpen}<p class="sr-only" role="status">{form.message}</p>{/if}
<style>
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
.stat {
font-size: 1.6rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
small {
color: var(--site-muted);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
color: var(--site-text);
}
th,
td {
padding: 0.4rem 0.5rem 0.4rem 0;
white-space: nowrap;
}
</style>

View File

@@ -1,51 +0,0 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { parseCsv, toPayload, validatePayload, writeRows } from '#lib/server/health-import.js';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const MAX_BYTES = 2_000_000;
// Owner-only import. Same formats as scripts/health-import.ts:
// POST application/json {"vaccinations":[...], "sleep":[...]} or [...] with ?table=
// POST text/csv header row + rows, with ?table=
// ?dry_run=1 validate only
// Only JSON/CSV content types are accepted, so a cross-site form can't post here
// (browsers preflight these types, and we never send CORS headers).
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const type = (event.request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
if (type !== 'application/json' && type !== 'text/csv')
error(415, 'Use application/json or text/csv');
const text = await event.request.text();
if (text.length > MAX_BYTES) error(413, 'Body too large');
const table = event.url.searchParams.get('table');
let payload;
try {
payload =
type === 'text/csv' ? toPayload(parseCsv(text), table) : toPayload(JSON.parse(text), table);
} catch (e) {
return json(
{ ok: false, error: e instanceof Error ? e.message : 'Bad input' },
{ status: 400 }
);
}
const { clean, errors } = validatePayload(payload);
if (errors.length) return json({ ok: false, errors: errors.slice(0, 100) }, { status: 400 });
const rows = Object.fromEntries(Object.entries(clean).map(([t, r]) => [t, r.length]));
if (event.url.searchParams.get('dry_run') === '1') return json({ ok: true, dryRun: true, rows });
try {
return json({ ok: true, counts: await writeRows(db, clean) });
} catch (e) {
return json(
{ ok: false, error: e instanceof Error ? e.message : 'Import failed' },
{ status: 400 }
);
}
};
// Any other method: still 404 for non-owners (no 405 that would reveal the endpoint).
export const fallback: RequestHandler = (event) => {
requireOwner(event);
error(405, 'Use POST');
};