Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d246552951 | |||
| 2dee93f7b9 |
@@ -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,
|
||||||
|
|||||||
146
drizzle/20261023232911_books/migration.sql
Normal file
146
drizzle/20261023232911_books/migration.sql
Normal 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 $$;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
945
scripts/books-ingest.py
Normal 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()
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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);
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
162
src/lib/server/db/schema/books.ts
Normal file
162
src/lib/server/db/schema/books.ts
Normal 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;
|
||||||
@@ -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'))
|
|
||||||
]
|
|
||||||
);
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}/`));
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {};
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
@@ -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');
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user