Compare commits
14 Commits
health
...
books-ui-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cada618bf | |||
| 7914b0140b | |||
| 178fa8ddce | |||
| 9c8f20a618 | |||
| 9c46e281c2 | |||
| 8b2dfed239 | |||
| 2da375451a | |||
| e4de2dcec3 | |||
| 00c1e8b1e7 | |||
| 9a1b21ec39 | |||
| b1ce715ec7 | |||
| 064d41595f | |||
| fa2456b99e | |||
| 0cf5d44062 |
@@ -39,3 +39,9 @@ HOUSE_WORKER_TOKEN=
|
||||
HOUSE_DISCORD_WEBHOOK_URL=
|
||||
HOUSE_NTFY_URL=
|
||||
HOUSE_NTFY_TOKEN=
|
||||
|
||||
# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app).
|
||||
# Keep these in ~/.config/money-sync.env (mode 600), never in git.
|
||||
ENABLEBANKING_APP_ID=
|
||||
ENABLEBANKING_KEY_PATH=
|
||||
ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback
|
||||
|
||||
@@ -19,3 +19,9 @@ data/cod-challenges.json
|
||||
data/game-images.json
|
||||
data/cod-achievement-art.json
|
||||
src/lib/house-tracker/fixtures/
|
||||
|
||||
# Vendored third-party code
|
||||
static/vendor/
|
||||
|
||||
# Generated drizzle snapshots
|
||||
drizzle/*/snapshot.json
|
||||
|
||||
24
README.md
24
README.md
@@ -236,6 +236,30 @@ local database and migrations, and `db/reorg/README.md` for the one-time move of
|
||||
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||||
upsert reference data and run on every production deploy.
|
||||
|
||||
## Book tracker
|
||||
|
||||
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
|
||||
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
|
||||
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
|
||||
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
|
||||
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
|
||||
position per file, autosaves progress and sessions, and stores highlights with notes.
|
||||
|
||||
Files are never public. They are streamed (with Range support) by
|
||||
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
|
||||
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
|
||||
`hooks.server.ts`).
|
||||
|
||||
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
|
||||
|
||||
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
||||
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||||
|
||||
In production, `scripts/deploy-remote.sh` writes `/opt/personal-website/compose.books.yml`, which
|
||||
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
|
||||
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||||
be opened.
|
||||
|
||||
## Money tracker
|
||||
|
||||
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -14,9 +14,11 @@
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"jose": "^6.2.10",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
@@ -325,6 +327,30 @@
|
||||
|
||||
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
|
||||
@@ -837,6 +863,8 @@
|
||||
|
||||
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
|
||||
|
||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||
|
||||
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
|
||||
|
||||
13
deploy/systemd/money-sync.service
Normal file
13
deploy/systemd/money-sync.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Money tracker bank sync (Enable Banking -> money.transactions)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# A checkout of stan/website (main once merged). Not the live dev tree.
|
||||
WorkingDirectory=%h/dev/website-money
|
||||
# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed.
|
||||
EnvironmentFile=%h/.config/money-sync.env
|
||||
ExecStart=/usr/bin/bun scripts/bank-sync.ts
|
||||
TimeoutStartSec=600
|
||||
10
deploy/systemd/money-sync.timer
Normal file
10
deploy/systemd/money-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam
|
||||
RandomizedDelaySec=300
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -20,7 +20,8 @@ export default defineConfig({
|
||||
'animaldex',
|
||||
'site',
|
||||
'meta',
|
||||
'health'
|
||||
'health',
|
||||
'books'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
CREATE TABLE "money"."bank_sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" text NOT NULL,
|
||||
"provider" text DEFAULT 'enablebanking' NOT NULL,
|
||||
"session_id" text NOT NULL CONSTRAINT "bank_sessions_session_id_key" UNIQUE,
|
||||
"aspsp_name" text NOT NULL,
|
||||
"aspsp_country" text NOT NULL,
|
||||
"psu_type" text,
|
||||
"status" text DEFAULT 'authorized' NOT NULL,
|
||||
"valid_until" timestamp with time zone,
|
||||
"accounts" jsonb DEFAULT '[]' NOT NULL,
|
||||
"last_synced_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "bank_sessions_status_check" CHECK (status IN ('authorized','expired','revoked','error'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "scope" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "owner" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "iban" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "external_uid" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "provider" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "external_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "source" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "booking_status" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "counterparty" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "category" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "raw" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD COLUMN "value_date" date;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_external_uid_key" UNIQUE("external_uid");--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_external_id_key" UNIQUE("account_id","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "bank_sessions_user_id_index" ON "money"."bank_sessions" ("user_id");--> statement-breakpoint
|
||||
ALTER TABLE "money"."bank_sessions" ADD CONSTRAINT "bank_sessions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_scope_check" CHECK (scope IN ('personal','joint'));--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_provider_check" CHECK (provider IN ('manual','enablebanking'));--> statement-breakpoint
|
||||
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_source_check" CHECK (source IN ('manual','csv','enablebanking'));--> statement-breakpoint
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- Backfill: the shared account with Juhee is the only joint one; everything else stays personal.
|
||||
UPDATE "money"."accounts" SET "scope" = 'joint', "owner" = 'Stan & Juhee' WHERE lower("name") = 'joint';
|
||||
--> statement-breakpoint
|
||||
UPDATE "money"."accounts" SET "owner" = 'Stan' WHERE "scope" = 'personal' AND "owner" IS NULL;
|
||||
--> statement-breakpoint
|
||||
-- updated_at trigger, same as every other core table (07_triggers_meta_grants.sql).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON money.bank_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- 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. Default
|
||||
-- privileges on `money` already cover new tables; this is explicit for clarity.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON money.bank_sessions TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE money.bank_sessions_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON money.bank_sessions TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_cents" integer;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_type" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_at" timestamp with time zone;
|
||||
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
190
drizzle/20261024013208_books/migration.sql
Normal file
190
drizzle/20261024013208_books/migration.sql
Normal file
@@ -0,0 +1,190 @@
|
||||
CREATE SCHEMA "books";
|
||||
--> statement-breakpoint
|
||||
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
|
||||
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> 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,
|
||||
"status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL,
|
||||
"rating" smallint,
|
||||
"last_read_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),
|
||||
CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "books"."sessions" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"work_id" integer NOT NULL,
|
||||
"file_id" integer,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ended_at" timestamp with time zone,
|
||||
"start_percent" real,
|
||||
"end_percent" real,
|
||||
"start_page" integer,
|
||||
"end_page" integer,
|
||||
"pages_read" integer,
|
||||
"source" text DEFAULT 'reader' NOT NULL,
|
||||
"note" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sessions_percent_check" CHECK (("start_percent" is null or "start_percent" between 0 and 100) and ("end_percent" is null or "end_percent" between 0 and 100)),
|
||||
CONSTRAINT "sessions_end_check" CHECK ("ended_at" is null or "ended_at" >= "started_at")
|
||||
);
|
||||
--> 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 "progress_status_idx" ON "books"."progress" ("status");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_work_id_idx" ON "books"."sessions" ("work_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_started_at_idx" ON "books"."sessions" ("started_at");--> 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"."sessions" ADD CONSTRAINT "sessions_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_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 $$;
|
||||
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: updated_at trigger and explicit grants (default privileges from the books migration cover new tables too).
|
||||
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.sessions FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON books.sessions TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE books.sessions_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON books.sessions TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
13722
drizzle/20261024013208_books/snapshot.json
Normal file
13722
drizzle/20261024013208_books/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,6 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/', 'static/vendor/']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -92,9 +92,11 @@
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"jose": "^6.2.10",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
|
||||
112
scripts/bank-link.ts
Normal file
112
scripts/bank-link.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Link Revolut (personal + joint) to the money tracker through Enable Banking.
|
||||
//
|
||||
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts interactive consent
|
||||
// ... scripts/bank-link.ts --check verify key + app id only
|
||||
// ... scripts/bank-link.ts --code '<redirect URL or code>' [--yes] finish a started consent
|
||||
// ... scripts/bank-link.ts --map <uid|IBAN>=<Tracker account> [--map ...] override the mapping
|
||||
// ... scripts/bank-link.ts --aspsp Revolut --country NL --user <auth user id>
|
||||
//
|
||||
// Needs ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH, DB_URL (and optionally
|
||||
// ENABLEBANKING_REDIRECT_URL). Never prints the private key.
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import postgres from 'postgres';
|
||||
import {
|
||||
configFromEnv,
|
||||
createEnableBankingClient,
|
||||
parseAuthCode
|
||||
} from '../src/lib/server/enable-banking';
|
||||
import { planAccountMapping, saveSession, trackerUserId } from '../src/lib/server/bank-sync';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string) => args.includes(name);
|
||||
const value = (name: string) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const overrides: Record<string, string> = {};
|
||||
args.forEach((arg, i) => {
|
||||
if (arg !== '--map') return;
|
||||
const [key, ...rest] = (args[i + 1] ?? '').split('=');
|
||||
if (!key) return;
|
||||
overrides[key] = rest.join('=');
|
||||
overrides[key.replace(/\s/g, '').toUpperCase()] = rest.join('=');
|
||||
});
|
||||
|
||||
const client = createEnableBankingClient(configFromEnv());
|
||||
const aspsp = { name: value('--aspsp') ?? 'Revolut', country: value('--country') ?? 'NL' };
|
||||
const redirectUrl =
|
||||
process.env.ENABLEBANKING_REDIRECT_URL ??
|
||||
'https://stanrunge.dev/projects/money-tracker/bank-callback';
|
||||
|
||||
const app = await client.application();
|
||||
console.log(
|
||||
`Enable Banking app "${app.name}" (${app.environment}, ${app.active ? 'active' : 'INACTIVE'}), services ${app.services.join(',')}`
|
||||
);
|
||||
const { aspsps } = await client.aspsps(aspsp.country);
|
||||
const bank = aspsps.find((a) => a.name.toLowerCase() === aspsp.name.toLowerCase());
|
||||
if (!bank) throw new Error(`${aspsp.name} (${aspsp.country}) is not available for this app`);
|
||||
console.log(
|
||||
`${bank.name} ${bank.country}: max consent ${Math.floor(bank.maximum_consent_validity / 86400)} days`
|
||||
);
|
||||
if (flag('--check')) process.exit(0);
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
let pasted = value('--code');
|
||||
if (!pasted) {
|
||||
// Stay one hour under the bank's maximum so the API never rejects valid_until.
|
||||
const validUntil = new Date(Date.now() + (bank.maximum_consent_validity - 3600) * 1000);
|
||||
const auth = await client.startAuth({
|
||||
aspsp: { name: bank.name, country: bank.country },
|
||||
redirectUrl,
|
||||
validUntil,
|
||||
psuType: 'personal',
|
||||
state: randomUUID()
|
||||
});
|
||||
console.log('\n1. Open this link and approve access in the Revolut app.');
|
||||
console.log(' Tick BOTH your personal account and the joint account with Juhee:\n');
|
||||
console.log(' ' + auth.url + '\n');
|
||||
console.log(`2. You'll land on ${redirectUrl}?code=...`);
|
||||
console.log(
|
||||
' Copy the full address from the browser bar (or just the code) and paste it here.'
|
||||
);
|
||||
console.log(' The code expires quickly, so paste it within a few minutes.\n');
|
||||
pasted = await rl.question('Redirect URL or code: ');
|
||||
}
|
||||
const parsed = parseAuthCode(pasted ?? '');
|
||||
if (parsed.error) throw new Error(`Consent failed: ${parsed.error}`);
|
||||
if (!parsed.code) throw new Error('No authorisation code found in what was pasted');
|
||||
|
||||
const session = await client.createSession(parsed.code);
|
||||
console.log(
|
||||
`\nSession authorised until ${session.access.valid_until}; ${session.accounts.length} account(s):`
|
||||
);
|
||||
|
||||
const sql = postgres(process.env.DB_URL!, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const userId = await trackerUserId(sql, value('--user'));
|
||||
const tracker = await sql<
|
||||
{ id: number; name: string; scope: string; iban: string | null; external_uid: string | null }[]
|
||||
>`select id, name, scope, iban, external_uid from money.accounts where user_id = ${userId} order by id`;
|
||||
const plan = planAccountMapping(session.accounts, tracker, overrides);
|
||||
for (const { bank: account, tracker: target, reason } of plan) {
|
||||
const iban = account.account_id?.iban ?? '(no IBAN)';
|
||||
console.log(
|
||||
` ${account.uid} ${iban} ${account.currency ?? ''} ${account.name ?? ''}${account.details ? ' / ' + account.details : ''}` +
|
||||
`\n -> ${target ? target.name : 'not linked'} (${reason})`
|
||||
);
|
||||
}
|
||||
const answer = flag('--yes') ? 'y' : await rl.question('\nSave this session and mapping? [y/N] ');
|
||||
if (!/^y(es)?$/i.test(answer.trim())) {
|
||||
console.log('Nothing saved. Re-run with --code is not possible (codes are single-use);');
|
||||
console.log('start again and use --map <uid>=<Tracker account> to fix the mapping.');
|
||||
process.exit(1);
|
||||
}
|
||||
await saveSession(sql, userId, session, plan);
|
||||
console.log(
|
||||
'Saved. Next: bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run'
|
||||
);
|
||||
} finally {
|
||||
rl.close();
|
||||
await sql.end();
|
||||
}
|
||||
48
scripts/bank-sync.ts
Normal file
48
scripts/bank-sync.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Pull booked bank transactions into money.transactions (Enable Banking).
|
||||
//
|
||||
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts [options]
|
||||
// --dry-run fetch and report, write nothing
|
||||
// --reconcile match against ALL hand-entered rows (account + amount + date ±1 day)
|
||||
// and attach the bank id to them instead of inserting duplicates;
|
||||
// run once after linking (with --dry-run first)
|
||||
// --from YYYY-MM-DD override the start date (default: last synced date - 7 days, or the
|
||||
// account's first transaction on the first run)
|
||||
// --account <name> only this tracker account
|
||||
//
|
||||
// Dedupe: unique (account_id, external_id); re-running is always safe.
|
||||
import postgres from 'postgres';
|
||||
import { configFromEnv, createEnableBankingClient } from '../src/lib/server/enable-banking';
|
||||
import { syncBankAccounts } from '../src/lib/server/bank-sync';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const value = (name: string) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const from = value('--from');
|
||||
if (from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) throw new Error('--from must be YYYY-MM-DD');
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is not set');
|
||||
|
||||
const client = createEnableBankingClient(configFromEnv());
|
||||
const sql = postgres(process.env.DB_URL, { max: 2, connect_timeout: 10, onnotice: () => {} });
|
||||
const dryRun = args.includes('--dry-run');
|
||||
try {
|
||||
const results = await syncBankAccounts(sql, client, {
|
||||
dryRun,
|
||||
reconcile: args.includes('--reconcile'),
|
||||
from,
|
||||
accountName: value('--account')
|
||||
});
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`${r.account}: fetched ${r.fetched}, booked ${r.booked}, new ${r.inserted}, ` +
|
||||
`matched to existing ${r.matched}, already synced ${r.alreadyKnown}` +
|
||||
(r.balance ? `, bank balance ${r.balance}` : '') +
|
||||
(r.error ? `\n ERROR ${r.error}` : '')
|
||||
);
|
||||
}
|
||||
if (dryRun) console.log('(dry run: nothing written)');
|
||||
if (results.some((r) => r.error)) process.exitCode = 1;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
952
scripts/books-ingest.py
Normal file
952
scripts/books-ingest.py
Normal file
@@ -0,0 +1,952 @@
|
||||
#!/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/<sha1(cover_url)[:16]>.jpg (stable across dev/prod).
|
||||
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
|
||||
|
||||
def log(*a):
|
||||
print(*a, flush=True)
|
||||
|
||||
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-move", action="store_true", help="write DB rows (and covers) but leave files in the inbox; for a dev rehearsal before the prod run")
|
||||
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] = []
|
||||
|
||||
|
||||
# ---- 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, status) values (%s, 100, %s, 'finished') 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 / (hashlib.sha1(w.cover_url.encode()).hexdigest()[:16] + ".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
|
||||
if args.no_move:
|
||||
log("--no-move: DB rows written, files left in inbox")
|
||||
return
|
||||
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()
|
||||
@@ -107,6 +107,39 @@ try {
|
||||
}
|
||||
if (schema.app_has_ddl)
|
||||
throw new Error('DB_URL must use the app role (personal_app), not the schema owner');
|
||||
// Private health schema: the app role reads and writes it, nobody else (not even personal_ro).
|
||||
const [health] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
count(*) filter (
|
||||
where exists (
|
||||
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||
where r.rolname = 'personal_ro'
|
||||
)
|
||||
) as ro_read,
|
||||
count(*) as tables,
|
||||
has_schema_privilege('public', 'health', 'usage') as public_usage
|
||||
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'health' and c.relkind = 'r'
|
||||
`;
|
||||
if (
|
||||
Number(health.tables) !== 7 ||
|
||||
Number(health.app_rw) !== 7 ||
|
||||
Number(health.ro_read) !== 0 ||
|
||||
health.public_usage
|
||||
)
|
||||
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
|
||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||
const [energy] = await client`
|
||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||
where granularity = 'Hour' and classification = 'Current'
|
||||
and valid_from > now() - interval '7 days'
|
||||
`;
|
||||
if (!Number(energy.hours))
|
||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||
console.log(
|
||||
`health: ${health.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
);
|
||||
const [catalog] = await client`
|
||||
select
|
||||
(select count(*) from ref.pokemon) as pokemon,
|
||||
|
||||
@@ -18,6 +18,29 @@ environment_name=production
|
||||
|
||||
image="git.stanrunge.dev/stan/website:$sha"
|
||||
compose="$directory/compose.prod.yml"
|
||||
compose_files=(-f "$compose")
|
||||
# Book files (ebooks + covers) live on the host and are bind-mounted read-only at /books;
|
||||
# the app streams them only to the owner via /trackers/books/files/<id> (no static path).
|
||||
# Layout: $books_host_dir/library/<files.path> and $books_host_dir/covers/<works.cover_path>.
|
||||
# compose.prod.yml is host-managed, so the mount goes in a generated override file.
|
||||
books_host_dir=${BOOKS_HOST_DIR:-/home/stan/books}
|
||||
case "$books_host_dir" in /*) ;; *) echo 'BOOKS_HOST_DIR must be absolute' >&2; exit 2 ;; esac
|
||||
if [ -d "$books_host_dir/library" ]; then
|
||||
sudo tee "$directory/compose.books.yml" >/dev/null <<YAML
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
BOOKS_ROOT: /books
|
||||
volumes:
|
||||
- type: bind
|
||||
source: $books_host_dir
|
||||
target: /books
|
||||
read_only: true
|
||||
YAML
|
||||
compose_files+=(-f "$directory/compose.books.yml")
|
||||
else
|
||||
echo "No $books_host_dir/library on this host; book files will not be readable" >&2
|
||||
fi
|
||||
# Images built before the repo rename (stan/personal-website -> stan/website) keep
|
||||
# their old local name; retag so rollbacks to those SHAs still work.
|
||||
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||
@@ -34,7 +57,7 @@ rollback() {
|
||||
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
|
||||
echo "Health check failed; restoring prior image tag $previous_tag" >&2
|
||||
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
|
||||
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
|
||||
sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
|
||||
fi
|
||||
}
|
||||
trap rollback ERR
|
||||
@@ -92,9 +115,9 @@ sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||
|
||||
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
|
||||
switched=1
|
||||
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
|
||||
sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
|
||||
|
||||
container=$(sudo docker compose --env-file "$directory/.env" -f "$compose" ps -q app)
|
||||
container=$(sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" ps -q app)
|
||||
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
||||
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
|
||||
if [ "$actual_image" != "$expected_image" ]; then
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
import { isOwner, isOwnerOnlyPath } from '#lib/server/owner.js';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
@@ -11,17 +11,15 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
|
||||
event.locals.user = session?.user ?? null;
|
||||
event.locals.session = session?.session ?? null;
|
||||
// Owner-only areas (health tracker). Page loads, __data.json and the import endpoint
|
||||
// 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.
|
||||
// Owner-only areas (health tracker, book files, reader and book APIs). Page loads,
|
||||
// __data.json and endpoints 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,
|
||||
// including writes to the otherwise public /trackers/books pages.
|
||||
const ownerOnly = isOwnerOnlyPath(event.url.pathname);
|
||||
if (
|
||||
ownerOnly &&
|
||||
!isOwner(event) &&
|
||||
event.request.method !== 'GET' &&
|
||||
event.request.method !== 'HEAD'
|
||||
)
|
||||
const owner = isOwner(event);
|
||||
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
|
||||
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
|
||||
error(404, 'Not Found');
|
||||
const response = await resolve(event);
|
||||
if (ownerOnly) {
|
||||
|
||||
133
src/lib/books/BookCover.svelte
Normal file
133
src/lib/books/BookCover.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
// A book cover: the real image when there is one, otherwise a generated cover in a
|
||||
// colour derived from the title (also used when the image fails to load).
|
||||
import { authorLine, coverHue } from './shared.js';
|
||||
|
||||
let {
|
||||
src,
|
||||
title,
|
||||
authors = [],
|
||||
placeholder = false,
|
||||
size = 'md',
|
||||
class: className = ''
|
||||
}: {
|
||||
src: string | null;
|
||||
title: string;
|
||||
authors?: readonly string[];
|
||||
placeholder?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let failed = $state<string | null>(null);
|
||||
const showImage = $derived(!!src && failed !== src);
|
||||
const hue = $derived(coverHue(title));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={['cover', `cover-${size}`, placeholder && 'is-placeholder', className]}
|
||||
style:--hue={hue}
|
||||
>
|
||||
{#if showImage}
|
||||
<img
|
||||
{src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={() => (failed = src)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="generated" aria-hidden="true">
|
||||
<span class="g-title">{title}</span>
|
||||
{#if authors.length}<span class="g-author">{authorLine(authors)}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
|
||||
background: var(--site-hover);
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.25),
|
||||
0 8px 24px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
/* Spine highlight, like a real paperback. */
|
||||
.cover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(0 0 0 / 0.28) 0,
|
||||
rgb(255 255 255 / 0.12) 2.5%,
|
||||
rgb(0 0 0 / 0.08) 5%,
|
||||
transparent 9%
|
||||
);
|
||||
border-radius: inherit;
|
||||
}
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.generated {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14% 10% 10% 14%;
|
||||
color: hsl(var(--hue) 30% 96%);
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 80% at 100% 0%,
|
||||
hsl(calc(var(--hue) + 40) 60% 45% / 0.55),
|
||||
transparent 60%
|
||||
),
|
||||
linear-gradient(160deg, hsl(var(--hue) 45% 32%), hsl(calc(var(--hue) + 20) 50% 16%));
|
||||
}
|
||||
.g-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
/* Scale with the cover's width so thumbnails don't break words mid-way. */
|
||||
font-size: clamp(0.55rem, 12cqi, 1.6rem);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
.g-author {
|
||||
font-size: clamp(0.5rem, 6.5cqi, 0.85rem);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@container (max-width: 90px) {
|
||||
.g-author {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.is-placeholder {
|
||||
box-shadow: none;
|
||||
outline: 1.5px dashed color-mix(in srgb, var(--site-muted) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.is-placeholder img,
|
||||
.is-placeholder .generated {
|
||||
filter: grayscale(0.85) opacity(0.55);
|
||||
}
|
||||
</style>
|
||||
68
src/lib/books/Stars.svelte
Normal file
68
src/lib/books/Stars.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
// 1-5 star rating. Read-only by default; with `name` it renders radio buttons for a form.
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
|
||||
let {
|
||||
value,
|
||||
size = 14,
|
||||
name,
|
||||
onchange
|
||||
}: {
|
||||
value: number | null;
|
||||
size?: number;
|
||||
name?: string;
|
||||
onchange?: (value: number | null) => void;
|
||||
} = $props();
|
||||
|
||||
let hover = $state<number | null>(null);
|
||||
const shown = $derived(hover ?? value ?? 0);
|
||||
</script>
|
||||
|
||||
{#if name || onchange}
|
||||
<span class="stars interactive" role="radiogroup" aria-label="Rating">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<button
|
||||
type={name ? 'submit' : 'button'}
|
||||
{name}
|
||||
value={value === n ? '' : String(n)}
|
||||
role="radio"
|
||||
aria-checked={value === n}
|
||||
aria-label={`${n} star${n > 1 ? 's' : ''}${value === n ? ' (click to clear)' : ''}`}
|
||||
onpointerenter={() => (hover = n)}
|
||||
onpointerleave={() => (hover = null)}
|
||||
onclick={() => onchange?.(value === n ? null : n)}
|
||||
>
|
||||
<Star {size} class={n <= shown ? 'on' : 'off'} />
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
{:else if value}
|
||||
<span class="stars" aria-label={`${value} out of 5 stars`} title={`${value}/5`}>
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
<Star {size} class={n <= value ? 'on' : 'off'} aria-hidden="true" />
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.stars :global(.on) {
|
||||
fill: #f5b301;
|
||||
color: #f5b301;
|
||||
}
|
||||
.stars :global(.off) {
|
||||
color: color-mix(in srgb, var(--site-muted) 50%, transparent);
|
||||
}
|
||||
.interactive button {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
.interactive button:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
</style>
|
||||
56
src/lib/books/progress.test.ts
Normal file
56
src/lib/books/progress.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pagesRead, parseProgress } from './progress';
|
||||
import { parsePdfLocator, pdfLocator } from './shared';
|
||||
|
||||
describe('progress payload', () => {
|
||||
it('accepts a reader save and rounds the percent', () => {
|
||||
expect(
|
||||
parseProgress({
|
||||
fileId: 3,
|
||||
locator: 'epubcfi(/6/4!/4/2)',
|
||||
percent: 12.34567,
|
||||
sessionId: null
|
||||
})
|
||||
).toMatchObject({ fileId: 3, locator: 'epubcfi(/6/4!/4/2)', percent: 12.35 });
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
'nope',
|
||||
{},
|
||||
{ percent: -1 },
|
||||
{ percent: 101 },
|
||||
{ percent: 'ten' },
|
||||
{ percent: 10, page: 1.5 },
|
||||
{ percent: 10, fileId: -2 },
|
||||
{ percent: 10, locator: 'x'.repeat(2001) }
|
||||
])('rejects %j', (body) => {
|
||||
expect(parseProgress(body)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pages read', () => {
|
||||
it('uses the page delta when pages are known', () => {
|
||||
expect(pagesRead({ startPage: 10, endPage: 34 })).toBe(24);
|
||||
});
|
||||
it('scales the percent delta by the page count', () => {
|
||||
expect(pagesRead({ startPercent: 10, endPercent: 20, pageCount: 300 })).toBe(30);
|
||||
});
|
||||
it('never goes negative and needs enough data', () => {
|
||||
expect(pagesRead({ startPage: 50, endPage: 20 })).toBe(0);
|
||||
expect(pagesRead({ startPercent: 40, endPercent: 30, pageCount: 100 })).toBe(0);
|
||||
expect(pagesRead({ startPercent: 10, endPercent: 20 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pdf locators', () => {
|
||||
it('round-trips pages and rects', () => {
|
||||
const loc = pdfLocator(12, [{ x: 0.1, y: 0.2, w: 0.3333333, h: 0.02 }]);
|
||||
expect(loc).toBe('page:12#r=0.1,0.2,0.3333,0.02');
|
||||
expect(parsePdfLocator(loc)).toEqual({
|
||||
page: 12,
|
||||
rects: [{ x: 0.1, y: 0.2, w: 0.3333, h: 0.02 }]
|
||||
});
|
||||
expect(parsePdfLocator('page:3')).toEqual({ page: 3, rects: [] });
|
||||
expect(parsePdfLocator('epubcfi(/6/2)')).toBeNull();
|
||||
});
|
||||
});
|
||||
41
src/lib/books/progress.ts
Normal file
41
src/lib/books/progress.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Validation and maths for the reader's progress API (pure, unit tested).
|
||||
import { z } from 'zod';
|
||||
|
||||
export const progressPayload = z.object({
|
||||
fileId: z.number().int().positive().nullish(),
|
||||
locator: z.string().max(2000).nullish(),
|
||||
page: z.number().int().min(0).max(100000).nullish(),
|
||||
percent: z.number().min(0).max(100),
|
||||
sessionId: z.number().int().positive().nullish(),
|
||||
/** Total pages in the opened file (PDF), used for pages-read on sessions. */
|
||||
pageCount: z.number().int().positive().max(100000).nullish()
|
||||
});
|
||||
export type ProgressPayload = z.infer<typeof progressPayload>;
|
||||
|
||||
export function parseProgress(body: unknown): ProgressPayload | null {
|
||||
const result = progressPayload.safeParse(body);
|
||||
if (!result.success) return null;
|
||||
const value = result.data;
|
||||
return { ...value, percent: Math.round(value.percent * 100) / 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages read in a session: the page delta when both pages are known (PDF), otherwise the
|
||||
* percent delta scaled by the book's page count. Never negative (going back isn't reading).
|
||||
*/
|
||||
export function pagesRead(input: {
|
||||
startPage?: number | null;
|
||||
endPage?: number | null;
|
||||
startPercent?: number | null;
|
||||
endPercent?: number | null;
|
||||
pageCount?: number | null;
|
||||
}): number | null {
|
||||
const { startPage, endPage, startPercent, endPercent, pageCount } = input;
|
||||
if (startPage != null && endPage != null) return Math.max(0, endPage - startPage);
|
||||
if (startPercent != null && endPercent != null && pageCount)
|
||||
return Math.max(0, Math.round(((endPercent - startPercent) / 100) * pageCount));
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A reader session continues if the last save was less than this long ago. */
|
||||
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||
228
src/lib/books/reader/EpubReader.svelte
Normal file
228
src/lib/books/reader/EpubReader.svelte
Normal file
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
|
||||
import { onMount } from 'svelte';
|
||||
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
|
||||
import {
|
||||
fetchWithProgress,
|
||||
themeColors,
|
||||
type EngineProps,
|
||||
type ReaderSettings,
|
||||
type ReaderTheme,
|
||||
type TocItem
|
||||
} from './types.js';
|
||||
|
||||
type FoliateToc = { label?: string; href?: string; subitems?: FoliateToc[] | null };
|
||||
type Annotation = { value: string; color?: string };
|
||||
type Renderer = HTMLElement & {
|
||||
setStyles(css: string): void;
|
||||
getContents(): { doc: Document; index: number }[];
|
||||
};
|
||||
interface FoliateView extends HTMLElement {
|
||||
book: { toc?: FoliateToc[] };
|
||||
renderer: Renderer;
|
||||
open(file: File): Promise<void>;
|
||||
init(options: { lastLocation?: unknown; showTextStart?: boolean }): Promise<void>;
|
||||
close(): void;
|
||||
goTo(target: string | number): Promise<unknown>;
|
||||
goToFraction(fraction: number): Promise<void>;
|
||||
goLeft(): Promise<void>;
|
||||
goRight(): Promise<void>;
|
||||
addAnnotation(annotation: Annotation): Promise<unknown>;
|
||||
deleteAnnotation(annotation: Annotation): Promise<unknown>;
|
||||
getCFI(index: number, range: Range): string;
|
||||
}
|
||||
type DrawFn = (rects: unknown, options: Record<string, unknown>) => SVGElement;
|
||||
|
||||
let {
|
||||
url,
|
||||
name,
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
theme,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
onhighlightclick,
|
||||
onkey,
|
||||
onloading,
|
||||
onerror
|
||||
}: EngineProps = $props();
|
||||
|
||||
let host = $state<FoliateView>();
|
||||
let view = $state<FoliateView>();
|
||||
let highlightDraw: DrawFn | undefined;
|
||||
|
||||
const fonts = {
|
||||
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif',
|
||||
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
|
||||
};
|
||||
|
||||
function css(s: ReaderSettings, t: ReaderTheme) {
|
||||
const c = themeColors[t];
|
||||
const family = s.font === 'publisher' ? null : fonts[s.font];
|
||||
return `
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${s.fontSize}% !important; }
|
||||
body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} }
|
||||
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''}
|
||||
p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; }
|
||||
p { hyphens: auto; -webkit-hyphens: auto; widows: 2; orphans: 2; }
|
||||
a:any-link { color: ${c.link} !important; }
|
||||
${t === 'light' ? '' : 'body *:not(a, a *) { color: inherit !important; background-color: transparent !important; border-color: currentColor; }'}
|
||||
img, svg, video { max-width: 100%; height: auto; }
|
||||
aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
|
||||
::selection { background: rgba(250, 204, 21, 0.45); }`;
|
||||
}
|
||||
|
||||
function apply(v: FoliateView) {
|
||||
const r = v.renderer;
|
||||
r.setAttribute('flow', settings.flow);
|
||||
r.setAttribute('margin', '48px');
|
||||
r.setAttribute('gap', '6%');
|
||||
r.setAttribute('max-inline-size', '720px');
|
||||
r.setAttribute('max-column-count', '2');
|
||||
r.setStyles(css(settings, theme));
|
||||
v.style.background = themeColors[theme].bg;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (view) apply(view);
|
||||
});
|
||||
|
||||
const color = (h: Highlight) =>
|
||||
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
|
||||
const toToc = (items: FoliateToc[] = []): TocItem[] =>
|
||||
items
|
||||
.filter((i) => i.href)
|
||||
.map((i) => ({
|
||||
label: (i.label ?? '').trim() || 'Untitled',
|
||||
target: i.href!,
|
||||
children: toToc(i.subitems ?? [])
|
||||
}));
|
||||
|
||||
export async function goTo(target: string | number) {
|
||||
await view?.goTo(target);
|
||||
}
|
||||
export async function goToPercent(percent: number) {
|
||||
await view?.goToFraction(Math.min(0.9999, Math.max(0, percent / 100)));
|
||||
}
|
||||
export function next() {
|
||||
void view?.goRight();
|
||||
}
|
||||
export function prev() {
|
||||
void view?.goLeft();
|
||||
}
|
||||
export function addHighlight(h: Highlight) {
|
||||
if (h.locator) void view?.addAnnotation({ value: h.locator, color: color(h) }).catch(() => {});
|
||||
}
|
||||
export function removeHighlight(h: Highlight) {
|
||||
if (h.locator) void view?.deleteAnnotation({ value: h.locator }).catch(() => {});
|
||||
}
|
||||
export function clearSelection() {
|
||||
for (const { doc } of view?.renderer.getContents() ?? []) doc.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
let el: FoliateView | undefined;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const base = '/vendor/foliate-js';
|
||||
const [, overlayer] = await Promise.all([
|
||||
import(/* @vite-ignore */ `${base}/view.js`),
|
||||
import(/* @vite-ignore */ `${base}/overlayer.js`)
|
||||
]);
|
||||
highlightDraw = overlayer.Overlayer.highlight;
|
||||
onloading(0);
|
||||
const blob = await fetchWithProgress(url, onloading, abort.signal);
|
||||
if (disposed) return;
|
||||
onloading(null);
|
||||
|
||||
el = host;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('relocate', (e) => {
|
||||
const d = (e as CustomEvent).detail;
|
||||
onrelocate({
|
||||
percent: Math.max(0, Math.min(100, (d.fraction ?? 0) * 100)),
|
||||
locator: d.cfi,
|
||||
page: null,
|
||||
pageCount: null,
|
||||
label: d.tocItem?.label?.trim() || null
|
||||
});
|
||||
});
|
||||
el.addEventListener('load', (e) => {
|
||||
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
|
||||
doc.addEventListener('keydown', onkey);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
doc.addEventListener('selectionchange', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const sel = doc.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount || !text) return onselection(null);
|
||||
try {
|
||||
onselection({ text, locator: el!.getCFI(index, sel.getRangeAt(0)), page: null });
|
||||
} catch {
|
||||
onselection(null);
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
el.addEventListener('create-overlay', () => {
|
||||
for (const h of highlights) addHighlight(h);
|
||||
});
|
||||
el.addEventListener('draw-annotation', (e) => {
|
||||
const { draw, annotation } = (e as CustomEvent).detail as {
|
||||
draw: (fn: DrawFn, opts: Record<string, unknown>) => void;
|
||||
annotation: Annotation;
|
||||
};
|
||||
if (highlightDraw)
|
||||
draw(highlightDraw, { color: annotation.color ?? highlightCss.yellow });
|
||||
});
|
||||
el.addEventListener('show-annotation', (e) => {
|
||||
const value = (e as CustomEvent).detail.value as string;
|
||||
const h = highlights.find((x) => x.locator === value);
|
||||
if (h) onhighlightclick(h.id);
|
||||
});
|
||||
|
||||
await el.open(new File([blob], name, { type: blob.type }));
|
||||
if (disposed) return;
|
||||
apply(el);
|
||||
view = el;
|
||||
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
|
||||
await el.init({ lastLocation, showTextStart: !lastLocation });
|
||||
onready(toToc(el.book.toc));
|
||||
} catch (e) {
|
||||
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
|
||||
onerror(e instanceof Error ? e.message : 'Could not open this book');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
el?.close?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="host">
|
||||
<foliate-view bind:this={host}></foliate-view>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.host :global(foliate-view) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
541
src/lib/books/reader/PdfReader.svelte
Normal file
541
src/lib/books/reader/PdfReader.svelte
Normal file
@@ -0,0 +1,541 @@
|
||||
<script lang="ts">
|
||||
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer
|
||||
// for selection, and highlights stored as page-relative rectangles.
|
||||
import { onMount } from 'svelte';
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import {
|
||||
highlightCss,
|
||||
parsePdfLocator,
|
||||
pdfLocator,
|
||||
type Highlight,
|
||||
type HighlightColor,
|
||||
type Rect
|
||||
} from '#lib/books/shared.js';
|
||||
import { type EngineProps, type TocItem } from './types.js';
|
||||
|
||||
let {
|
||||
url,
|
||||
start,
|
||||
startPercent,
|
||||
highlights,
|
||||
settings,
|
||||
theme,
|
||||
onready,
|
||||
onrelocate,
|
||||
onselection,
|
||||
onhighlightclick,
|
||||
onloading,
|
||||
onerror
|
||||
}: EngineProps = $props();
|
||||
|
||||
type PageInfo = { num: number; width: number; height: number }; // at scale 1
|
||||
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
|
||||
|
||||
let scroller: HTMLDivElement;
|
||||
let pdf: PDFDocumentProxy | undefined;
|
||||
let pdfjs: typeof import('pdfjs-dist') | undefined;
|
||||
let pages = $state<PageInfo[]>([]);
|
||||
let width = $state(0);
|
||||
let current = 1;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const live = new Map<number, Live>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const pageEls = new Map<number, HTMLElement>();
|
||||
let observer: IntersectionObserver | undefined;
|
||||
|
||||
const scale = $derived.by(() => {
|
||||
const first = pages[0];
|
||||
if (!first || !width) return 1;
|
||||
const fit = Math.min(width - 24, 920) / first.width;
|
||||
return Math.max(0.3, fit * settings.pdfZoom);
|
||||
});
|
||||
|
||||
const byPage = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
|
||||
const map = new Map<number, { h: Highlight; rects: Rect[] }[]>();
|
||||
for (const h of highlights) {
|
||||
const loc = parsePdfLocator(h.locator);
|
||||
if (!loc || !loc.rects.length) continue;
|
||||
const list = map.get(loc.page) ?? [];
|
||||
list.push({ h, rects: loc.rects });
|
||||
map.set(loc.page, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const color = (h: Highlight) =>
|
||||
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
|
||||
|
||||
async function render(num: number) {
|
||||
const el = pageEls.get(num);
|
||||
if (!pdf || !pdfjs || !el || live.has(num)) return;
|
||||
const layer = el.querySelector<HTMLElement>('.layer')!;
|
||||
const entry: Live = { el, layer };
|
||||
live.set(num, entry);
|
||||
const page: PDFPageProxy = await pdf.getPage(num);
|
||||
if (live.get(num) !== entry) return;
|
||||
const s = scale;
|
||||
const viewport = page.getViewport({ scale: s });
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(viewport.width * dpr);
|
||||
canvas.height = Math.floor(viewport.height * dpr);
|
||||
const text = document.createElement('div');
|
||||
text.className = 'textLayer';
|
||||
layer.replaceChildren(canvas, text);
|
||||
el.style.setProperty('--scale-factor', String(s));
|
||||
el.style.setProperty('--total-scale-factor', String(s));
|
||||
entry.task = page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined
|
||||
});
|
||||
try {
|
||||
await entry.task.promise;
|
||||
if (live.get(num) !== entry) return;
|
||||
await new pdfjs.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: text,
|
||||
viewport
|
||||
}).render();
|
||||
} catch {
|
||||
// cancelled (scrolled away or re-zoomed)
|
||||
}
|
||||
}
|
||||
|
||||
function unrender(num: number) {
|
||||
const entry = live.get(num);
|
||||
if (!entry) return;
|
||||
entry.task?.cancel();
|
||||
entry.layer.replaceChildren();
|
||||
live.delete(num);
|
||||
}
|
||||
|
||||
function page(node: HTMLElement, num: number) {
|
||||
pageEls.set(num, node);
|
||||
observer?.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
observer?.unobserve(node);
|
||||
pageEls.delete(num);
|
||||
unrender(num);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Re-render everything when the zoom or the width changes, keeping the reading position.
|
||||
let lastScale = 0;
|
||||
$effect(() => {
|
||||
const s = scale;
|
||||
// The first scale with pages loaded is the baseline; the initial jump to the saved
|
||||
// position happens in onMount and must not be undone by a "keep position" here.
|
||||
if (!pages.length || !width) return;
|
||||
if (!lastScale) {
|
||||
lastScale = s;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(s - lastScale) < 0.001) return;
|
||||
lastScale = s;
|
||||
const keep = position();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToPosition(keep.page, keep.fraction);
|
||||
for (const [num, node] of pageEls) {
|
||||
const r = node.getBoundingClientRect();
|
||||
const v = scroller.getBoundingClientRect();
|
||||
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function position() {
|
||||
if (!scroller) return { page: 1, fraction: 0 };
|
||||
const anchor = scroller.scrollTop + scroller.clientHeight * 0.3;
|
||||
for (const p of pages) {
|
||||
const node = pageEls.get(p.num);
|
||||
if (!node) continue;
|
||||
const top = node.offsetTop;
|
||||
const bottom = top + node.offsetHeight;
|
||||
if (anchor < bottom + 8)
|
||||
return {
|
||||
page: p.num,
|
||||
fraction: Math.max(0, Math.min(1, (anchor - top) / node.offsetHeight))
|
||||
};
|
||||
}
|
||||
return { page: pages.length, fraction: 1 };
|
||||
}
|
||||
|
||||
function scrollToPosition(num: number, fraction = 0) {
|
||||
const node = pageEls.get(Math.max(1, Math.min(pages.length, num)));
|
||||
if (!node) return;
|
||||
scroller.scrollTop =
|
||||
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
function onscroll() {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const { page: num, fraction } = position();
|
||||
current = num;
|
||||
onrelocate({
|
||||
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
|
||||
locator: pdfLocator(num),
|
||||
page: num,
|
||||
pageCount: pages.length,
|
||||
label: labelFor(num)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let flatToc: { page: number; label: string }[] = [];
|
||||
const labelFor = (num: number) => {
|
||||
let label: string | null = null;
|
||||
for (const item of flatToc) if (item.page <= num) label = item.label;
|
||||
return label;
|
||||
};
|
||||
|
||||
export async function goTo(target: string | number) {
|
||||
if (typeof target === 'number') return scrollToPosition(target);
|
||||
const loc = parsePdfLocator(target);
|
||||
if (!loc) return;
|
||||
const y = loc.rects[0]?.y;
|
||||
if (y != null) scrollToPosition(loc.page, Math.max(0, y - 0.05) || 0.0001);
|
||||
else scrollToPosition(loc.page);
|
||||
}
|
||||
export async function goToPercent(percent: number) {
|
||||
const p = (percent / 100) * pages.length;
|
||||
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
|
||||
}
|
||||
export function next() {
|
||||
scrollToPosition(current + 1);
|
||||
}
|
||||
export function prev() {
|
||||
const { fraction } = position();
|
||||
scrollToPosition(fraction > 0.1 ? current : current - 1);
|
||||
}
|
||||
export function addHighlight() {
|
||||
// Highlights are derived from props; nothing to draw imperatively.
|
||||
}
|
||||
export function removeHighlight() {}
|
||||
export function clearSelection() {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
/** Selection rects as fractions of the page, merged per line. */
|
||||
function selectionRects(range: Range, pageEl: HTMLElement): Rect[] {
|
||||
const box = pageEl.getBoundingClientRect();
|
||||
const rects = [...range.getClientRects()]
|
||||
.filter((r) => r.width > 1 && r.height > 1)
|
||||
.map((r) => ({
|
||||
x: (r.left - box.left) / box.width,
|
||||
y: (r.top - box.top) / box.height,
|
||||
w: r.width / box.width,
|
||||
h: r.height / box.height
|
||||
}))
|
||||
.filter((r) => r.x >= -0.01 && r.y >= -0.01 && r.x < 1 && r.y < 1)
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const merged: Rect[] = [];
|
||||
for (const r of rects) {
|
||||
const last = merged.at(-1);
|
||||
if (last && Math.abs(last.y - r.y) < last.h * 0.5 && r.x <= last.x + last.w + 0.02) {
|
||||
const right = Math.max(last.x + last.w, r.x + r.w);
|
||||
const bottom = Math.max(last.y + last.h, r.y + r.h);
|
||||
last.x = Math.min(last.x, r.x);
|
||||
last.y = Math.min(last.y, r.y);
|
||||
last.w = right - last.x;
|
||||
last.h = bottom - last.y;
|
||||
} else merged.push({ ...r });
|
||||
}
|
||||
return merged.slice(0, 60);
|
||||
}
|
||||
|
||||
function hitHighlight(e: MouseEvent, num: number) {
|
||||
if (!document.getSelection()?.isCollapsed) return;
|
||||
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = (e.clientX - box.left) / box.width;
|
||||
const y = (e.clientY - box.top) / box.height;
|
||||
const hit = byPage
|
||||
.get(num)
|
||||
?.find(({ rects }) =>
|
||||
rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
|
||||
);
|
||||
if (hit) onhighlightclick(hit.h.id);
|
||||
}
|
||||
|
||||
async function resolveToc(doc: PDFDocumentProxy): Promise<TocItem[]> {
|
||||
const outline = await doc.getOutline().catch(() => null);
|
||||
if (!outline) return [];
|
||||
let budget = 600;
|
||||
const walk = async (items: typeof outline): Promise<TocItem[]> => {
|
||||
const out: TocItem[] = [];
|
||||
for (const item of items) {
|
||||
if (budget-- <= 0) break;
|
||||
let num: number | null = null;
|
||||
try {
|
||||
const dest =
|
||||
typeof item.dest === 'string' ? await doc.getDestination(item.dest) : item.dest;
|
||||
const ref = dest?.[0];
|
||||
if (ref && typeof ref === 'object') num = (await doc.getPageIndex(ref)) + 1;
|
||||
else if (typeof ref === 'number') num = ref + 1;
|
||||
} catch {
|
||||
num = null;
|
||||
}
|
||||
const children = item.items?.length ? await walk(item.items) : [];
|
||||
if (num) {
|
||||
out.push({ label: item.title.trim() || `Page ${num}`, target: num, children });
|
||||
flatToc.push({ page: num, label: item.title.trim() });
|
||||
} else out.push(...children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const toc = await walk(outline);
|
||||
flatToc = flatToc.sort((a, b) => a.page - b.page);
|
||||
return toc;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
|
||||
resize.observe(scroller);
|
||||
width = scroller.clientWidth;
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const num = Number((entry.target as HTMLElement).dataset.page);
|
||||
if (entry.isIntersecting) void render(num);
|
||||
else unrender(num);
|
||||
}
|
||||
},
|
||||
{ root: scroller, rootMargin: '150% 0px' }
|
||||
);
|
||||
|
||||
let selTimer: ReturnType<typeof setTimeout>;
|
||||
const onSelectionChange = () => {
|
||||
clearTimeout(selTimer);
|
||||
selTimer = setTimeout(() => {
|
||||
const sel = document.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
const node = sel?.anchorNode;
|
||||
const pageEl = (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(
|
||||
'.pdf-page'
|
||||
);
|
||||
if (!sel || sel.isCollapsed || !text || !pageEl || !scroller.contains(pageEl))
|
||||
return onselection(null);
|
||||
const num = Number(pageEl.dataset.page);
|
||||
const rects = selectionRects(sel.getRangeAt(0), pageEl);
|
||||
if (!rects.length) return onselection(null);
|
||||
onselection({ text, locator: pdfLocator(num, rects), page: num });
|
||||
}, 250);
|
||||
};
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
onloading(0);
|
||||
const task = pdfjs.getDocument({
|
||||
url,
|
||||
withCredentials: true,
|
||||
rangeChunkSize: 1 << 20
|
||||
});
|
||||
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
|
||||
onloading(total ? Math.min(1, loaded / total) : null);
|
||||
const doc = await task.promise;
|
||||
if (disposed) return void doc.destroy();
|
||||
pdf = doc;
|
||||
onloading(null);
|
||||
// Use the first page's size for every page up front; real sizes fill in quickly.
|
||||
const first = (await doc.getPage(1)).getViewport({ scale: 1 });
|
||||
pages = Array.from({ length: doc.numPages }, (_, i) => ({
|
||||
num: i + 1,
|
||||
width: first.width,
|
||||
height: first.height
|
||||
}));
|
||||
void (async () => {
|
||||
for (let i = 2; i <= Math.min(doc.numPages, 2000) && !disposed; i++) {
|
||||
const vp = (await doc.getPage(i)).getViewport({ scale: 1 });
|
||||
if (vp.width !== first.width || vp.height !== first.height)
|
||||
pages[i - 1] = { num: i, width: vp.width, height: vp.height };
|
||||
}
|
||||
})();
|
||||
requestAnimationFrame(async () => {
|
||||
const loc = parsePdfLocator(start);
|
||||
if (loc) scrollToPosition(loc.page, loc.rects[0]?.y ?? 0);
|
||||
else if (startPercent > 0) await goToPercent(startPercent);
|
||||
onscroll();
|
||||
onready(await resolveToc(doc));
|
||||
});
|
||||
} catch (e) {
|
||||
if (!disposed) onerror(e instanceof Error ? e.message : 'Could not open this PDF');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
resize.disconnect();
|
||||
observer?.disconnect();
|
||||
for (const num of [...live.keys()]) unrender(num);
|
||||
void pdf?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={['scroller', `t-${theme}`]}
|
||||
style:background={theme === 'light' ? '#e9e9e7' : theme === 'sepia' ? '#e6dcc4' : '#0b0b0c'}
|
||||
bind:this={scroller}
|
||||
{onscroll}
|
||||
>
|
||||
<div class="pages">
|
||||
{#each pages as p (p.num)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="pdf-page"
|
||||
data-page={p.num}
|
||||
style:width={`${Math.floor(p.width * scale)}px`}
|
||||
style:height={`${Math.floor(p.height * scale)}px`}
|
||||
use:page={p.num}
|
||||
onclick={(e) => hitHighlight(e, p.num)}
|
||||
>
|
||||
<div class="layer"></div>
|
||||
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
|
||||
{#each rects as r, i (i)}
|
||||
<span
|
||||
class="hl"
|
||||
style:left={`${r.x * 100}%`}
|
||||
style:top={`${r.y * 100}%`}
|
||||
style:width={`${r.w * 100}%`}
|
||||
style:height={`${r.h * 100}%`}
|
||||
style:background={color(h)}
|
||||
></span>
|
||||
{/each}
|
||||
{/each}
|
||||
<span class="num">{p.num}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroller {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 12px 96px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
.pdf-page {
|
||||
position: relative;
|
||||
background: white;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.12),
|
||||
0 8px 24px rgb(0 0 0 / 0.08);
|
||||
flex: none;
|
||||
}
|
||||
.t-sepia .pdf-page :global(canvas) {
|
||||
filter: sepia(0.35) brightness(0.97);
|
||||
}
|
||||
.t-dark .pdf-page {
|
||||
background: #1a1a1c;
|
||||
}
|
||||
.t-dark .pdf-page :global(canvas) {
|
||||
filter: invert(0.9) hue-rotate(180deg);
|
||||
}
|
||||
.layer,
|
||||
.layer :global(canvas) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.hl {
|
||||
position: absolute;
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.45;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.t-dark .hl {
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.num {
|
||||
position: absolute;
|
||||
right: -2.25rem;
|
||||
bottom: 0;
|
||||
font-size: 0.7rem;
|
||||
color: rgb(128 128 128 / 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.num {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* pdf.js text layer (from pdfjs-dist/web/pdf_viewer.css, trimmed) */
|
||||
.pdf-page :global(.textLayer) {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
z-index: 2;
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
--scale-round-x: 1px;
|
||||
--scale-round-y: 1px;
|
||||
}
|
||||
.pdf-page :global(.textLayer :is(span, br)) {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
user-select: text;
|
||||
}
|
||||
.pdf-page :global(.textLayer > :not(.markedContent)),
|
||||
.pdf-page :global(.textLayer .markedContent span:not(.markedContent)) {
|
||||
z-index: 1;
|
||||
--font-height: 0;
|
||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||
--scale-x: 1;
|
||||
--rotate: 0deg;
|
||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
||||
}
|
||||
.pdf-page :global(.textLayer .markedContent) {
|
||||
display: contents;
|
||||
}
|
||||
.pdf-page :global(.textLayer ::selection) {
|
||||
background: rgb(250 204 21 / 0.4);
|
||||
color: transparent;
|
||||
}
|
||||
.pdf-page :global(.textLayer br::selection) {
|
||||
background: transparent;
|
||||
}
|
||||
.pdf-page :global(.textLayer .endOfContent) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 100% 0 0;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
89
src/lib/books/reader/types.ts
Normal file
89
src/lib/books/reader/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
|
||||
import type { Highlight } from '#lib/books/shared.js';
|
||||
|
||||
export type TocItem = { label: string; target: string | number; children: TocItem[] };
|
||||
|
||||
export type Relocation = {
|
||||
percent: number; // 0-100
|
||||
locator: string; // EPUB CFI or `page:N`
|
||||
page: number | null; // PDF page (1-based), or null for reflowable books
|
||||
pageCount: number | null;
|
||||
label: string | null; // current chapter
|
||||
};
|
||||
|
||||
export type TextSelection = { text: string; locator: string; page: number | null };
|
||||
|
||||
export type ReaderTheme = 'light' | 'sepia' | 'dark';
|
||||
export type ReaderSettings = {
|
||||
theme: 'auto' | ReaderTheme;
|
||||
fontSize: number; // percent
|
||||
lineHeight: number;
|
||||
font: 'publisher' | 'serif' | 'sans';
|
||||
flow: 'paginated' | 'scrolled';
|
||||
pdfZoom: number; // 1 = fit width
|
||||
};
|
||||
export const defaultSettings: ReaderSettings = {
|
||||
theme: 'auto',
|
||||
fontSize: 100,
|
||||
lineHeight: 1.5,
|
||||
font: 'publisher',
|
||||
flow: 'paginated',
|
||||
pdfZoom: 1
|
||||
};
|
||||
|
||||
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
|
||||
light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' },
|
||||
sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' },
|
||||
dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' }
|
||||
};
|
||||
|
||||
/** What both engines expose to the shell through bind:this. */
|
||||
export interface ReaderEngine {
|
||||
goTo(target: string | number): Promise<void>;
|
||||
goToPercent(percent: number): Promise<void>;
|
||||
next(): void;
|
||||
prev(): void;
|
||||
addHighlight(h: Highlight): void;
|
||||
removeHighlight(h: Highlight): void;
|
||||
clearSelection(): void;
|
||||
}
|
||||
|
||||
export type EngineProps = {
|
||||
url: string;
|
||||
name: string;
|
||||
start: string | null;
|
||||
startPercent: number;
|
||||
highlights: Highlight[];
|
||||
settings: ReaderSettings;
|
||||
theme: ReaderTheme;
|
||||
onready: (toc: TocItem[]) => void;
|
||||
onrelocate: (r: Relocation) => void;
|
||||
onselection: (s: TextSelection | null) => void;
|
||||
onhighlightclick: (id: number) => void;
|
||||
onkey: (e: KeyboardEvent) => void;
|
||||
onloading: (fraction: number | null) => void;
|
||||
onerror: (message: string) => void;
|
||||
};
|
||||
|
||||
/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */
|
||||
export async function fetchWithProgress(
|
||||
url: string,
|
||||
onprogress: (fraction: number | null) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(url, { signal, credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length')) || 0;
|
||||
if (!res.body || !total) return res.blob();
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let loaded = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
loaded += value.byteLength;
|
||||
onprogress(Math.min(1, loaded / total));
|
||||
}
|
||||
return new Blob(chunks, { type: res.headers.get('content-type') ?? '' });
|
||||
}
|
||||
104
src/lib/books/shared.ts
Normal file
104
src/lib/books/shared.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Shared (client + server) types and helpers for the book tracker.
|
||||
export const readingStatuses = ['want_to_read', 'reading', 'finished', 'abandoned'] as const;
|
||||
export type ReadingStatus = (typeof readingStatuses)[number];
|
||||
|
||||
export const statusLabels: Record<ReadingStatus, string> = {
|
||||
want_to_read: 'want to read',
|
||||
reading: 'reading',
|
||||
finished: 'finished',
|
||||
abandoned: 'abandoned'
|
||||
};
|
||||
|
||||
export function isReadingStatus(value: unknown): value is ReadingStatus {
|
||||
return typeof value === 'string' && (readingStatuses as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Formats the in-browser reader can open. MOBI/AZW3/FB2/CBZ go through foliate-js too. */
|
||||
export const readableFormats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz'] as const;
|
||||
export function isReadable(format: string): boolean {
|
||||
return (readableFormats as readonly string[]).includes(format.toLowerCase());
|
||||
}
|
||||
/** Preferred file to open when a work has several formats. */
|
||||
export const formatPreference = ['epub', 'azw3', 'mobi', 'azw', 'fb2', 'pdf', 'cbz'];
|
||||
|
||||
/** One card in the library. Public fields only, unless noted. */
|
||||
export type BookCard = {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
authors: string[];
|
||||
year: number | null;
|
||||
tags: string[];
|
||||
pageCount: number | null;
|
||||
cover: string | null; // URL (our cover endpoint or a remote cover), or null for a generated one
|
||||
status: ReadingStatus | null; // null: never touched
|
||||
percent: number;
|
||||
rating: number | null;
|
||||
lastReadAt: string | null;
|
||||
finishedAt: string | null;
|
||||
addedAt: string;
|
||||
// Owner only (empty / false for everyone else):
|
||||
formats: string[];
|
||||
hasFile: boolean;
|
||||
};
|
||||
|
||||
export type LibraryStats = {
|
||||
total: number;
|
||||
finishedThisYear: number;
|
||||
pagesThisYear: number;
|
||||
reading: number;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type Highlight = {
|
||||
id: number;
|
||||
fileId: number | null;
|
||||
locator: string | null;
|
||||
page: number | null;
|
||||
highlight: string | null;
|
||||
note: string | null;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const highlightColors = ['yellow', 'green', 'blue', 'pink', 'purple'] as const;
|
||||
export type HighlightColor = (typeof highlightColors)[number];
|
||||
export const highlightCss: Record<HighlightColor, string> = {
|
||||
yellow: '#facc15',
|
||||
green: '#4ade80',
|
||||
blue: '#60a5fa',
|
||||
pink: '#f472b6',
|
||||
purple: '#c084fc'
|
||||
};
|
||||
|
||||
/** Deterministic hue for generated covers, so a book always gets the same colour. */
|
||||
export function coverHue(title: string): number {
|
||||
let hash = 0;
|
||||
for (const char of title) hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
|
||||
return hash % 360;
|
||||
}
|
||||
|
||||
export function authorLine(authors: readonly string[]): string {
|
||||
if (authors.length <= 2) return authors.join(' & ');
|
||||
return `${authors[0]} et al.`;
|
||||
}
|
||||
|
||||
/** PDF highlight locators: `page:12` or `page:12#r=x,y,w,h;x,y,w,h` (rects as page fractions). */
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
export function pdfLocator(page: number, rects: Rect[] = []): string {
|
||||
const r = rects
|
||||
.map((rect) => [rect.x, rect.y, rect.w, rect.h].map((n) => +n.toFixed(4)).join(','))
|
||||
.join(';');
|
||||
return r ? `page:${page}#r=${r}` : `page:${page}`;
|
||||
}
|
||||
export function parsePdfLocator(locator: string | null): { page: number; rects: Rect[] } | null {
|
||||
const match = /^page:(\d+)(?:#r=([\d.,;-]+))?$/.exec(locator ?? '');
|
||||
if (!match) return null;
|
||||
const rects = (match[2] ?? '')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.split(',').map(Number))
|
||||
.filter((n) => n.length === 4 && n.every(Number.isFinite))
|
||||
.map(([x, y, w, h]) => ({ x, y, w, h }));
|
||||
return { page: Number(match[1]), rects };
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
|
||||
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
||||
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
||||
@@ -36,7 +37,8 @@
|
||||
'cs prices': CandlestickChart,
|
||||
'house tracker': House,
|
||||
lists: ListChecks,
|
||||
health: HeartPulse
|
||||
health: HeartPulse,
|
||||
books: BookOpen
|
||||
};
|
||||
|
||||
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
||||
|
||||
104
src/lib/energy/EnergyChart.svelte
Normal file
104
src/lib/energy/EnergyChart.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" module>
|
||||
export type ChartTheme = {
|
||||
text: string;
|
||||
muted: string;
|
||||
border: string;
|
||||
surface: string;
|
||||
dark: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core';
|
||||
|
||||
// Generic ECharts wrapper for the energy tracker: same lazy-loading and theme handling as
|
||||
// the CS price chart, but the caller builds the option from the current theme colours.
|
||||
let {
|
||||
option,
|
||||
label,
|
||||
height = 'min(52vh, 440px)'
|
||||
}: {
|
||||
option: (theme: ChartTheme) => EChartsCoreOption;
|
||||
label: string;
|
||||
height?: string;
|
||||
} = $props();
|
||||
let element: HTMLDivElement;
|
||||
let chart: ECharts | undefined;
|
||||
let ready = $state(false);
|
||||
|
||||
function update() {
|
||||
if (!chart) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const read = (name: string) => style.getPropertyValue(name).trim();
|
||||
chart.setOption(
|
||||
option({
|
||||
text: read('--site-text'),
|
||||
muted: read('--site-muted'),
|
||||
border: read('--site-border'),
|
||||
surface: read('--site-surface'),
|
||||
dark: read('--site-bg') === '#000'
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let resize: ResizeObserver | undefined;
|
||||
let theme: MutationObserver | undefined;
|
||||
import('echarts/core').then(async (echarts) => {
|
||||
const [
|
||||
{ LineChart, BarChart, PieChart },
|
||||
{ GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkAreaComponent },
|
||||
{ CanvasRenderer }
|
||||
] = await Promise.all([
|
||||
import('echarts/charts'),
|
||||
import('echarts/components'),
|
||||
import('echarts/renderers')
|
||||
]);
|
||||
if (disposed) return;
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
MarkAreaComponent,
|
||||
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(() => {
|
||||
// Re-render when the option builder (and therefore its inputs) changes.
|
||||
void option;
|
||||
if (ready) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chart" style:height bind:this={element} role="img" aria-label={label}></div>
|
||||
|
||||
<style>
|
||||
.chart {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
}
|
||||
</style>
|
||||
42
src/lib/energy/data.test.ts
Normal file
42
src/lib/energy/data.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fillGaps, gaps, latestSnapshot, seasonalTime, toBuckets, type EnergyRow } from './data';
|
||||
|
||||
const hour = 3_600_000;
|
||||
const rows: EnergyRow[] = [
|
||||
{ t: 0, type: 'ElectricityMix', value: 100, ef: 0.2 },
|
||||
{ t: 0, type: 'ElectricityLoad', value: 90 },
|
||||
{ t: 0, type: 'Wind', value: 60, pct: 0.5 },
|
||||
{ t: 0, type: 'FossilGasPower', value: 30, pct: 0.1 },
|
||||
{ t: 0, type: 'Nuclear', value: 10, pct: 1 },
|
||||
{ t: hour, type: 'ElectricityMix', value: 100 },
|
||||
{ t: hour, type: 'Wind', value: 20 }
|
||||
];
|
||||
|
||||
describe('energy buckets', () => {
|
||||
it('flags buckets whose sources do not add up to the total', () => {
|
||||
const buckets = toBuckets(rows);
|
||||
expect(buckets.map((bucket) => bucket.incomplete)).toEqual([false, true]);
|
||||
expect(buckets[0].sum).toBe(100);
|
||||
expect(buckets[0].load).toBe(90);
|
||||
});
|
||||
|
||||
it('summarises the latest complete bucket', () => {
|
||||
const snapshot = latestSnapshot(toBuckets(rows))!;
|
||||
expect(snapshot.t).toBe(0);
|
||||
expect(snapshot.renewableShare).toBeCloseTo(0.6);
|
||||
expect(snapshot.lowCarbonShare).toBeCloseTo(0.7);
|
||||
expect(snapshot.fossilShare).toBeCloseTo(0.3);
|
||||
expect(snapshot.sources[0]).toMatchObject({ type: 'Wind', share: 0.6, utilization: 0.5 });
|
||||
});
|
||||
|
||||
it('finds and fills missing hours', () => {
|
||||
expect(gaps([0, hour, 4 * hour])).toEqual([{ from: 2 * hour, to: 4 * hour, hours: 2 }]);
|
||||
const filled = fillGaps(toBuckets([...rows, { t: 3 * hour, type: 'Wind', value: 1 }]), hour);
|
||||
expect(filled.map((bucket) => 'missing' in bucket)).toEqual([false, false, true, false]);
|
||||
});
|
||||
|
||||
it('maps dates onto one seasonal year', () => {
|
||||
expect(seasonalTime('2023-02-28')).toBe(Date.UTC(2000, 1, 28));
|
||||
expect(seasonalTime('2024-12-31')).toBe(Date.UTC(2000, 11, 31));
|
||||
});
|
||||
});
|
||||
195
src/lib/energy/data.ts
Normal file
195
src/lib/energy/data.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// Pure helpers for the energy tracker. Inputs come from the ned-collector tables in the
|
||||
// `energy` schema (NED.nl electricity data and AGSI/GIE gas storage).
|
||||
|
||||
export type SourceType =
|
||||
| 'Nuclear'
|
||||
| 'WindOffshoreC'
|
||||
| 'Wind'
|
||||
| 'Solar'
|
||||
| 'BiomassPower'
|
||||
| 'WastePower'
|
||||
| 'WKK Total'
|
||||
| 'FossilGasPower'
|
||||
| 'FossilHardCoal'
|
||||
| 'OtherPower';
|
||||
|
||||
export type Source = {
|
||||
type: SourceType;
|
||||
label: string;
|
||||
color: string;
|
||||
renewable: boolean;
|
||||
fossil: boolean;
|
||||
};
|
||||
|
||||
// Stacking order, bottom to top: steady baseload first, weather-driven next, fossil on top.
|
||||
export const SOURCES: readonly Source[] = [
|
||||
{ type: 'Nuclear', label: 'Nuclear', color: '#a78bfa', renewable: false, fossil: false },
|
||||
{
|
||||
type: 'WindOffshoreC',
|
||||
label: 'Wind offshore',
|
||||
color: '#0284c7',
|
||||
renewable: true,
|
||||
fossil: false
|
||||
},
|
||||
{ type: 'Wind', label: 'Wind onshore', color: '#38bdf8', renewable: true, fossil: false },
|
||||
{ type: 'Solar', label: 'Solar', color: '#facc15', renewable: true, fossil: false },
|
||||
{ type: 'BiomassPower', label: 'Biomass', color: '#65a30d', renewable: true, fossil: false },
|
||||
{ type: 'WastePower', label: 'Waste', color: '#a3a3a3', renewable: false, fossil: false },
|
||||
{ type: 'WKK Total', label: 'CHP (WKK)', color: '#fdba74', renewable: false, fossil: true },
|
||||
{ type: 'FossilGasPower', label: 'Fossil gas', color: '#f97316', renewable: false, fossil: true },
|
||||
{ type: 'FossilHardCoal', label: 'Hard coal', color: '#57534e', renewable: false, fossil: true },
|
||||
{ type: 'OtherPower', label: 'Other', color: '#94a3b8', renewable: false, fossil: false }
|
||||
];
|
||||
|
||||
export const MIX = 'ElectricityMix';
|
||||
export const LOAD = 'ElectricityLoad';
|
||||
const sourceTypes = new Set<string>(SOURCES.map((source) => source.type));
|
||||
|
||||
/** One row per (time bucket, type). `value` is MWh for hours/days and GWh for months/years. */
|
||||
export type EnergyRow = {
|
||||
t: number;
|
||||
type: string;
|
||||
value: number;
|
||||
pct?: number | null;
|
||||
ef?: number | null;
|
||||
hours?: number;
|
||||
expected?: number;
|
||||
};
|
||||
|
||||
export type Bucket = {
|
||||
t: number;
|
||||
mix: number | null;
|
||||
load: number | null;
|
||||
ef: number | null;
|
||||
sources: Partial<Record<SourceType, { value: number; pct: number | null }>>;
|
||||
sum: number;
|
||||
hours?: number;
|
||||
/** Hours in the period (23 or 25 on DST change days). */
|
||||
expected?: number;
|
||||
/** The source rows don't add up to the NED total, so the bucket is still being filled in. */
|
||||
incomplete: boolean;
|
||||
};
|
||||
|
||||
/** Group rows into buckets ordered by time. A bucket is incomplete when its sources sum to
|
||||
* less than 98% (or more than 102%) of the published total. */
|
||||
export function toBuckets(rows: readonly EnergyRow[]): Bucket[] {
|
||||
const buckets = new Map<number, Bucket>();
|
||||
for (const row of rows) {
|
||||
let bucket = buckets.get(row.t);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
t: row.t,
|
||||
mix: null,
|
||||
load: null,
|
||||
ef: null,
|
||||
sources: {},
|
||||
sum: 0,
|
||||
incomplete: false
|
||||
};
|
||||
buckets.set(row.t, bucket);
|
||||
}
|
||||
if (row.hours !== undefined) bucket.hours = Math.max(bucket.hours ?? 0, row.hours);
|
||||
if (row.expected !== undefined) bucket.expected = row.expected;
|
||||
if (row.type === MIX) {
|
||||
bucket.mix = row.value;
|
||||
bucket.ef = row.ef ?? null;
|
||||
} else if (row.type === LOAD) bucket.load = row.value;
|
||||
else if (sourceTypes.has(row.type)) {
|
||||
bucket.sources[row.type as SourceType] = { value: row.value, pct: row.pct ?? null };
|
||||
bucket.sum += row.value;
|
||||
}
|
||||
}
|
||||
const list = [...buckets.values()].sort((a, b) => a.t - b.t);
|
||||
for (const bucket of list) {
|
||||
bucket.incomplete =
|
||||
bucket.mix === null ? bucket.sum === 0 : Math.abs(bucket.sum / bucket.mix - 1) > 0.02;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
t: number;
|
||||
total: number;
|
||||
load: number | null;
|
||||
ef: number | null;
|
||||
renewableShare: number;
|
||||
lowCarbonShare: number;
|
||||
fossilShare: number;
|
||||
sources: {
|
||||
type: SourceType;
|
||||
label: string;
|
||||
color: string;
|
||||
value: number;
|
||||
share: number;
|
||||
utilization: number | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
/** The latest complete bucket, broken down by source. */
|
||||
export function latestSnapshot(buckets: readonly Bucket[]): Snapshot | null {
|
||||
const bucket = [...buckets]
|
||||
.reverse()
|
||||
.find((candidate) => !candidate.incomplete && candidate.sum > 0);
|
||||
if (!bucket) return null;
|
||||
const total = bucket.sum;
|
||||
const share = (filter: (source: Source) => boolean) =>
|
||||
SOURCES.filter(filter).reduce(
|
||||
(sum, source) => sum + (bucket.sources[source.type]?.value ?? 0),
|
||||
0
|
||||
) / total;
|
||||
return {
|
||||
t: bucket.t,
|
||||
total,
|
||||
load: bucket.load,
|
||||
ef: bucket.ef,
|
||||
renewableShare: share((source) => source.renewable),
|
||||
lowCarbonShare: share((source) => source.renewable || source.type === 'Nuclear'),
|
||||
fossilShare: share((source) => source.fossil),
|
||||
sources: SOURCES.map((source) => {
|
||||
const entry = bucket.sources[source.type];
|
||||
return {
|
||||
type: source.type,
|
||||
label: source.label,
|
||||
color: source.color,
|
||||
value: entry?.value ?? 0,
|
||||
share: (entry?.value ?? 0) / total,
|
||||
utilization: entry?.pct ?? null
|
||||
};
|
||||
}).sort((a, b) => b.value - a.value)
|
||||
};
|
||||
}
|
||||
|
||||
/** Insert empty buckets so charts show gaps where the collector missed data. */
|
||||
export function fillGaps(
|
||||
buckets: readonly Bucket[],
|
||||
step: number
|
||||
): (Bucket | { t: number; missing: true })[] {
|
||||
const out: (Bucket | { t: number; missing: true })[] = [];
|
||||
for (const bucket of buckets) {
|
||||
const previous = out.at(-1);
|
||||
if (previous)
|
||||
for (let t = previous.t + step; t < bucket.t; t += step) out.push({ t, missing: true });
|
||||
out.push(bucket);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Hours missing between consecutive buckets, as [from, to) ranges in epoch ms. */
|
||||
export function gaps(
|
||||
times: readonly number[],
|
||||
step = 3_600_000
|
||||
): { from: number; to: number; hours: number }[] {
|
||||
const result = [];
|
||||
for (let index = 1; index < times.length; index++) {
|
||||
const missing = Math.round((times[index] - times[index - 1]) / step) - 1;
|
||||
if (missing > 0)
|
||||
result.push({ from: times[index - 1] + step, to: times[index], hours: missing });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Map a date onto leap year 2000 so different years can share one seasonal x axis. */
|
||||
export function seasonalTime(day: string) {
|
||||
const [, month, date] = day.split('-').map(Number);
|
||||
return Date.UTC(2000, month - 1, date);
|
||||
}
|
||||
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
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 Plus from 'lucide-svelte/icons/plus';
|
||||
import { descriptionSuggestions, type SuggestionSource } from './quick-add.js';
|
||||
|
||||
let {
|
||||
accounts,
|
||||
transactions,
|
||||
today
|
||||
}: {
|
||||
accounts: { id: number; name: string }[];
|
||||
transactions: SuggestionSource[];
|
||||
today: string;
|
||||
} = $props();
|
||||
|
||||
const storageKey = 'money-tracker:last-account';
|
||||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
let kind = $state<'expense' | 'income'>('expense');
|
||||
let description = $state('');
|
||||
let amount = $state('');
|
||||
let accountId = $state('');
|
||||
let date = $state('');
|
||||
let listOpen = $state(false);
|
||||
let highlighted = $state(-1);
|
||||
let message = $state('');
|
||||
let failed = $state(false);
|
||||
let saving = $state(false);
|
||||
let descriptionInput = $state<HTMLInputElement | null>(null);
|
||||
let amountInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let suggestions = $derived(descriptionSuggestions(transactions, today));
|
||||
let matches = $derived.by(() => {
|
||||
const query = description.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return suggestions
|
||||
.filter((item) => item.key.includes(query) && item.key !== query)
|
||||
.sort((a, b) => Number(b.key.startsWith(query)) - Number(a.key.startsWith(query)))
|
||||
.slice(0, 6);
|
||||
});
|
||||
let signedAmount = $derived.by(() => {
|
||||
const raw = amount.trim().replace(',', '.').replace(/^[-+]/, '');
|
||||
return raw ? (kind === 'expense' ? '-' : '') + raw : '';
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
if (!date) date = today;
|
||||
if (!accountId && accounts.length) {
|
||||
const remembered =
|
||||
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
|
||||
accountId = String(
|
||||
accounts.find((account) => String(account.id) === remembered)?.id ?? accounts[0].id
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function choose(index: number) {
|
||||
const item = matches[index];
|
||||
if (!item) return;
|
||||
description = item.description;
|
||||
amount = (Math.abs(item.amountCents) / 100).toFixed(2);
|
||||
kind = item.amountCents < 0 ? 'expense' : 'income';
|
||||
if (item.accountId && accounts.some((account) => account.id === item.accountId))
|
||||
accountId = String(item.accountId);
|
||||
listOpen = false;
|
||||
highlighted = -1;
|
||||
await tick();
|
||||
amountInput?.select();
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (!matches.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
listOpen = true;
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
highlighted = (highlighted + step + matches.length) % matches.length;
|
||||
} else if (event.key === 'Enter' && listOpen && highlighted >= 0) {
|
||||
event.preventDefault();
|
||||
choose(highlighted);
|
||||
} else if (event.key === 'Escape') {
|
||||
listOpen = false;
|
||||
highlighted = -1;
|
||||
} else if (event.key === 'Tab' && listOpen && highlighted >= 0) choose(highlighted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="quick-add"
|
||||
method="POST"
|
||||
action="?/saveTransaction"
|
||||
aria-label="Quick add transaction"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
const saved = { description, signedAmount };
|
||||
return async ({ result, update }) => {
|
||||
saving = false;
|
||||
if (result.type === 'success') {
|
||||
localStorage.setItem(storageKey, accountId);
|
||||
failed = false;
|
||||
message = `Saved ${saved.description} · ${euro.format(Number(saved.signedAmount))}`;
|
||||
description = '';
|
||||
amount = '';
|
||||
await update({ reset: false });
|
||||
descriptionInput?.focus();
|
||||
} else {
|
||||
failed = true;
|
||||
message =
|
||||
result.type === 'failure' && typeof result.data?.message === 'string'
|
||||
? result.data.message
|
||||
: 'Could not save this transaction.';
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="quick-kind" role="group" aria-label="Transaction type">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kind === 'expense' ? 'default' : 'ghost'}
|
||||
aria-pressed={kind === 'expense'}
|
||||
onclick={() => (kind = 'expense')}>Expense</Button
|
||||
><Button
|
||||
type="button"
|
||||
variant={kind === 'income' ? 'default' : 'ghost'}
|
||||
aria-pressed={kind === 'income'}
|
||||
onclick={() => (kind = 'income')}>Income</Button
|
||||
>
|
||||
</div>
|
||||
<div class="quick-description">
|
||||
<label class="sr-only" for="quick-description">Description</label>
|
||||
<Input
|
||||
id="quick-description"
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
placeholder="What was it? e.g. Albert Heijn"
|
||||
required
|
||||
maxlength={500}
|
||||
role="combobox"
|
||||
aria-expanded={listOpen && matches.length > 0}
|
||||
aria-controls="quick-suggestions"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={highlighted >= 0 ? `quick-suggestion-${highlighted}` : undefined}
|
||||
bind:ref={descriptionInput}
|
||||
bind:value={description}
|
||||
oninput={() => {
|
||||
listOpen = true;
|
||||
highlighted = -1;
|
||||
}}
|
||||
onkeydown={keydown}
|
||||
onfocus={() => (listOpen = true)}
|
||||
onblur={() => setTimeout(() => (listOpen = false), 120)}
|
||||
/>
|
||||
{#if listOpen && matches.length}
|
||||
<ul id="quick-suggestions" role="listbox" class="quick-suggestions">
|
||||
{#each matches as item, index (item.key)}
|
||||
<li
|
||||
id={`quick-suggestion-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === highlighted}
|
||||
class:highlighted={index === highlighted}
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
choose(index);
|
||||
}}
|
||||
>
|
||||
<span>{item.description}</span>
|
||||
<span class="muted"
|
||||
>{euro.format(item.amountCents / 100)} · {accounts.find(
|
||||
(a) => a.id === item.accountId
|
||||
)?.name ?? ''}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="sr-only" for="quick-amount">Amount in euros</label>
|
||||
<Input
|
||||
id="quick-amount"
|
||||
class="quick-amount"
|
||||
inputmode="decimal"
|
||||
placeholder="0.00"
|
||||
required
|
||||
pattern="[-+]?[0-9]+([.,][0-9]{(1, 2)})?"
|
||||
bind:ref={amountInput}
|
||||
bind:value={amount}
|
||||
/>
|
||||
<input type="hidden" name="amount" value={signedAmount} />
|
||||
<label class="sr-only" for="quick-account">Account</label>
|
||||
<NativeSelect id="quick-account" name="accountId" bind:value={accountId} required
|
||||
>{#each accounts as account (account.id)}<option value={String(account.id)}
|
||||
>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
<label class="sr-only" for="quick-date">Date</label>
|
||||
<Input id="quick-date" class="quick-date" type="date" name="date" bind:value={date} required />
|
||||
<Button type="submit" disabled={saving || !accounts.length}><Plus size={16} /> Add</Button>
|
||||
{#if message}<p class="quick-message" class:form-error={failed} role="status">{message}</p>{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.quick-add {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.quick-kind {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.quick-description {
|
||||
position: relative;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.quick-add :global(.quick-amount) {
|
||||
width: 110px;
|
||||
text-align: right;
|
||||
}
|
||||
.quick-add :global(.quick-date) {
|
||||
width: 150px;
|
||||
}
|
||||
.quick-suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--site-bg);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
.quick-suggestions li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.quick-suggestions li.highlighted,
|
||||
.quick-suggestions li:hover {
|
||||
background: var(--site-border);
|
||||
}
|
||||
.quick-message {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
24
src/lib/money-tracker/quick-add.test.ts
Normal file
24
src/lib/money-tracker/quick-add.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { descriptionSuggestions } from './quick-add';
|
||||
|
||||
describe('quick-add suggestions', () => {
|
||||
it('ranks by use and fills the usual amount and latest account', () => {
|
||||
const rows = [
|
||||
{ description: 'PLUS', amountCents: -1200, accountId: 1, date: '2026-10-01' },
|
||||
{ description: 'plus', amountCents: -1200, accountId: 1, date: '2026-10-03' },
|
||||
{ description: 'Plus', amountCents: -800, accountId: 4, date: '2026-10-05' },
|
||||
{ description: 'From Juhee', amountCents: 5000, accountId: 4, date: '2026-10-02' },
|
||||
{ description: 'Future', amountCents: -1, accountId: 1, date: '2026-12-01' }
|
||||
];
|
||||
const [first, second, ...rest] = descriptionSuggestions(rows, '2026-10-24');
|
||||
expect(first).toMatchObject({
|
||||
key: 'plus',
|
||||
description: 'Plus',
|
||||
amountCents: -1200,
|
||||
accountId: 4,
|
||||
count: 3
|
||||
});
|
||||
expect(second).toMatchObject({ description: 'From Juhee', amountCents: 5000 });
|
||||
expect(rest).toEqual([]);
|
||||
});
|
||||
});
|
||||
73
src/lib/money-tracker/quick-add.ts
Normal file
73
src/lib/money-tracker/quick-add.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type SuggestionSource = {
|
||||
description: string;
|
||||
amountCents: number;
|
||||
accountId: number | null;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type Suggestion = {
|
||||
key: string;
|
||||
description: string;
|
||||
/** Most common amount for this description (latest wins ties). */
|
||||
amountCents: number;
|
||||
/** Account used most recently with this description. */
|
||||
accountId: number | null;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
};
|
||||
|
||||
/** Past descriptions ranked by use, each with its usual amount and latest account. */
|
||||
export function descriptionSuggestions(rows: SuggestionSource[], today: string): Suggestion[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
description: string;
|
||||
amounts: Map<number, number>;
|
||||
accountId: number | null;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
lastAmount: number;
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
if (row.date > today) continue;
|
||||
const key = row.description.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const group = groups.get(key) ?? {
|
||||
description: row.description.trim(),
|
||||
amounts: new Map(),
|
||||
accountId: row.accountId,
|
||||
count: 0,
|
||||
lastDate: '',
|
||||
lastAmount: row.amountCents
|
||||
};
|
||||
group.count++;
|
||||
group.amounts.set(row.amountCents, (group.amounts.get(row.amountCents) ?? 0) + 1);
|
||||
if (row.date >= group.lastDate) {
|
||||
group.lastDate = row.date;
|
||||
group.description = row.description.trim();
|
||||
group.accountId = row.accountId;
|
||||
group.lastAmount = row.amountCents;
|
||||
}
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([key, group]) => {
|
||||
let amountCents = group.lastAmount;
|
||||
let best = group.amounts.get(amountCents) ?? 0;
|
||||
for (const [value, uses] of group.amounts)
|
||||
if (uses > best) {
|
||||
best = uses;
|
||||
amountCents = value;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
description: group.description,
|
||||
amountCents,
|
||||
accountId: group.accountId,
|
||||
count: group.count,
|
||||
lastDate: group.lastDate
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
||||
}
|
||||
@@ -58,12 +58,24 @@ export const trackers = [
|
||||
href: '/projects/cs-prices',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'energy',
|
||||
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
||||
href: '/projects/energy',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'animaldex',
|
||||
subtitle: 'add neighborhood pets to the world map',
|
||||
href: '/projects/animaldex',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'books',
|
||||
subtitle: 'my library: what I am reading, have read and want to read',
|
||||
href: '/trackers/books',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'lists',
|
||||
subtitle: 'books, games, places and other things worth keeping',
|
||||
|
||||
@@ -38,7 +38,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/trackers': {
|
||||
title: 'Trackers',
|
||||
description:
|
||||
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Animaldex and lists.'
|
||||
'Stan Runge’s trackers: games, money, houses, books, Counter-Strike prices, Dutch energy, Animaldex and lists.'
|
||||
},
|
||||
'/trackers/books': {
|
||||
title: 'Books',
|
||||
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
|
||||
},
|
||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||
'/projects/game-tracker': {
|
||||
@@ -69,6 +73,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
description:
|
||||
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
|
||||
},
|
||||
'/projects/energy': {
|
||||
title: 'Energy',
|
||||
description:
|
||||
'Dutch electricity generation mix, source utilization, consumption trends, CO₂ intensity and gas storage, from NED.nl data.'
|
||||
},
|
||||
'/projects/derby': {
|
||||
title: 'Derby Lens',
|
||||
description: 'Estimate horse racing odds and compare them with market prices.'
|
||||
|
||||
192
src/lib/server/bank-sync.test.ts
Normal file
192
src/lib/server/bank-sync.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import {
|
||||
amountToCents,
|
||||
externalIdFor,
|
||||
findMatch,
|
||||
mapBankTransaction,
|
||||
pickBalance,
|
||||
planAccountMapping
|
||||
} from './bank-sync';
|
||||
import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking';
|
||||
|
||||
const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
|
||||
entry_reference: 'ref-1',
|
||||
transaction_amount: { currency: 'EUR', amount: '12.34' },
|
||||
credit_debit_indicator: 'DBIT',
|
||||
status: 'BOOK',
|
||||
booking_date: '2026-10-20',
|
||||
value_date: '2026-10-19',
|
||||
creditor: { name: 'Albert Heijn' },
|
||||
remittance_information: ['AH 1234 Gouda'],
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('bank transaction mapping', () => {
|
||||
it('parses decimal amounts exactly', () => {
|
||||
expect(amountToCents('12.34')).toBe(1234);
|
||||
expect(amountToCents('-0.5')).toBe(-50);
|
||||
expect(amountToCents('100')).toBe(10000);
|
||||
expect(amountToCents('1.005')).toBe(101);
|
||||
});
|
||||
|
||||
it('signs debits negative and uses the counterparty as description', () => {
|
||||
const row = mapBankTransaction(tx())!;
|
||||
expect(row).toMatchObject({
|
||||
externalId: 'ref-1',
|
||||
date: '2026-10-20',
|
||||
valueDate: '2026-10-19',
|
||||
amountCents: -1234,
|
||||
description: 'Albert Heijn',
|
||||
counterparty: 'Albert Heijn',
|
||||
bookingStatus: 'BOOK'
|
||||
});
|
||||
const credit = mapBankTransaction(
|
||||
tx({ credit_debit_indicator: 'CRDT', creditor: null, debtor: { name: 'Juhee' } })
|
||||
)!;
|
||||
expect(credit.amountCents).toBe(1234);
|
||||
expect(credit.description).toBe('Juhee');
|
||||
});
|
||||
|
||||
it('skips pending transactions', () => {
|
||||
expect(mapBankTransaction(tx({ status: 'PDNG' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to remittance info and to a stable content hash', () => {
|
||||
const anonymous = tx({ entry_reference: null, creditor: null });
|
||||
const row = mapBankTransaction(anonymous)!;
|
||||
expect(row.description).toBe('AH 1234 Gouda');
|
||||
expect(row.externalId).toMatch(/^h:[0-9a-f]{32}$/);
|
||||
expect(externalIdFor(anonymous)).toBe(row.externalId);
|
||||
expect(
|
||||
externalIdFor({ ...anonymous, transaction_amount: { currency: 'EUR', amount: '1.00' } })
|
||||
).not.toBe(row.externalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile matching', () => {
|
||||
const rows = [
|
||||
{ id: 1, date: '2026-10-18', amountCents: -1234, description: 'Groceries' },
|
||||
{ id: 2, date: '2026-10-21', amountCents: -1234, description: 'Albert Heijn' },
|
||||
{ id: 3, date: '2026-10-20', amountCents: -999, description: 'Albert Heijn' }
|
||||
];
|
||||
it('matches same amount within one day, preferring description overlap', () => {
|
||||
const m = findMatch(
|
||||
{ date: '2026-10-20', amountCents: -1234, description: 'Albert Heijn' },
|
||||
rows,
|
||||
new Set()
|
||||
);
|
||||
expect(m?.id).toBe(2);
|
||||
});
|
||||
it('never reuses a row and ignores rows more than a day away', () => {
|
||||
expect(
|
||||
findMatch({ date: '2026-10-20', amountCents: -1234, description: 'x' }, rows, new Set([2]))
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('account mapping', () => {
|
||||
const tracker = [
|
||||
{ id: 1, name: 'Revolut', scope: 'personal', iban: null, external_uid: null },
|
||||
{ id: 4, name: 'Joint', scope: 'joint', iban: null, external_uid: null }
|
||||
];
|
||||
it('maps the joint account and the personal EUR account', () => {
|
||||
const plan = planAccountMapping(
|
||||
[
|
||||
{ uid: 'a', account_id: { iban: 'LT1' }, name: 'Stan Runge', currency: 'EUR' },
|
||||
{ uid: 'b', account_id: { iban: 'LT2' }, name: 'Stan Runge & Juhee', currency: 'EUR' },
|
||||
{ uid: 'c', account_id: { iban: 'LT3' }, name: 'Stan Runge', currency: 'USD' }
|
||||
],
|
||||
tracker
|
||||
);
|
||||
expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([
|
||||
['b', 'Joint'],
|
||||
['a', 'Revolut'],
|
||||
['c', null]
|
||||
]);
|
||||
});
|
||||
it('honours overrides and existing IBANs', () => {
|
||||
const plan = planAccountMapping(
|
||||
[
|
||||
{ uid: 'a', account_id: { iban: 'LT1' }, name: 'X', currency: 'EUR' },
|
||||
{ uid: 'b', account_id: { iban: 'LT2' }, name: 'Y', currency: 'EUR' }
|
||||
],
|
||||
[{ ...tracker[0], iban: 'LT2' }, tracker[1]],
|
||||
{ a: 'Joint' }
|
||||
);
|
||||
expect(plan.map((p) => [p.bank.uid, p.tracker?.name])).toEqual([
|
||||
['a', 'Joint'],
|
||||
['b', 'Revolut']
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable banking client', () => {
|
||||
it('parses codes from redirect URLs, query strings and bare codes', () => {
|
||||
expect(
|
||||
parseAuthCode(
|
||||
'https://stanrunge.dev/projects/money-tracker/bank-callback?state=s&code=abc-123'
|
||||
)
|
||||
).toEqual({
|
||||
code: 'abc-123',
|
||||
state: 's',
|
||||
error: undefined
|
||||
});
|
||||
expect(parseAuthCode('abc-123').code).toBe('abc-123');
|
||||
expect(parseAuthCode('?error=access_denied&error_description=Cancelled').error).toBe(
|
||||
'access_denied: Cancelled'
|
||||
);
|
||||
});
|
||||
|
||||
it('signs RS256 JWTs with the app id as kid and pages through transactions', async () => {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const calls: string[] = [];
|
||||
const fakeFetch = (async (url: string, init: RequestInit) => {
|
||||
calls.push(url);
|
||||
const auth = String((init.headers as Record<string, string>).Authorization).slice(7);
|
||||
expect(decodeProtectedHeader(auth)).toMatchObject({
|
||||
alg: 'RS256',
|
||||
kid: 'app-id',
|
||||
typ: 'JWT'
|
||||
});
|
||||
const { payload } = await jwtVerify(auth, publicKey, {
|
||||
issuer: 'enablebanking.com',
|
||||
audience: 'api.enablebanking.com'
|
||||
});
|
||||
expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(86400);
|
||||
const page = new URL(url).searchParams.get('continuation_key');
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
page
|
||||
? { transactions: [tx({ entry_reference: 'r2' })] }
|
||||
: { transactions: [tx()], continuation_key: 'next' }
|
||||
)
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const client = createEnableBankingClient({
|
||||
appId: 'app-id',
|
||||
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
fetch: fakeFetch
|
||||
});
|
||||
const all = await client.transactions('uid-1', { dateFrom: '2026-01-01' });
|
||||
expect(all.map((t) => t.entry_reference)).toEqual(['ref-1', 'r2']);
|
||||
expect(calls[1]).toContain('continuation_key=next');
|
||||
expect(calls[0]).toContain('date_from=2026-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bank balance', () => {
|
||||
const b = (balance_type: string, amount: string) => ({
|
||||
balance_type,
|
||||
balance_amount: { amount, currency: 'EUR' }
|
||||
});
|
||||
it('prefers the available balance the bank app shows', () => {
|
||||
expect(pickBalance([b('CLBD', '70.00'), b('ITAV', '69.88')])?.balance_amount.amount).toBe(
|
||||
'69.88'
|
||||
);
|
||||
expect(pickBalance([b('XPCD', '1.00'), b('CLBD', '2.00')])?.balance_type).toBe('CLBD');
|
||||
expect(pickBalance([b('OTHR', '3.00')])?.balance_type).toBe('OTHR');
|
||||
expect(pickBalance([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
475
src/lib/server/bank-sync.ts
Normal file
475
src/lib/server/bank-sync.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
// Bank -> money.transactions sync (Enable Banking). Pure mapping/matching helpers are
|
||||
// exported for unit tests; the DB functions take a postgres.js `Sql` so they run the
|
||||
// same from the bun scripts (scripts/bank-link.ts, scripts/bank-sync.ts).
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Sql } from 'postgres';
|
||||
import type {
|
||||
Balance,
|
||||
BankAccount,
|
||||
BankTransaction,
|
||||
EnableBankingClient,
|
||||
SessionResponse
|
||||
} from './enable-banking';
|
||||
import { EnableBankingError } from './enable-banking';
|
||||
|
||||
export type MappedTransaction = {
|
||||
externalId: string;
|
||||
date: string;
|
||||
valueDate: string | null;
|
||||
description: string;
|
||||
counterparty: string | null;
|
||||
amountCents: number;
|
||||
bookingStatus: string;
|
||||
raw: BankTransaction;
|
||||
};
|
||||
|
||||
export function amountToCents(amount: string): number {
|
||||
const raw = amount.trim();
|
||||
if (!/^-?\d+(?:\.\d+)?$/.test(raw)) throw new Error(`Invalid amount ${amount}`);
|
||||
const negative = raw.startsWith('-');
|
||||
const [whole, fraction = ''] = raw.replace('-', '').split('.');
|
||||
const cents = Number(whole) * 100 + Number((fraction + '00').slice(0, 2));
|
||||
const rounded = Number(fraction.slice(2, 3) || '0') >= 5 ? cents + 1 : cents;
|
||||
return negative ? -rounded : rounded;
|
||||
}
|
||||
|
||||
/** Stable id for a bank transaction: the bank's own reference, else a content hash. */
|
||||
export function externalIdFor(tx: BankTransaction): string {
|
||||
const ref = tx.entry_reference?.trim() || tx.transaction_id?.trim();
|
||||
if (ref) return ref;
|
||||
const fingerprint = [
|
||||
tx.booking_date ?? tx.transaction_date ?? tx.value_date ?? '',
|
||||
tx.credit_debit_indicator,
|
||||
tx.transaction_amount.amount,
|
||||
tx.transaction_amount.currency,
|
||||
tx.creditor?.name ?? '',
|
||||
tx.debtor?.name ?? '',
|
||||
(tx.remittance_information ?? []).join('|')
|
||||
].join('\u001f');
|
||||
return 'h:' + createHash('sha256').update(fingerprint).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
export function isBooked(tx: BankTransaction) {
|
||||
return tx.status ? tx.status === 'BOOK' : Boolean(tx.booking_date);
|
||||
}
|
||||
|
||||
/** Maps one booked bank transaction to a tracker row, or null for pending ones. */
|
||||
export function mapBankTransaction(tx: BankTransaction): MappedTransaction | null {
|
||||
if (!isBooked(tx)) return null;
|
||||
const date = tx.booking_date ?? tx.transaction_date ?? tx.value_date;
|
||||
if (!date) return null;
|
||||
const magnitude = Math.abs(amountToCents(tx.transaction_amount.amount));
|
||||
const amountCents = tx.credit_debit_indicator === 'DBIT' ? -magnitude : magnitude;
|
||||
const counterparty =
|
||||
(tx.credit_debit_indicator === 'DBIT' ? tx.creditor?.name : tx.debtor?.name)?.trim() || null;
|
||||
const remittance = (tx.remittance_information ?? []).map((line) => line.trim()).filter(Boolean);
|
||||
const description = (
|
||||
counterparty ||
|
||||
remittance.join(' ') ||
|
||||
tx.bank_transaction_code?.description ||
|
||||
tx.note ||
|
||||
(amountCents < 0 ? 'Card payment' : 'Incoming transfer')
|
||||
).slice(0, 500);
|
||||
return {
|
||||
externalId: externalIdFor(tx),
|
||||
date: date.slice(0, 10),
|
||||
valueDate: tx.value_date?.slice(0, 10) ?? null,
|
||||
description,
|
||||
counterparty,
|
||||
amountCents,
|
||||
bookingStatus: tx.status ?? 'BOOK',
|
||||
raw: tx
|
||||
};
|
||||
}
|
||||
|
||||
function dayDiff(a: string, b: string) {
|
||||
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
|
||||
}
|
||||
|
||||
export type ExistingRow = { id: number; date: string; amountCents: number; description: string };
|
||||
|
||||
/**
|
||||
* Finds the hand-entered row a bank transaction corresponds to: same amount, date within
|
||||
* ±1 day, not matched yet. Prefers the closest date, then a description overlap.
|
||||
*/
|
||||
export function findMatch(
|
||||
tx: Pick<MappedTransaction, 'date' | 'amountCents' | 'description'>,
|
||||
candidates: ExistingRow[],
|
||||
used: Set<number>
|
||||
): ExistingRow | null {
|
||||
const words = new Set(
|
||||
tx.description
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.filter((w) => w.length > 2)
|
||||
);
|
||||
let best: { row: ExistingRow; score: number } | null = null;
|
||||
for (const row of candidates) {
|
||||
if (used.has(row.id) || row.amountCents !== tx.amountCents) continue;
|
||||
const diff = dayDiff(row.date, tx.date);
|
||||
if (diff > 1) continue;
|
||||
const overlap = row.description
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.some((w) => words.has(w));
|
||||
const score = diff * 10 - (overlap ? 1 : 0) + row.id / 1e9;
|
||||
if (!best || score < best.score) best = { row, score };
|
||||
}
|
||||
return best?.row ?? null;
|
||||
}
|
||||
|
||||
export function isoDate(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function addDays(date: string, days: number) {
|
||||
return isoDate(new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000));
|
||||
}
|
||||
|
||||
/** Heuristic: is this bank account the joint one shared with Juhee? */
|
||||
export function looksJoint(account: BankAccount) {
|
||||
const text = [account.name, account.details, account.product].filter(Boolean).join(' ');
|
||||
return /joint|gezamenlijk|shared|\bjuhee\b|\s&\s|\sand\s|,/i.test(text);
|
||||
}
|
||||
|
||||
type TrackerAccount = {
|
||||
id: number;
|
||||
name: string;
|
||||
scope: string;
|
||||
iban: string | null;
|
||||
external_uid: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides which tracker account each bank account feeds. Order: explicit override
|
||||
* (uid/IBAN -> tracker account name), same IBAN, joint heuristic -> the joint-scoped
|
||||
* account, first remaining EUR current account -> "Revolut". Others are skipped.
|
||||
*/
|
||||
export function planAccountMapping(
|
||||
bankAccounts: BankAccount[],
|
||||
tracker: TrackerAccount[],
|
||||
overrides: Record<string, string> = {}
|
||||
) {
|
||||
const byName = new Map(tracker.map((a) => [a.name.toLowerCase(), a]));
|
||||
const used = new Set<number>();
|
||||
const plan: { bank: BankAccount; tracker: TrackerAccount | null; reason: string }[] = [];
|
||||
const take = (bank: BankAccount, account: TrackerAccount | undefined | null, reason: string) => {
|
||||
if (account && !used.has(account.id)) {
|
||||
used.add(account.id);
|
||||
plan.push({ bank, tracker: account, reason });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const pending: BankAccount[] = [];
|
||||
for (const bank of bankAccounts) {
|
||||
const iban = bank.account_id?.iban?.replace(/\s/g, '').toUpperCase();
|
||||
const override = overrides[bank.uid ?? ''] ?? (iban ? overrides[iban] : undefined);
|
||||
if (override !== undefined) {
|
||||
if (override === '' || override === '-')
|
||||
plan.push({ bank, tracker: null, reason: 'skipped (override)' });
|
||||
else if (!take(bank, byName.get(override.toLowerCase()), 'override'))
|
||||
plan.push({ bank, tracker: null, reason: `override target "${override}" not found/used` });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
iban &&
|
||||
take(
|
||||
bank,
|
||||
tracker.find((a) => a.iban?.replace(/\s/g, '').toUpperCase() === iban),
|
||||
'same IBAN'
|
||||
)
|
||||
)
|
||||
continue;
|
||||
pending.push(bank);
|
||||
}
|
||||
const eur = (bank: BankAccount) => !bank.currency || bank.currency === 'EUR';
|
||||
// Joint first, so a personal-looking account listed earlier can't take "Revolut" from it.
|
||||
const rest = pending.filter(
|
||||
(bank) =>
|
||||
!(
|
||||
eur(bank) &&
|
||||
looksJoint(bank) &&
|
||||
take(
|
||||
bank,
|
||||
tracker.find((a) => a.scope === 'joint'),
|
||||
'joint account'
|
||||
)
|
||||
)
|
||||
);
|
||||
for (const bank of rest) {
|
||||
if (
|
||||
eur(bank) &&
|
||||
bank.cash_account_type !== 'SVGS' &&
|
||||
take(bank, byName.get('revolut'), 'personal EUR account')
|
||||
)
|
||||
continue;
|
||||
plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' });
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export async function trackerUserId(sql: Sql, explicit?: string) {
|
||||
if (explicit) return explicit;
|
||||
const rows = await sql<{ user_id: string }[]>`select distinct user_id from money.accounts`;
|
||||
if (rows.length !== 1)
|
||||
throw new Error(`Expected one money-tracker user, found ${rows.length}; pass --user <id>`);
|
||||
return rows[0].user_id;
|
||||
}
|
||||
|
||||
/** Stores a freshly authorised session and links bank accounts to tracker accounts. */
|
||||
export async function saveSession(
|
||||
sql: Sql,
|
||||
userId: string,
|
||||
session: SessionResponse,
|
||||
plan: ReturnType<typeof planAccountMapping>
|
||||
) {
|
||||
const accounts = session.accounts.map((a) => ({
|
||||
uid: a.uid ?? null,
|
||||
iban: a.account_id?.iban ?? null,
|
||||
name: a.name ?? null,
|
||||
details: a.details ?? null,
|
||||
product: a.product ?? null,
|
||||
currency: a.currency ?? null,
|
||||
cash_account_type: a.cash_account_type ?? null,
|
||||
identification_hash: a.identification_hash ?? null
|
||||
}));
|
||||
await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
insert into money.bank_sessions
|
||||
(user_id, provider, session_id, aspsp_name, aspsp_country, psu_type, status, valid_until, accounts)
|
||||
values (${userId}, 'enablebanking', ${session.session_id}, ${session.aspsp.name},
|
||||
${session.aspsp.country}, ${session.psu_type}, 'authorized', ${session.access.valid_until},
|
||||
${tx.json(accounts)})
|
||||
on conflict (session_id) do update set status = 'authorized', valid_until = excluded.valid_until,
|
||||
accounts = excluded.accounts, last_error = null`;
|
||||
for (const { bank, tracker } of plan) {
|
||||
if (!tracker || !bank.uid) continue;
|
||||
// Account uids are per session: free the uid from any older mapping first.
|
||||
await tx`update money.accounts set external_uid = null where external_uid = ${bank.uid} and id <> ${tracker.id}`;
|
||||
await tx`
|
||||
update money.accounts
|
||||
set external_uid = ${bank.uid}, provider = 'enablebanking',
|
||||
iban = coalesce(${bank.account_id?.iban ?? null}, iban)
|
||||
where id = ${tracker.id} and user_id = ${userId}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The balance Stan sees in the bank app: interim available, else closing booked. */
|
||||
export function pickBalance(balances: Balance[]): Balance | undefined {
|
||||
const order = ['ITAV', 'CLAV', 'ITBD', 'CLBD', 'XPCD'];
|
||||
for (const type of order) {
|
||||
const found = balances.find((b) => b.balance_type === type);
|
||||
if (found) return found;
|
||||
}
|
||||
return balances[0];
|
||||
}
|
||||
|
||||
/** Synthetic row that makes the tracker's sum equal the bank balance. */
|
||||
export const BALANCE_ADJUSTMENT_ID = 'balance-adjustment';
|
||||
|
||||
export type SyncOptions = {
|
||||
dryRun?: boolean;
|
||||
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
|
||||
reconcile?: boolean;
|
||||
from?: string;
|
||||
accountName?: string;
|
||||
log?: (line: string) => void;
|
||||
today?: string;
|
||||
};
|
||||
|
||||
export type AccountSyncResult = {
|
||||
account: string;
|
||||
fetched: number;
|
||||
booked: number;
|
||||
inserted: number;
|
||||
matched: number;
|
||||
alreadyKnown: number;
|
||||
balance?: string;
|
||||
/** Bank balance minus the tracker's own rows, absorbed by the adjustment row. */
|
||||
adjustmentCents?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function syncBankAccounts(
|
||||
sql: Sql,
|
||||
client: EnableBankingClient,
|
||||
options: SyncOptions = {}
|
||||
) {
|
||||
const log = options.log ?? console.log;
|
||||
const today = options.today ?? isoDate(new Date());
|
||||
const accounts = await sql<
|
||||
{ id: number; user_id: string; name: string; external_uid: string }[]
|
||||
>`select id, user_id, name, external_uid from money.accounts
|
||||
where provider = 'enablebanking' and external_uid is not null order by id`;
|
||||
const selected = options.accountName
|
||||
? accounts.filter((a) => a.name.toLowerCase() === options.accountName!.toLowerCase())
|
||||
: accounts;
|
||||
if (!selected.length) {
|
||||
log('No linked bank accounts. Run scripts/bank-link.ts first.');
|
||||
return [];
|
||||
}
|
||||
const results: AccountSyncResult[] = [];
|
||||
for (const account of selected) {
|
||||
const result: AccountSyncResult = {
|
||||
account: account.name,
|
||||
fetched: 0,
|
||||
booked: 0,
|
||||
inserted: 0,
|
||||
matched: 0,
|
||||
alreadyKnown: 0
|
||||
};
|
||||
results.push(result);
|
||||
try {
|
||||
const [{ last }] = await sql<{ last: string | null }[]>`
|
||||
select max(date)::text as last from money.transactions
|
||||
where account_id = ${account.id} and source = 'enablebanking'`;
|
||||
const [{ first }] = await sql<{ first: string | null }[]>`
|
||||
select min(date)::text as first from money.transactions where account_id = ${account.id}`;
|
||||
const dateFrom = options.from ?? (last ? addDays(last, -7) : (first ?? addDays(today, -89)));
|
||||
let raw: BankTransaction[];
|
||||
try {
|
||||
raw = await client.transactions(account.external_uid, { dateFrom });
|
||||
} catch (error) {
|
||||
// Many banks only serve ~90 days of history after the first hour of consent.
|
||||
if (error instanceof EnableBankingError && error.code === 'WRONG_TRANSACTIONS_PERIOD') {
|
||||
log(`${account.name}: ${dateFrom} is too far back; retrying from 89 days ago`);
|
||||
raw = await client.transactions(account.external_uid, { dateFrom: addDays(today, -89) });
|
||||
} else throw error;
|
||||
}
|
||||
result.fetched = raw.length;
|
||||
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
|
||||
result.booked = mapped.length;
|
||||
if (!mapped.length) {
|
||||
await syncBalance(sql, client, account, result, options, today, log);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = mapped.map((t) => t.externalId);
|
||||
const known = new Set(
|
||||
(
|
||||
await sql<{ external_id: string }[]>`
|
||||
select external_id from money.transactions
|
||||
where account_id = ${account.id} and external_id = any(${ids})`
|
||||
).map((r) => r.external_id)
|
||||
);
|
||||
const minDate = mapped.reduce((m, t) => (t.date < m ? t.date : m), mapped[0].date);
|
||||
const candidates = await sql<ExistingRow[]>`
|
||||
select id, date::text as date, amount_cents as "amountCents", description
|
||||
from money.transactions
|
||||
where account_id = ${account.id} and external_id is null
|
||||
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`;
|
||||
const used = new Set<number>();
|
||||
const inserts: MappedTransaction[] = [];
|
||||
const updates: { id: number; tx: MappedTransaction }[] = [];
|
||||
// Oldest first so matches are claimed in chronological order.
|
||||
for (const tx of [...mapped].sort((a, b) => a.date.localeCompare(b.date))) {
|
||||
if (known.has(tx.externalId)) {
|
||||
result.alreadyKnown++;
|
||||
continue;
|
||||
}
|
||||
known.add(tx.externalId);
|
||||
const match = findMatch(tx, candidates, used);
|
||||
if (match) {
|
||||
used.add(match.id);
|
||||
updates.push({ id: match.id, tx });
|
||||
} else inserts.push(tx);
|
||||
}
|
||||
result.matched = updates.length;
|
||||
result.inserted = inserts.length;
|
||||
if (!options.dryRun) {
|
||||
await sql.begin(async (q) => {
|
||||
for (const { id, tx } of updates) {
|
||||
// Keep Stan's own description/category; attach the bank identity.
|
||||
await q`
|
||||
update money.transactions set external_id = ${tx.externalId},
|
||||
counterparty = coalesce(counterparty, ${tx.counterparty}),
|
||||
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
|
||||
raw = ${q.json(tx.raw as never)}
|
||||
where id = ${id} and external_id is null`;
|
||||
}
|
||||
for (const tx of inserts) {
|
||||
await q`
|
||||
insert into money.transactions
|
||||
(user_id, account_id, date, description, amount_cents, external_id, source,
|
||||
booking_status, counterparty, raw, value_date)
|
||||
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
|
||||
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
|
||||
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
|
||||
on conflict (account_id, external_id) do nothing`;
|
||||
}
|
||||
});
|
||||
}
|
||||
await syncBalance(sql, client, account, result, options, today, log);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
if (!options.dryRun) {
|
||||
const failed = results.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
|
||||
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
|
||||
await sql`
|
||||
update money.bank_sessions set last_synced_at = now(),
|
||||
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
|
||||
status = case when ${expired} then 'expired'
|
||||
when valid_until is not null and valid_until < now() then 'expired' else status end
|
||||
where status = 'authorized'`;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function syncBalance(
|
||||
sql: Sql,
|
||||
client: EnableBankingClient,
|
||||
account: { id: number; user_id: string; name: string; external_uid: string },
|
||||
result: AccountSyncResult,
|
||||
options: SyncOptions,
|
||||
today: string,
|
||||
log: (line: string) => void
|
||||
) {
|
||||
let balances: Balance[];
|
||||
try {
|
||||
({ balances } = await client.balances(account.external_uid));
|
||||
} catch (error) {
|
||||
log(`${account.name}: balance unavailable (${error instanceof Error ? error.message : error})`);
|
||||
return;
|
||||
}
|
||||
const chosen = pickBalance(balances);
|
||||
if (!chosen) return;
|
||||
const bankCents = amountToCents(chosen.balance_amount.amount);
|
||||
result.balance = `${chosen.balance_amount.amount} ${chosen.balance_amount.currency} (${chosen.balance_type})`;
|
||||
await sql.begin(async (q) => {
|
||||
const [row] = await q<{ tracked: number; adjustment: number | null; first: string | null }[]>`
|
||||
select (a.opening_balance_cents + coalesce(sum(t.amount_cents) filter (
|
||||
where t.date <= ${today} and t.external_id is distinct from ${BALANCE_ADJUSTMENT_ID}), 0))::int as tracked,
|
||||
(select amount_cents from money.transactions
|
||||
where account_id = a.id and external_id = ${BALANCE_ADJUSTMENT_ID}) as adjustment,
|
||||
min(t.date)::text as first
|
||||
from money.accounts a left join money.transactions t on t.account_id = a.id
|
||||
where a.id = ${account.id} group by a.id`;
|
||||
const needed = bankCents - row.tracked;
|
||||
result.adjustmentCents = needed;
|
||||
if (row.adjustment !== needed)
|
||||
log(
|
||||
`${account.name}: tracker rows sum differs from bank by ${(needed / 100).toFixed(2)}; adjustment ${row.adjustment ?? 'none'} -> ${needed}`
|
||||
);
|
||||
if (options.dryRun) return;
|
||||
await q`
|
||||
update money.accounts set bank_balance_cents = ${bankCents},
|
||||
bank_balance_type = ${chosen.balance_type}, bank_balance_at = now()
|
||||
where id = ${account.id}`;
|
||||
if (row.adjustment === null) {
|
||||
if (needed !== 0)
|
||||
await q`
|
||||
insert into money.transactions
|
||||
(user_id, account_id, date, description, amount_cents, external_id, source, booking_status)
|
||||
values (${account.user_id}, ${account.id}, ${row.first ?? today}, 'Bank balance adjustment',
|
||||
${needed}, ${BALANCE_ADJUSTMENT_ID}, 'enablebanking', 'ADJ')`;
|
||||
} else if (row.adjustment !== needed) {
|
||||
await q`
|
||||
update money.transactions set amount_cents = ${needed}
|
||||
where account_id = ${account.id} and external_id = ${BALANCE_ADJUSTMENT_ID}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
77
src/lib/server/books/files.test.ts
Normal file
77
src/lib/server/books/files.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseRange, safeResolve, streamFile } from './files';
|
||||
|
||||
let root: string;
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'books-'));
|
||||
await mkdir(path.join(root, 'library', 'a'), { recursive: true });
|
||||
await writeFile(path.join(root, 'library', 'a', 'book.epub'), '0123456789');
|
||||
await writeFile(path.join(root, 'secret.txt'), 'nope');
|
||||
await symlink(path.join(root, 'secret.txt'), path.join(root, 'library', 'escape.epub'));
|
||||
});
|
||||
afterAll(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
describe('safeResolve', () => {
|
||||
it('finds files inside the library', async () => {
|
||||
expect(await safeResolve('library', 'a/book.epub', root)).toBe(
|
||||
path.join(root, 'library', 'a', 'book.epub')
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
'../secret.txt',
|
||||
'a/../../secret.txt',
|
||||
'/etc/passwd',
|
||||
'escape.epub',
|
||||
'a',
|
||||
'',
|
||||
'missing.pdf'
|
||||
])('refuses %s', async (relative) => {
|
||||
expect(await safeResolve('library', relative, root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRange', () => {
|
||||
it('parses single ranges', () => {
|
||||
expect(parseRange('bytes=0-3', 10)).toEqual({ start: 0, end: 3 });
|
||||
expect(parseRange('bytes=5-', 10)).toEqual({ start: 5, end: 9 });
|
||||
expect(parseRange('bytes=-4', 10)).toEqual({ start: 6, end: 9 });
|
||||
expect(parseRange('bytes=8-100', 10)).toEqual({ start: 8, end: 9 });
|
||||
});
|
||||
it('ignores or rejects bad ranges', () => {
|
||||
expect(parseRange(null, 10)).toBeNull();
|
||||
expect(parseRange('bytes=0-1,4-5', 10)).toBeNull();
|
||||
expect(parseRange('bytes=10-', 10)).toBe('invalid');
|
||||
expect(parseRange('bytes=5-2', 10)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamFile', () => {
|
||||
const file = () => path.join(root, 'library', 'a', 'book.epub');
|
||||
it('serves the whole file', async () => {
|
||||
const res = await streamFile(file(), new Request('http://x/'), {});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/epub+zip');
|
||||
expect(await res.text()).toBe('0123456789');
|
||||
});
|
||||
it('serves partial content', async () => {
|
||||
const res = await streamFile(
|
||||
file(),
|
||||
new Request('http://x/', { headers: { range: 'bytes=2-4' } }),
|
||||
{}
|
||||
);
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get('content-range')).toBe('bytes 2-4/10');
|
||||
expect(await res.text()).toBe('234');
|
||||
});
|
||||
it('answers 416 for unsatisfiable ranges', async () => {
|
||||
const res = await streamFile(
|
||||
file(),
|
||||
new Request('http://x/', { headers: { range: 'bytes=20-' } }),
|
||||
{}
|
||||
);
|
||||
expect(res.status).toBe(416);
|
||||
});
|
||||
});
|
||||
115
src/lib/server/books/files.ts
Normal file
115
src/lib/server/books/files.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Book files and covers on disk. Files are owner-only and only ever streamed through an
|
||||
// authenticated endpoint; nothing here is under static/.
|
||||
//
|
||||
// BOOKS_ROOT holds library/ (book files, books.files.path is relative to it) and covers/
|
||||
// (books.works.cover_path is relative to it). In prod the container gets the host's books
|
||||
// directory as a read-only bind mount at /books (see scripts/deploy-remote.sh).
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export function booksRoot(): string {
|
||||
if (process.env.BOOKS_ROOT) return process.env.BOOKS_ROOT;
|
||||
return existsSync('/books/library') ? '/books' : path.join(homedir(), 'books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `relative` inside `<root>/<area>`, following symlinks, and refuses anything that
|
||||
* escapes it (`..`, absolute paths, symlinks out). Returns null when missing or unsafe.
|
||||
*/
|
||||
export async function safeResolve(
|
||||
area: 'library' | 'covers',
|
||||
relative: string,
|
||||
root = booksRoot()
|
||||
): Promise<string | null> {
|
||||
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
|
||||
const base = path.join(root, area);
|
||||
try {
|
||||
const realBase = await realpath(base);
|
||||
const real = await realpath(path.join(base, relative));
|
||||
if (real !== realBase && !real.startsWith(realBase + path.sep)) return null;
|
||||
return (await stat(real)).isFile() ? real : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
epub: 'application/epub+zip',
|
||||
pdf: 'application/pdf',
|
||||
mobi: 'application/x-mobipocket-ebook',
|
||||
azw: 'application/vnd.amazon.ebook',
|
||||
azw3: 'application/vnd.amazon.ebook',
|
||||
fb2: 'application/x-fictionbook+xml',
|
||||
cbz: 'application/vnd.comicbook+zip',
|
||||
djvu: 'image/vnd.djvu',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
avif: 'image/avif'
|
||||
};
|
||||
export function contentType(file: string): string {
|
||||
return contentTypes[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Parses a single `bytes=a-b` range. Multi-range requests get the whole file. */
|
||||
export function parseRange(
|
||||
header: string | null,
|
||||
size: number
|
||||
): { start: number; end: number } | 'invalid' | null {
|
||||
if (!header) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!match || (!match[1] && !match[2])) return null;
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (!match[1]) {
|
||||
const suffix = Number(match[2]);
|
||||
if (suffix === 0) return 'invalid';
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(match[1]);
|
||||
end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
||||
}
|
||||
if (start > end || start >= size) return 'invalid';
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Streams a file with Range support (pdf.js fetches large PDFs in chunks). */
|
||||
export async function streamFile(
|
||||
file: string,
|
||||
request: Request,
|
||||
headers: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const { size, mtime } = await stat(file);
|
||||
const base = {
|
||||
'content-type': contentType(file),
|
||||
'accept-ranges': 'bytes',
|
||||
'last-modified': mtime.toUTCString(),
|
||||
...headers
|
||||
};
|
||||
const range = parseRange(request.headers.get('range'), size);
|
||||
if (range === 'invalid')
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...base, 'content-range': `bytes */${size}` }
|
||||
});
|
||||
const { start, end } = range ?? { start: 0, end: size - 1 };
|
||||
const length = size === 0 ? 0 : end - start + 1;
|
||||
const body =
|
||||
request.method === 'HEAD' || size === 0
|
||||
? null
|
||||
: (Readable.toWeb(createReadStream(file, { start, end })) as unknown as ReadableStream);
|
||||
return new Response(body, {
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
...base,
|
||||
'content-length': String(length),
|
||||
...(range ? { 'content-range': `bytes ${start}-${end}/${size}` } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
373
src/lib/server/books/library.ts
Normal file
373
src/lib/server/books/library.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
// Book tracker queries. Public callers get catalogue fields only; owner-only data (files,
|
||||
// notes, highlights, sessions) is loaded separately and only after requireOwner().
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
bookFiles,
|
||||
bookNotes,
|
||||
bookProgress,
|
||||
bookSessions,
|
||||
bookWorks
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { and, asc, desc, eq, gte, sql } from 'drizzle-orm';
|
||||
import {
|
||||
formatPreference,
|
||||
isReadable,
|
||||
type BookCard,
|
||||
type Highlight,
|
||||
type LibraryStats,
|
||||
type ReadingStatus
|
||||
} from '#lib/books/shared.js';
|
||||
import { pagesRead, SESSION_GAP_MS, type ProgressPayload } from '#lib/books/progress.js';
|
||||
|
||||
const TIME_ZONE = 'Europe/Amsterdam';
|
||||
export const currentYear = () =>
|
||||
Number(
|
||||
new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE, year: 'numeric' }).format(new Date())
|
||||
);
|
||||
|
||||
export function coverSrc(work: {
|
||||
id: number;
|
||||
coverPath: string | null;
|
||||
coverUrl: string | null;
|
||||
updatedAt?: Date | null;
|
||||
}) {
|
||||
if (work.coverPath)
|
||||
return `/trackers/books/covers/${work.id}${work.updatedAt ? `?v=${work.updatedAt.getTime().toString(36)}` : ''}`;
|
||||
if (work.coverUrl?.startsWith('https://')) return work.coverUrl;
|
||||
return null;
|
||||
}
|
||||
|
||||
const iso = (d: Date | null | undefined) => (d ? d.toISOString() : null);
|
||||
|
||||
export async function libraryCards(owner: boolean): Promise<BookCard[]> {
|
||||
const formats = db
|
||||
.select({
|
||||
workId: bookFiles.workId,
|
||||
formats: sql<string[]>`array_agg(distinct lower(${bookFiles.format}))`.as('formats')
|
||||
})
|
||||
.from(bookFiles)
|
||||
.groupBy(bookFiles.workId)
|
||||
.as('f');
|
||||
const rows = await db
|
||||
.select({
|
||||
id: bookWorks.id,
|
||||
title: bookWorks.title,
|
||||
subtitle: bookWorks.subtitle,
|
||||
authors: bookWorks.authors,
|
||||
year: bookWorks.year,
|
||||
tags: bookWorks.tags,
|
||||
pageCount: bookWorks.pageCount,
|
||||
coverPath: bookWorks.coverPath,
|
||||
coverUrl: bookWorks.coverUrl,
|
||||
createdAt: bookWorks.createdAt,
|
||||
updatedAt: bookWorks.updatedAt,
|
||||
status: bookProgress.status,
|
||||
percent: bookProgress.percent,
|
||||
rating: bookProgress.rating,
|
||||
lastReadAt: bookProgress.lastReadAt,
|
||||
finishedAt: bookProgress.finishedAt,
|
||||
formats: formats.formats
|
||||
})
|
||||
.from(bookWorks)
|
||||
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
|
||||
.leftJoin(formats, eq(formats.workId, bookWorks.id))
|
||||
.orderBy(asc(bookWorks.title));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
authors: r.authors,
|
||||
year: r.year,
|
||||
tags: r.tags,
|
||||
pageCount: r.pageCount,
|
||||
cover: coverSrc(r),
|
||||
status: r.status ?? null,
|
||||
percent: r.percent ?? 0,
|
||||
rating: r.rating ?? null,
|
||||
lastReadAt: iso(r.lastReadAt),
|
||||
finishedAt: iso(r.finishedAt),
|
||||
addedAt: r.createdAt.toISOString(),
|
||||
formats: owner ? (r.formats ?? []) : [],
|
||||
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false
|
||||
}));
|
||||
}
|
||||
|
||||
export async function libraryStats(): Promise<LibraryStats> {
|
||||
const year = currentYear();
|
||||
const yearStart = sql`make_timestamptz(${year}, 1, 1, 0, 0, 0, ${TIME_ZONE})`;
|
||||
const [[counts], [pages]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: sql<number>`(select count(*)::int from ${bookWorks})`,
|
||||
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
|
||||
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
|
||||
})
|
||||
.from(bookProgress),
|
||||
db
|
||||
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
|
||||
.from(bookSessions)
|
||||
.where(gte(bookSessions.startedAt, yearStart))
|
||||
]);
|
||||
return {
|
||||
total: counts?.total ?? 0,
|
||||
finishedThisYear: counts?.finished ?? 0,
|
||||
pagesThisYear: pages?.pages ?? 0,
|
||||
reading: counts?.reading ?? 0,
|
||||
year
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWork(id: number) {
|
||||
const [work] = await db.select().from(bookWorks).where(eq(bookWorks.id, id)).limit(1);
|
||||
if (!work) return null;
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(bookProgress)
|
||||
.where(eq(bookProgress.workId, id))
|
||||
.limit(1);
|
||||
return { work, progress: progress ?? null };
|
||||
}
|
||||
|
||||
export async function workFiles(workId: number) {
|
||||
const files = await db
|
||||
.select({
|
||||
id: bookFiles.id,
|
||||
path: bookFiles.path,
|
||||
format: bookFiles.format,
|
||||
sizeBytes: bookFiles.sizeBytes
|
||||
})
|
||||
.from(bookFiles)
|
||||
.where(eq(bookFiles.workId, workId));
|
||||
const rank = (f: string) => {
|
||||
const i = formatPreference.indexOf(f.toLowerCase());
|
||||
return i === -1 ? 99 : i;
|
||||
};
|
||||
return files
|
||||
.map((f) => ({ ...f, format: f.format.toLowerCase(), readable: isReadable(f.format) }))
|
||||
.sort((a, b) => rank(a.format) - rank(b.format));
|
||||
}
|
||||
|
||||
export async function workNotes(workId: number): Promise<Highlight[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(bookNotes)
|
||||
.where(eq(bookNotes.workId, workId))
|
||||
.orderBy(desc(bookNotes.createdAt));
|
||||
return rows.map((n) => ({
|
||||
id: n.id,
|
||||
fileId: n.fileId,
|
||||
locator: n.locator,
|
||||
page: n.page,
|
||||
highlight: n.highlight,
|
||||
note: n.note,
|
||||
color: n.color,
|
||||
createdAt: n.createdAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function workSessions(workId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(bookSessions)
|
||||
.where(eq(bookSessions.workId, workId))
|
||||
.orderBy(desc(bookSessions.startedAt))
|
||||
.limit(200);
|
||||
return rows.map((s) => ({
|
||||
id: s.id,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
endedAt: iso(s.endedAt),
|
||||
startPercent: s.startPercent,
|
||||
endPercent: s.endPercent,
|
||||
startPage: s.startPage,
|
||||
endPage: s.endPage,
|
||||
pagesRead: s.pagesRead,
|
||||
source: s.source,
|
||||
note: s.note
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sets the reading status, keeping the start/finish dates and percent consistent. */
|
||||
export async function setStatus(workId: number, status: ReadingStatus) {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(bookProgress)
|
||||
.values({
|
||||
workId,
|
||||
status,
|
||||
startedAt: status === 'want_to_read' ? null : now,
|
||||
finishedAt: status === 'finished' ? now : null,
|
||||
percent: status === 'finished' ? 100 : 0
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: bookProgress.workId,
|
||||
set: {
|
||||
status,
|
||||
startedAt:
|
||||
status === 'want_to_read'
|
||||
? sql`${bookProgress.startedAt}`
|
||||
: sql`coalesce(${bookProgress.startedAt}, ${now.toISOString()}::timestamptz)`,
|
||||
finishedAt:
|
||||
status === 'finished'
|
||||
? sql`coalesce(${bookProgress.finishedAt}, ${now.toISOString()}::timestamptz)`
|
||||
: null,
|
||||
percent: status === 'finished' ? 100 : sql`${bookProgress.percent}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function setRating(workId: number, rating: number | null) {
|
||||
await db
|
||||
.insert(bookProgress)
|
||||
.values({ workId, rating })
|
||||
.onConflictDoUpdate({ target: bookProgress.workId, set: { rating } });
|
||||
}
|
||||
|
||||
/** Manual progress from the detail page (percent and/or page). */
|
||||
export async function setManualProgress(workId: number, percent: number, page: number | null) {
|
||||
await db
|
||||
.insert(bookProgress)
|
||||
.values({
|
||||
workId,
|
||||
percent,
|
||||
page,
|
||||
status: percent > 0 ? 'reading' : 'want_to_read',
|
||||
startedAt: percent > 0 ? new Date() : null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: bookProgress.workId,
|
||||
set: {
|
||||
percent,
|
||||
page,
|
||||
status: sql`case when ${bookProgress.status} = 'want_to_read' and ${percent} > 0 then 'reading'::books.reading_status else ${bookProgress.status} end`,
|
||||
startedAt: sql`coalesce(${bookProgress.startedAt}, case when ${percent} > 0 then now() end)`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the reader position and keeps a reading session going. Returns the session id the
|
||||
* reader should send back with its next save. A save more than SESSION_GAP_MS after the
|
||||
* last one starts a new session.
|
||||
*/
|
||||
export async function saveProgress(workId: number, input: ProgressPayload) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [work] = await tx
|
||||
.select({ pageCount: bookWorks.pageCount })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
if (!work) return null;
|
||||
if (input.fileId) {
|
||||
const [file] = await tx
|
||||
.select({ id: bookFiles.id })
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
|
||||
.limit(1);
|
||||
if (!file) return null;
|
||||
}
|
||||
const [before] = await tx
|
||||
.select()
|
||||
.from(bookProgress)
|
||||
.where(eq(bookProgress.workId, workId))
|
||||
.limit(1);
|
||||
const now = new Date();
|
||||
await tx
|
||||
.insert(bookProgress)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
percent: input.percent,
|
||||
status: 'reading',
|
||||
startedAt: now,
|
||||
lastReadAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: bookProgress.workId,
|
||||
set: {
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
percent: input.percent,
|
||||
lastReadAt: now,
|
||||
status: sql`case when ${bookProgress.status} = 'want_to_read' then 'reading'::books.reading_status else ${bookProgress.status} end`,
|
||||
startedAt: sql`coalesce(${bookProgress.startedAt}, now())`
|
||||
}
|
||||
});
|
||||
|
||||
const pageCount = input.pageCount ?? work.pageCount;
|
||||
let session = input.sessionId
|
||||
? (
|
||||
await tx
|
||||
.select()
|
||||
.from(bookSessions)
|
||||
.where(and(eq(bookSessions.id, input.sessionId), eq(bookSessions.workId, workId)))
|
||||
.limit(1)
|
||||
)[0]
|
||||
: undefined;
|
||||
const last = session?.endedAt ?? session?.startedAt;
|
||||
if (session && last && now.getTime() - last.getTime() > SESSION_GAP_MS) session = undefined;
|
||||
if (!session) {
|
||||
[session] = await tx
|
||||
.insert(bookSessions)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
startedAt: now,
|
||||
endedAt: now,
|
||||
startPercent: before?.percent ?? input.percent,
|
||||
endPercent: input.percent,
|
||||
startPage: before?.page ?? input.page ?? null,
|
||||
endPage: input.page ?? null,
|
||||
pagesRead: 0,
|
||||
source: 'reader'
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
const startPage = session.startPage ?? input.page ?? null;
|
||||
await tx
|
||||
.update(bookSessions)
|
||||
.set({
|
||||
endedAt: now,
|
||||
endPercent: input.percent,
|
||||
endPage: input.page ?? null,
|
||||
startPage,
|
||||
pagesRead: pagesRead({
|
||||
startPage: input.page != null ? startPage : null,
|
||||
endPage: input.page,
|
||||
startPercent: session.startPercent,
|
||||
endPercent: input.percent,
|
||||
pageCount
|
||||
})
|
||||
})
|
||||
.where(eq(bookSessions.id, session.id));
|
||||
}
|
||||
return { sessionId: session.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function logManualSession(
|
||||
workId: number,
|
||||
input: {
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
startPage: number | null;
|
||||
endPage: number | null;
|
||||
startPercent: number | null;
|
||||
endPercent: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
) {
|
||||
const [work] = await db
|
||||
.select({ pageCount: bookWorks.pageCount })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
await db.insert(bookSessions).values({
|
||||
workId,
|
||||
...input,
|
||||
pagesRead: pagesRead({ ...input, pageCount: work?.pageCount }),
|
||||
source: 'manual'
|
||||
});
|
||||
}
|
||||
84
src/lib/server/books/notes.ts
Normal file
84
src/lib/server/books/notes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Highlights and notes (books.notes). A row with `highlight` is a highlight from the reader
|
||||
// (optionally with a note); a row with only `note` is a free-form note on the book.
|
||||
import { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { bookFiles, bookNotes, bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { highlightColors, type Highlight } from '#lib/books/shared.js';
|
||||
|
||||
export const notePayload = z
|
||||
.object({
|
||||
fileId: z.number().int().positive().nullish(),
|
||||
locator: z.string().max(4000).nullish(),
|
||||
page: z.number().int().min(0).max(100000).nullish(),
|
||||
highlight: z.string().trim().max(20000).nullish(),
|
||||
note: z.string().trim().max(20000).nullish(),
|
||||
color: z.enum(highlightColors).nullish()
|
||||
})
|
||||
.refine((n) => !!(n.highlight || n.note), { message: 'Empty note' });
|
||||
export const noteUpdate = z.object({
|
||||
note: z.string().trim().max(20000).nullable().optional(),
|
||||
color: z.enum(highlightColors).nullable().optional()
|
||||
});
|
||||
|
||||
const toHighlight = (n: typeof bookNotes.$inferSelect): Highlight => ({
|
||||
id: n.id,
|
||||
fileId: n.fileId,
|
||||
locator: n.locator,
|
||||
page: n.page,
|
||||
highlight: n.highlight,
|
||||
note: n.note,
|
||||
color: n.color,
|
||||
createdAt: n.createdAt.toISOString()
|
||||
});
|
||||
|
||||
export async function createNote(workId: number, input: z.infer<typeof notePayload>) {
|
||||
const [work] = await db
|
||||
.select({ id: bookWorks.id })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
if (!work) return null;
|
||||
if (input.fileId) {
|
||||
const [file] = await db
|
||||
.select({ id: bookFiles.id })
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
|
||||
.limit(1);
|
||||
if (!file) return null;
|
||||
}
|
||||
const [row] = await db
|
||||
.insert(bookNotes)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
highlight: input.highlight || null,
|
||||
note: input.note || null,
|
||||
color: input.highlight ? (input.color ?? 'yellow') : null
|
||||
})
|
||||
.returning();
|
||||
return toHighlight(row);
|
||||
}
|
||||
|
||||
export async function updateNote(workId: number, id: number, input: z.infer<typeof noteUpdate>) {
|
||||
const set: Partial<typeof bookNotes.$inferInsert> = {};
|
||||
if (input.note !== undefined) set.note = input.note || null;
|
||||
if (input.color !== undefined) set.color = input.color;
|
||||
if (!Object.keys(set).length) return null;
|
||||
const [row] = await db
|
||||
.update(bookNotes)
|
||||
.set(set)
|
||||
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
|
||||
.returning();
|
||||
return row ? toHighlight(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteNote(workId: number, id: number) {
|
||||
const rows = await db
|
||||
.delete(bookNotes)
|
||||
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
|
||||
.returning({ id: bookNotes.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
209
src/lib/server/db/schema/books.ts
Normal file
209
src/lib/server/db/schema/books.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
real,
|
||||
smallint,
|
||||
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
|
||||
]);
|
||||
|
||||
// Reading state per work (on books.progress), shown publicly on /trackers/books.
|
||||
export const bookReadingStatus = booksSchema.enum('reading_status', [
|
||||
'want_to_read',
|
||||
'reading',
|
||||
'finished',
|
||||
'abandoned'
|
||||
]);
|
||||
|
||||
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'),
|
||||
status: bookReadingStatus('status').notNull().default('want_to_read'),
|
||||
rating: smallint('rating'), // 1-5 stars
|
||||
lastReadAt: timestamptz('last_read_at'), // last time the reader saved a position
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('progress_work_id_key').on(t.workId),
|
||||
check('progress_percent_check', sql`${t.percent} between 0 and 100`),
|
||||
check('progress_rating_check', sql`${t.rating} is null or ${t.rating} between 1 and 5`),
|
||||
index('progress_status_idx').on(t.status)
|
||||
]
|
||||
);
|
||||
|
||||
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)]
|
||||
);
|
||||
|
||||
// One reading session: opened in the reader (source 'reader') or logged by hand ('manual').
|
||||
export const bookSessions = booksSchema.table(
|
||||
'sessions',
|
||||
{
|
||||
id: id(),
|
||||
workId: integer('work_id')
|
||||
.notNull()
|
||||
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||
startedAt: timestamptz('started_at').notNull().defaultNow(),
|
||||
endedAt: timestamptz('ended_at'),
|
||||
startPercent: real('start_percent'),
|
||||
endPercent: real('end_percent'),
|
||||
startPage: integer('start_page'),
|
||||
endPage: integer('end_page'),
|
||||
pagesRead: integer('pages_read'), // derived on save: page delta, or percent delta x page_count
|
||||
source: text('source').notNull().default('reader'),
|
||||
note: text('note'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('sessions_work_id_idx').on(t.workId),
|
||||
index('sessions_started_at_idx').on(t.startedAt),
|
||||
check(
|
||||
'sessions_percent_check',
|
||||
sql`(${t.startPercent} is null or ${t.startPercent} between 0 and 100) and (${t.endPercent} is null or ${t.endPercent} between 0 and 100)`
|
||||
),
|
||||
check('sessions_end_check', sql`${t.endedAt} is null or ${t.endedAt} >= ${t.startedAt}`)
|
||||
]
|
||||
);
|
||||
|
||||
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;
|
||||
export type BookSession = typeof bookSessions.$inferSelect;
|
||||
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];
|
||||
@@ -13,3 +13,4 @@ export * from './money-tracker';
|
||||
export * from './cs-prices';
|
||||
export * from './house-tracker';
|
||||
export * from './health';
|
||||
export * from './books';
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { boolean, check, date, index, integer, numeric, text, unique } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
text,
|
||||
unique
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { moneySchema } from './schemas';
|
||||
import { id, timestamps } from './helpers';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
import { user } from './auth';
|
||||
|
||||
export const moneyAccounts = moneySchema.table(
|
||||
@@ -15,12 +25,26 @@ export const moneyAccounts = moneySchema.table(
|
||||
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
|
||||
kind: text('kind').notNull().default('spendable'),
|
||||
color: text('color'),
|
||||
// Household scope: Stan's own money vs. the joint account with Juhee.
|
||||
scope: text('scope').notNull().default('personal'),
|
||||
owner: text('owner'),
|
||||
iban: text('iban'),
|
||||
// Bank-side account identifier (Enable Banking account uid) for synced accounts.
|
||||
externalUid: text('external_uid'),
|
||||
provider: text('provider').notNull().default('manual'),
|
||||
// Last balance reported by the bank (synced accounts); the source of truth for the total.
|
||||
bankBalanceCents: integer('bank_balance_cents'),
|
||||
bankBalanceType: text('bank_balance_type'),
|
||||
bankBalanceAt: timestamptz('bank_balance_at'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [
|
||||
unique().on(table.userId, table.name),
|
||||
unique('accounts_external_uid_key').on(table.externalUid),
|
||||
index().on(table.userId),
|
||||
check('accounts_kind_check', sql`kind IN ('spendable','investment')`)
|
||||
check('accounts_kind_check', sql`kind IN ('spendable','investment')`),
|
||||
check('accounts_scope_check', sql`scope IN ('personal','joint')`),
|
||||
check('accounts_provider_check', sql`provider IN ('manual','enablebanking')`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -37,9 +61,53 @@ export const moneyTransactions = moneySchema.table(
|
||||
date: date('date', { mode: 'string' }).notNull(),
|
||||
description: text('description').notNull(),
|
||||
amountCents: integer('amount_cents').notNull(),
|
||||
// Bank transaction id (Enable Banking entry_reference / transaction_id, or a
|
||||
// content hash for CSV imports). NULLs are distinct, so manual rows never clash.
|
||||
externalId: text('external_id'),
|
||||
source: text('source').notNull().default('manual'),
|
||||
bookingStatus: text('booking_status'),
|
||||
counterparty: text('counterparty'),
|
||||
category: text('category'),
|
||||
raw: jsonb('raw'),
|
||||
valueDate: date('value_date', { mode: 'string' }),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index().on(table.userId, table.date, table.id), index().on(table.accountId)]
|
||||
(table) => [
|
||||
index().on(table.userId, table.date, table.id),
|
||||
index().on(table.accountId),
|
||||
unique('transactions_account_external_id_key').on(table.accountId, table.externalId),
|
||||
check('transactions_source_check', sql`source IN ('manual','csv','enablebanking')`)
|
||||
]
|
||||
);
|
||||
|
||||
// Open-banking consent sessions (Enable Banking). One row per authorised session;
|
||||
// `accounts` keeps the bank-side account list returned when the session was created.
|
||||
export const moneyBankSessions = moneySchema.table(
|
||||
'bank_sessions',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider').notNull().default('enablebanking'),
|
||||
sessionId: text('session_id').notNull(),
|
||||
aspspName: text('aspsp_name').notNull(),
|
||||
aspspCountry: text('aspsp_country').notNull(),
|
||||
psuType: text('psu_type'),
|
||||
status: text('status').notNull().default('authorized'),
|
||||
validUntil: timestamptz('valid_until'),
|
||||
accounts: jsonb('accounts')
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
lastSyncedAt: timestamptz('last_synced_at'),
|
||||
lastError: text('last_error'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [
|
||||
unique('bank_sessions_session_id_key').on(table.sessionId),
|
||||
index().on(table.userId),
|
||||
check('bank_sessions_status_check', sql`status IN ('authorized','expired','revoked','error')`)
|
||||
]
|
||||
);
|
||||
|
||||
export const moneyRecurring = moneySchema.table(
|
||||
|
||||
@@ -13,3 +13,4 @@ export const animaldexSchema = pgSchema('animaldex');
|
||||
export const siteSchema = pgSchema('site');
|
||||
export const metaSchema = pgSchema('meta');
|
||||
export const healthSchema = pgSchema('health');
|
||||
export const booksSchema = pgSchema('books');
|
||||
|
||||
260
src/lib/server/enable-banking.ts
Normal file
260
src/lib/server/enable-banking.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// Minimal Enable Banking (https://enablebanking.com/docs/api/reference/) client for
|
||||
// account information (AIS): consent (POST /auth + POST /sessions), balances and
|
||||
// transactions. Every request carries a short-lived RS256 JWT signed with the
|
||||
// application's private key; the key never leaves this process.
|
||||
//
|
||||
// Framework-free on purpose: used by the SvelteKit callback route and by the
|
||||
// bun scripts in scripts/ (bank-link.ts, bank-sync.ts), which don't load $app/env.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createPrivateKey, randomUUID, type KeyObject } from 'node:crypto';
|
||||
import { SignJWT } from 'jose';
|
||||
|
||||
export const ENABLE_BANKING_API = 'https://api.enablebanking.com';
|
||||
|
||||
export type EnableBankingConfig = {
|
||||
appId: string;
|
||||
/** PEM private key (PKCS#1 or PKCS#8). Prefer keyPath so the key isn't in env. */
|
||||
privateKeyPem?: string;
|
||||
keyPath?: string;
|
||||
baseUrl?: string;
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
|
||||
export type Amount = { currency: string; amount: string };
|
||||
|
||||
export type BankAccount = {
|
||||
uid?: string;
|
||||
account_id?: { iban?: string; other?: { identification: string; scheme_name: string } };
|
||||
name?: string;
|
||||
details?: string;
|
||||
product?: string;
|
||||
currency?: string;
|
||||
cash_account_type?: string;
|
||||
usage?: string;
|
||||
identification_hash?: string;
|
||||
};
|
||||
|
||||
export type BankTransaction = {
|
||||
entry_reference?: string | null;
|
||||
transaction_id?: string | null;
|
||||
transaction_amount: Amount;
|
||||
credit_debit_indicator: 'CRDT' | 'DBIT';
|
||||
status?: string;
|
||||
booking_date?: string | null;
|
||||
value_date?: string | null;
|
||||
transaction_date?: string | null;
|
||||
creditor?: { name?: string | null } | null;
|
||||
debtor?: { name?: string | null } | null;
|
||||
creditor_account?: { iban?: string | null } | null;
|
||||
debtor_account?: { iban?: string | null } | null;
|
||||
remittance_information?: string[] | null;
|
||||
bank_transaction_code?: { description?: string | null } | null;
|
||||
merchant_category_code?: string | null;
|
||||
note?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type Balance = {
|
||||
name?: string;
|
||||
balance_amount: Amount;
|
||||
balance_type: string;
|
||||
reference_date?: string | null;
|
||||
last_change_date_time?: string | null;
|
||||
};
|
||||
|
||||
export type StartAuthResponse = { url: string; authorization_id: string; psu_id_hash?: string };
|
||||
export type SessionResponse = {
|
||||
session_id: string;
|
||||
accounts: BankAccount[];
|
||||
aspsp: { name: string; country: string };
|
||||
psu_type: string;
|
||||
access: { valid_until: string };
|
||||
};
|
||||
|
||||
export class EnableBankingError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | undefined,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'EnableBankingError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads ENABLEBANKING_APP_ID and ENABLEBANKING_KEY_PATH / ENABLEBANKING_PRIVATE_KEY. */
|
||||
export function configFromEnv(env: Record<string, string | undefined> = process.env) {
|
||||
const appId = env.ENABLEBANKING_APP_ID?.trim();
|
||||
if (!appId) throw new Error('ENABLEBANKING_APP_ID is not set');
|
||||
const keyPath = env.ENABLEBANKING_KEY_PATH?.trim();
|
||||
const privateKeyPem = env.ENABLEBANKING_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||
if (!keyPath && !privateKeyPem)
|
||||
throw new Error('Set ENABLEBANKING_KEY_PATH (preferred) or ENABLEBANKING_PRIVATE_KEY');
|
||||
return { appId, keyPath, privateKeyPem } satisfies EnableBankingConfig;
|
||||
}
|
||||
|
||||
export function createEnableBankingClient(config: EnableBankingConfig) {
|
||||
const baseUrl = config.baseUrl ?? ENABLE_BANKING_API;
|
||||
const doFetch = config.fetch ?? fetch;
|
||||
let key: KeyObject | undefined;
|
||||
let cached: { token: string; expires: number } | undefined;
|
||||
|
||||
function privateKey() {
|
||||
if (!key) {
|
||||
const pem =
|
||||
config.privateKeyPem ?? (config.keyPath ? readFileSync(config.keyPath, 'utf8') : '');
|
||||
if (!pem) throw new Error('No Enable Banking private key configured');
|
||||
key = createPrivateKey(pem);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** RS256 JWT, valid for one hour (the API accepts at most 24 h). */
|
||||
async function token() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (cached && cached.expires - 60 > now) return cached.token;
|
||||
const jwt = await new SignJWT({})
|
||||
.setProtectedHeader({ typ: 'JWT', alg: 'RS256', kid: config.appId })
|
||||
.setIssuer('enablebanking.com')
|
||||
.setAudience('api.enablebanking.com')
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey());
|
||||
cached = { token: jwt, expires: now + 3600 };
|
||||
return jwt;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const response = await doFetch(baseUrl + path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${await token()}`,
|
||||
Accept: 'application/json',
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(60_000)
|
||||
});
|
||||
const text = await response.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = (data ?? {}) as { message?: string; error?: string; detail?: unknown };
|
||||
throw new EnableBankingError(
|
||||
response.status,
|
||||
detail.error,
|
||||
`Enable Banking ${method} ${path.split('?')[0]} failed (${response.status}${detail.error ? ' ' + detail.error : ''}): ${detail.message ?? text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
application: () =>
|
||||
request<{
|
||||
name: string;
|
||||
kid: string;
|
||||
environment: string;
|
||||
active: boolean;
|
||||
redirect_urls: string[];
|
||||
countries: string[];
|
||||
services: string[];
|
||||
}>('GET', '/application'),
|
||||
aspsps: (country: string, psuType = 'personal') =>
|
||||
request<{
|
||||
aspsps: {
|
||||
name: string;
|
||||
country: string;
|
||||
maximum_consent_validity: number;
|
||||
psu_types: string[];
|
||||
}[];
|
||||
}>('GET', `/aspsps?${new URLSearchParams({ country, psu_type: psuType, service: 'AIS' })}`),
|
||||
startAuth: (input: {
|
||||
aspsp: { name: string; country: string };
|
||||
redirectUrl: string;
|
||||
validUntil: Date;
|
||||
psuType?: 'personal' | 'business';
|
||||
state?: string;
|
||||
language?: string;
|
||||
}) =>
|
||||
request<StartAuthResponse>('POST', '/auth', {
|
||||
access: { valid_until: input.validUntil.toISOString() },
|
||||
aspsp: input.aspsp,
|
||||
state: input.state ?? randomUUID(),
|
||||
redirect_url: input.redirectUrl,
|
||||
psu_type: input.psuType ?? 'personal',
|
||||
...(input.language ? { language: input.language } : {})
|
||||
}),
|
||||
createSession: (code: string) => request<SessionResponse>('POST', '/sessions', { code }),
|
||||
getSession: (sessionId: string) =>
|
||||
request<{
|
||||
status: string;
|
||||
accounts: string[];
|
||||
access: { valid_until: string };
|
||||
aspsp: { name: string; country: string };
|
||||
}>('GET', `/sessions/${encodeURIComponent(sessionId)}`),
|
||||
deleteSession: (sessionId: string) =>
|
||||
request<{ message: string }>('DELETE', `/sessions/${encodeURIComponent(sessionId)}`),
|
||||
accountDetails: (uid: string) =>
|
||||
request<BankAccount>('GET', `/accounts/${encodeURIComponent(uid)}/details`),
|
||||
balances: (uid: string) =>
|
||||
request<{ balances: Balance[] }>('GET', `/accounts/${encodeURIComponent(uid)}/balances`),
|
||||
/** All transactions from dateFrom (inclusive), following continuation_key pages. */
|
||||
async transactions(
|
||||
uid: string,
|
||||
options: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
status?: 'BOOK' | 'PDNG';
|
||||
maxPages?: number;
|
||||
} = {}
|
||||
) {
|
||||
const all: BankTransaction[] = [];
|
||||
let continuationKey: string | undefined;
|
||||
for (let page = 0; page < (options.maxPages ?? 200); page++) {
|
||||
const query = new URLSearchParams();
|
||||
if (options.dateFrom) query.set('date_from', options.dateFrom);
|
||||
if (options.dateTo) query.set('date_to', options.dateTo);
|
||||
if (options.status) query.set('transaction_status', options.status);
|
||||
if (continuationKey) query.set('continuation_key', continuationKey);
|
||||
const result = await request<{
|
||||
transactions: BankTransaction[];
|
||||
continuation_key?: string | null;
|
||||
}>(
|
||||
'GET',
|
||||
`/accounts/${encodeURIComponent(uid)}/transactions${query.size ? '?' + query : ''}`
|
||||
);
|
||||
all.push(...(result.transactions ?? []));
|
||||
continuationKey = result.continuation_key ?? undefined;
|
||||
if (!continuationKey) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type EnableBankingClient = ReturnType<typeof createEnableBankingClient>;
|
||||
|
||||
/** Extracts the authorisation code from a pasted redirect URL, query string or bare code. */
|
||||
export function parseAuthCode(input: string): { code?: string; error?: string; state?: string } {
|
||||
const value = input.trim();
|
||||
if (!value) return {};
|
||||
if (/^[\w-]+$/.test(value) && !value.includes('=')) return { code: value };
|
||||
let params: URLSearchParams;
|
||||
try {
|
||||
params = new URL(value).searchParams;
|
||||
} catch {
|
||||
params = new URLSearchParams(value.replace(/^.*\?/, ''));
|
||||
}
|
||||
const error = params.get('error');
|
||||
return {
|
||||
code: params.get('code') ?? undefined,
|
||||
state: params.get('state') ?? undefined,
|
||||
error: error ? `${error}: ${params.get('error_description') ?? ''}`.trim() : undefined
|
||||
};
|
||||
}
|
||||
121
src/lib/server/energy.ts
Normal file
121
src/lib/server/energy.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { EnergyRow } from '#lib/energy/data.js';
|
||||
|
||||
// Read-only queries over the ned-collector tables in the `energy` schema. The app role
|
||||
// already has USAGE + SELECT there; nothing on the site writes to these tables.
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
const num = (value: unknown) => (value === null || value === undefined ? null : Number(value));
|
||||
const time = (value: unknown) => new Date(value as string | Date).getTime();
|
||||
const rows = (result: unknown) => result as Raw[];
|
||||
|
||||
function energyRows(result: unknown): EnergyRow[] {
|
||||
return rows(result).map((row) => ({
|
||||
t: time(row.t),
|
||||
type: String(row.type),
|
||||
value: Number(row.value),
|
||||
pct: num(row.pct),
|
||||
ef: num(row.ef),
|
||||
...(row.hours === undefined ? {} : { hours: Number(row.hours) }),
|
||||
...(row.expected === undefined ? {} : { expected: Number(row.expected) })
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadEnergy() {
|
||||
const [hourly, daily, forecast, monthly, yearly, gasNl, gasSites, runs, coverage] =
|
||||
await Promise.all([
|
||||
// Last 7 days of hourly measurements, counted back from the newest hour NED published.
|
||||
db.execute(sql`WITH latest AS (
|
||||
SELECT max(valid_from) AS t FROM energy.ned_utilization
|
||||
WHERE granularity = 'Hour' AND classification = 'Current')
|
||||
SELECT valid_from AS t, type, volume_kwh::float8 / 1000 AS value,
|
||||
percentage::float8 AS pct, emission_factor::float8 AS ef
|
||||
FROM energy.ned_utilization, latest
|
||||
WHERE granularity = 'Hour' AND classification = 'Current'
|
||||
AND valid_from > latest.t - interval '7 days'
|
||||
ORDER BY 1, 2`),
|
||||
// Daily totals (Amsterdam days) from hourly data, last 31 days. `expected` is 23 or 25
|
||||
// on DST change days so partial days can be flagged.
|
||||
db.execute(sql`WITH latest AS (
|
||||
SELECT max(valid_from) AS t FROM energy.ned_utilization
|
||||
WHERE granularity = 'Hour' AND classification = 'Current'),
|
||||
days AS (
|
||||
SELECT date_trunc('day', valid_from AT TIME ZONE 'Europe/Amsterdam') AS day, type,
|
||||
sum(volume_kwh) AS kwh, sum(emission_kg) AS kg, count(*)::int AS hours
|
||||
FROM energy.ned_utilization, latest
|
||||
WHERE granularity = 'Hour' AND classification = 'Current'
|
||||
AND valid_from > latest.t - interval '31 days'
|
||||
GROUP BY 1, 2)
|
||||
SELECT day AT TIME ZONE 'Europe/Amsterdam' AS t, type, kwh::float8 / 1000 AS value, hours,
|
||||
(extract(epoch FROM (day + interval '1 day') AT TIME ZONE 'Europe/Amsterdam'
|
||||
- day AT TIME ZONE 'Europe/Amsterdam') / 3600)::int AS expected,
|
||||
(kg / nullif(kwh, 0))::float8 AS ef
|
||||
FROM days ORDER BY 1, 2`),
|
||||
db.execute(sql`WITH latest AS (
|
||||
SELECT max(valid_from) AS t FROM energy.ned_utilization
|
||||
WHERE granularity = 'Hour' AND classification = 'Current')
|
||||
SELECT valid_from AS t, type, volume_kwh::float8 / 1000 AS value
|
||||
FROM energy.ned_utilization, latest
|
||||
WHERE granularity = 'Hour' AND classification = 'Forecast'
|
||||
AND valid_from > latest.t AND valid_from <= latest.t + interval '8 days'
|
||||
AND type IN ('Wind', 'WindOffshoreC', 'Solar', 'ElectricityLoad', 'ElectricityMix')
|
||||
ORDER BY 1, 2`),
|
||||
db.execute(sql`SELECT valid_from AS t, type, volume_kwh::float8 / 1e6 AS value,
|
||||
percentage::float8 AS pct, emission_factor::float8 AS ef
|
||||
FROM energy.ned_utilization
|
||||
WHERE granularity = 'Month' AND classification = 'Current'
|
||||
ORDER BY 1, 2`),
|
||||
db.execute(sql`SELECT valid_from AS t, type, volume_kwh::float8 / 1e6 AS value,
|
||||
emission_factor::float8 AS ef
|
||||
FROM energy.ned_utilization
|
||||
WHERE granularity = 'Year' AND classification = 'Current'
|
||||
ORDER BY 1, 2`),
|
||||
db.execute(sql`SELECT gas_day::text AS day, fill_pct::float8 AS fill, volume_twh::float8 AS twh
|
||||
FROM energy.gas_storage_daily WHERE site = 'NL' ORDER BY gas_day`),
|
||||
db.execute(sql`SELECT site, gas_day::text AS day, fill_pct::float8 AS fill,
|
||||
volume_twh::float8 AS twh
|
||||
FROM energy.gas_storage_latest WHERE site <> 'NL' ORDER BY volume_twh DESC`),
|
||||
db.execute(sql`SELECT
|
||||
(SELECT max(started_at) FROM energy.collector_runs) AS last_run,
|
||||
(SELECT max(finished_at) FROM energy.collector_runs WHERE ok) AS last_ok,
|
||||
(SELECT count(*)::int FROM energy.collector_runs
|
||||
WHERE NOT ok AND started_at > now() - interval '7 days') AS failed_week`),
|
||||
db.execute(sql`SELECT min(valid_from) AS first_hour, max(valid_from) AS last_hour,
|
||||
count(DISTINCT valid_from)::int AS hours
|
||||
FROM energy.ned_utilization WHERE granularity = 'Hour' AND classification = 'Current'`)
|
||||
]);
|
||||
|
||||
const run = rows(runs)[0] ?? {};
|
||||
const cover = rows(coverage)[0] ?? {};
|
||||
return {
|
||||
hourly: energyRows(hourly),
|
||||
daily: energyRows(daily),
|
||||
forecast: energyRows(forecast),
|
||||
monthly: energyRows(monthly),
|
||||
yearly: energyRows(yearly),
|
||||
gas: rows(gasNl).map((row) => ({
|
||||
day: String(row.day),
|
||||
fill: Number(row.fill),
|
||||
twh: Number(row.twh)
|
||||
})),
|
||||
gasSites: rows(gasSites).map((row) => ({
|
||||
site: String(row.site),
|
||||
day: String(row.day),
|
||||
fill: Number(row.fill),
|
||||
twh: Number(row.twh)
|
||||
})),
|
||||
collector: {
|
||||
lastRun: run.last_run ? time(run.last_run) : null,
|
||||
lastOk: run.last_ok ? time(run.last_ok) : null,
|
||||
failedWeek: Number(run.failed_week ?? 0)
|
||||
},
|
||||
coverage: {
|
||||
firstHour: cover.first_hour ? time(cover.first_hour) : null,
|
||||
lastHour: cover.last_hour ? time(cover.last_hour) : null,
|
||||
hours: Number(cover.hours ?? 0)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type EnergyData = Awaited<ReturnType<typeof loadEnergy>>;
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker';
|
||||
import {
|
||||
cents,
|
||||
csvDate,
|
||||
importCents,
|
||||
parseCsv,
|
||||
parseTransactionImport,
|
||||
planImport
|
||||
} from './money-tracker';
|
||||
|
||||
describe('transaction import parsing', () => {
|
||||
it('handles quoted commas and semicolon-separated European amounts', () => {
|
||||
@@ -63,3 +70,50 @@ describe('transaction import parsing', () => {
|
||||
).toThrow('Quote values containing commas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revolut statements and import dedupe', () => {
|
||||
const statement =
|
||||
'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n' +
|
||||
'CARD_PAYMENT,Current,2026-10-01 10:00:00,2026-10-02 09:00:00,Albert Heijn,-12.34,0.00,EUR,COMPLETED,100.00\n' +
|
||||
'TOPUP,Current,2026-10-03 10:00:00,,Pending top-up,50.00,0.00,EUR,PENDING,\n' +
|
||||
'TRANSFER,Current,2026-10-04 10:00:00,2026-10-04 10:00:01,To Joint,-100.00,0.50,EUR,COMPLETED,0.00\n' +
|
||||
'CARD_PAYMENT,Current,2026-10-05 10:00:00,2026-10-05 11:00:00,Coffee,-3.00,0.00,USD,COMPLETED,0.00\n';
|
||||
|
||||
it('keeps completed EUR rows, uses the completed date and deducts fees', () => {
|
||||
const rows = parseTransactionImport(statement, 'Revolut');
|
||||
expect(rows.map((row) => [row.date, row.account, row.description, row.amountCents])).toEqual([
|
||||
['2026-10-02', 'Revolut', 'Albert Heijn', -1234],
|
||||
['2026-10-04', 'Revolut', 'To Joint', -10050]
|
||||
]);
|
||||
expect(rows[0].externalId).toMatch(/^csv:[0-9a-f]{32}$/);
|
||||
expect(parseTransactionImport(statement, 'Revolut')[0].externalId).toBe(rows[0].externalId);
|
||||
expect(() => parseTransactionImport(statement)).toThrow('Choose which account');
|
||||
});
|
||||
|
||||
it('gives identical rows in one file distinct, stable ids', () => {
|
||||
const file =
|
||||
'date,account,description,amount\n2026-10-01,Cash,Kiosk,-2\n2026-10-01,Cash,Kiosk,-2\n';
|
||||
const [a, b] = parseTransactionImport(file);
|
||||
expect(a.externalId).not.toBe(b.externalId);
|
||||
expect(parseTransactionImport(file).map((row) => row.externalId)).toEqual([
|
||||
a.externalId,
|
||||
b.externalId
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips rows already imported or already entered by hand (±1 day)', () => {
|
||||
const rows = [
|
||||
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:a' },
|
||||
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:b' },
|
||||
{ accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' },
|
||||
{ accountId: 2, date: '2026-10-04', amountCents: -500, externalId: 'csv:d' }
|
||||
];
|
||||
const existing = [
|
||||
{ id: 10, accountId: 1, date: '2026-10-01', amountCents: -1234, externalId: null },
|
||||
{ id: 11, accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' }
|
||||
];
|
||||
const { insert, duplicates } = planImport(rows, existing);
|
||||
expect(insert.map((row) => row.externalId)).toEqual(['csv:b', 'csv:d']);
|
||||
expect(duplicates).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
|
||||
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
|
||||
@@ -130,21 +131,93 @@ export type ImportedTransaction = {
|
||||
account: string;
|
||||
description: string;
|
||||
amountCents: number;
|
||||
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
const rows = parseCsv(input);
|
||||
if (rows.length < 2 || rows.length > 20_001)
|
||||
throw new Error('The file must have a header and 1–20,000 rows.');
|
||||
const header = rows.shift()!.map((value) =>
|
||||
export type ImportFormat = 'tracker' | 'revolut';
|
||||
|
||||
function headerOf(rows: string[][]) {
|
||||
return rows[0].map((value) =>
|
||||
value
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/** Revolut app statement export: Type, Product, Started Date, Completed Date, Description, Amount, Fee, Currency, State, Balance. */
|
||||
export function isRevolutHeader(header: string[]) {
|
||||
return ['type', 'started date', 'description', 'amount', 'currency', 'state'].every((key) =>
|
||||
header.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
function importId(parts: (string | number)[], seen: Map<string, number>) {
|
||||
const base = parts.join('\u001f');
|
||||
// Identical rows in one file (two coffees, same day) stay distinct but stable.
|
||||
const occurrence = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, occurrence);
|
||||
return (
|
||||
'csv:' + createHash('sha256').update(`${base}\u001f${occurrence}`).digest('hex').slice(0, 32)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses either the tracker's own export (date, account, description, amount) or a Revolut
|
||||
* statement. Revolut files have no account column, so `defaultAccount` names the tracker
|
||||
* account they belong to; only COMPLETED EUR rows are kept and the fee is deducted.
|
||||
*/
|
||||
export function parseTransactionImport(
|
||||
input: string,
|
||||
defaultAccount?: string
|
||||
): ImportedTransaction[] {
|
||||
const rows = parseCsv(input);
|
||||
if (rows.length < 2 || rows.length > 20_001)
|
||||
throw new Error('The file must have a header and 1–20,000 rows.');
|
||||
const header = headerOf(rows);
|
||||
rows.shift();
|
||||
const seen = new Map<string, number>();
|
||||
if (isRevolutHeader(header)) {
|
||||
const account = defaultAccount?.trim();
|
||||
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
|
||||
const col = (key: string) => header.indexOf(key);
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const state = (row[col('state')] ?? '').trim().toUpperCase();
|
||||
if (state && state !== 'COMPLETED') continue;
|
||||
const currency = (row[col('currency')] ?? 'EUR').trim().toUpperCase();
|
||||
if (currency && currency !== 'EUR') continue;
|
||||
const started = (row[col('started date')] ?? '').trim();
|
||||
const completed = col('completed date') >= 0 ? (row[col('completed date')] ?? '').trim() : '';
|
||||
const date = csvDate((completed || started).slice(0, 10));
|
||||
const amount = importCents(row[col('amount')]);
|
||||
const fee = col('fee') >= 0 ? (importCents(row[col('fee')] || '0') ?? undefined) : 0;
|
||||
const description = (row[col('description')] ?? '').trim().slice(0, 500) || 'Revolut';
|
||||
if (!date || amount == null || fee == null)
|
||||
throw new Error(`Invalid data in statement row ${index + 1}. No rows were imported.`);
|
||||
const amountCents = amount - fee;
|
||||
if (!amountCents) continue;
|
||||
parsed.push({
|
||||
date,
|
||||
account,
|
||||
description,
|
||||
amountCents,
|
||||
externalId: importId(
|
||||
['revolut', account.toLowerCase(), started, amount, fee, description],
|
||||
seen
|
||||
)
|
||||
});
|
||||
}
|
||||
if (!parsed.length) throw new Error('No completed EUR transactions found in this statement.');
|
||||
return parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
|
||||
if (columns.some((index) => index < 0))
|
||||
throw new Error('Headers must include date, account, description and amount.');
|
||||
const accountColumn = columns[1];
|
||||
if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount))
|
||||
throw new Error(
|
||||
'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.'
|
||||
);
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.length !== header.length)
|
||||
@@ -152,7 +225,8 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
`Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.`
|
||||
);
|
||||
const date = csvDate(row[columns[0]] ?? '');
|
||||
const account = (row[columns[1]] ?? '').trim();
|
||||
const account =
|
||||
(accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || '';
|
||||
const description = (row[columns[2]] ?? '').trim();
|
||||
const amountCents = importCents(row[columns[3]]);
|
||||
if (
|
||||
@@ -164,7 +238,64 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
amountCents == null
|
||||
)
|
||||
throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`);
|
||||
parsed.push({ date, account, description, amountCents });
|
||||
parsed.push({
|
||||
date,
|
||||
account,
|
||||
description,
|
||||
amountCents,
|
||||
externalId: importId([date, account.toLowerCase(), description, amountCents], seen)
|
||||
});
|
||||
}
|
||||
return parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export type ExistingImportRow = {
|
||||
id: number;
|
||||
accountId: number;
|
||||
date: string;
|
||||
amountCents: number;
|
||||
externalId: string | null;
|
||||
};
|
||||
|
||||
function daysApart(a: string, b: string) {
|
||||
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits imported rows into new ones and duplicates: same import id already stored, or an
|
||||
* existing row on the same account with the same amount within ±1 day (each existing row
|
||||
* can only absorb one imported row).
|
||||
*/
|
||||
export function planImport<
|
||||
T extends { accountId: number; date: string; amountCents: number; externalId: string }
|
||||
>(rows: T[], existing: ExistingImportRow[]) {
|
||||
const ids = new Set(
|
||||
existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : []))
|
||||
);
|
||||
const byKey = new Map<string, ExistingImportRow[]>();
|
||||
for (const row of existing) {
|
||||
const key = `${row.accountId}:${row.amountCents}`;
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), row]);
|
||||
}
|
||||
const claimed = new Set<number>();
|
||||
const insert: T[] = [];
|
||||
let duplicates = 0;
|
||||
for (const row of rows) {
|
||||
const idKey = `${row.accountId}:${row.externalId}`;
|
||||
if (ids.has(idKey)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
|
||||
.filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
|
||||
.sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
|
||||
if (match) {
|
||||
claimed.add(match.id);
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
ids.add(idKey);
|
||||
insert.push(row);
|
||||
}
|
||||
return { insert, duplicates };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOwner, isOwnerOnlyPath } from './owner';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
|
||||
|
||||
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
|
||||
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
|
||||
@@ -17,4 +17,22 @@ describe('owner gate', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
||||
});
|
||||
|
||||
it('gates the book reader, files and APIs but not the catalogue', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/books/files/12')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/read')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/progress')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/notes/3')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
|
||||
});
|
||||
it('only lets the owner write in owner areas', () => {
|
||||
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/books')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||
expect(isOwnerWritePath('/lists/books')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,32 @@ export function requireOwner(event: OwnerEvent) {
|
||||
}
|
||||
|
||||
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||
export const ownerOnlyPrefixes = ['/trackers/health'] as const;
|
||||
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
|
||||
|
||||
/**
|
||||
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
|
||||
* APIs. /trackers/books and /trackers/books/<id> themselves are public catalogue pages.
|
||||
*/
|
||||
export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes)(\/|$)/] as const;
|
||||
|
||||
/**
|
||||
* Areas where only the owner may write: any non-GET request from anyone else is refused
|
||||
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
||||
*/
|
||||
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
|
||||
|
||||
export function isOwnerOnlyPath(pathname: string): boolean {
|
||||
const path = pathname.toLowerCase().replace(/\/+$/, '');
|
||||
return ownerOnlyPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
const path = normalise(pathname);
|
||||
return (
|
||||
ownerOnlyPrefixes.some((prefix) => under(path, prefix)) ||
|
||||
ownerOnlyPatterns.some((pattern) => pattern.test(path))
|
||||
);
|
||||
}
|
||||
|
||||
export function isOwnerWritePath(pathname: string): boolean {
|
||||
const path = normalise(pathname);
|
||||
return ownerWritePrefixes.some((prefix) => under(path, prefix));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
||||
'/': 'Home',
|
||||
'/projects': 'Projects',
|
||||
'/trackers': 'Trackers',
|
||||
'/trackers/books': 'Books',
|
||||
'/blog': 'Blog',
|
||||
'/lists': 'Lists',
|
||||
'/login': 'Sign in',
|
||||
@@ -30,6 +31,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
||||
'/projects/animaldex/admin': 'Animaldex admin',
|
||||
'/projects/derby': 'Derby Lens',
|
||||
'/projects/cs-prices': 'CS Prices',
|
||||
'/projects/energy': 'Energy',
|
||||
'/projects/game-tracker': 'Game Tracker',
|
||||
'/projects/language-learner': 'Language Learner',
|
||||
'/projects/money-tracker': 'Money Tracker',
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
let { data, children } = $props();
|
||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
||||
// The book reader is a full-screen view; the site header and footer would sit on top of it.
|
||||
const immersive = $derived(/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname));
|
||||
let menuOpen = $state(false);
|
||||
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
||||
let openMenu = $state<string | null>(null);
|
||||
@@ -184,215 +186,224 @@
|
||||
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
|
||||
Dialogs/sheets/popovers are portalled to <body> and still render above both.
|
||||
-->
|
||||
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="flex shrink-0 items-center gap-3 font-semibold tracking-tight"
|
||||
aria-label="Stan Runge home"
|
||||
>
|
||||
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
||||
><img
|
||||
src={asset('favicon-32x32.png')}
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
class="h-full w-full scale-[1.7]"
|
||||
/></span
|
||||
{#if !immersive}
|
||||
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="flex shrink-0 items-center gap-3 font-semibold tracking-tight"
|
||||
aria-label="Stan Runge home"
|
||||
>
|
||||
<span>stan runge</span>
|
||||
</a>
|
||||
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
{#if menus[item.href]}
|
||||
{@const menu = menus[item.href]}
|
||||
<div
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label={`${menu.label} menu`}
|
||||
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
||||
onpointerleave={handleMenuPointerLeave}
|
||||
>
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="cursor-pointer gap-1"
|
||||
aria-label={`Browse ${menu.label}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenu === item.href}
|
||||
onclick={() => (openMenu = null)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') openMenu = null;
|
||||
}}>{item.label}<ChevronDown size={15} /></Button
|
||||
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
||||
><img
|
||||
src={asset('favicon-32x32.png')}
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
class="h-full w-full scale-[1.7]"
|
||||
/></span
|
||||
>
|
||||
<span>stan runge</span>
|
||||
</a>
|
||||
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
{#if menus[item.href]}
|
||||
{@const menu = menus[item.href]}
|
||||
<div
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label={`${menu.label} menu`}
|
||||
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
||||
onpointerleave={handleMenuPointerLeave}
|
||||
>
|
||||
{#if openMenu === item.href}
|
||||
<div
|
||||
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
aria-label={menu.label}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="cursor-pointer gap-1"
|
||||
aria-label={`Browse ${menu.label}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenu === item.href}
|
||||
onclick={() => (openMenu = null)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') openMenu = null;
|
||||
}}>{item.label}<ChevronDown size={15} /></Button
|
||||
>
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
||||
{#if openMenu === item.href}
|
||||
<div
|
||||
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
aria-label={menu.label}
|
||||
>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{#each menu.items as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
rel={project.external ? 'external' : undefined}
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
href={resolve(item.href)}
|
||||
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
||||
>
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{#each menu.items as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
rel={project.external ? 'external' : undefined}
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>
|
||||
<ProjectIcon name={project.name} class="size-4" />
|
||||
</span>
|
||||
<span class="min-w-0"
|
||||
><span class="block font-medium">{project.name}</span><span
|
||||
class="block truncate text-xs text-muted-foreground"
|
||||
>{project.subtitle}</span
|
||||
></span
|
||||
>
|
||||
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm">{item.label}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}
|
||||
title={`Theme: ${theme}`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button href={resolve('account')} variant="outline" size="sm" class="max-w-36 truncate"
|
||||
>{data.user.name}</Button
|
||||
>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(
|
||||
`login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
|
||||
)}
|
||||
variant="outline"
|
||||
size="sm">sign in</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<Sheet.Root bind:open={menuOpen}>
|
||||
<Sheet.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' }) + ' ml-auto md:hidden'}
|
||||
aria-label="Open navigation"><Menu size={20} /></Sheet.Trigger
|
||||
>
|
||||
<Sheet.Content side="right" class="overflow-y-auto p-6">
|
||||
<Sheet.Header
|
||||
><Sheet.Title>stan runge</Sheet.Title><Sheet.Description>Navigation</Sheet.Description
|
||||
></Sheet.Header
|
||||
>
|
||||
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
>
|
||||
<ProjectIcon name={project.name} class="size-4" />
|
||||
</span>
|
||||
<span class="min-w-0"
|
||||
><span class="block font-medium">{project.name}</span><span
|
||||
class="block truncate text-xs text-muted-foreground"
|
||||
>{project.subtitle}</span
|
||||
></span
|
||||
>
|
||||
{#if project.external}<ExternalLink
|
||||
size={14}
|
||||
class="ml-auto shrink-0"
|
||||
/>{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{item.label}</Button
|
||||
>
|
||||
{#if menus[item.href]}
|
||||
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
||||
{#each menus[item.href].items as project (project.name)}
|
||||
<Button
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{project.name}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<Sheet.Footer
|
||||
class="mt-auto flex-row items-center justify-between border-t border-border pt-4"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button href={resolve('account')} variant="outline" onclick={() => (menuOpen = false)}
|
||||
>account</Button
|
||||
>
|
||||
<form method="POST" action="/logout">
|
||||
<Button type="submit" variant="outline">sign out</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Button href={resolve('login')} variant="outline" onclick={() => (menuOpen = false)}
|
||||
>sign in</Button
|
||||
>
|
||||
<Button
|
||||
href={resolve('register')}
|
||||
variant="outline"
|
||||
onclick={() => (menuOpen = false)}>register</Button
|
||||
size="sm">{item.label}</Button
|
||||
>
|
||||
{/if}
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
|
||||
<footer
|
||||
class="mt-16 border-t border-[var(--site-border)]"
|
||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
|
||||
>
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
|
||||
>{data.version.slice(0, 7)}</span
|
||||
></span
|
||||
>
|
||||
<a
|
||||
href="https://git.stanrunge.dev/stan/website"
|
||||
rel="external"
|
||||
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
|
||||
>source <ExternalLink size={14} aria-hidden="true" /></a
|
||||
>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
|
||||
{#each socialLinks as { icon: Icon, label, href } (label)}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<Button
|
||||
{href}
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
rel="external"
|
||||
aria-label={label}
|
||||
title={label}><Icon class="size-6" /></Button
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}
|
||||
title={`Theme: ${theme}`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{/each}
|
||||
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
{#if data.user}
|
||||
<Button href={resolve('account')} variant="outline" size="sm" class="max-w-36 truncate"
|
||||
>{data.user.name}</Button
|
||||
>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(
|
||||
`login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
|
||||
)}
|
||||
variant="outline"
|
||||
size="sm">sign in</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<Sheet.Root bind:open={menuOpen}>
|
||||
<Sheet.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' }) + ' ml-auto md:hidden'}
|
||||
aria-label="Open navigation"><Menu size={20} /></Sheet.Trigger
|
||||
>
|
||||
<Sheet.Content side="right" class="overflow-y-auto p-6">
|
||||
<Sheet.Header
|
||||
><Sheet.Title>stan runge</Sheet.Title><Sheet.Description>Navigation</Sheet.Description
|
||||
></Sheet.Header
|
||||
>
|
||||
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{item.label}</Button
|
||||
>
|
||||
{#if menus[item.href]}
|
||||
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
||||
{#each menus[item.href].items as project (project.name)}
|
||||
<Button
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{project.name}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<Sheet.Footer
|
||||
class="mt-auto flex-row items-center justify-between border-t border-border pt-4"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button
|
||||
href={resolve('account')}
|
||||
variant="outline"
|
||||
onclick={() => (menuOpen = false)}>account</Button
|
||||
>
|
||||
<form method="POST" action="/logout">
|
||||
<Button type="submit" variant="outline">sign out</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Button href={resolve('login')} variant="outline" onclick={() => (menuOpen = false)}
|
||||
>sign in</Button
|
||||
>
|
||||
<Button
|
||||
href={resolve('register')}
|
||||
variant="outline"
|
||||
onclick={() => (menuOpen = false)}>register</Button
|
||||
>
|
||||
{/if}
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
|
||||
{#if !immersive}
|
||||
<footer
|
||||
class="mt-16 border-t border-[var(--site-border)]"
|
||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
|
||||
>
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
|
||||
>{data.version.slice(0, 7)}</span
|
||||
></span
|
||||
>
|
||||
<a
|
||||
href="https://git.stanrunge.dev/stan/website"
|
||||
rel="external"
|
||||
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
|
||||
>source <ExternalLink size={14} aria-hidden="true" /></a
|
||||
>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
|
||||
{#each socialLinks as { icon: Icon, label, href } (label)}
|
||||
<Button
|
||||
{href}
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
rel="external"
|
||||
aria-label={label}
|
||||
title={label}><Icon class="size-6" /></Button
|
||||
>
|
||||
{/each}
|
||||
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
39
src/routes/privacy/+page.svelte
Normal file
39
src/routes/privacy/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<svelte:head><title>Privacy · stanrunge.dev</title></svelte:head>
|
||||
|
||||
<article class="mx-auto max-w-2xl space-y-4 px-6 py-10 leading-relaxed">
|
||||
<h1 class="text-2xl font-bold">Privacy</h1>
|
||||
<p class="text-sm opacity-70">Last updated 24 October 2026</p>
|
||||
<p>
|
||||
stanrunge.dev is a personal website run by Stan Runge in the Netherlands. This page explains
|
||||
what personal data it handles and why.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Accounts</h2>
|
||||
<p>
|
||||
If you register, the site stores your name, email address, and the sign-in methods you link
|
||||
(such as Google or osu!), so you can log in. If you use a password, only a secure hash of it is
|
||||
stored. Session cookies keep you signed in; there are no advertising or third-party tracking
|
||||
cookies.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Bank data (money tracker)</h2>
|
||||
<p>
|
||||
The money tracker is a private tool used only by the site owner. With the account holders'
|
||||
explicit consent, it reads account details, balances and booked transactions from Revolut
|
||||
through Enable Banking Oy, a licensed account information service provider. This data is used
|
||||
only to show the account holders their own finances. It is stored in a database on a server
|
||||
controlled by the site owner, is never sold or shared, and consent can be withdrawn at any time
|
||||
in the bank app or by asking the site owner, after which no further data is fetched. Access is
|
||||
limited to read-only account information; no payments can be made.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Hosting and logs</h2>
|
||||
<p>
|
||||
The site is self-hosted. Web server logs (IP address, time, requested page) are kept briefly for
|
||||
security and troubleshooting. Backups are encrypted.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Your rights</h2>
|
||||
<p>
|
||||
Under the GDPR you can ask to see, correct, export or delete your data, or object to its use.
|
||||
Contact the site owner to do so. You may also complain to the Autoriteit Persoonsgegevens.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Contact</h2>
|
||||
<p>Stan Runge, via the contact details on the home page of stanrunge.dev.</p>
|
||||
</article>
|
||||
4
src/routes/projects/energy/+page.server.ts
Normal file
4
src/routes/projects/energy/+page.server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { loadEnergy } from '#lib/server/energy.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => ({ energy: await loadEnergy() });
|
||||
896
src/routes/projects/energy/+page.svelte
Normal file
896
src/routes/projects/energy/+page.svelte
Normal file
@@ -0,0 +1,896 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
|
||||
import {
|
||||
SOURCES,
|
||||
fillGaps,
|
||||
gaps,
|
||||
latestSnapshot,
|
||||
seasonalTime,
|
||||
toBuckets,
|
||||
type Bucket
|
||||
} from '#lib/energy/data.js';
|
||||
import { relativeTime } from '#lib/relative-time.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let energy = $derived(data.energy);
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const zone = 'Europe/Amsterdam';
|
||||
const formatHour = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const formatTick = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const formatDay = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const formatDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: zone });
|
||||
const formatMonth = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
const formatYear = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' });
|
||||
const formatSeason = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'UTC',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const whole = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 0 });
|
||||
const one = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
|
||||
const percent = new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 0 });
|
||||
const escapeHtml = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!
|
||||
);
|
||||
|
||||
let hourly = $derived(toBuckets(energy.hourly));
|
||||
let daily = $derived(toBuckets(energy.daily));
|
||||
let monthly = $derived(toBuckets(energy.monthly));
|
||||
let yearly = $derived(toBuckets(energy.yearly));
|
||||
let forecast = $derived(toBuckets(energy.forecast));
|
||||
let now = $derived(latestSnapshot(hourly));
|
||||
let incompleteHours = $derived(hourly.filter((bucket) => bucket.incomplete));
|
||||
let hourGaps = $derived(gaps(hourly.map((bucket) => bucket.t)));
|
||||
let latestGas = $derived(energy.gas.at(-1));
|
||||
let latestPeriod = $derived(energy.coverage.lastHour ?? Date.now());
|
||||
|
||||
type Range = 'day' | 'week' | 'month' | 'year' | 'years';
|
||||
let range = $state<Range>('week');
|
||||
const rangeLabels: Record<Range, string> = {
|
||||
day: 'day',
|
||||
week: 'week',
|
||||
month: 'month',
|
||||
year: '12 months',
|
||||
years: 'all years'
|
||||
};
|
||||
|
||||
function axis(theme: ChartTheme) {
|
||||
return {
|
||||
axisLine: { lineStyle: { color: theme.border } },
|
||||
axisLabel: { color: theme.muted },
|
||||
splitLine: { lineStyle: { color: theme.border } }
|
||||
};
|
||||
}
|
||||
function tooltip(theme: ChartTheme, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: theme.border,
|
||||
confine: true,
|
||||
textStyle: { color: theme.text },
|
||||
...extra
|
||||
};
|
||||
}
|
||||
function legend(theme: ChartTheme) {
|
||||
return { top: 0, type: 'scroll', textStyle: { color: theme.muted }, itemWidth: 14 };
|
||||
}
|
||||
|
||||
// YYYY-MM-DD of an instant in Amsterdam, used to compare calendar periods.
|
||||
const localDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone });
|
||||
const monthKey = (t: number) => localDay.format(t).slice(0, 7);
|
||||
const yearKey = (t: number) => localDay.format(t).slice(0, 4);
|
||||
/** Is the bucket's calendar period still running (current month/year)? */
|
||||
const isPartial = (bucket: Partial<Bucket>, key: (t: number) => string) =>
|
||||
key(bucket.t!) === key(latestPeriod);
|
||||
|
||||
let trend = $derived.by(() => {
|
||||
if (range === 'day' || range === 'week') {
|
||||
const from = latestPeriod - (range === 'day' ? 24 : 24 * 7) * HOUR;
|
||||
const points = fillGaps(
|
||||
hourly.filter((bucket) => bucket.t > from),
|
||||
HOUR
|
||||
).map((bucket) => ('missing' in bucket || bucket.incomplete ? { t: bucket.t } : bucket));
|
||||
return { kind: 'hour' as const, unit: 'MWh', scale: 1, points: points as Partial<Bucket>[] };
|
||||
}
|
||||
if (range === 'month')
|
||||
return { kind: 'day' as const, unit: 'GWh', scale: 1000, points: daily as Partial<Bucket>[] };
|
||||
if (range === 'year')
|
||||
return {
|
||||
kind: 'month' as const,
|
||||
unit: 'GWh',
|
||||
scale: 1,
|
||||
points: monthly as Partial<Bucket>[]
|
||||
};
|
||||
return {
|
||||
kind: 'year' as const,
|
||||
unit: 'TWh',
|
||||
scale: 1000,
|
||||
// Before 2021 NED only publishes wind and solar per year, so those totals would mislead.
|
||||
points: yearly.filter((bucket) => bucket.mix !== null) as Partial<Bucket>[]
|
||||
};
|
||||
});
|
||||
|
||||
function trendLabel(t: number) {
|
||||
const kind = trend.kind;
|
||||
if (kind === 'day') return formatDay.format(t);
|
||||
if (kind === 'month') return formatMonth.format(t);
|
||||
return formatYear.format(t);
|
||||
}
|
||||
function partialNote(bucket: Partial<Bucket>) {
|
||||
if (trend.kind === 'day' && bucket.hours !== undefined && bucket.expected !== undefined)
|
||||
if (bucket.hours < bucket.expected) return `${bucket.hours}/${bucket.expected} h`;
|
||||
if (trend.kind === 'month' && isPartial(bucket, monthKey)) return 'so far';
|
||||
if (trend.kind === 'year' && isPartial(bucket, yearKey)) return 'so far';
|
||||
return '';
|
||||
}
|
||||
|
||||
let trendOption = $derived((theme: ChartTheme) => {
|
||||
const { kind, unit, scale, points } = trend;
|
||||
const hourlyAxis = kind === 'hour';
|
||||
const value = (raw: number | null | undefined) =>
|
||||
raw === null || raw === undefined ? null : raw / scale;
|
||||
const x = (bucket: Partial<Bucket>) => (hourlyAxis ? bucket.t! : trendLabel(bucket.t!));
|
||||
const categories = hourlyAxis
|
||||
? undefined
|
||||
: points.map((bucket) => {
|
||||
const note = partialNote(bucket);
|
||||
return note ? `${trendLabel(bucket.t!)}*` : trendLabel(bucket.t!);
|
||||
});
|
||||
const series: Record<string, unknown>[] = SOURCES.map((source) => ({
|
||||
name: source.label,
|
||||
type: hourlyAxis ? 'line' : 'bar',
|
||||
stack: 'mix',
|
||||
areaStyle: hourlyAxis ? { opacity: 0.85 } : undefined,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 0 },
|
||||
itemStyle: { color: source.color },
|
||||
emphasis: { focus: 'series' },
|
||||
data: points.map((bucket, index) =>
|
||||
hourlyAxis
|
||||
? [x(bucket), value(bucket.sources?.[source.type]?.value)]
|
||||
: [categories![index], value(bucket.sources?.[source.type]?.value)]
|
||||
)
|
||||
}));
|
||||
series.push({
|
||||
name: 'Consumption',
|
||||
type: 'line',
|
||||
showSymbol: !hourlyAxis,
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 2, type: 'dashed', color: theme.text },
|
||||
itemStyle: { color: theme.text },
|
||||
data: points.map((bucket, index) => [
|
||||
hourlyAxis ? x(bucket) : categories![index],
|
||||
value(bucket.load)
|
||||
])
|
||||
});
|
||||
const showCo2 = kind !== 'year';
|
||||
if (showCo2)
|
||||
series.push({
|
||||
name: 'CO₂ intensity',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1.5, color: '#ef4444' },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
data: points.map((bucket, index) => [
|
||||
hourlyAxis ? x(bucket) : categories![index],
|
||||
bucket.ef === null || bucket.ef === undefined ? null : Math.round(bucket.ef * 1000)
|
||||
])
|
||||
});
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
legend: legend(theme),
|
||||
grid: { left: 64, right: showCo2 ? 64 : 20, top: 48, bottom: hourlyAxis ? 64 : 36 },
|
||||
xAxis: hourlyAxis
|
||||
? {
|
||||
type: 'time',
|
||||
...axis(theme),
|
||||
splitLine: { show: false },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
hideOverlap: true,
|
||||
formatter: (t: number) => formatTick.format(t)
|
||||
}
|
||||
}
|
||||
: { type: 'category', data: categories, ...axis(theme), splitLine: { show: false } },
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: unit,
|
||||
nameTextStyle: { color: theme.muted },
|
||||
...axis(theme),
|
||||
axisLabel: { color: theme.muted, formatter: (v: number) => whole.format(v) }
|
||||
},
|
||||
...(showCo2
|
||||
? [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'g CO₂/kWh',
|
||||
nameTextStyle: { color: theme.muted },
|
||||
...axis(theme),
|
||||
splitLine: { show: false },
|
||||
min: 0
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
dataZoom: hourlyAxis
|
||||
? [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 10,
|
||||
height: 18,
|
||||
borderColor: theme.border,
|
||||
textStyle: { color: theme.muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
]
|
||||
: [],
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'axis',
|
||||
formatter: (
|
||||
params: { seriesName: string; marker: string; value: [number | string, number | null] }[]
|
||||
) => {
|
||||
if (!params.length) return '';
|
||||
const head = params[0].value[0];
|
||||
const index = hourlyAxis ? -1 : categories!.indexOf(String(head));
|
||||
const bucket = hourlyAxis ? points.find((point) => point.t === head) : points[index];
|
||||
const title = hourlyAxis ? formatHour.format(Number(head)) : trendLabel(bucket!.t!);
|
||||
const note = bucket ? partialNote(bucket) : '';
|
||||
const lines = params
|
||||
.filter((param) => param.value[1] !== null && param.value[1] !== undefined)
|
||||
.map((param) => {
|
||||
const suffix = param.seriesName === 'CO₂ intensity' ? ' g/kWh' : ` ${unit}`;
|
||||
return `${param.marker}${escapeHtml(param.seriesName)}: <b>${one.format(param.value[1]!)}${suffix}</b>`;
|
||||
});
|
||||
if (!lines.length) return `${title}<br>no data for this hour`;
|
||||
const total = bucket?.sum
|
||||
? `<br>Generation: <b>${one.format(bucket.sum / scale)} ${unit}</b>`
|
||||
: '';
|
||||
return `${title}${note ? ` (${note})` : ''}${total}<br>${lines.join('<br>')}`;
|
||||
}
|
||||
}),
|
||||
series
|
||||
};
|
||||
});
|
||||
|
||||
let mixOption = $derived((theme: ChartTheme) => ({
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'item',
|
||||
formatter: (param: { name: string; value: number; percent: number }) =>
|
||||
`${escapeHtml(param.name)}<br><b>${whole.format(param.value)} MWh</b> · ${one.format(param.percent)}%`
|
||||
}),
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['48%', '78%'],
|
||||
itemStyle: { borderColor: theme.surface, borderWidth: 2 },
|
||||
label: { color: theme.muted, formatter: '{b}\n{d}%' },
|
||||
data: (now?.sources ?? [])
|
||||
.filter((source) => source.value > 0)
|
||||
.map((source) => ({
|
||||
name: source.label,
|
||||
value: Math.round(source.value),
|
||||
itemStyle: { color: source.color }
|
||||
}))
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
let utilizationOption = $derived((theme: ChartTheme) => {
|
||||
const sources = [...(now?.sources ?? [])].reverse();
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 96, right: 48, top: 8, bottom: 28 },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
...axis(theme),
|
||||
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: sources.map((source) => source.label),
|
||||
...axis(theme),
|
||||
splitLine: { show: false }
|
||||
},
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'item',
|
||||
formatter: (param: { name: string; value: number }) =>
|
||||
`${escapeHtml(param.name)}<br>running at <b>${one.format(param.value)}%</b> of capacity`
|
||||
}),
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barMaxWidth: 16,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: theme.muted,
|
||||
formatter: (param: { value: number }) => `${whole.format(param.value)}%`
|
||||
},
|
||||
data: sources.map((source) => ({
|
||||
value: source.utilization === null ? 0 : Math.round(source.utilization * 1000) / 10,
|
||||
itemStyle: { color: source.color }
|
||||
}))
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
let forecastOption = $derived((theme: ChartTheme) => {
|
||||
const weather = SOURCES.filter((source) =>
|
||||
['WindOffshoreC', 'Wind', 'Solar'].includes(source.type)
|
||||
);
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
legend: legend(theme),
|
||||
grid: { left: 64, right: 20, top: 48, bottom: 32 },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
...axis(theme),
|
||||
splitLine: { show: false },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
hideOverlap: true,
|
||||
formatter: (t: number) => formatDay.format(t)
|
||||
}
|
||||
},
|
||||
yAxis: { type: 'value', name: 'MWh', nameTextStyle: { color: theme.muted }, ...axis(theme) },
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (v: number | null) => (v === null ? '–' : `${whole.format(v)} MWh`)
|
||||
}),
|
||||
series: [
|
||||
...weather.map((source) => ({
|
||||
name: source.label,
|
||||
type: 'line',
|
||||
stack: 'weather',
|
||||
areaStyle: { opacity: 0.85 },
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 0 },
|
||||
itemStyle: { color: source.color },
|
||||
data: forecast.map((bucket) => [bucket.t, bucket.sources[source.type]?.value ?? null])
|
||||
})),
|
||||
{
|
||||
name: 'Total generation',
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1.5, color: theme.muted },
|
||||
itemStyle: { color: theme.muted },
|
||||
data: forecast.map((bucket) => [bucket.t, bucket.mix])
|
||||
},
|
||||
{
|
||||
name: 'Consumption',
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2, type: 'dashed', color: theme.text },
|
||||
itemStyle: { color: theme.text },
|
||||
data: forecast.map((bucket) => [bucket.t, bucket.load])
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
let seasonOption = $derived((theme: ChartTheme) => {
|
||||
const years: Record<string, [number, number][]> = {};
|
||||
for (const row of energy.gas)
|
||||
(years[row.day.slice(0, 4)] ??= []).push([seasonalTime(row.day), row.fill]);
|
||||
const list = Object.entries(years).filter(([, points]) => points.length > 30);
|
||||
const current = list.at(-1)?.[0];
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
legend: legend(theme),
|
||||
grid: { left: 48, right: 20, top: 48, bottom: 32 },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: Date.UTC(2000, 0, 1),
|
||||
max: Date.UTC(2000, 11, 31),
|
||||
...axis(theme),
|
||||
splitLine: { show: false },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
formatter: (t: number) => formatSeason.format(t).split(' ')[1]
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
...axis(theme),
|
||||
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||
},
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
label: { formatter: ({ value }: { value: number }) => formatSeason.format(value) }
|
||||
},
|
||||
valueFormatter: (v: number) => `${one.format(v)}%`
|
||||
}),
|
||||
series: list.map(([year, points], index) => ({
|
||||
name: year,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
data: points,
|
||||
lineStyle: { width: year === current ? 3 : 1.3, opacity: year === current ? 1 : 0.7 },
|
||||
itemStyle: {
|
||||
color:
|
||||
year === current
|
||||
? '#f97316'
|
||||
: `hsl(${200 + index * 22} 55% ${theme.dark ? '68%' : '42%'})`
|
||||
},
|
||||
z: year === current ? 3 : 2
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
let gasPowerOption = $derived((theme: ChartTheme) => {
|
||||
const months = monthly.filter((bucket) => bucket.sum > 0);
|
||||
// Fill level on the last reported day of each month.
|
||||
const fillAt = (t: number) =>
|
||||
energy.gas.findLast((row) => row.day.startsWith(monthKey(t)))?.fill ?? null;
|
||||
const labels = months.map((bucket) =>
|
||||
isPartial(bucket, monthKey)
|
||||
? `${formatMonth.format(bucket.t)}*`
|
||||
: formatMonth.format(bucket.t)
|
||||
);
|
||||
const share = (bucket: Bucket, type: 'FossilGasPower' | 'WKK Total') =>
|
||||
Math.round(((bucket.sources[type]?.value ?? 0) / bucket.sum) * 1000) / 10;
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
legend: legend(theme),
|
||||
grid: { left: 48, right: 20, top: 48, bottom: 32 },
|
||||
xAxis: { type: 'category', data: labels, ...axis(theme), splitLine: { show: false } },
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
...axis(theme),
|
||||
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||
},
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (v: number | null) => (v === null ? '–' : `${one.format(v)}%`)
|
||||
}),
|
||||
series: [
|
||||
{
|
||||
name: 'Fossil gas power (share of generation)',
|
||||
type: 'bar',
|
||||
stack: 'gas',
|
||||
itemStyle: { color: '#f97316' },
|
||||
data: months.map((bucket) => share(bucket, 'FossilGasPower'))
|
||||
},
|
||||
{
|
||||
name: 'CHP (WKK, mostly gas)',
|
||||
type: 'bar',
|
||||
stack: 'gas',
|
||||
itemStyle: { color: '#fdba74' },
|
||||
data: months.map((bucket) => share(bucket, 'WKK Total'))
|
||||
},
|
||||
{
|
||||
name: 'Gas storage fill (month end)',
|
||||
type: 'line',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2.5, color: '#0ea5e9' },
|
||||
itemStyle: { color: '#0ea5e9' },
|
||||
data: months.map((bucket) => fillAt(bucket.t))
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
let sitesOption = $derived((theme: ChartTheme) => {
|
||||
const sites = [...energy.gasSites].reverse();
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 96, right: 96, top: 8, bottom: 28 },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
...axis(theme),
|
||||
axisLabel: { color: theme.muted, formatter: '{value}%' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: sites.map((site) => site.site),
|
||||
...axis(theme),
|
||||
splitLine: { show: false }
|
||||
},
|
||||
tooltip: tooltip(theme, {
|
||||
trigger: 'item',
|
||||
formatter: (param: { dataIndex: number }) => {
|
||||
const site = sites[param.dataIndex];
|
||||
return `${escapeHtml(site.site)} · ${site.day}<br><b>${one.format(site.fill)}%</b> full · ${one.format(site.twh)} TWh`;
|
||||
}
|
||||
}),
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { color: '#0ea5e9' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: theme.muted,
|
||||
formatter: (param: { dataIndex: number }) =>
|
||||
`${whole.format(sites[param.dataIndex].fill)}% · ${one.format(sites[param.dataIndex].twh)} TWh`
|
||||
},
|
||||
data: sites.map((site) => site.fill)
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
let missingHours = $derived(hourGaps.reduce((sum, gap) => sum + gap.hours, 0));
|
||||
let clock = $state(Date.now());
|
||||
$effect(() => {
|
||||
const timer = setInterval(() => (clock = Date.now()), 60_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Energy · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Dutch electricity mix, utilization, trends and gas storage from NED.nl data, updated hourly."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="energy-page">
|
||||
<header class="intro">
|
||||
<div>
|
||||
<p class="eyebrow">grid archive / netherlands</p>
|
||||
<h1>Energy</h1>
|
||||
<p class="lede">
|
||||
Where Dutch electricity comes from hour by hour, how hard each source is running, and how
|
||||
full the gas storages are. Data from NED.nl, collected hourly.
|
||||
</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
{#if now}
|
||||
<div><strong>{percent.format(now.renewableShare)}</strong><span>renewable now</span></div>
|
||||
{#if now.ef !== null}
|
||||
<div><strong>{whole.format(now.ef * 1000)}</strong><span>g CO₂ / kWh</span></div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if latestGas}
|
||||
<div><strong>{one.format(latestGas.fill)}%</strong><span>gas storage full</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !now}
|
||||
<section class="panel empty">No electricity data has been collected yet.</section>
|
||||
{:else}
|
||||
<section class="grid two">
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Current mix</h2>
|
||||
<span class="muted">{formatHour.format(now.t)} · {whole.format(now.total)} MWh</span>
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={mixOption}
|
||||
label="Electricity generation mix for the latest hour"
|
||||
height="320px"
|
||||
/>
|
||||
<dl class="facts">
|
||||
<div>
|
||||
<dt>Low-carbon</dt>
|
||||
<dd>{percent.format(now.lowCarbonShare)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fossil</dt>
|
||||
<dd>{percent.format(now.fossilShare)}</dd>
|
||||
</div>
|
||||
{#if now.load !== null}
|
||||
<div>
|
||||
<dt>{now.load > now.total ? 'Net import' : 'Net export'}</dt>
|
||||
<dd>{whole.format(Math.abs(now.total - now.load))} MWh</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Utilization</h2>
|
||||
<span class="muted">share of installed capacity in use</span>
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={utilizationOption}
|
||||
label="Utilization of each electricity source for the latest hour"
|
||||
height="320px"
|
||||
/>
|
||||
<p class="note">
|
||||
NED's load factor per source. Nuclear and "other" are reported near 100% almost
|
||||
constantly, so treat those as estimates.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Generation and consumption</h2>
|
||||
<Tabs.Root bind:value={range}>
|
||||
<Tabs.List>
|
||||
{#each Object.entries(rangeLabels) as [value, label] (value)}
|
||||
<Tabs.Trigger {value}>{label}</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={trendOption}
|
||||
label={`Electricity generation by source, ${rangeLabels[range]}`}
|
||||
/>
|
||||
<p class="note">
|
||||
Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂
|
||||
intensity. * = incomplete (still running or hours missing).
|
||||
{#if range === 'month' && daily.length < 28}
|
||||
Hourly collection started {formatDate.format(energy.coverage.firstHour ?? now.t)}, so this
|
||||
fills up over the coming weeks.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if forecast.length}
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Next days</h2>
|
||||
<span class="muted">NED forecast for wind, solar, generation and consumption</span>
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={forecastOption}
|
||||
label="Forecast of wind and solar generation and consumption"
|
||||
height="320px"
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<section class="grid two">
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Gas storage by year</h2>
|
||||
{#if latestGas}
|
||||
<span class="muted"
|
||||
>{one.format(latestGas.twh)} TWh on {formatDate.format(new Date(latestGas.day))}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={seasonOption}
|
||||
label="Dutch gas storage fill level per year"
|
||||
height="340px"
|
||||
/>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Gas vs electricity</h2>
|
||||
<span class="muted">monthly</span>
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={gasPowerOption}
|
||||
label="Share of electricity from gas compared with gas storage fill"
|
||||
height="340px"
|
||||
/>
|
||||
<p class="note">
|
||||
NED has no gas consumption series here, so this compares how much electricity is made from
|
||||
gas with how full storage is.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if energy.gasSites.length}
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Storage sites</h2>
|
||||
<span class="muted">latest day per site</span>
|
||||
</div>
|
||||
<EnergyChart
|
||||
option={sitesOption}
|
||||
label="Fill level per Dutch gas storage site"
|
||||
height="240px"
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<footer class="coverage">
|
||||
<p>
|
||||
{#if energy.coverage.firstHour && energy.coverage.lastHour}
|
||||
Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format(
|
||||
energy.coverage.lastHour
|
||||
)} ({whole.format(energy.coverage.hours)} hours{missingHours
|
||||
? `, ${missingHours} missing in the last week`
|
||||
: ''}{incompleteHours.length
|
||||
? `, ${incompleteHours.length} still incomplete and left out`
|
||||
: ''}).
|
||||
{/if}
|
||||
{#if monthly.length}
|
||||
Monthly from {formatMonth.format(monthly[0].t)}, yearly from {formatYear.format(
|
||||
yearly[0]?.t ?? monthly[0].t
|
||||
)}.
|
||||
{/if}
|
||||
{#if energy.gas.length}
|
||||
Gas storage from {formatDate.format(new Date(energy.gas[0].day))}.
|
||||
{/if}
|
||||
</p>
|
||||
<p>
|
||||
Source: <a href="https://ned.nl" rel="external noopener">NED.nl</a>.
|
||||
{#if energy.collector.lastOk}
|
||||
Last successful collection {relativeTime(energy.collector.lastOk, clock)}{energy.collector
|
||||
.failedWeek
|
||||
? ` · ${energy.collector.failedWeek} failed runs this week`
|
||||
: ''}.
|
||||
{/if}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.energy-page {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
.intro {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--site-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(2.8rem, 6vw, 5.2rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.075em;
|
||||
margin: 0.7rem 0 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.lede {
|
||||
color: var(--site-muted);
|
||||
max-width: 40rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stats div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
}
|
||||
.stats strong {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
.stats span,
|
||||
.facts dt {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.69rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.grid > .panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
border-radius: 8px;
|
||||
padding: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.muted,
|
||||
.note,
|
||||
.coverage {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.note {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.facts {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.facts div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.facts dd {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.coverage {
|
||||
margin-top: 2rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.coverage a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.intro {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
.stats div {
|
||||
align-items: start;
|
||||
}
|
||||
.grid.two {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
invalid,
|
||||
optionalText,
|
||||
parseTransactionImport,
|
||||
planImport,
|
||||
positiveId,
|
||||
textField,
|
||||
utilityKind,
|
||||
validDate
|
||||
} from '#lib/server/money-tracker.js';
|
||||
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
@@ -48,7 +49,14 @@ export const load: PageServerLoad = async (event) => {
|
||||
.where(eq(moneyAccounts.userId, userId))
|
||||
.orderBy(asc(moneyAccounts.name)),
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: moneyTransactions.id,
|
||||
accountId: moneyTransactions.accountId,
|
||||
date: moneyTransactions.date,
|
||||
description: moneyTransactions.description,
|
||||
amountCents: moneyTransactions.amountCents,
|
||||
source: moneyTransactions.source
|
||||
})
|
||||
.from(moneyTransactions)
|
||||
.where(eq(moneyTransactions.userId, userId))
|
||||
.orderBy(asc(moneyTransactions.date), asc(moneyTransactions.id)),
|
||||
@@ -452,18 +460,18 @@ export const actions: Actions = {
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
|
||||
return invalid('Choose a CSV or TSV file under 2 MB.');
|
||||
const chosenId = positiveId(form.get('accountId'));
|
||||
const accounts = await db.select().from(moneyAccounts).where(eq(moneyAccounts.userId, user.id));
|
||||
const chosen = chosenId ? accounts.find((account) => account.id === chosenId) : undefined;
|
||||
if (chosenId && !chosen) return invalid('Choose an account.');
|
||||
let parsed: ImportedTransaction[];
|
||||
try {
|
||||
parsed = parseTransactionImport(await file.text());
|
||||
parsed = parseTransactionImport(await file.text(), chosen?.name);
|
||||
} catch (cause) {
|
||||
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(moneyAccounts)
|
||||
.where(eq(moneyAccounts.userId, user.id));
|
||||
const ids = new Map(existing.map((account) => [account.name.toLowerCase(), account.id]));
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const ids = new Map(accounts.map((account) => [account.name.toLowerCase(), account.id]));
|
||||
for (const name of [...new Set(parsed.map((row) => row.account))]) {
|
||||
if (ids.has(name.toLowerCase())) continue;
|
||||
const [account] = await tx
|
||||
@@ -472,18 +480,50 @@ export const actions: Actions = {
|
||||
.returning({ id: moneyAccounts.id });
|
||||
ids.set(name.toLowerCase(), account.id);
|
||||
}
|
||||
for (let i = 0; i < parsed.length; i += 500) {
|
||||
await tx.insert(moneyTransactions).values(
|
||||
parsed.slice(i, i + 500).map((row) => ({
|
||||
userId: user.id,
|
||||
accountId: ids.get(row.account.toLowerCase())!,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
amountCents: row.amountCents
|
||||
}))
|
||||
const rows = parsed.map((row) => ({
|
||||
...row,
|
||||
accountId: ids.get(row.account.toLowerCase())!
|
||||
}));
|
||||
const accountIds = [...new Set(rows.map((row) => row.accountId))];
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: moneyTransactions.id,
|
||||
accountId: moneyTransactions.accountId,
|
||||
date: moneyTransactions.date,
|
||||
amountCents: moneyTransactions.amountCents,
|
||||
externalId: moneyTransactions.externalId
|
||||
})
|
||||
.from(moneyTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(moneyTransactions.userId, user.id),
|
||||
inArray(moneyTransactions.accountId, accountIds),
|
||||
gte(moneyTransactions.date, addDays(rows[0].date, -1))
|
||||
)
|
||||
);
|
||||
const { insert, duplicates } = planImport(rows, existing);
|
||||
for (let i = 0; i < insert.length; i += 500) {
|
||||
await tx
|
||||
.insert(moneyTransactions)
|
||||
.values(
|
||||
insert.slice(i, i + 500).map((row) => ({
|
||||
userId: user.id,
|
||||
accountId: row.accountId,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
amountCents: row.amountCents,
|
||||
externalId: row.externalId,
|
||||
source: 'csv'
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return { imported: insert.length, duplicates };
|
||||
});
|
||||
return { imported: parsed.length };
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
function addDays(date: string, days: number) {
|
||||
return new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import Upload from 'lucide-svelte/icons/upload';
|
||||
import Download from 'lucide-svelte/icons/download';
|
||||
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
||||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||
@@ -68,6 +71,8 @@
|
||||
let pageNumber = $state(1);
|
||||
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
|
||||
let dialogOpen = $state(false);
|
||||
let importAccountId = $state('');
|
||||
let importNotice = $state('');
|
||||
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
||||
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
||||
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||||
@@ -280,6 +285,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
|
||||
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
|
||||
|
||||
<div class="dashboard">
|
||||
<section class="panel span-8">
|
||||
<div class="panel-heading">
|
||||
@@ -359,7 +367,18 @@
|
||||
onclick={() => toggleAccount(account.id)}
|
||||
>{@render swatch(account.id)}<span
|
||||
><strong>{account.name}</strong><small class="capitalize"
|
||||
>{account.kind} · opening {amount(account.openingBalanceCents)}</small
|
||||
>{account.kind} · {#if account.bankBalanceAt}bank {amount(
|
||||
account.bankBalanceCents ?? 0
|
||||
)}, {new Date(account.bankBalanceAt).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}{#if account.bankBalanceCents !== (balances.get(account.id) ?? 0)}<span
|
||||
class="text-destructive"
|
||||
>
|
||||
· differs from tracker</span
|
||||
>{/if}{:else}opening {amount(account.openingBalanceCents)}{/if}</small
|
||||
></span
|
||||
></Button
|
||||
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
|
||||
@@ -380,9 +399,13 @@
|
||||
<h2>Transactions</h2>
|
||||
<p>{filtered.length} of {data.transactions.length} entries</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
|
||||
><Download size={16} /> Export CSV</Button
|
||||
>
|
||||
<div class="header-actions">
|
||||
<Button variant="outline" href={resolve('/projects/money-tracker/transactions')}
|
||||
>All transactions</Button
|
||||
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
|
||||
><Download size={16} /> Export CSV</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label class="sr-only" for="transaction-search">Search transactions</label><Input
|
||||
@@ -430,7 +453,10 @@
|
||||
><tbody
|
||||
>{#each shown as row (row.id)}<tr class:future={row.date > data.today}
|
||||
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
|
||||
>{row.description}</td
|
||||
>{row.description}{#if row.source !== 'manual'}
|
||||
<Badge variant="secondary" class="ml-2"
|
||||
>{row.source === 'enablebanking' ? 'bank' : row.source}</Badge
|
||||
>{/if}</td
|
||||
><td class="nowrap"
|
||||
><span class="account-cell"
|
||||
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
|
||||
@@ -743,10 +769,11 @@
|
||||
{:else if mode === 'import'}
|
||||
<p class="dialog-copy">
|
||||
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
|
||||
columns. Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
|
||||
columns, or a Revolut statement export (only completed EUR rows are imported, fees included).
|
||||
Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
|
||||
Empty rows are skipped, and entries appear in date order. “Start balance” rows import as transactions,
|
||||
so leave those accounts’ opening balances at zero. New account names are created automatically.
|
||||
Importing a file twice adds duplicate transactions.
|
||||
Rows already imported, or already entered with the same amount within a day, are skipped.
|
||||
</p>
|
||||
<form
|
||||
method="POST"
|
||||
@@ -756,7 +783,11 @@
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
showActionError = result.type === 'failure';
|
||||
if (result.type === 'success') dialogOpen = false;
|
||||
if (result.type === 'success') {
|
||||
dialogOpen = false;
|
||||
const done = result.data as { imported?: number; duplicates?: number } | undefined;
|
||||
importNotice = `Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates.`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label class="field"
|
||||
@@ -767,6 +798,16 @@
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
>Account (for files without an account column, like Revolut statements)<NativeSelect
|
||||
name="accountId"
|
||||
bind:value={importAccountId}
|
||||
><option value="">From the file</option
|
||||
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
|
||||
>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<div class="dialog-footer">
|
||||
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
|
||||
>Cancel</Button
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Enable Banking redirects here after Revolut consent. The code is single-use and only
|
||||
// shown to the admin, who pastes it into scripts/bank-link.ts on the server.
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user)
|
||||
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
|
||||
requireAdmin(event);
|
||||
const params = event.url.searchParams;
|
||||
return {
|
||||
code: params.get('code')?.slice(0, 500) ?? '',
|
||||
error: params.get('error')?.slice(0, 200) ?? '',
|
||||
errorDescription: params.get('error_description')?.slice(0, 500) ?? '',
|
||||
fullUrl: event.url.href
|
||||
};
|
||||
};
|
||||
53
src/routes/projects/money-tracker/bank-callback/+page.svelte
Normal file
53
src/routes/projects/money-tracker/bank-callback/+page.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import * as Card from '#lib/components/ui/card/index.js';
|
||||
import * as Alert from '#lib/components/ui/alert/index.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let copied = $state(false);
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(data.fullUrl);
|
||||
copied = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head
|
||||
><title>Bank connection · Money tracker</title><meta
|
||||
name="robots"
|
||||
content="noindex"
|
||||
/></svelte:head
|
||||
>
|
||||
|
||||
<section class="mx-auto max-w-xl space-y-6 p-6">
|
||||
<h1 class="text-2xl font-bold">Bank connection</h1>
|
||||
{#if data.error}
|
||||
<Alert.Root variant="destructive"
|
||||
><Alert.Title>Revolut did not grant access</Alert.Title><Alert.Description
|
||||
>{data.error}{data.errorDescription ? `: ${data.errorDescription}` : ''}. Run
|
||||
<code>scripts/bank-link.ts</code> again to retry.</Alert.Description
|
||||
></Alert.Root
|
||||
>
|
||||
{:else if data.code}
|
||||
<Card.Root
|
||||
><Card.Header
|
||||
><Card.Title>Almost done</Card.Title><Card.Description
|
||||
>Copy this address and paste it into the waiting <code>bank-link</code> prompt. The code is
|
||||
single-use and expires within minutes.</Card.Description
|
||||
></Card.Header
|
||||
><Card.Content class="space-y-3"
|
||||
><code class="block break-all rounded bg-muted p-3 text-xs">{data.fullUrl}</code><Button
|
||||
onclick={copy}>{copied ? 'Copied' : 'Copy address'}</Button
|
||||
></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
{:else}
|
||||
<p>
|
||||
No authorisation code in this link. Start a connection with <code>scripts/bank-link.ts</code>.
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<a class="underline" href={resolve('/projects/money-tracker')}>Back to the money tracker</a>
|
||||
</p>
|
||||
</section>
|
||||
114
src/routes/projects/money-tracker/transactions/+page.server.ts
Normal file
114
src/routes/projects/money-tracker/transactions/+page.server.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { moneyAccounts, moneyTransactions } from '#lib/server/db/schema/index.js';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { positiveId, validDate } from '#lib/server/money-tracker.js';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lt,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
sum,
|
||||
type SQL
|
||||
} from 'drizzle-orm';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const sources = ['manual', 'csv', 'enablebanking', 'linked'] as const;
|
||||
const sorts = ['newest', 'oldest', 'largest', 'smallest'] as const;
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user)
|
||||
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
|
||||
const user = requireAdmin(event);
|
||||
const params = event.url.searchParams;
|
||||
const q = (params.get('q') ?? '').trim().slice(0, 100);
|
||||
const accountIds = params
|
||||
.getAll('account')
|
||||
.map(positiveId)
|
||||
.filter((id): id is number => id !== null);
|
||||
const source = sources.find((item) => item === params.get('source')) ?? '';
|
||||
const type = params.get('type') === 'in' ? 'in' : params.get('type') === 'out' ? 'out' : '';
|
||||
const from = validDate(params.get('from')) ?? '';
|
||||
const to = validDate(params.get('to')) ?? '';
|
||||
const sort = sorts.find((item) => item === params.get('sort')) ?? 'newest';
|
||||
const requestedPage = positiveId(params.get('page')) ?? 1;
|
||||
|
||||
const t = moneyTransactions;
|
||||
const filters: (SQL | undefined)[] = [eq(t.userId, user.id)];
|
||||
if (q) {
|
||||
const pattern = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
|
||||
filters.push(or(ilike(t.description, pattern), ilike(t.counterparty, pattern)));
|
||||
}
|
||||
if (accountIds.length) filters.push(inArray(t.accountId, accountIds));
|
||||
if (source === 'linked') filters.push(and(eq(t.source, 'manual'), isNotNull(t.externalId)));
|
||||
else if (source) filters.push(eq(t.source, source));
|
||||
if (type === 'in') filters.push(gt(t.amountCents, 0));
|
||||
if (type === 'out') filters.push(lt(t.amountCents, 0));
|
||||
if (from) filters.push(gte(t.date, from));
|
||||
if (to) filters.push(lte(t.date, to));
|
||||
const where = and(...filters);
|
||||
|
||||
const [accounts, [totals]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: moneyAccounts.id,
|
||||
name: moneyAccounts.name,
|
||||
color: moneyAccounts.color,
|
||||
scope: moneyAccounts.scope
|
||||
})
|
||||
.from(moneyAccounts)
|
||||
.where(eq(moneyAccounts.userId, user.id))
|
||||
.orderBy(asc(moneyAccounts.name)),
|
||||
db
|
||||
.select({ count: count(), sum: sum(t.amountCents) })
|
||||
.from(t)
|
||||
.where(where)
|
||||
]);
|
||||
const pages = Math.max(1, Math.ceil(totals.count / PAGE_SIZE));
|
||||
const page = Math.min(requestedPage, pages);
|
||||
const order =
|
||||
sort === 'oldest'
|
||||
? [asc(t.date), asc(t.id)]
|
||||
: sort === 'largest'
|
||||
? [desc(t.amountCents), desc(t.date)]
|
||||
: sort === 'smallest'
|
||||
? [asc(t.amountCents), desc(t.date)]
|
||||
: [desc(t.date), desc(t.id)];
|
||||
const rows = await db
|
||||
.select({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
description: t.description,
|
||||
counterparty: t.counterparty,
|
||||
amountCents: t.amountCents,
|
||||
accountId: t.accountId,
|
||||
source: t.source,
|
||||
linked: sql<boolean>`${t.externalId} is not null`
|
||||
})
|
||||
.from(t)
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
rows,
|
||||
total: totals.count,
|
||||
sumCents: Number(totals.sum ?? 0),
|
||||
page,
|
||||
pages,
|
||||
filters: { q, accountIds, source, type, from, to, sort }
|
||||
};
|
||||
};
|
||||
205
src/routes/projects/money-tracker/transactions/+page.svelte
Normal file
205
src/routes/projects/money-tracker/transactions/+page.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { Badge } from '#lib/components/ui/badge/index.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 * as Table from '#lib/components/ui/table/index.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
const sourceLabels: Record<string, string> = {
|
||||
manual: 'Manual',
|
||||
csv: 'CSV',
|
||||
enablebanking: 'Bank'
|
||||
};
|
||||
let names = $derived(new Map(data.accounts.map((account) => [account.id, account.name])));
|
||||
|
||||
function dateLabel(value: string) {
|
||||
return new Date(value + 'T12:00:00Z').toLocaleDateString('en-NL', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
function href(changes: Record<string, string | number | null>) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, String(value));
|
||||
}
|
||||
const query = params.toString();
|
||||
return resolve('/projects/money-tracker/transactions') + (query ? '?' + query : '');
|
||||
}
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
const params = new SvelteURLSearchParams();
|
||||
for (const [key, value] of form)
|
||||
if (typeof value === 'string' && value) params.append(key, value);
|
||||
const query = params.toString();
|
||||
goto(resolve('/projects/money-tracker/transactions') + (query ? '?' + query : ''), {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
function autoSubmit(event: Event) {
|
||||
(event.currentTarget as HTMLElement).closest('form')?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Transactions · Money tracker</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-screen-xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
|
||||
href={resolve('/projects/money-tracker')}><ArrowLeft size={14} /> Money tracker</a
|
||||
>
|
||||
<h1 class="text-2xl font-bold">Transactions</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
{data.total.toLocaleString('en-NL')} matching · net
|
||||
<span class:text-green-600={data.sumCents > 0} class:text-red-600={data.sumCents < 0}
|
||||
>{euro.format(data.sumCents / 100)}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="grid gap-3 sm:grid-cols-2 lg:grid-cols-6" method="GET" onsubmit={submit}>
|
||||
<label class="space-y-1 text-sm lg:col-span-2"
|
||||
><span>Search</span><Input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Description or counterparty"
|
||||
value={data.filters.q}
|
||||
/></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Account</span><NativeSelect
|
||||
name="account"
|
||||
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
|
||||
onchange={autoSubmit}
|
||||
><option value="">All accounts</option>{#each data.accounts as account (account.id)}<option
|
||||
value={String(account.id)}>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Source</span><NativeSelect
|
||||
name="source"
|
||||
value={data.filters.source}
|
||||
onchange={autoSubmit}
|
||||
><option value="">Any source</option><option value="manual">Manual</option><option
|
||||
value="linked">Manual, matched to bank</option
|
||||
><option value="csv">CSV import</option><option value="enablebanking">Bank sync</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Type</span><NativeSelect name="type" value={data.filters.type} onchange={autoSubmit}
|
||||
><option value="">In and out</option><option value="out">Expenses</option><option value="in"
|
||||
>Income</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Sort</span><NativeSelect name="sort" value={data.filters.sort} onchange={autoSubmit}
|
||||
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
|
||||
><option value="largest">Largest income</option><option value="smallest"
|
||||
>Largest expense</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>From</span><Input
|
||||
type="date"
|
||||
name="from"
|
||||
value={data.filters.from}
|
||||
onchange={autoSubmit}
|
||||
/></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>To</span><Input
|
||||
type="date"
|
||||
name="to"
|
||||
value={data.filters.to}
|
||||
onchange={autoSubmit}
|
||||
/></label
|
||||
>
|
||||
<div class="flex items-end gap-2">
|
||||
<Button type="submit">Apply</Button><Button
|
||||
variant="ghost"
|
||||
href={resolve('/projects/money-tracker/transactions')}>Reset</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Date</Table.Head><Table.Head>Description</Table.Head><Table.Head
|
||||
>Account</Table.Head
|
||||
><Table.Head>Source</Table.Head><Table.Head class="text-right">Amount</Table.Head
|
||||
></Table.Row
|
||||
></Table.Header
|
||||
>
|
||||
<Table.Body>
|
||||
{#each data.rows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="whitespace-nowrap">{dateLabel(row.date)}</Table.Cell>
|
||||
<Table.Cell
|
||||
><span class="font-medium">{row.description}</span
|
||||
>{#if row.counterparty && row.counterparty !== row.description}<span
|
||||
class="block text-xs opacity-60">{row.counterparty}</span
|
||||
>{/if}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="whitespace-nowrap"
|
||||
>{names.get(row.accountId ?? 0) ?? 'Unassigned'}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><Badge variant={row.source === 'manual' ? 'outline' : 'secondary'}
|
||||
>{sourceLabels[row.source] ?? row.source}</Badge
|
||||
>{#if row.source === 'manual' && row.linked}<Badge
|
||||
variant="ghost"
|
||||
title="Matched to a bank transaction">✓ bank</Badge
|
||||
>{/if}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
class={'text-right tabular-nums ' +
|
||||
(row.amountCents < 0 ? 'text-red-600' : 'text-green-600')}
|
||||
>{euro.format(row.amountCents / 100)}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row
|
||||
><Table.Cell colspan={5} class="py-8 text-center opacity-70"
|
||||
>No matching transactions.</Table.Cell
|
||||
></Table.Row
|
||||
>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if data.pages > 1}
|
||||
<nav class="flex items-center justify-between text-sm" aria-label="Pagination">
|
||||
<span>Page {data.page} of {data.pages}</span>
|
||||
<div class="flex gap-2">
|
||||
{#if data.page > 1}<Button variant="outline" href={href({ page: data.page - 1 })}
|
||||
>Previous</Button
|
||||
>{/if}
|
||||
{#if data.page < data.pages}<Button variant="outline" href={href({ page: data.page + 1 })}
|
||||
>Next</Button
|
||||
>{/if}
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { listSummaries } from '#lib/server/lists.js';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { blogArticles } from '#lib/server/db/schema/index.js';
|
||||
import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { SITE_ORIGIN } from '#lib/seo.js';
|
||||
import { races } from '#lib/derby/races.js';
|
||||
import { COURSES } from '#lib/language-learner/courses/index.js';
|
||||
@@ -13,11 +13,13 @@ const staticPaths = [
|
||||
'/trackers',
|
||||
'/blog',
|
||||
'/lists',
|
||||
'/trackers/books',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
'/projects/game-tracker/pokemon/locations',
|
||||
'/projects/animaldex',
|
||||
'/projects/energy',
|
||||
'/projects/derby',
|
||||
'/projects/spotify-beatmap-finder',
|
||||
'/projects/world-opinions',
|
||||
@@ -39,12 +41,13 @@ function escapeXml(value: string): string {
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const [articles, lists] = await Promise.all([
|
||||
const [articles, lists, books] = await Promise.all([
|
||||
db
|
||||
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
||||
.from(blogArticles)
|
||||
.where(eq(blogArticles.status, 'published')),
|
||||
listSummaries()
|
||||
listSummaries(),
|
||||
db.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt }).from(bookWorks)
|
||||
]);
|
||||
const entries: { path: string; lastmod?: string }[] = [
|
||||
...staticPaths.map((path) => ({ path })),
|
||||
@@ -53,7 +56,11 @@ export const GET: RequestHandler = async () => {
|
||||
path: `/blog/${encodeURIComponent(article.slug)}`,
|
||||
lastmod: article.updatedAt.toISOString()
|
||||
})),
|
||||
...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` }))
|
||||
...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` })),
|
||||
...books.map((book) => ({
|
||||
path: `/trackers/books/${book.id}`,
|
||||
lastmod: book.updatedAt.toISOString()
|
||||
}))
|
||||
];
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries
|
||||
.map(
|
||||
|
||||
36
src/routes/terms/+page.svelte
Normal file
36
src/routes/terms/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Terms · stanrunge.dev</title></svelte:head>
|
||||
|
||||
<article class="mx-auto max-w-2xl space-y-4 px-6 py-10 leading-relaxed">
|
||||
<h1 class="text-2xl font-bold">Terms of use</h1>
|
||||
<p class="text-sm opacity-70">Last updated 24 October 2026</p>
|
||||
<p>
|
||||
stanrunge.dev is a personal, non-commercial website by Stan Runge. By using it you agree to
|
||||
these terms.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Use of the site</h2>
|
||||
<p>
|
||||
The projects and tools here are provided as-is, for fun and personal use, without warranty of
|
||||
any kind. They may change or disappear at any time. Don't misuse the site: no attempts to break
|
||||
in, overload it, scrape it aggressively, or use it for anything unlawful.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Accounts</h2>
|
||||
<p>
|
||||
You're responsible for activity on your account. Accounts that abuse the site may be removed.
|
||||
You can ask for your account to be deleted at any time.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Money tracker</h2>
|
||||
<p>
|
||||
The money tracker is a private tool for the site owner and household. Its bank connection is
|
||||
read-only (account information via Enable Banking) and nothing shown in it is financial advice.
|
||||
</p>
|
||||
<h2 class="pt-2 text-lg font-semibold">Liability</h2>
|
||||
<p>
|
||||
To the extent the law allows, the site owner is not liable for any loss arising from use of the
|
||||
site. Dutch law applies.
|
||||
</p>
|
||||
<p>See also the <a class="underline" href={resolve('/privacy')}>privacy page</a>.</p>
|
||||
</article>
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">trackers</h1>
|
||||
<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, books, prices, animals and lists.
|
||||
</p>
|
||||
<CatalogGrid items={visibleTrackers(data.isOwner)} />
|
||||
</div>
|
||||
|
||||
12
src/routes/trackers/books/+page.server.ts
Normal file
12
src/routes/trackers/books/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { libraryCards, libraryStats } from '#lib/server/books/library.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Public catalogue: covers, titles, status, progress and rating. Files, formats and
|
||||
// placeholders (no file yet) are only included for the owner.
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const owner = isOwner(event);
|
||||
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
const [books, stats] = await Promise.all([libraryCards(owner), libraryStats()]);
|
||||
return { books, stats, owner };
|
||||
};
|
||||
670
src/routes/trackers/books/+page.svelte
Normal file
670
src/routes/trackers/books/+page.svelte
Normal file
@@ -0,0 +1,670 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import BookCover from '#lib/books/BookCover.svelte';
|
||||
import Stars from '#lib/books/Stars.svelte';
|
||||
import {
|
||||
authorLine,
|
||||
readingStatuses,
|
||||
statusLabels,
|
||||
type BookCard,
|
||||
type ReadingStatus
|
||||
} from '#lib/books/shared.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
|
||||
import ListIcon from 'lucide-svelte/icons/list';
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import FileQuestion from 'lucide-svelte/icons/file-question';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
type Sort = 'recent' | 'title' | 'author' | 'progress' | 'rating' | 'added';
|
||||
const sorts: Record<Sort, string> = {
|
||||
recent: 'recently read',
|
||||
title: 'title',
|
||||
author: 'author',
|
||||
progress: 'progress',
|
||||
rating: 'rating',
|
||||
added: 'recently added'
|
||||
};
|
||||
|
||||
let query = $state('');
|
||||
let status = $state<ReadingStatus | 'all' | 'none'>('all');
|
||||
let subject = $state('');
|
||||
let format = $state('');
|
||||
let sort = $state<Sort>('recent');
|
||||
let view = $state<'grid' | 'list'>('grid');
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('en-GB');
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
|
||||
const reading = $derived(
|
||||
data.books
|
||||
.filter((b) => b.status === 'reading')
|
||||
.toSorted((a, b) => (b.lastReadAt ?? '').localeCompare(a.lastReadAt ?? ''))
|
||||
);
|
||||
const counts = $derived.by(() => {
|
||||
const c: Record<string, number> = { all: data.books.length, none: 0 };
|
||||
for (const s of readingStatuses) c[s] = 0;
|
||||
for (const b of data.books) c[b.status ?? 'none']++;
|
||||
return c;
|
||||
});
|
||||
const subjects = $derived.by(() => {
|
||||
const tally: Record<string, number> = {};
|
||||
for (const b of data.books) for (const t of b.tags) tally[t] = (tally[t] ?? 0) + 1;
|
||||
return Object.entries(tally)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 80);
|
||||
});
|
||||
const formats = $derived([...new Set(data.books.flatMap((b) => b.formats))].sort());
|
||||
const placeholders = $derived(data.owner ? data.books.filter((b) => !b.hasFile).length : 0);
|
||||
|
||||
const surname = (b: BookCard) => (b.authors[0] ?? '~').split(' ').at(-1)!.toLowerCase();
|
||||
const compare: Record<Sort, (a: BookCard, b: BookCard) => number> = {
|
||||
recent: (a, b) =>
|
||||
(b.lastReadAt ?? b.finishedAt ?? '').localeCompare(a.lastReadAt ?? a.finishedAt ?? '') ||
|
||||
a.title.localeCompare(b.title),
|
||||
title: (a, b) => a.title.localeCompare(b.title),
|
||||
author: (a, b) => surname(a).localeCompare(surname(b)) || a.title.localeCompare(b.title),
|
||||
progress: (a, b) => b.percent - a.percent || a.title.localeCompare(b.title),
|
||||
rating: (a, b) => (b.rating ?? 0) - (a.rating ?? 0) || a.title.localeCompare(b.title),
|
||||
added: (a, b) => b.addedAt.localeCompare(a.addedAt)
|
||||
};
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return data.books
|
||||
.filter((b) => {
|
||||
if (status !== 'all' && (b.status ?? 'none') !== status) return false;
|
||||
if (subject && !b.tags.includes(subject)) return false;
|
||||
if (format === 'placeholder' ? b.hasFile : format && !b.formats.includes(format))
|
||||
return false;
|
||||
if (!words.length) return true;
|
||||
const haystack = [b.title, b.subtitle, ...b.authors, ...b.tags, b.year]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return words.every((w) => haystack.includes(w));
|
||||
})
|
||||
.toSorted(compare[sort]);
|
||||
});
|
||||
const filtering = $derived(!!query || status !== 'all' || !!subject || !!format);
|
||||
|
||||
function clearFilters() {
|
||||
query = '';
|
||||
status = 'all';
|
||||
subject = '';
|
||||
format = '';
|
||||
}
|
||||
|
||||
function setView(next: 'grid' | 'list') {
|
||||
view = next;
|
||||
try {
|
||||
localStorage.setItem('books:view', next);
|
||||
} catch {
|
||||
// Not persisted; fine.
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the last grid/list choice (client only).
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('books:view');
|
||||
if (saved === 'list' || saved === 'grid') view = saved;
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
});
|
||||
|
||||
const pct = (n: number) => `${Math.round(n)}%`;
|
||||
const bookHref = (b: BookCard) => resolve('/trackers/books/[id=integer]', { id: String(b.id) });
|
||||
const readHref = (b: BookCard) =>
|
||||
resolve('/trackers/books/[id=integer]/read', { id: String(b.id) });
|
||||
</script>
|
||||
|
||||
{#snippet progressBar(b: BookCard, thin = false)}
|
||||
{#if b.status === 'reading' || (b.percent > 0 && b.status !== 'finished')}
|
||||
<span
|
||||
class={['bar', thin && 'thin']}
|
||||
role="progressbar"
|
||||
aria-label="Progress"
|
||||
aria-valuenow={Math.round(b.percent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}><span style:width={pct(b.percent)}></span></span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet statusChip(s: ReadingStatus | null)}
|
||||
{#if s}<span class={['chip', `s-${s}`]}>{statusLabels[s]}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">books</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
What I'm reading, what I've read and what's next.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="stats mb-10">
|
||||
<div>
|
||||
<dt>finished in {data.stats.year}</dt>
|
||||
<dd>{numberFormat.format(data.stats.finishedThisYear)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>pages read in {data.stats.year}</dt>
|
||||
<dd>{numberFormat.format(data.stats.pagesThisYear)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>currently reading</dt>
|
||||
<dd>{data.stats.reading}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>in the library</dt>
|
||||
<dd>
|
||||
{numberFormat.format(data.stats.total)}
|
||||
{#if placeholders}<small title="Books without a file yet"
|
||||
>{placeholders} placeholder{placeholders === 1 ? '' : 's'}</small
|
||||
>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if reading.length && !filtering}
|
||||
<section class="mb-12" aria-labelledby="now-reading">
|
||||
<h2 id="now-reading" class="mb-4 text-lg font-semibold tracking-tight">now reading</h2>
|
||||
<ul class="now">
|
||||
{#each reading.slice(0, 6) as b (b.id)}
|
||||
<li class="now-card">
|
||||
<a href={bookHref(b)} class="now-cover" aria-label={b.title}>
|
||||
<BookCover src={b.cover} title={b.title} authors={b.authors} size="md" />
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href={bookHref(b)} class="line-clamp-2 leading-snug font-medium hover:underline"
|
||||
>{b.title}</a
|
||||
>
|
||||
<p class="mt-0.5 truncate text-sm text-[var(--site-muted)]">
|
||||
{authorLine(b.authors)}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-[var(--site-muted)]">
|
||||
<span class="bar grow"><span style:width={pct(b.percent)}></span></span>
|
||||
<span class="tabular-nums">{pct(b.percent)}</span>
|
||||
</div>
|
||||
{#if data.owner && b.hasFile}
|
||||
<a href={readHref(b)} class="continue"><BookOpen size={14} /> continue</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search title, author or subject"
|
||||
aria-label="Search books"
|
||||
bind:value={query}
|
||||
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if subjects.length}
|
||||
<NativeSelect aria-label="Subject" bind:value={subject} class="max-w-44">
|
||||
<option value="">all subjects</option>
|
||||
{#each subjects as [name, count] (name)}<option value={name}>{name} ({count})</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
{/if}
|
||||
{#if data.owner && (formats.length || placeholders)}
|
||||
<NativeSelect aria-label="Format" bind:value={format}>
|
||||
<option value="">all formats</option>
|
||||
{#each formats as f (f)}<option value={f}>{f.toUpperCase()}</option>{/each}
|
||||
{#if placeholders}<option value="placeholder">no file yet</option>{/if}
|
||||
</NativeSelect>
|
||||
{/if}
|
||||
<NativeSelect aria-label="Sort by" bind:value={sort}>
|
||||
{#each Object.entries(sorts) as [key, label] (key)}<option value={key}>{label}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
<div class="toggle" role="group" aria-label="View">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={view === 'grid'}
|
||||
aria-label="Cover grid"
|
||||
onclick={() => setView('grid')}><LayoutGrid size={16} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={view === 'list'}
|
||||
aria-label="List"
|
||||
onclick={() => setView('list')}><ListIcon size={16} /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label="Status">
|
||||
{#each ['all', ...readingStatuses] as const as s (s)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === s}
|
||||
onclick={() => (status = s)}
|
||||
class={[status === s && 'active']}
|
||||
>{s === 'all' ? 'all' : statusLabels[s]} <span>{counts[s]}</span></button
|
||||
>
|
||||
{/each}
|
||||
{#if counts.none}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === 'none'}
|
||||
onclick={() => (status = 'none')}
|
||||
class={[status === 'none' && 'active']}>unsorted <span>{counts.none}</span></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if filtering}
|
||||
<p class="mb-4 flex items-center gap-2 text-sm text-[var(--site-muted)]">
|
||||
{filtered.length} of {data.books.length} books
|
||||
<button type="button" class="clear" onclick={clearFilters}><X size={12} /> clear</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !data.books.length}
|
||||
<div class="empty">
|
||||
<BookOpen size={28} aria-hidden="true" />
|
||||
<p>The library is still being catalogued. Check back soon.</p>
|
||||
</div>
|
||||
{:else if !filtered.length}
|
||||
<div class="empty"><p>No books match these filters.</p></div>
|
||||
{:else if view === 'grid'}
|
||||
<ul class="grid">
|
||||
{#each filtered as b (b.id)}
|
||||
<li>
|
||||
<a
|
||||
href={bookHref(b)}
|
||||
class="card"
|
||||
title={b.subtitle ? `${b.title}: ${b.subtitle}` : b.title}
|
||||
>
|
||||
<div class="cover-wrap">
|
||||
<BookCover
|
||||
src={b.cover}
|
||||
title={b.title}
|
||||
authors={b.authors}
|
||||
placeholder={data.owner && !b.hasFile}
|
||||
/>
|
||||
{#if data.owner && !b.hasFile}
|
||||
<span class="badge" title="No file yet"><FileQuestion size={12} /> no file</span>
|
||||
{/if}
|
||||
{#if b.status === 'finished'}<span class="done" title="Finished">✓</span>{/if}
|
||||
<div class="cover-bar">{@render progressBar(b, true)}</div>
|
||||
</div>
|
||||
<span class="mt-2.5 line-clamp-2 text-sm leading-snug font-medium">{b.title}</span>
|
||||
<span class="mt-0.5 truncate text-xs text-[var(--site-muted)]"
|
||||
>{authorLine(b.authors) || 'unknown author'}</span
|
||||
>
|
||||
<span class="mt-1 flex min-h-4 items-center gap-2">
|
||||
<Stars value={b.rating} size={11} />
|
||||
{#if b.status === 'reading'}<span
|
||||
class="text-xs text-[var(--site-muted)] tabular-nums">{pct(b.percent)}</span
|
||||
>{/if}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each filtered as b (b.id)}
|
||||
<li>
|
||||
<a href={bookHref(b)} class="row">
|
||||
<span class="w-10 shrink-0"
|
||||
><BookCover
|
||||
src={b.cover}
|
||||
title={b.title}
|
||||
size="sm"
|
||||
placeholder={data.owner && !b.hasFile}
|
||||
/></span
|
||||
>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">{b.title}</span>
|
||||
<span class="block truncate text-sm text-[var(--site-muted)]"
|
||||
>{authorLine(b.authors) || 'unknown author'}{b.year ? ` · ${b.year}` : ''}</span
|
||||
>
|
||||
</span>
|
||||
<span class="hidden w-28 sm:block">{@render statusChip(b.status)}</span>
|
||||
<span class="hidden w-32 items-center gap-2 md:flex">
|
||||
{#if b.percent > 0}<span class="bar grow"
|
||||
><span style:width={pct(b.percent)}></span></span
|
||||
><span class="w-9 text-right text-xs text-[var(--site-muted)] tabular-nums"
|
||||
>{pct(b.percent)}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<span class="hidden w-20 lg:block"><Stars value={b.rating} size={11} /></span>
|
||||
<span class="hidden w-24 text-right text-xs text-[var(--site-muted)] lg:block">
|
||||
{#if b.lastReadAt}{dateFormat.format(new Date(b.lastReadAt))}{/if}
|
||||
</span>
|
||||
{#if data.owner && !b.hasFile}
|
||||
<span class="badge static" title="No file yet"><FileQuestion size={12} /></span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.stats > div {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
.stats dt {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.stats dd {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stats small {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--site-muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.now {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
.now-card {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
.now-cover {
|
||||
width: 4.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.continue {
|
||||
margin-top: 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.continue:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 16rem;
|
||||
max-width: 26rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
color: var(--site-muted);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.toggle button {
|
||||
padding: 0.45rem 0.6rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.toggle button[aria-pressed='true'] {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
margin-bottom: 1.5rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabs button {
|
||||
white-space: nowrap;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.tabs button span {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.75rem 1.1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.cover-wrap {
|
||||
position: relative;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
filter 0.2s ease;
|
||||
}
|
||||
.card:hover .cover-wrap,
|
||||
.card:focus-visible .cover-wrap {
|
||||
transform: translateY(-4px) rotate(-0.4deg);
|
||||
filter: drop-shadow(0 12px 18px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
.cover-bar {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 6%;
|
||||
bottom: 6%;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(0 0 0 / 0.7);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.badge.static {
|
||||
position: static;
|
||||
background: var(--site-hover);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.done {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 10%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: block;
|
||||
height: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--site-text);
|
||||
}
|
||||
.bar.thin {
|
||||
height: 0.3rem;
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.bar.thin > span {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.list li + li {
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.chip.s-reading {
|
||||
color: #2563eb;
|
||||
border-color: color-mix(in srgb, #2563eb 40%, transparent);
|
||||
}
|
||||
.chip.s-finished {
|
||||
color: #16a34a;
|
||||
border-color: color-mix(in srgb, #16a34a 40%, transparent);
|
||||
}
|
||||
.chip.s-abandoned {
|
||||
color: #b45309;
|
||||
border-color: color-mix(in srgb, #b45309 40%, transparent);
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1rem;
|
||||
border: 1px dashed var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
182
src/routes/trackers/books/[id=integer]/+page.server.ts
Normal file
182
src/routes/trackers/books/[id=integer]/+page.server.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { bookSessions } from '#lib/server/db/schema/index.js';
|
||||
import { isOwner, requireOwner } from '#lib/server/owner.js';
|
||||
import {
|
||||
coverSrc,
|
||||
getWork,
|
||||
logManualSession,
|
||||
setManualProgress,
|
||||
setRating,
|
||||
setStatus,
|
||||
workFiles,
|
||||
workNotes,
|
||||
workSessions
|
||||
} from '#lib/server/books/library.js';
|
||||
import { createNote, deleteNote, updateNote } from '#lib/server/books/notes.js';
|
||||
import { isReadingStatus } from '#lib/books/shared.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const id = Number(event.params.id);
|
||||
const found = await getWork(id);
|
||||
if (!found) error(404, 'Not Found');
|
||||
const owner = isOwner(event);
|
||||
const { work, progress } = found;
|
||||
const book = {
|
||||
id: work.id,
|
||||
title: work.title,
|
||||
subtitle: work.subtitle,
|
||||
authors: work.authors,
|
||||
year: work.year,
|
||||
publisher: work.publisher,
|
||||
isbn13: work.isbn13,
|
||||
isbn10: work.isbn10,
|
||||
language: work.language,
|
||||
pageCount: work.pageCount,
|
||||
description: work.description,
|
||||
tags: work.tags,
|
||||
cover: coverSrc(work),
|
||||
openlibraryWorkId: work.openlibraryWorkId
|
||||
};
|
||||
const reading = {
|
||||
status: progress?.status ?? null,
|
||||
percent: progress?.percent ?? 0,
|
||||
page: progress?.page ?? null,
|
||||
rating: progress?.rating ?? null,
|
||||
startedAt: progress?.startedAt?.toISOString() ?? null,
|
||||
finishedAt: progress?.finishedAt?.toISOString() ?? null,
|
||||
lastReadAt: progress?.lastReadAt?.toISOString() ?? null
|
||||
};
|
||||
const seo = {
|
||||
title: `${work.title}${work.authors.length ? ` by ${work.authors.join(', ')}` : ''}`,
|
||||
description:
|
||||
work.description?.slice(0, 160) ??
|
||||
`${work.title} on Stan Runge's bookshelf${reading.status ? ` (${reading.status.replace(/_/g, ' ')})` : ''}.`
|
||||
};
|
||||
if (!owner) return { book, reading, owner: false as const, seo };
|
||||
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
const [files, notes, sessions] = await Promise.all([
|
||||
workFiles(id),
|
||||
workNotes(id),
|
||||
workSessions(id)
|
||||
]);
|
||||
return {
|
||||
book,
|
||||
reading,
|
||||
owner: true as const,
|
||||
seo,
|
||||
files: files.map(({ id, format, sizeBytes, readable, path }) => ({
|
||||
id,
|
||||
format,
|
||||
sizeBytes,
|
||||
readable,
|
||||
name: path.split('/').at(-1) ?? path
|
||||
})),
|
||||
notes,
|
||||
sessions
|
||||
};
|
||||
};
|
||||
|
||||
const int = (value: FormDataEntryValue | null) => {
|
||||
if (value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) ? n : NaN;
|
||||
};
|
||||
const date = (value: FormDataEntryValue | null) => {
|
||||
if (!value) return null;
|
||||
const d = new Date(String(value));
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
status: async (event) => {
|
||||
requireOwner(event);
|
||||
const status = (await event.request.formData()).get('status');
|
||||
if (!isReadingStatus(status)) return fail(400, { message: 'Unknown status' });
|
||||
await setStatus(Number(event.params.id), status);
|
||||
return { ok: true };
|
||||
},
|
||||
rating: async (event) => {
|
||||
requireOwner(event);
|
||||
const rating = int((await event.request.formData()).get('rating'));
|
||||
if (rating !== null && !(rating >= 1 && rating <= 5))
|
||||
return fail(400, { message: 'Rating is 1-5' });
|
||||
await setRating(Number(event.params.id), rating);
|
||||
return { ok: true };
|
||||
},
|
||||
progress: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const page = int(form.get('page'));
|
||||
const pageCount = int(form.get('pageCount'));
|
||||
let percent = form.get('percent') === '' ? null : Number(form.get('percent'));
|
||||
if (Number.isNaN(page) || (percent !== null && !(percent >= 0 && percent <= 100)))
|
||||
return fail(400, { message: 'Invalid progress' });
|
||||
if (percent === null && page !== null && pageCount)
|
||||
percent = Math.min(100, (page / pageCount) * 100);
|
||||
if (percent === null) return fail(400, { message: 'Enter a percent or a page' });
|
||||
await setManualProgress(Number(event.params.id), Math.round(percent * 100) / 100, page);
|
||||
return { ok: true };
|
||||
},
|
||||
addNote: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const note = String(form.get('note') ?? '').trim();
|
||||
const page = int(form.get('page'));
|
||||
if (!note) return fail(400, { message: 'Write something first' });
|
||||
if (Number.isNaN(page)) return fail(400, { message: 'Invalid page' });
|
||||
await createNote(Number(event.params.id), { note, page });
|
||||
return { ok: true };
|
||||
},
|
||||
editNote: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const id = int(form.get('id'));
|
||||
if (!id) return fail(400, { message: 'Missing note' });
|
||||
const updated = await updateNote(Number(event.params.id), id, {
|
||||
note: String(form.get('note') ?? '').trim() || null
|
||||
});
|
||||
if (!updated) return fail(404, { message: 'Note not found' });
|
||||
return { ok: true };
|
||||
},
|
||||
deleteNote: async (event) => {
|
||||
requireOwner(event);
|
||||
const id = int((await event.request.formData()).get('id'));
|
||||
if (!id || !(await deleteNote(Number(event.params.id), id)))
|
||||
return fail(404, { message: 'Note not found' });
|
||||
return { ok: true };
|
||||
},
|
||||
logSession: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const startedAt = date(form.get('startedAt'));
|
||||
const endedAt = date(form.get('endedAt'));
|
||||
const startPage = int(form.get('startPage'));
|
||||
const endPage = int(form.get('endPage'));
|
||||
if (!startedAt || endedAt === undefined || Number.isNaN(startPage) || Number.isNaN(endPage))
|
||||
return fail(400, { message: 'Check the dates and pages' });
|
||||
if (endedAt && endedAt < startedAt) return fail(400, { message: 'End is before start' });
|
||||
await logManualSession(Number(event.params.id), {
|
||||
startedAt,
|
||||
endedAt,
|
||||
startPage,
|
||||
endPage,
|
||||
startPercent: null,
|
||||
endPercent: null,
|
||||
note: String(form.get('note') ?? '').trim() || null
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
deleteSession: async (event) => {
|
||||
requireOwner(event);
|
||||
const id = int((await event.request.formData()).get('id'));
|
||||
if (!id) return fail(400, { message: 'Missing session' });
|
||||
await db
|
||||
.delete(bookSessions)
|
||||
.where(and(eq(bookSessions.id, id), eq(bookSessions.workId, Number(event.params.id))));
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
683
src/routes/trackers/books/[id=integer]/+page.svelte
Normal file
683
src/routes/trackers/books/[id=integer]/+page.svelte
Normal file
@@ -0,0 +1,683 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import BookCover from '#lib/books/BookCover.svelte';
|
||||
import Stars from '#lib/books/Stars.svelte';
|
||||
import {
|
||||
highlightCss,
|
||||
readingStatuses,
|
||||
statusLabels,
|
||||
type HighlightColor
|
||||
} from '#lib/books/shared.js';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import Highlighter from 'lucide-svelte/icons/highlighter';
|
||||
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import FileQuestion from 'lucide-svelte/icons/file-question';
|
||||
import Lock from 'lucide-svelte/icons/lock';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const book = $derived(data.book);
|
||||
const reading = $derived(data.reading);
|
||||
let descriptionOpen = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let showSessionForm = $state(false);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
const timeFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
const localInput = (d: Date) =>
|
||||
new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Amsterdam',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
.format(d)
|
||||
.replace(' ', 'T');
|
||||
const size = (bytes: number) =>
|
||||
bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.ceil(bytes / 1e3)} kB`;
|
||||
const minutes = (a: string, b: string | null) =>
|
||||
b ? Math.max(1, Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000)) : null;
|
||||
|
||||
const readable = $derived(data.owner ? data.files.find((f) => f.readable) : undefined);
|
||||
const highlights = $derived(data.owner ? data.notes.filter((n) => n.highlight) : []);
|
||||
const freeNotes = $derived(data.owner ? data.notes.filter((n) => !n.highlight) : []);
|
||||
const readHref = $derived(resolve('/trackers/books/[id=integer]/read', { id: String(book.id) }));
|
||||
const meta = $derived(
|
||||
[
|
||||
['published', book.year],
|
||||
['publisher', book.publisher],
|
||||
['pages', book.pageCount],
|
||||
['language', book.language?.toUpperCase()],
|
||||
['ISBN', book.isbn13 ?? book.isbn10]
|
||||
].filter(([, v]) => v != null && v !== '') as [string, string | number][]
|
||||
);
|
||||
|
||||
const keep: SubmitFunction =
|
||||
() =>
|
||||
async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
};
|
||||
const done: SubmitFunction =
|
||||
() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
editing = null;
|
||||
showSessionForm = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/trackers/books')}
|
||||
class="mb-8 inline-flex items-center gap-1.5 text-sm text-[var(--site-muted)] hover:text-[var(--site-text)]"
|
||||
><ArrowLeft size={14} /> all books</a
|
||||
>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="cover-box">
|
||||
<BookCover
|
||||
src={book.cover}
|
||||
title={book.title}
|
||||
authors={book.authors}
|
||||
size="lg"
|
||||
placeholder={data.owner && !data.files.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if data.owner}
|
||||
{#if readable}
|
||||
<Button href={readHref} class="mt-5 w-full gap-2" size="lg"
|
||||
><BookOpen size={16} />
|
||||
{reading.percent > 0 && reading.status !== 'finished'
|
||||
? 'continue reading'
|
||||
: 'read'}</Button
|
||||
>
|
||||
{:else if data.files.length}
|
||||
<p class="mt-5 text-center text-sm text-[var(--site-muted)]">
|
||||
{data.files.map((f) => f.format.toUpperCase()).join(', ')} can't be opened in the browser
|
||||
reader yet.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="placeholder-note">
|
||||
<FileQuestion size={16} /> No file yet. This is a placeholder until a copy lands in the library.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/status" use:enhance={keep} class="status-group">
|
||||
{#each readingStatuses as s (s)}
|
||||
<button
|
||||
name="status"
|
||||
value={s}
|
||||
class={['seg', reading.status === s && 'active']}
|
||||
aria-pressed={reading.status === s}>{statusLabels[s]}</button
|
||||
>
|
||||
{/each}
|
||||
</form>
|
||||
<form method="POST" action="?/rating" use:enhance={keep} class="mt-3 flex justify-center">
|
||||
<Stars value={reading.rating} size={22} name="rating" />
|
||||
</form>
|
||||
{:else}
|
||||
<div class="mt-5 flex flex-col items-center gap-2">
|
||||
{#if reading.status}<span class={['chip', `s-${reading.status}`]}
|
||||
>{statusLabels[reading.status]}</span
|
||||
>{/if}
|
||||
<Stars value={reading.rating} size={18} />
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl">{book.title}</h1>
|
||||
{#if book.subtitle}<p class="mt-2 text-lg text-[var(--site-muted)]">{book.subtitle}</p>{/if}
|
||||
{#if book.authors.length}<p class="mt-3 text-lg">{book.authors.join(', ')}</p>{/if}
|
||||
|
||||
{#if meta.length}
|
||||
<dl class="meta">
|
||||
{#each meta as [label, value] (label)}
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if book.tags.length}
|
||||
<ul class="mt-5 flex flex-wrap gap-1.5">
|
||||
{#each book.tags.slice(0, 16) as tag (tag)}<li class="tag">{tag}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if book.description}
|
||||
<div class="mt-6">
|
||||
<p class={['description', !descriptionOpen && 'clamped']}>{book.description}</p>
|
||||
{#if book.description.length > 400}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 text-sm text-[var(--site-muted)] underline-offset-2 hover:underline"
|
||||
onclick={() => (descriptionOpen = !descriptionOpen)}
|
||||
>{descriptionOpen ? 'less' : 'more'}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="panel mt-8">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold tracking-tight">progress</h2>
|
||||
<p class="text-sm text-[var(--site-muted)]">
|
||||
{#if reading.startedAt}started {dateFormat.format(new Date(reading.startedAt))}{/if}
|
||||
{#if reading.finishedAt}· finished {dateFormat.format(
|
||||
new Date(reading.finishedAt)
|
||||
)}{/if}
|
||||
{#if reading.lastReadAt && reading.status === 'reading'}· last read {timeFormat.format(
|
||||
new Date(reading.lastReadAt)
|
||||
)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<span class="bar grow"><span style:width={`${reading.percent}%`}></span></span>
|
||||
<span class="text-sm font-medium tabular-nums">{Math.round(reading.percent)}%</span>
|
||||
</div>
|
||||
{#if reading.page && book.pageCount}
|
||||
<p class="mt-1 text-xs text-[var(--site-muted)]">
|
||||
page {reading.page} of {book.pageCount}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.owner}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/progress"
|
||||
use:enhance={done}
|
||||
class="mt-4 flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<label class="field"
|
||||
><span>percent</span><Input
|
||||
name="percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="w-24"
|
||||
value={reading.percent ? Math.round(reading.percent * 10) / 10 : ''}
|
||||
/></label
|
||||
>
|
||||
{#if book.pageCount}
|
||||
<label class="field"
|
||||
><span>or page</span><Input
|
||||
name="page"
|
||||
type="number"
|
||||
min="0"
|
||||
max={book.pageCount}
|
||||
class="w-24"
|
||||
value={reading.page ?? ''}
|
||||
/></label
|
||||
>
|
||||
<input type="hidden" name="pageCount" value={book.pageCount} />
|
||||
{/if}
|
||||
<Button type="submit" variant="outline" size="sm">update</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if data.owner}
|
||||
{#if form?.message}<p class="mt-4 text-sm text-red-500" role="alert">{form.message}</p>{/if}
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="section-title"><NotebookPen size={18} /> notes</h2>
|
||||
<form method="POST" action="?/addNote" use:enhance={done} class="mb-4 space-y-2">
|
||||
<Textarea name="note" rows={3} placeholder="Thoughts, quotes, a summary…" required />
|
||||
<div class="flex items-center gap-2">
|
||||
<Input name="page" type="number" min="0" placeholder="page" class="w-24" />
|
||||
<Button type="submit" size="sm">add note</Button>
|
||||
</div>
|
||||
</form>
|
||||
{#if freeNotes.length}
|
||||
<ul class="space-y-3">
|
||||
{#each freeNotes as n (n.id)}
|
||||
<li class="note">
|
||||
{#if editing === n.id}
|
||||
<form method="POST" action="?/editNote" use:enhance={done} class="space-y-2">
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<Textarea name="note" rows={4} value={n.note ?? ''} />
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" size="sm">save</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => (editing = null)}>cancel</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="whitespace-pre-wrap">{n.note}</p>
|
||||
<footer>
|
||||
<span
|
||||
>{dateFormat.format(new Date(n.createdAt))}{n.page
|
||||
? ` · p. ${n.page}`
|
||||
: ''}</span
|
||||
>
|
||||
<span class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="Edit note"
|
||||
onclick={() => (editing = n.id)}><Pencil size={13} /></button
|
||||
>
|
||||
<form method="POST" action="?/deleteNote" use:enhance={done}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button class="icon" aria-label="Delete note"><Trash2 size={13} /></button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--site-muted)]">No notes yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="section-title"><Highlighter size={18} /> highlights</h2>
|
||||
{#if highlights.length}
|
||||
<ul class="space-y-3">
|
||||
{#each highlights as h (h.id)}
|
||||
<li
|
||||
class="highlight"
|
||||
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
|
||||
highlightCss.yellow}
|
||||
>
|
||||
<blockquote>{h.highlight}</blockquote>
|
||||
{#if editing === h.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/editNote"
|
||||
use:enhance={done}
|
||||
class="mt-2 space-y-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={h.id} />
|
||||
<Textarea
|
||||
name="note"
|
||||
rows={3}
|
||||
value={h.note ?? ''}
|
||||
placeholder="Add a note"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" size="sm">save</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => (editing = null)}>cancel</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{:else if h.note}
|
||||
<p class="mt-2 text-sm whitespace-pre-wrap">{h.note}</p>
|
||||
{/if}
|
||||
<footer>
|
||||
<span>
|
||||
{dateFormat.format(new Date(h.createdAt))}{h.page ? ` · p. ${h.page}` : ''}
|
||||
{#if readable && h.locator}
|
||||
· <a
|
||||
class="underline-offset-2 hover:underline"
|
||||
href={`${readHref}?at=${encodeURIComponent(h.locator)}`}>open in reader</a
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
aria-label="Edit note"
|
||||
onclick={() => (editing = h.id)}><Pencil size={13} /></button
|
||||
>
|
||||
<form method="POST" action="?/deleteNote" use:enhance={done}>
|
||||
<input type="hidden" name="id" value={h.id} />
|
||||
<button class="icon" aria-label="Delete highlight"
|
||||
><Trash2 size={13} /></button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
</footer>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--site-muted)]">
|
||||
Select text in the reader to highlight it{readable ? '' : ' (once there is a file)'}.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="section-title mb-0"><History size={18} /> reading history</h2>
|
||||
<Button size="sm" variant="outline" onclick={() => (showSessionForm = !showSessionForm)}
|
||||
>{showSessionForm ? 'cancel' : 'log a session'}</Button
|
||||
>
|
||||
</div>
|
||||
{#if showSessionForm}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/logSession"
|
||||
use:enhance={done}
|
||||
class="panel mb-4 grid gap-3 sm:grid-cols-2"
|
||||
>
|
||||
<label class="field"
|
||||
><span>start</span><Input
|
||||
name="startedAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
value={localInput(new Date(Date.now() - 30 * 60000))}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
><span>end</span><Input
|
||||
name="endedAt"
|
||||
type="datetime-local"
|
||||
value={localInput(new Date())}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
><span>from page</span><Input
|
||||
name="startPage"
|
||||
type="number"
|
||||
min="0"
|
||||
value={reading.page ?? ''}
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
><span>to page</span><Input name="endPage" type="number" min="0" /></label
|
||||
>
|
||||
<label class="field sm:col-span-2"
|
||||
><span>note</span><Input name="note" placeholder="optional" /></label
|
||||
>
|
||||
<div class="sm:col-span-2"><Button type="submit" size="sm">save session</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
{#if data.sessions.length}
|
||||
<ul class="sessions">
|
||||
{#each data.sessions as s (s.id)}
|
||||
<li>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block">{timeFormat.format(new Date(s.startedAt))}</span>
|
||||
<span class="block text-xs text-[var(--site-muted)]">
|
||||
{#if minutes(s.startedAt, s.endedAt)}{minutes(s.startedAt, s.endedAt)} min{/if}
|
||||
{#if s.startPercent != null && s.endPercent != null}
|
||||
· {Math.round(s.startPercent)}% → {Math.round(s.endPercent)}%{/if}
|
||||
{#if s.startPage != null && s.endPage != null}
|
||||
· p. {s.startPage} → {s.endPage}{/if}
|
||||
{#if s.source === 'manual'}· logged by hand{/if}
|
||||
{#if s.note}· {s.note}{/if}
|
||||
</span>
|
||||
</span>
|
||||
{#if s.pagesRead}<span class="pages">{s.pagesRead} p.</span>{/if}
|
||||
<form method="POST" action="?/deleteSession" use:enhance={done}>
|
||||
<input type="hidden" name="id" value={s.id} />
|
||||
<button class="icon" aria-label="Delete session"><Trash2 size={13} /></button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--site-muted)]">
|
||||
Sessions are logged automatically while you read.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if data.files.length}
|
||||
<section class="mt-10">
|
||||
<h2 class="section-title"><Lock size={16} /> files</h2>
|
||||
<ul class="text-sm text-[var(--site-muted)]">
|
||||
{#each data.files as f (f.id)}
|
||||
<li class="flex justify-between gap-4 py-1">
|
||||
<span class="truncate">{f.name}</span>
|
||||
<span class="shrink-0 tabular-nums"
|
||||
>{f.format.toUpperCase()} · {size(f.sizeBytes)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.layout {
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
gap: 3.5rem;
|
||||
}
|
||||
.side {
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
.cover-box {
|
||||
max-width: 14rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.cover-box {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
.placeholder-note {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-muted);
|
||||
border: 1px dashed var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.status-group {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.seg {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.seg:hover {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.seg.active {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.meta {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 2rem;
|
||||
}
|
||||
.meta dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.meta dd {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-hover);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.description {
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.description.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.bar {
|
||||
display: block;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--site-text);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.note,
|
||||
.highlight {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.highlight blockquote {
|
||||
border-left: 3px solid var(--hl);
|
||||
padding-left: 0.85rem;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
line-height: 1.6;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--hl) 12%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
.note footer,
|
||||
.highlight footer {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.3rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.icon:hover {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.sessions {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sessions li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.sessions li + li {
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.pages {
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.chip.s-reading {
|
||||
color: #2563eb;
|
||||
}
|
||||
.chip.s-finished {
|
||||
color: #16a34a;
|
||||
}
|
||||
.chip.s-abandoned {
|
||||
color: #b45309;
|
||||
}
|
||||
</style>
|
||||
14
src/routes/trackers/books/[id=integer]/notes/+server.ts
Normal file
14
src/routes/trackers/books/[id=integer]/notes/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Owner-only: create a highlight or note from the reader.
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { createNote, notePayload } from '#lib/server/books/notes.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const parsed = notePayload.safeParse(await event.request.json().catch(() => null));
|
||||
if (!parsed.success) error(400, 'Invalid note');
|
||||
const note = await createNote(Number(event.params.id), parsed.data);
|
||||
if (!note) error(404, 'Not Found');
|
||||
return json(note, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// Owner-only: edit or delete one highlight/note.
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { deleteNote, noteUpdate, updateNote } from '#lib/server/books/notes.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const parsed = noteUpdate.safeParse(await event.request.json().catch(() => null));
|
||||
if (!parsed.success) error(400, 'Invalid note');
|
||||
const note = await updateNote(Number(event.params.id), Number(event.params.noteId), parsed.data);
|
||||
if (!note) error(404, 'Not Found');
|
||||
return json(note);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const ok = await deleteNote(Number(event.params.id), Number(event.params.noteId));
|
||||
if (!ok) error(404, 'Not Found');
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
21
src/routes/trackers/books/[id=integer]/progress/+server.ts
Normal file
21
src/routes/trackers/books/[id=integer]/progress/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Owner-only: the reader saves its position here (debounced, and via sendBeacon on close).
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { parseProgress } from '#lib/books/progress.js';
|
||||
import { saveProgress } from '#lib/server/books/library.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(await event.request.text());
|
||||
} catch {
|
||||
error(400, 'Invalid JSON');
|
||||
}
|
||||
const input = parseProgress(body);
|
||||
if (!input) error(400, 'Invalid progress');
|
||||
const result = await saveProgress(Number(event.params.id), input);
|
||||
if (!result) error(404, 'Not Found');
|
||||
return json(result, { headers: { 'cache-control': 'private, no-store' } });
|
||||
};
|
||||
52
src/routes/trackers/books/[id=integer]/read/+page.server.ts
Normal file
52
src/routes/trackers/books/[id=integer]/read/+page.server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Owner-only in-browser reader. Everyone else gets the same 404 as a missing page.
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { resolve } from '$app/paths';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { getWork, workFiles, workNotes } from '#lib/server/books/library.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
const id = Number(event.params.id);
|
||||
const found = await getWork(id);
|
||||
if (!found) error(404, 'Not Found');
|
||||
const { work, progress } = found;
|
||||
|
||||
const files = (await workFiles(id)).filter((f) => f.readable);
|
||||
const wanted = Number(event.url.searchParams.get('file'));
|
||||
const file =
|
||||
files.find((f) => f.id === wanted) ?? files.find((f) => f.id === progress?.fileId) ?? files[0];
|
||||
if (!file) error(404, 'No readable file for this book yet');
|
||||
|
||||
// Resume where we left off, but only if the saved position belongs to this file.
|
||||
const sameFile = progress?.fileId === file.id;
|
||||
const notes = (await workNotes(id)).filter(
|
||||
(n) => n.highlight && n.locator && (n.fileId === file.id || n.fileId === null)
|
||||
);
|
||||
|
||||
return {
|
||||
book: {
|
||||
id: work.id,
|
||||
title: work.title,
|
||||
authors: work.authors,
|
||||
pageCount: work.pageCount
|
||||
},
|
||||
file: {
|
||||
id: file.id,
|
||||
format: file.format,
|
||||
engine: file.format === 'pdf' ? ('pdf' as const) : ('foliate' as const),
|
||||
url: resolve('/trackers/books/files/[id=integer]', { id: String(file.id) }),
|
||||
name: file.path.split('/').at(-1) ?? `book.${file.format}`
|
||||
},
|
||||
otherFiles: files.filter((f) => f.id !== file.id).map((f) => ({ id: f.id, format: f.format })),
|
||||
resume: {
|
||||
locator: sameFile ? (progress?.locator ?? null) : null,
|
||||
percent: sameFile ? (progress?.percent ?? 0) : 0,
|
||||
page: sameFile ? (progress?.page ?? null) : null
|
||||
},
|
||||
jumpTo: event.url.searchParams.get('at'),
|
||||
highlights: notes,
|
||||
seo: { title: `Reading ${work.title}`, description: work.title, index: false }
|
||||
};
|
||||
};
|
||||
999
src/routes/trackers/books/[id=integer]/read/+page.svelte
Normal file
999
src/routes/trackers/books/[id=integer]/read/+page.svelte
Normal file
@@ -0,0 +1,999 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import EpubReader from '#lib/books/reader/EpubReader.svelte';
|
||||
import PdfReader from '#lib/books/reader/PdfReader.svelte';
|
||||
import {
|
||||
defaultSettings,
|
||||
themeColors,
|
||||
type ReaderEngine,
|
||||
type ReaderSettings,
|
||||
type ReaderTheme,
|
||||
type Relocation,
|
||||
type TextSelection,
|
||||
type TocItem
|
||||
} from '#lib/books/reader/types.js';
|
||||
import {
|
||||
highlightColors,
|
||||
highlightCss,
|
||||
type Highlight,
|
||||
type HighlightColor
|
||||
} from '#lib/books/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import ListTree from 'lucide-svelte/icons/list-tree';
|
||||
import Highlighter from 'lucide-svelte/icons/highlighter';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const SETTINGS_KEY = 'books-reader-settings';
|
||||
const isPdf = $derived(data.file.engine === 'pdf');
|
||||
const progressUrl = $derived(
|
||||
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
|
||||
);
|
||||
const notesUrl = $derived(
|
||||
resolve('/trackers/books/[id=integer]/notes', { id: String(data.book.id) })
|
||||
);
|
||||
const detailUrl = $derived(resolve('/trackers/books/[id=integer]', { id: String(data.book.id) }));
|
||||
|
||||
let engine = $state<ReaderEngine>();
|
||||
let settings = $state<ReaderSettings>({ ...defaultSettings });
|
||||
let siteDark = $state(true);
|
||||
// Seeded from the load; the reader adds/edits/deletes in place (writable derived).
|
||||
let highlights = $derived<Highlight[]>(data.highlights);
|
||||
|
||||
let toc = $state<TocItem[]>([]);
|
||||
let ready = $state(false);
|
||||
let loading = $state<number | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let location = $state<Relocation | null>(null);
|
||||
let panel = $state<'toc' | 'highlights' | 'settings' | null>(null);
|
||||
let selection = $state<TextSelection | null>(null);
|
||||
let noteFor = $state<'selection' | number | null>(null);
|
||||
let noteDraft = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
let saveState = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
let scrubbing = $state<number | null>(null);
|
||||
|
||||
const theme = $derived<ReaderTheme>(
|
||||
settings.theme === 'auto' ? (siteDark ? 'dark' : 'light') : settings.theme
|
||||
);
|
||||
const colors = $derived(themeColors[theme]);
|
||||
const active = $derived(highlights.find((h) => h.id === activeId) ?? null);
|
||||
const percent = $derived(scrubbing ?? location?.percent ?? data.resume.percent);
|
||||
const start = $derived(data.jumpTo ?? data.resume.locator);
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? 'null');
|
||||
if (saved && typeof saved === 'object') settings = { ...defaultSettings, ...saved };
|
||||
} catch {
|
||||
// ignore broken settings
|
||||
}
|
||||
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light');
|
||||
const onHide = () => flush(true);
|
||||
const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
|
||||
window.addEventListener('pagehide', onHide);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', onHide);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
flush(true);
|
||||
};
|
||||
});
|
||||
|
||||
function updateSettings(patch: Partial<ReaderSettings>) {
|
||||
settings = { ...settings, ...patch };
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
// ---- progress saving: debounced while reading, flushed on leave ----
|
||||
let sessionId: number | null = null;
|
||||
let pending: Relocation | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let baseline: string | null = null;
|
||||
|
||||
function onrelocate(r: Relocation) {
|
||||
location = r;
|
||||
if (!ready) return;
|
||||
if (baseline === null) {
|
||||
baseline = r.locator; // the restored position itself isn't reading
|
||||
return;
|
||||
}
|
||||
if (r.locator === baseline && !pending) return;
|
||||
pending = r;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => flush(), 2000);
|
||||
}
|
||||
|
||||
async function flush(leaving = false) {
|
||||
clearTimeout(timer);
|
||||
if (!pending) return;
|
||||
const r = pending;
|
||||
pending = null;
|
||||
const body = JSON.stringify({
|
||||
fileId: data.file.id,
|
||||
locator: r.locator,
|
||||
page: r.page,
|
||||
percent: Math.round(r.percent * 100) / 100,
|
||||
pageCount: r.pageCount,
|
||||
sessionId
|
||||
});
|
||||
saveState = 'saving';
|
||||
try {
|
||||
const res = await fetch(progressUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
keepalive: leaving
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
sessionId = (await res.json()).sessionId ?? sessionId;
|
||||
saveState = 'saved';
|
||||
} catch {
|
||||
saveState = 'error';
|
||||
if (!leaving) pending ??= r;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(() => void flush(true));
|
||||
|
||||
// ---- highlights ----
|
||||
async function createHighlight(color: HighlightColor, note: string | null = null) {
|
||||
const sel = selection;
|
||||
if (!sel) return;
|
||||
const res = await fetch(notesUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileId: data.file.id,
|
||||
locator: sel.locator,
|
||||
page: sel.page ?? location?.page ?? null,
|
||||
highlight: sel.text.slice(0, 20000),
|
||||
note: note || null,
|
||||
color
|
||||
})
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const h: Highlight = await res.json();
|
||||
highlights = [h, ...highlights];
|
||||
engine?.addHighlight(h);
|
||||
engine?.clearSelection();
|
||||
selection = null;
|
||||
noteFor = null;
|
||||
noteDraft = '';
|
||||
}
|
||||
|
||||
async function patchHighlight(
|
||||
h: Highlight,
|
||||
patch: { note?: string | null; color?: HighlightColor }
|
||||
) {
|
||||
const res = await fetch(`${notesUrl}/${h.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: Highlight = await res.json();
|
||||
highlights = highlights.map((x) => (x.id === h.id ? updated : x));
|
||||
if (patch.color) engine?.addHighlight(updated);
|
||||
}
|
||||
|
||||
async function removeHighlight(h: Highlight) {
|
||||
const res = await fetch(`${notesUrl}/${h.id}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) return;
|
||||
highlights = highlights.filter((x) => x.id !== h.id);
|
||||
engine?.removeHighlight(h);
|
||||
activeId = null;
|
||||
}
|
||||
|
||||
function openHighlight(id: number) {
|
||||
activeId = id;
|
||||
noteDraft = highlights.find((h) => h.id === id)?.note ?? '';
|
||||
noteFor = null;
|
||||
}
|
||||
|
||||
function onselection(s: TextSelection | null) {
|
||||
selection = s;
|
||||
if (s) activeId = null;
|
||||
else if (noteFor === 'selection') noteFor = null;
|
||||
}
|
||||
|
||||
// ---- navigation ----
|
||||
function onkey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, select, [contenteditable="true"]')) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
case 'l':
|
||||
engine?.next();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
case 'h':
|
||||
engine?.prev();
|
||||
break;
|
||||
case ' ':
|
||||
if (isPdf) return;
|
||||
if (e.shiftKey) engine?.prev();
|
||||
else engine?.next();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (panel || activeId || selection) {
|
||||
panel = null;
|
||||
activeId = null;
|
||||
engine?.clearSelection();
|
||||
selection = null;
|
||||
} else return;
|
||||
break;
|
||||
case 't':
|
||||
panel = panel === 'toc' ? null : 'toc';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
async function jump(target: string | number) {
|
||||
await engine?.goTo(target);
|
||||
if (window.matchMedia('(max-width: 900px)').matches) panel = null;
|
||||
}
|
||||
|
||||
const fmtPercent = (p: number) => `${p < 10 ? p.toFixed(1) : Math.round(p)}%`;
|
||||
const tocLabel = $derived(location?.label ?? null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<div class="reader" style:--r-bg={colors.bg} style:--r-fg={colors.fg} data-theme={theme}>
|
||||
<header class="bar top">
|
||||
<a href={detailUrl} class="icon-btn" aria-label="Back to the book"><ArrowLeft size={18} /></a>
|
||||
<div class="min-w-0 flex-1 text-center">
|
||||
<p class="truncate text-sm font-medium">{data.book.title}</p>
|
||||
<p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', panel === 'toc' && 'on']}
|
||||
aria-label="Contents"
|
||||
title="Contents (t)"
|
||||
onclick={() => (panel = panel === 'toc' ? null : 'toc')}><ListTree size={18} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', panel === 'highlights' && 'on']}
|
||||
aria-label="Highlights"
|
||||
onclick={() => (panel = panel === 'highlights' ? null : 'highlights')}
|
||||
><Highlighter size={18} />{#if highlights.length}<span class="count">{highlights.length}</span
|
||||
>{/if}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', panel === 'settings' && 'on']}
|
||||
aria-label="Display settings"
|
||||
onclick={() => (panel = panel === 'settings' ? null : 'settings')}><Type size={18} /></button
|
||||
>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<main class="book">
|
||||
{#if isPdf}
|
||||
<PdfReader
|
||||
bind:this={engine}
|
||||
url={data.file.url}
|
||||
name={data.file.name}
|
||||
{start}
|
||||
startPercent={data.resume.percent}
|
||||
{highlights}
|
||||
{settings}
|
||||
{theme}
|
||||
onready={(t) => {
|
||||
toc = t;
|
||||
ready = true;
|
||||
}}
|
||||
{onrelocate}
|
||||
{onselection}
|
||||
onhighlightclick={openHighlight}
|
||||
{onkey}
|
||||
onloading={(f) => (loading = f)}
|
||||
onerror={(m) => (failure = m)}
|
||||
/>
|
||||
{:else}
|
||||
<EpubReader
|
||||
bind:this={engine}
|
||||
url={data.file.url}
|
||||
name={data.file.name}
|
||||
{start}
|
||||
startPercent={data.resume.percent}
|
||||
{highlights}
|
||||
{settings}
|
||||
{theme}
|
||||
onready={(t) => {
|
||||
toc = t;
|
||||
ready = true;
|
||||
}}
|
||||
{onrelocate}
|
||||
{onselection}
|
||||
onhighlightclick={openHighlight}
|
||||
{onkey}
|
||||
onloading={(f) => (loading = f)}
|
||||
onerror={(m) => (failure = m)}
|
||||
/>
|
||||
{#if ready && settings.flow === 'paginated'}
|
||||
<button
|
||||
type="button"
|
||||
class="turn left"
|
||||
aria-label="Previous page"
|
||||
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="turn right"
|
||||
aria-label="Next page"
|
||||
onclick={() => engine?.next()}><ChevronRight size={22} /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if failure}
|
||||
<div class="overlay">
|
||||
<p class="font-medium">Couldn't open this book</p>
|
||||
<p class="mt-1 text-sm opacity-70">{failure}</p>
|
||||
<a href={detailUrl} class="mt-4 text-sm underline">back to the book</a>
|
||||
</div>
|
||||
{:else if !ready}
|
||||
<div class="overlay">
|
||||
<p class="text-sm opacity-70">
|
||||
{loading === null || loading === 0
|
||||
? 'Opening…'
|
||||
: `Downloading ${Math.round(loading * 100)}%`}
|
||||
</p>
|
||||
<span class="loadbar"><span style:width={`${(loading ?? 0) * 100}%`}></span></span>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if panel}
|
||||
<aside class="panel" aria-label={panel}>
|
||||
<div class="panel-head">
|
||||
<h2>
|
||||
{panel === 'toc' ? 'Contents' : panel === 'highlights' ? 'Highlights' : 'Display'}
|
||||
</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" onclick={() => (panel = null)}
|
||||
><X size={16} /></button
|
||||
>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{#if panel === 'toc'}
|
||||
{#snippet tree(items: TocItem[], depth: number)}
|
||||
<ul>
|
||||
{#each items as item, i (i)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class={['toc-item', tocLabel === item.label && 'current']}
|
||||
style:padding-left={`${0.75 + depth * 0.9}rem`}
|
||||
onclick={() => jump(item.target)}>{item.label}</button
|
||||
>
|
||||
{#if item.children.length && depth < 4}{@render tree(
|
||||
item.children,
|
||||
depth + 1
|
||||
)}{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/snippet}
|
||||
{#if toc.length}{@render tree(toc, 0)}{:else}<p class="empty">
|
||||
No table of contents in this file.
|
||||
</p>{/if}
|
||||
{:else if panel === 'highlights'}
|
||||
{#if highlights.length}
|
||||
<ul class="space-y-2">
|
||||
{#each highlights as h (h.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="hl-item"
|
||||
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
|
||||
highlightCss.yellow}
|
||||
onclick={() => {
|
||||
if (h.locator) void jump(h.locator);
|
||||
openHighlight(h.id);
|
||||
}}
|
||||
>
|
||||
<span class="quote">{h.highlight}</span>
|
||||
{#if h.note}<span class="note">{h.note}</span>{/if}
|
||||
{#if h.page}<span class="meta">p. {h.page}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">
|
||||
Select text to highlight it. Highlights also show on the book page.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-5">
|
||||
<fieldset>
|
||||
<legend>Theme</legend>
|
||||
<div class="themes">
|
||||
{#each ['auto', 'light', 'sepia', 'dark'] as const as t (t)}
|
||||
<button
|
||||
type="button"
|
||||
class={['theme-swatch', settings.theme === t && 'on']}
|
||||
style:background={t === 'auto'
|
||||
? 'linear-gradient(135deg, #fdfdfb 50%, #121214 50%)'
|
||||
: themeColors[t].bg}
|
||||
style:color={t === 'auto' ? '#888' : themeColors[t].fg}
|
||||
onclick={() => updateSettings({ theme: t })}>{t}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{#if isPdf}
|
||||
<fieldset>
|
||||
<legend>Zoom</legend>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom out"
|
||||
onclick={() =>
|
||||
updateSettings({
|
||||
pdfZoom: Math.max(0.5, +(settings.pdfZoom - 0.1).toFixed(2))
|
||||
})}><Minus size={14} /></button
|
||||
>
|
||||
<span>{Math.round(settings.pdfZoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom in"
|
||||
onclick={() =>
|
||||
updateSettings({
|
||||
pdfZoom: Math.min(3, +(settings.pdfZoom + 0.1).toFixed(2))
|
||||
})}><Plus size={14} /></button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<fieldset>
|
||||
<legend>Text size</legend>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Smaller text"
|
||||
onclick={() =>
|
||||
updateSettings({ fontSize: Math.max(70, settings.fontSize - 10) })}
|
||||
><Minus size={14} /></button
|
||||
>
|
||||
<span>{settings.fontSize}%</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Larger text"
|
||||
onclick={() =>
|
||||
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })}
|
||||
><Plus size={14} /></button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Font</legend>
|
||||
<div class="seg">
|
||||
{#each [['publisher', 'Book'], ['serif', 'Serif'], ['sans', 'Sans']] as const as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class={settings.font === value && 'on'}
|
||||
onclick={() => updateSettings({ font: value })}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Line spacing</legend>
|
||||
<div class="seg">
|
||||
{#each [1.3, 1.5, 1.75] as value (value)}
|
||||
<button
|
||||
type="button"
|
||||
class={settings.lineHeight === value && 'on'}
|
||||
onclick={() => updateSettings({ lineHeight: value })}
|
||||
>{value === 1.3 ? 'Tight' : value === 1.5 ? 'Normal' : 'Loose'}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Layout</legend>
|
||||
<div class="seg">
|
||||
<button
|
||||
type="button"
|
||||
class={settings.flow === 'paginated' && 'on'}
|
||||
onclick={() => updateSettings({ flow: 'paginated' })}>Pages</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={settings.flow === 'scrolled' && 'on'}
|
||||
onclick={() => updateSettings({ flow: 'scrolled' })}>Scroll</button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
<p class="text-xs opacity-60">
|
||||
Keys: ← → or h l to turn pages, t for contents, Esc to close.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selection || active}
|
||||
<div class="sheet" role="dialog" aria-label={active ? 'Highlight' : 'Highlight selection'}>
|
||||
{#if active}
|
||||
<p class="sheet-quote">{active.highlight}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each highlightColors as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class={['dot', active.color === c && 'on']}
|
||||
style:background={highlightCss[c]}
|
||||
aria-label={`Colour ${c}`}
|
||||
onclick={() => patchHighlight(active, { color: c })}
|
||||
></button>
|
||||
{/each}
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Delete highlight"
|
||||
onclick={() => removeHighlight(active)}><Trash2 size={16} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
onclick={() => (activeId = null)}><X size={16} /></button
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
class="sheet-note"
|
||||
rows="2"
|
||||
placeholder="Add a note…"
|
||||
bind:value={noteDraft}
|
||||
onblur={() => {
|
||||
if ((active.note ?? '') !== noteDraft.trim())
|
||||
void patchHighlight(active, { note: noteDraft.trim() || null });
|
||||
}}></textarea>
|
||||
{:else if selection}
|
||||
<div class="flex items-center gap-2">
|
||||
{#each highlightColors as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class="dot"
|
||||
style:background={highlightCss[c]}
|
||||
aria-label={`Highlight ${c}`}
|
||||
title={`Highlight ${c}`}
|
||||
onclick={() => createHighlight(c, noteFor === 'selection' ? noteDraft.trim() : null)}
|
||||
></button>
|
||||
{/each}
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
type="button"
|
||||
class={['icon-btn', noteFor === 'selection' && 'on']}
|
||||
aria-label="Highlight with a note"
|
||||
onclick={() => {
|
||||
noteFor = noteFor === 'selection' ? null : 'selection';
|
||||
noteDraft = '';
|
||||
}}><MessageSquarePlus size={16} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Copy"
|
||||
onclick={() => {
|
||||
void navigator.clipboard?.writeText(selection?.text ?? '');
|
||||
engine?.clearSelection();
|
||||
selection = null;
|
||||
}}><Copy size={16} /></button
|
||||
>
|
||||
</div>
|
||||
{#if noteFor === 'selection'}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
class="sheet-note"
|
||||
rows="2"
|
||||
placeholder="Note, then pick a colour"
|
||||
autofocus
|
||||
bind:value={noteDraft}></textarea>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="bar bottom">
|
||||
<span class="w-12 text-xs tabular-nums opacity-70">{fmtPercent(percent)}</span>
|
||||
<input
|
||||
class="scrub"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={percent}
|
||||
aria-label="Position in book"
|
||||
style:--fill={`${percent}%`}
|
||||
oninput={(e) => (scrubbing = Number(e.currentTarget.value))}
|
||||
onchange={async (e) => {
|
||||
await engine?.goToPercent(Number(e.currentTarget.value));
|
||||
scrubbing = null;
|
||||
}}
|
||||
/>
|
||||
<span class="w-24 text-right text-xs tabular-nums opacity-70">
|
||||
{#if location?.page && location.pageCount}{location.page} / {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
|
||||
saved{:else if saveState === 'saved'}saved{/if}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--r-bg);
|
||||
color: var(--r-fg);
|
||||
--r-line: color-mix(in srgb, var(--r-fg) 12%, transparent);
|
||||
--r-soft: color-mix(in srgb, var(--r-fg) 7%, transparent);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
.top {
|
||||
height: 3.25rem;
|
||||
border-bottom: 1px solid var(--r-line);
|
||||
}
|
||||
.bottom {
|
||||
height: 3rem;
|
||||
border-top: 1px solid var(--r-line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.75;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
.icon-btn:hover,
|
||||
.icon-btn.on {
|
||||
opacity: 1;
|
||||
background: var(--r-soft);
|
||||
}
|
||||
.count {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.05rem;
|
||||
font-size: 0.6rem;
|
||||
min-width: 1rem;
|
||||
line-height: 1rem;
|
||||
border-radius: 999px;
|
||||
background: var(--r-fg);
|
||||
color: var(--r-bg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.book {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.turn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
z-index: 2;
|
||||
width: 2.5rem;
|
||||
height: 5rem;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.25;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.turn:hover {
|
||||
opacity: 0.8;
|
||||
background: var(--r-soft);
|
||||
}
|
||||
.turn.left {
|
||||
left: 0.5rem;
|
||||
}
|
||||
.turn.right {
|
||||
right: 0.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.turn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--r-bg);
|
||||
padding: 2rem;
|
||||
}
|
||||
.loadbar {
|
||||
margin-top: 0.75rem;
|
||||
width: 12rem;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--r-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.loadbar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--r-fg);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
.panel {
|
||||
width: 22rem;
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--r-line);
|
||||
background: var(--r-bg);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.panel {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 5;
|
||||
box-shadow: -12px 0 32px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.75rem 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--r-line);
|
||||
}
|
||||
.panel-head h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.toc-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
padding-block: 0.4rem;
|
||||
padding-right: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.toc-item:hover {
|
||||
background: var(--r-soft);
|
||||
opacity: 1;
|
||||
}
|
||||
.toc-item.current {
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
.hl-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 3px solid var(--hl);
|
||||
background: color-mix(in srgb, var(--hl) 10%, transparent);
|
||||
}
|
||||
.hl-item:hover {
|
||||
background: color-mix(in srgb, var(--hl) 18%, transparent);
|
||||
}
|
||||
.hl-item .quote {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hl-item .note {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hl-item .meta {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
fieldset legend {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.themes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.theme-swatch {
|
||||
height: 2.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--r-line);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.theme-swatch.on {
|
||||
outline: 2px solid var(--r-fg);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.stepper,
|
||||
.seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--r-line);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stepper button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
.stepper button:hover,
|
||||
.seg button:hover {
|
||||
background: var(--r-soft);
|
||||
}
|
||||
.stepper span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.seg button + button {
|
||||
border-left: 1px solid var(--r-line);
|
||||
}
|
||||
.seg button.on {
|
||||
background: var(--r-fg);
|
||||
color: var(--r-bg);
|
||||
}
|
||||
.sheet {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 3.75rem;
|
||||
translate: -50% 0;
|
||||
z-index: 10;
|
||||
width: min(26rem, calc(100% - 1.5rem));
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.875rem;
|
||||
border: 1px solid var(--r-line);
|
||||
background: color-mix(in srgb, var(--r-bg) 92%, var(--r-fg));
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
|
||||
animation: rise 0.15s ease-out;
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: -50% 6px;
|
||||
}
|
||||
}
|
||||
.sheet-quote {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
max-height: 6rem;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.sheet-note {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--r-line);
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
color: inherit;
|
||||
}
|
||||
.dot {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.dot:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
.dot.on {
|
||||
border-color: var(--r-fg);
|
||||
}
|
||||
.scrub {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--r-fg) var(--fill),
|
||||
color-mix(in srgb, var(--r-fg) 18%, transparent) var(--fill)
|
||||
);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scrub::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--r-fg);
|
||||
}
|
||||
.scrub::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--r-fg);
|
||||
}
|
||||
</style>
|
||||
157
src/routes/trackers/books/auth.test.ts
Normal file
157
src/routes/trackers/books/auth.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// Auth gating for the book tracker: files, reader, progress and notes are owner-only and
|
||||
// answer 404 to everyone else; the public pages never leak files or notes.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
|
||||
vi.mock('#lib/server/db/index.js', () => ({ db: {} }));
|
||||
vi.mock('#lib/server/books/library.js', () => ({
|
||||
coverSrc: vi.fn(() => null),
|
||||
getWork: vi.fn(),
|
||||
workFiles: vi.fn(async () => []),
|
||||
workNotes: vi.fn(async () => []),
|
||||
workSessions: vi.fn(async () => []),
|
||||
saveProgress: vi.fn(async () => ({ sessionId: 7 })),
|
||||
setStatus: vi.fn(),
|
||||
setRating: vi.fn(),
|
||||
setManualProgress: vi.fn(),
|
||||
logManualSession: vi.fn(),
|
||||
libraryCards: vi.fn(async () => []),
|
||||
libraryStats: vi.fn(async () => ({}))
|
||||
}));
|
||||
vi.mock('#lib/server/books/notes.js', async (original) => ({
|
||||
...(await original<typeof import('#lib/server/books/notes.js')>()),
|
||||
createNote: vi.fn(async () => ({ id: 1 })),
|
||||
updateNote: vi.fn(async () => ({ id: 1 })),
|
||||
deleteNote: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
import * as library from '#lib/server/books/library.js';
|
||||
import * as notes from '#lib/server/books/notes.js';
|
||||
import { POST as progress } from './[id=integer]/progress/+server';
|
||||
import { POST as createNote } from './[id=integer]/notes/+server';
|
||||
import {
|
||||
PATCH as patchNote,
|
||||
DELETE as deleteNote
|
||||
} from './[id=integer]/notes/[noteId=integer]/+server';
|
||||
import { GET as file } from './files/[id=integer]/+server';
|
||||
import { load as readerLoad } from './[id=integer]/read/+page.server';
|
||||
import { actions, load as detailLoad } from './[id=integer]/+page.server';
|
||||
|
||||
const OWNER = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' };
|
||||
const STRANGER = { id: 'u2', email: 'someone@example.com', role: 'admin' };
|
||||
|
||||
function event(user: object | null, init: RequestInit = {}, params: Record<string, string> = {}) {
|
||||
return {
|
||||
locals: { user, session: null },
|
||||
params: { id: '1', noteId: '1', ...params },
|
||||
request: new Request('https://site.test/trackers/books/1', init),
|
||||
url: new URL('https://site.test/trackers/books/1'),
|
||||
setHeaders: vi.fn()
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function status(fn: () => unknown) {
|
||||
try {
|
||||
const result = await fn();
|
||||
return result instanceof Response ? result.status : 200;
|
||||
} catch (e) {
|
||||
if (isHttpError(e)) return e.status;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const json = (body: unknown, method = 'POST') => ({
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const work = {
|
||||
id: 1,
|
||||
title: 'Dune',
|
||||
subtitle: null,
|
||||
authors: ['Frank Herbert'],
|
||||
year: 1965,
|
||||
publisher: null,
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
language: 'en',
|
||||
pageCount: 412,
|
||||
description: null,
|
||||
tags: [],
|
||||
coverPath: null,
|
||||
coverUrl: null,
|
||||
openlibraryWorkId: null
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(library.getWork).mockResolvedValue({ work, progress: null } as never);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['anonymous', null],
|
||||
['another signed-in user', STRANGER]
|
||||
])('%s', (_, user) => {
|
||||
it('gets 404 from every owner-only endpoint', async () => {
|
||||
expect(await status(() => progress(event(user, json({ percent: 10 }))))).toBe(404);
|
||||
expect(await status(() => createNote(event(user, json({ note: 'hi' }))))).toBe(404);
|
||||
expect(await status(() => patchNote(event(user, json({ note: 'x' }, 'PATCH'))))).toBe(404);
|
||||
expect(await status(() => deleteNote(event(user, { method: 'DELETE' })))).toBe(404);
|
||||
expect(await status(() => file(event(user)))).toBe(404);
|
||||
expect(await status(() => readerLoad(event(user)))).toBe(404);
|
||||
for (const action of Object.values(actions))
|
||||
expect(
|
||||
await status(() => action(event(user, { method: 'POST', body: new FormData() })))
|
||||
).toBe(404);
|
||||
expect(library.saveProgress).not.toHaveBeenCalled();
|
||||
expect(notes.createNote).not.toHaveBeenCalled();
|
||||
expect(library.setStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
it('sees the public catalogue page without files, notes or sessions', async () => {
|
||||
const data = (await detailLoad(event(user))) as Record<string, unknown>;
|
||||
expect(data.owner).toBe(false);
|
||||
expect(data).not.toHaveProperty('files');
|
||||
expect(data).not.toHaveProperty('notes');
|
||||
expect(data).not.toHaveProperty('sessions');
|
||||
expect(library.workNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('owner', () => {
|
||||
it('saves progress and gets a session id back', async () => {
|
||||
const res = await progress(
|
||||
event(OWNER, json({ fileId: 2, locator: 'page:5', page: 5, percent: 1.234 }))
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ sessionId: 7 });
|
||||
expect(library.saveProgress).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ percent: 1.23, page: 5 })
|
||||
);
|
||||
});
|
||||
it('rejects invalid progress', async () => {
|
||||
expect(await status(() => progress(event(OWNER, json({ percent: 140 }))))).toBe(400);
|
||||
expect(await status(() => progress(event(OWNER, { method: 'POST', body: '{' })))).toBe(400);
|
||||
expect(library.saveProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it('404s progress for a book that does not exist', async () => {
|
||||
vi.mocked(library.saveProgress).mockResolvedValueOnce(null);
|
||||
expect(await status(() => progress(event(OWNER, json({ percent: 3 }))))).toBe(404);
|
||||
});
|
||||
it('creates highlights and refuses empty notes', async () => {
|
||||
expect(
|
||||
await status(() => createNote(event(OWNER, json({ highlight: 'quote', locator: 'page:1' }))))
|
||||
).toBe(201);
|
||||
expect(await status(() => createNote(event(OWNER, json({ note: ' ' }))))).toBe(400);
|
||||
});
|
||||
it('sees notes, files and sessions on the detail page', async () => {
|
||||
const data = (await detailLoad(event(OWNER))) as Record<string, unknown>;
|
||||
expect(data.owner).toBe(true);
|
||||
expect(data).toHaveProperty('notes');
|
||||
expect(data).toHaveProperty('files');
|
||||
});
|
||||
it('gets 404 from the reader when the book has no readable file', async () => {
|
||||
expect(await status(() => readerLoad(event(OWNER)))).toBe(404);
|
||||
});
|
||||
});
|
||||
23
src/routes/trackers/books/covers/[id=integer]/+server.ts
Normal file
23
src/routes/trackers/books/covers/[id=integer]/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Public cover images for the catalogue (covers only, never book files).
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { safeResolve, streamFile } from '#lib/server/books/files.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const [work] = await db
|
||||
.select({ coverPath: bookWorks.coverPath })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, Number(event.params.id)))
|
||||
.limit(1);
|
||||
const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null;
|
||||
if (!resolved) error(404, 'Not Found');
|
||||
const response = await streamFile(resolved, event.request, {
|
||||
'cache-control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||
'x-content-type-options': 'nosniff'
|
||||
});
|
||||
if (!response.headers.get('content-type')?.startsWith('image/')) error(404, 'Not Found');
|
||||
return response;
|
||||
};
|
||||
30
src/routes/trackers/books/files/[id=integer]/+server.ts
Normal file
30
src/routes/trackers/books/files/[id=integer]/+server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Owner-only book file download/stream for the reader. Everyone else gets 404, the same as
|
||||
// a file that doesn't exist. Files are read from BOOKS_ROOT/library, never from static/.
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { bookFiles } from '#lib/server/db/schema/index.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { safeResolve, streamFile } from '#lib/server/books/files.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const handler: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const [file] = await db
|
||||
.select({ path: bookFiles.path })
|
||||
.from(bookFiles)
|
||||
.where(eq(bookFiles.id, Number(event.params.id)))
|
||||
.limit(1);
|
||||
const resolved = file ? await safeResolve('library', file.path) : null;
|
||||
if (!resolved) error(404, 'Not Found');
|
||||
const name = resolved.split('/').at(-1) ?? 'book';
|
||||
return streamFile(resolved, event.request, {
|
||||
'cache-control': 'private, no-store',
|
||||
'x-robots-tag': 'noindex, nofollow, noarchive',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-disposition': `inline; filename*=UTF-8''${encodeURIComponent(name)}`
|
||||
});
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const HEAD = handler;
|
||||
21
static/vendor/foliate-js/LICENSE
vendored
Normal file
21
static/vendor/foliate-js/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 John Factotum
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1
static/vendor/foliate-js/VERSION
vendored
Normal file
1
static/vendor/foliate-js/VERSION
vendored
Normal file
@@ -0,0 +1 @@
|
||||
foliate-js @ 78914aef4466eb960965702401634c2cb348e9b1 (MIT), https://github.com/johnfactotum/foliate-js. vendor/pdfjs omitted: PDFs use pdfjs-dist from npm.
|
||||
45
static/vendor/foliate-js/comic-book.js
vendored
Normal file
45
static/vendor/foliate-js/comic-book.js
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
|
||||
const cache = new Map()
|
||||
const urls = new Map()
|
||||
const load = async name => {
|
||||
if (cache.has(name)) return cache.get(name)
|
||||
const src = URL.createObjectURL(await loadBlob(name))
|
||||
const page = URL.createObjectURL(
|
||||
new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}"></body></html>`], { type: 'text/html' }))
|
||||
urls.set(name, [src, page])
|
||||
cache.set(name, page)
|
||||
return page
|
||||
}
|
||||
const unload = name => {
|
||||
urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url))
|
||||
urls.delete(name)
|
||||
cache.delete(name)
|
||||
}
|
||||
|
||||
const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif']
|
||||
const files = entries
|
||||
.map(entry => entry.filename)
|
||||
.filter(name => exts.some(ext => name.endsWith(ext)))
|
||||
.sort(new Intl.Collator([], { numeric: true }).compare)
|
||||
if (!files.length) throw new Error('No supported image files in archive')
|
||||
|
||||
const book = {}
|
||||
book.getCover = () => loadBlob(files[0])
|
||||
book.metadata = { title: file.name }
|
||||
book.sections = files.map(name => ({
|
||||
id: name,
|
||||
load: () => load(name),
|
||||
unload: () => unload(name),
|
||||
size: getSize(name),
|
||||
}))
|
||||
book.toc = files.map(name => ({ label: name, href: name }))
|
||||
book.rendition = { layout: 'pre-paginated' }
|
||||
book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) })
|
||||
book.splitTOCHref = href => [href, null]
|
||||
book.getTOCFragment = doc => doc.documentElement
|
||||
book.destroy = () => {
|
||||
for (const arr of urls.values())
|
||||
for (const url of arr) URL.revokeObjectURL(url)
|
||||
}
|
||||
return book
|
||||
}
|
||||
241
static/vendor/foliate-js/dict.js
vendored
Normal file
241
static/vendor/foliate-js/dict.js
vendored
Normal file
@@ -0,0 +1,241 @@
|
||||
const decoder = new TextDecoder()
|
||||
const decode = decoder.decode.bind(decoder)
|
||||
|
||||
const concatTypedArray = (a, b) => {
|
||||
const result = new a.constructor(a.length + b.length)
|
||||
result.set(a)
|
||||
result.set(b, a.length)
|
||||
return result
|
||||
}
|
||||
|
||||
const strcmp = (a, b) => {
|
||||
a = a.toLowerCase(), b = b.toLowerCase()
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
class DictZip {
|
||||
#chlen
|
||||
#chunks
|
||||
#compressed
|
||||
inflate
|
||||
async load(file) {
|
||||
const header = new DataView(await file.slice(0, 12).arrayBuffer())
|
||||
if (header.getUint8(0) !== 31 || header.getUint8(1) !== 139
|
||||
|| header.getUint8(2) !== 8) throw new Error('Not a DictZip file')
|
||||
const flg = header.getUint8(3)
|
||||
if (!flg & 0b100) throw new Error('Missing FEXTRA flag')
|
||||
|
||||
const xlen = header.getUint16(10, true)
|
||||
const extra = new DataView(await file.slice(12, 12 + xlen).arrayBuffer())
|
||||
if (extra.getUint8(0) !== 82 || extra.getUint8(1) !== 65)
|
||||
throw new Error('Subfield ID should be RA')
|
||||
if (extra.getUint16(4, true) !== 1) throw new Error('Unsupported version')
|
||||
|
||||
this.#chlen = extra.getUint16(6, true)
|
||||
const chcnt = extra.getUint16(8, true)
|
||||
this.#chunks = []
|
||||
for (let i = 0, chunkOffset = 0; i < chcnt; i++) {
|
||||
const chunkSize = extra.getUint16(10 + 2 * i, true)
|
||||
this.#chunks.push([chunkOffset, chunkSize])
|
||||
chunkOffset = chunkOffset + chunkSize
|
||||
}
|
||||
|
||||
// skip to compressed data
|
||||
let offset = 12 + xlen
|
||||
const max = Math.min(offset + 512, file.size)
|
||||
const strArr = new Uint8Array(await file.slice(0, max).arrayBuffer())
|
||||
if (flg & 0b1000) { // fname
|
||||
const i = strArr.indexOf(0, offset)
|
||||
if (i < 0) throw new Error('Header too long')
|
||||
offset = i + 1
|
||||
}
|
||||
if (flg & 0b10000) { // fcomment
|
||||
const i = strArr.indexOf(0, offset)
|
||||
if (i < 0) throw new Error('Header too long')
|
||||
offset = i + 1
|
||||
}
|
||||
if (flg & 0b10) offset += 2 // fhcrc
|
||||
this.#compressed = file.slice(offset)
|
||||
}
|
||||
async read(offset, size) {
|
||||
const chunks = this.#chunks
|
||||
const startIndex = Math.trunc(offset / this.#chlen)
|
||||
const endIndex = Math.trunc((offset + size) / this.#chlen)
|
||||
const buf = await this.#compressed.slice(chunks[startIndex][0],
|
||||
chunks[endIndex][0] + chunks[endIndex][1]).arrayBuffer()
|
||||
let arr = new Uint8Array()
|
||||
for (let pos = 0, i = startIndex; i <= endIndex; i++) {
|
||||
const data = new Uint8Array(buf, pos, chunks[i][1])
|
||||
arr = concatTypedArray(arr, await this.inflate(data))
|
||||
pos += chunks[i][1]
|
||||
}
|
||||
const startOffset = offset - startIndex * this.#chlen
|
||||
return arr.subarray(startOffset, startOffset + size)
|
||||
}
|
||||
}
|
||||
|
||||
class Index {
|
||||
strcmp = strcmp
|
||||
// binary search
|
||||
bisect(query, start = 0, end = this.words.length - 1) {
|
||||
if (end - start === 1) {
|
||||
if (!this.strcmp(query, this.getWord(start))) return start
|
||||
if (!this.strcmp(query, this.getWord(end))) return end
|
||||
return null
|
||||
}
|
||||
const mid = Math.floor(start + (end - start) / 2)
|
||||
const cmp = this.strcmp(query, this.getWord(mid))
|
||||
if (cmp < 0) return this.bisect(query, start, mid)
|
||||
if (cmp > 0) return this.bisect(query, mid, end)
|
||||
return mid
|
||||
}
|
||||
// check for multiple definitions
|
||||
checkAdjacent(query, i) {
|
||||
if (i == null) return []
|
||||
let j = i
|
||||
const equals = i => {
|
||||
const word = this.getWord(i)
|
||||
return word ? this.strcmp(query, word) === 0 : false
|
||||
}
|
||||
while (equals(j - 1)) j--
|
||||
let k = i
|
||||
while (equals(k + 1)) k++
|
||||
return j === k ? [i] : Array.from({ length: k + 1 - j }, (_, i) => j + i)
|
||||
}
|
||||
lookup(query) {
|
||||
return this.checkAdjacent(query, this.bisect(query))
|
||||
}
|
||||
}
|
||||
|
||||
const decodeBase64Number = str => {
|
||||
const { length } = str
|
||||
let n = 0
|
||||
for (let i = 0; i < length; i++) {
|
||||
const c = str.charCodeAt(i)
|
||||
n += (c === 43 ? 62 // "+"
|
||||
: c === 47 ? 63 // "/"
|
||||
: c < 58 ? c + 4 // 0-9 -> 52-61
|
||||
: c < 91 ? c - 65 // A-Z -> 0-25
|
||||
: c - 71 // a-z -> 26-51
|
||||
) * 64 ** (length - 1 - i)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
class DictdIndex extends Index {
|
||||
getWord(i) {
|
||||
return this.words[i]
|
||||
}
|
||||
async load(file) {
|
||||
const words = []
|
||||
const offsets = []
|
||||
const sizes = []
|
||||
for (const line of decode(await file.arrayBuffer()).split('\n')) {
|
||||
const a = line.split('\t')
|
||||
words.push(a[0])
|
||||
offsets.push(decodeBase64Number(a[1]))
|
||||
sizes.push(decodeBase64Number(a[2]))
|
||||
}
|
||||
this.words = words
|
||||
this.offsets = offsets
|
||||
this.sizes = sizes
|
||||
}
|
||||
}
|
||||
|
||||
export class DictdDict {
|
||||
#dict = new DictZip()
|
||||
#idx = new DictdIndex()
|
||||
loadDict(file, inflate) {
|
||||
this.#dict.inflate = inflate
|
||||
return this.#dict.load(file)
|
||||
}
|
||||
async #readWord(i) {
|
||||
const word = this.#idx.getWord(i)
|
||||
const offset = this.#idx.offsets[i]
|
||||
const size = this.#idx.sizes[i]
|
||||
return { word, data: ['m', this.#dict.read(offset, size)] }
|
||||
}
|
||||
#readWords(arr) {
|
||||
return Promise.all(arr.map(this.#readWord.bind(this)))
|
||||
}
|
||||
lookup(query) {
|
||||
return this.#readWords(this.#idx.lookup(query))
|
||||
}
|
||||
}
|
||||
|
||||
class StarDictIndex extends Index {
|
||||
isSyn
|
||||
#arr
|
||||
getWord(i) {
|
||||
const word = this.words[i]
|
||||
if (!word) return
|
||||
return decode(this.#arr.subarray(word[0], word[1]))
|
||||
}
|
||||
async load(file) {
|
||||
const { isSyn } = this
|
||||
const buf = await file.arrayBuffer()
|
||||
const arr = new Uint8Array(buf)
|
||||
this.#arr = arr
|
||||
const view = new DataView(buf)
|
||||
const words = []
|
||||
const offsets = []
|
||||
const sizes = []
|
||||
for (let i = 0; i < arr.length;) {
|
||||
const newI = arr.subarray(0, i + 256).indexOf(0, i)
|
||||
if (newI < 0) throw new Error('Word too big')
|
||||
words.push([i, newI])
|
||||
offsets.push(view.getUint32(newI + 1))
|
||||
if (isSyn) i = newI + 5
|
||||
else {
|
||||
sizes.push(view.getUint32(newI + 5))
|
||||
i = newI + 9
|
||||
}
|
||||
}
|
||||
this.words = words
|
||||
this.offsets = offsets
|
||||
this.sizes = sizes
|
||||
}
|
||||
}
|
||||
|
||||
export class StarDict {
|
||||
#dict = new DictZip()
|
||||
#idx = new StarDictIndex()
|
||||
#syn = Object.assign(new StarDictIndex(), { isSyn: true })
|
||||
async loadIfo(file) {
|
||||
const str = decode(await file.arrayBuffer())
|
||||
this.ifo = Object.fromEntries(str.split('\n').map(line => {
|
||||
const sep = line.indexOf('=')
|
||||
if (sep < 0) return
|
||||
return [line.slice(0, sep), line.slice(sep + 1)]
|
||||
}).filter(x => x))
|
||||
}
|
||||
loadDict(file, inflate) {
|
||||
this.#dict.inflate = inflate
|
||||
return this.#dict.load(file)
|
||||
}
|
||||
loadIdx(file) {
|
||||
return this.#idx.load(file)
|
||||
}
|
||||
loadSyn(file) {
|
||||
if (file) return this.#syn.load(file)
|
||||
}
|
||||
async #readWord(i) {
|
||||
const word = this.#idx.getWord(i)
|
||||
const offset = this.#idx.offsets[i]
|
||||
const size = this.#idx.sizes[i]
|
||||
const data = await this.#dict.read(offset, size)
|
||||
const seq = this.ifo.sametypesequence
|
||||
if (!seq) throw new Error('TODO')
|
||||
if (seq.length === 1) return { word, data: [[seq[0], data]] }
|
||||
throw new Error('TODO')
|
||||
}
|
||||
#readWords(arr) {
|
||||
return Promise.all(arr.map(this.#readWord.bind(this)))
|
||||
}
|
||||
lookup(query) {
|
||||
return this.#readWords(this.#idx.lookup(query))
|
||||
}
|
||||
synonyms(query) {
|
||||
return this.#readWords(this.#syn.lookup(query).map(i => this.#syn.offsets[i]))
|
||||
}
|
||||
}
|
||||
1083
static/vendor/foliate-js/epub.js
vendored
Normal file
1083
static/vendor/foliate-js/epub.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
349
static/vendor/foliate-js/epubcfi.js
vendored
Normal file
349
static/vendor/foliate-js/epubcfi.js
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
const findIndices = (arr, f) => arr
|
||||
.map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null)
|
||||
const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) =>
|
||||
({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs
|
||||
const concatArrays = (a, b) =>
|
||||
a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1))
|
||||
|
||||
const isNumber = /\d/
|
||||
export const isCFI = /^epubcfi\((.*)\)$/
|
||||
const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&')
|
||||
|
||||
const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})`
|
||||
const unwrap = x => x.match(isCFI)?.[1] ?? x
|
||||
const lift = f => (...xs) =>
|
||||
`epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})`
|
||||
export const joinIndir = lift((...xs) => xs.join('!'))
|
||||
|
||||
const tokenizer = str => {
|
||||
const tokens = []
|
||||
let state, escape, value = ''
|
||||
const push = x => (tokens.push(x), state = null, value = '')
|
||||
const cat = x => (value += x, escape = false)
|
||||
for (const char of Array.from(str.trim()).concat('')) {
|
||||
if (char === '^' && !escape) {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if (state === '!') push(['!'])
|
||||
else if (state === ',') push([','])
|
||||
else if (state === '/' || state === ':') {
|
||||
if (isNumber.test(char)) {
|
||||
cat(char)
|
||||
continue
|
||||
} else push([state, parseInt(value)])
|
||||
} else if (state === '~') {
|
||||
if (isNumber.test(char) || char === '.') {
|
||||
cat(char)
|
||||
continue
|
||||
} else push(['~', parseFloat(value)])
|
||||
} else if (state === '@') {
|
||||
if (char === ':') {
|
||||
push(['@', parseFloat(value)])
|
||||
state = '@'
|
||||
continue
|
||||
}
|
||||
if (isNumber.test(char) || char === '.') {
|
||||
cat(char)
|
||||
continue
|
||||
} else push(['@', parseFloat(value)])
|
||||
} else if (state === '[') {
|
||||
if (char === ';' && !escape) {
|
||||
push(['[', value])
|
||||
state = ';'
|
||||
} else if (char === ',' && !escape) {
|
||||
push(['[', value])
|
||||
state = '['
|
||||
} else if (char === ']' && !escape) push(['[', value])
|
||||
else cat(char)
|
||||
continue
|
||||
} else if (state?.startsWith(';')) {
|
||||
if (char === '=' && !escape) {
|
||||
state = `;${value}`
|
||||
value = ''
|
||||
} else if (char === ';' && !escape) {
|
||||
push([state, value])
|
||||
state = ';'
|
||||
} else if (char === ']' && !escape) push([state, value])
|
||||
else cat(char)
|
||||
continue
|
||||
}
|
||||
if (char === '/' || char === ':' || char === '~' || char === '@'
|
||||
|| char === '[' || char === '!' || char === ',') state = char
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x)
|
||||
|
||||
const parser = tokens => {
|
||||
const parts = []
|
||||
let state
|
||||
for (const [type, val] of tokens) {
|
||||
if (type === '/') parts.push({ index: val })
|
||||
else {
|
||||
const last = parts[parts.length - 1]
|
||||
if (type === ':') last.offset = val
|
||||
else if (type === '~') last.temporal = val
|
||||
else if (type === '@') last.spatial = (last.spatial ?? []).concat(val)
|
||||
else if (type === ';s') last.side = val
|
||||
else if (type === '[') {
|
||||
if (state === '/' && val) last.id = val
|
||||
else {
|
||||
last.text = (last.text ?? []).concat(val)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
state = type
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// split at step indirections, then parse each part
|
||||
const parserIndir = tokens =>
|
||||
splitAt(tokens, findTokens(tokens, '!')).map(parser)
|
||||
|
||||
export const parse = cfi => {
|
||||
const tokens = tokenizer(unwrap(cfi))
|
||||
const commas = findTokens(tokens, ',')
|
||||
if (!commas.length) return parserIndir(tokens)
|
||||
const [parent, start, end] = splitAt(tokens, commas).map(parserIndir)
|
||||
return { parent, start, end }
|
||||
}
|
||||
|
||||
const partToString = ({ index, id, offset, temporal, spatial, text, side }) => {
|
||||
const param = side ? `;s=${side}` : ''
|
||||
return `/${index}`
|
||||
+ (id ? `[${escapeCFI(id)}${param}]` : '')
|
||||
// "CFI expressions [..] SHOULD include an explicit character offset"
|
||||
+ (offset != null && index % 2 ? `:${offset}` : '')
|
||||
+ (temporal ? `~${temporal}` : '')
|
||||
+ (spatial ? `@${spatial.join(':')}` : '')
|
||||
+ (text || (!id && side) ? '['
|
||||
+ (text?.map(escapeCFI)?.join(',') ?? '')
|
||||
+ param + ']' : '')
|
||||
}
|
||||
|
||||
const toInnerString = parsed => parsed.parent
|
||||
? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',')
|
||||
: parsed.map(parts => parts.map(partToString).join('')).join('!')
|
||||
|
||||
const toString = parsed => wrap(toInnerString(parsed))
|
||||
|
||||
export const collapse = (x, toEnd) => typeof x === 'string'
|
||||
? toString(collapse(parse(x), toEnd))
|
||||
: x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x
|
||||
|
||||
// create range CFI from two CFIs
|
||||
const buildRange = (from, to) => {
|
||||
if (typeof from === 'string') from = parse(from)
|
||||
if (typeof to === 'string') to = parse(to)
|
||||
from = collapse(from)
|
||||
to = collapse(to, true)
|
||||
// ranges across multiple documents are not allowed; handle local paths only
|
||||
const localFrom = from[from.length - 1], localTo = to[to.length - 1]
|
||||
const localParent = [], localStart = [], localEnd = []
|
||||
let pushToParent = true
|
||||
const len = Math.max(localFrom.length, localTo.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a = localFrom[i], b = localTo[i]
|
||||
pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset
|
||||
if (pushToParent) localParent.push(a)
|
||||
else {
|
||||
if (a) localStart.push(a)
|
||||
if (b) localEnd.push(b)
|
||||
}
|
||||
}
|
||||
// copy non-local paths from `from`
|
||||
const parent = from.slice(0, -1).concat([localParent])
|
||||
return toString({ parent, start: [localStart], end: [localEnd] })
|
||||
}
|
||||
|
||||
export const compare = (a, b) => {
|
||||
if (typeof a === 'string') a = parse(a)
|
||||
if (typeof b === 'string') b = parse(b)
|
||||
if (a.start || b.start) return compare(collapse(a), collapse(b))
|
||||
|| compare(collapse(a, true), collapse(b, true))
|
||||
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const p = a[i] ?? [], q = b[i] ?? []
|
||||
const maxIndex = Math.max(p.length, q.length) - 1
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
const x = p[i], y = q[i]
|
||||
if (!x) return -1
|
||||
if (!y) return 1
|
||||
if (x.index > y.index) return 1
|
||||
if (x.index < y.index) return -1
|
||||
if (i === maxIndex) {
|
||||
// TODO: compare temporal & spatial offsets
|
||||
if (x.offset > y.offset) return 1
|
||||
if (x.offset < y.offset) return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4
|
||||
const isElementNode = ({ nodeType }) => nodeType === 1
|
||||
|
||||
const getChildNodes = (node, filter) => {
|
||||
const nodes = Array.from(node.childNodes)
|
||||
// "content other than element and character data is ignored"
|
||||
.filter(node => isTextNode(node) || isElementNode(node))
|
||||
return filter ? nodes.map(node => {
|
||||
const accept = filter(node)
|
||||
if (accept === NodeFilter.FILTER_REJECT) return null
|
||||
else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter)
|
||||
else return node
|
||||
}).flat().filter(x => x) : nodes
|
||||
}
|
||||
|
||||
// child nodes are organized such that the result is always
|
||||
// [element, text, element, text, ..., element],
|
||||
// regardless of the actual structure in the document;
|
||||
// so multiple text nodes need to be combined, and nonexistent ones counted;
|
||||
// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec
|
||||
const indexChildNodes = (node, filter) => {
|
||||
const nodes = getChildNodes(node, filter)
|
||||
.reduce((arr, node) => {
|
||||
let last = arr[arr.length - 1]
|
||||
if (!last) arr.push(node)
|
||||
// "there is one chunk between each pair of child elements"
|
||||
else if (isTextNode(node)) {
|
||||
if (Array.isArray(last)) last.push(node)
|
||||
else if (isTextNode(last)) arr[arr.length - 1] = [last, node]
|
||||
else arr.push(node)
|
||||
} else {
|
||||
if (isElementNode(last)) arr.push(null, node)
|
||||
else arr.push(node)
|
||||
}
|
||||
return arr
|
||||
}, [])
|
||||
// "the first chunk is located before the first child element"
|
||||
if (isElementNode(nodes[0])) nodes.unshift('first')
|
||||
// "the last chunk is located after the last child element"
|
||||
if (isElementNode(nodes[nodes.length - 1])) nodes.push('last')
|
||||
// "'virtual' elements"
|
||||
nodes.unshift('before') // "0 is a valid index"
|
||||
nodes.push('after') // "n+2 is a valid index"
|
||||
return nodes
|
||||
}
|
||||
|
||||
const partsToNode = (node, parts, filter) => {
|
||||
const { id } = parts[parts.length - 1]
|
||||
if (id) {
|
||||
const el = node.ownerDocument.getElementById(id)
|
||||
if (el) return { node: el, offset: 0 }
|
||||
}
|
||||
for (const { index } of parts) {
|
||||
const newNode = node ? indexChildNodes(node, filter)[index] : null
|
||||
// handle non-existent nodes
|
||||
if (newNode === 'first') return { node: node.firstChild ?? node }
|
||||
if (newNode === 'last') return { node: node.lastChild ?? node }
|
||||
if (newNode === 'before') return { node, before: true }
|
||||
if (newNode === 'after') return { node, after: true }
|
||||
node = newNode
|
||||
}
|
||||
const { offset } = parts[parts.length - 1]
|
||||
if (!Array.isArray(node)) return { node, offset }
|
||||
// get underlying text node and offset from the chunk
|
||||
let sum = 0
|
||||
for (const n of node) {
|
||||
const { length } = n.nodeValue
|
||||
if (sum + length >= offset) return { node: n, offset: offset - sum }
|
||||
sum += length
|
||||
}
|
||||
}
|
||||
|
||||
const nodeToParts = (node, offset, filter) => {
|
||||
let { id, parentNode } = node
|
||||
while (filter && parentNode
|
||||
&& parentNode !== node.ownerDocument.documentElement
|
||||
&& filter(parentNode) === NodeFilter.FILTER_SKIP)
|
||||
parentNode = parentNode.parentNode
|
||||
const indexed = indexChildNodes(parentNode, filter)
|
||||
const index = indexed.findIndex(x =>
|
||||
Array.isArray(x) ? x.some(x => x === node) : x === node)
|
||||
// adjust offset as if merging the text nodes in the chunk
|
||||
const chunk = indexed[index]
|
||||
if (Array.isArray(chunk)) {
|
||||
let sum = 0
|
||||
for (const x of chunk) {
|
||||
if (x === node) {
|
||||
sum += offset
|
||||
break
|
||||
} else sum += x.nodeValue.length
|
||||
}
|
||||
offset = sum
|
||||
}
|
||||
const part = { id, index, offset }
|
||||
return (parentNode !== node.ownerDocument.documentElement
|
||||
? nodeToParts(parentNode, null, filter).concat(part) : [part])
|
||||
// remove ignored nodes
|
||||
.filter(x => x.index !== -1)
|
||||
}
|
||||
|
||||
export const fromRange = (range, filter) => {
|
||||
const { startContainer, startOffset, endContainer, endOffset } = range
|
||||
const start = nodeToParts(startContainer, startOffset, filter)
|
||||
if (range.collapsed) return toString([start])
|
||||
const end = nodeToParts(endContainer, endOffset, filter)
|
||||
return buildRange([start], [end])
|
||||
}
|
||||
|
||||
export const toRange = (doc, parts, filter) => {
|
||||
const startParts = collapse(parts)
|
||||
const endParts = collapse(parts, true)
|
||||
|
||||
const root = doc.documentElement
|
||||
const start = partsToNode(root, startParts[0], filter)
|
||||
const end = partsToNode(root, endParts[0], filter)
|
||||
|
||||
const range = doc.createRange()
|
||||
|
||||
if (start.before) range.setStartBefore(start.node)
|
||||
else if (start.after) range.setStartAfter(start.node)
|
||||
else range.setStart(start.node, start.offset)
|
||||
|
||||
if (end.before) range.setEndBefore(end.node)
|
||||
else if (end.after) range.setEndAfter(end.node)
|
||||
else range.setEnd(end.node, end.offset)
|
||||
return range
|
||||
}
|
||||
|
||||
// faster way of getting CFIs for sorted elements in a single parent
|
||||
export const fromElements = elements => {
|
||||
const results = []
|
||||
const { parentNode } = elements[0]
|
||||
const parts = nodeToParts(parentNode)
|
||||
for (const [index, node] of indexChildNodes(parentNode).entries()) {
|
||||
const el = elements[results.length]
|
||||
if (node === el)
|
||||
results.push(toString([parts.concat({ id: el.id, index })]))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export const toElement = (doc, parts) =>
|
||||
partsToNode(doc.documentElement, collapse(parts)).node
|
||||
|
||||
// turn indices into standard CFIs when you don't have an actual package document
|
||||
export const fake = {
|
||||
fromIndex: index => wrap(`/6/${(index + 1) * 2}`),
|
||||
toIndex: parts => parts?.at(-1).index / 2 - 1,
|
||||
}
|
||||
|
||||
// get CFI from Calibre bookmarks
|
||||
// see https://github.com/johnfactotum/foliate/issues/849
|
||||
export const fromCalibrePos = pos => {
|
||||
const [parts] = parse(pos)
|
||||
const item = parts.shift()
|
||||
parts.shift()
|
||||
return toString([[{ index: 6 }, item], parts])
|
||||
}
|
||||
export const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => {
|
||||
const pre = fake.fromIndex(spine_index) + '!'
|
||||
return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2))
|
||||
}
|
||||
360
static/vendor/foliate-js/fb2.js
vendored
Normal file
360
static/vendor/foliate-js/fb2.js
vendored
Normal file
@@ -0,0 +1,360 @@
|
||||
const normalizeWhitespace = str => str ? str
|
||||
.replace(/[\t\n\f\r ]+/g, ' ')
|
||||
.replace(/^[\t\n\f\r ]+/, '')
|
||||
.replace(/[\t\n\f\r ]+$/, '') : ''
|
||||
const getElementText = el => normalizeWhitespace(el?.textContent)
|
||||
|
||||
const NS = {
|
||||
XLINK: 'http://www.w3.org/1999/xlink',
|
||||
EPUB: 'http://www.idpf.org/2007/ops',
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
XML: 'application/xml',
|
||||
XHTML: 'application/xhtml+xml',
|
||||
}
|
||||
|
||||
const STYLE = {
|
||||
'strong': ['strong', 'self'],
|
||||
'emphasis': ['em', 'self'],
|
||||
'style': ['span', 'self'],
|
||||
'a': 'anchor',
|
||||
'strikethrough': ['s', 'self'],
|
||||
'sub': ['sub', 'self'],
|
||||
'sup': ['sup', 'self'],
|
||||
'code': ['code', 'self'],
|
||||
'image': 'image',
|
||||
}
|
||||
|
||||
const TABLE = {
|
||||
'tr': ['tr', {
|
||||
'th': ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']],
|
||||
'td': ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']],
|
||||
}, ['align']],
|
||||
}
|
||||
|
||||
const POEM = {
|
||||
'epigraph': ['blockquote'],
|
||||
'subtitle': ['h2', STYLE],
|
||||
'text-author': ['p', STYLE],
|
||||
'date': ['p', STYLE],
|
||||
'stanza': 'stanza',
|
||||
}
|
||||
|
||||
const SECTION = {
|
||||
'title': ['header', {
|
||||
'p': ['h1', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'epigraph': ['blockquote', 'self'],
|
||||
'image': 'image',
|
||||
'annotation': ['aside'],
|
||||
'section': ['section', 'self'],
|
||||
'p': ['p', STYLE],
|
||||
'poem': ['blockquote', POEM],
|
||||
'subtitle': ['h2', STYLE],
|
||||
'cite': ['blockquote', 'self'],
|
||||
'empty-line': ['br'],
|
||||
'table': ['table', TABLE],
|
||||
'text-author': ['p', STYLE],
|
||||
}
|
||||
POEM['epigraph'].push(SECTION)
|
||||
|
||||
const BODY = {
|
||||
'image': 'image',
|
||||
'title': ['section', {
|
||||
'p': ['h1', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'epigraph': ['section', SECTION],
|
||||
'section': ['section', SECTION],
|
||||
}
|
||||
|
||||
class FB2Converter {
|
||||
constructor(fb2) {
|
||||
this.fb2 = fb2
|
||||
this.doc = document.implementation.createDocument(NS.XHTML, 'html')
|
||||
// use this instead of `getElementById` to allow images like
|
||||
// `<image l:href="#img1.jpg" id="img1.jpg" />`
|
||||
this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'),
|
||||
el => [el.id, el]))
|
||||
}
|
||||
getImageSrc(el) {
|
||||
const href = el.getAttributeNS(NS.XLINK, 'href')
|
||||
if (!href) return 'data:,'
|
||||
const [, id] = href.split('#')
|
||||
if (!id) return href
|
||||
const bin = this.bins.get(id)
|
||||
return bin
|
||||
? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}`
|
||||
: href
|
||||
}
|
||||
image(node) {
|
||||
const el = this.doc.createElement('img')
|
||||
el.alt = node.getAttribute('alt')
|
||||
el.title = node.getAttribute('title')
|
||||
el.setAttribute('src', this.getImageSrc(node))
|
||||
return el
|
||||
}
|
||||
anchor(node) {
|
||||
const el = this.convert(node, { 'a': ['a', STYLE] })
|
||||
el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href'))
|
||||
if (node.getAttribute('type') === 'note')
|
||||
el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref')
|
||||
return el
|
||||
}
|
||||
stanza(node) {
|
||||
const el = this.convert(node, {
|
||||
'stanza': ['p', {
|
||||
'title': ['header', {
|
||||
'p': ['strong', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'subtitle': ['p', STYLE],
|
||||
}],
|
||||
})
|
||||
for (const child of node.children) if (child.nodeName === 'v') {
|
||||
el.append(this.doc.createTextNode(child.textContent))
|
||||
el.append(this.doc.createElement('br'))
|
||||
}
|
||||
return el
|
||||
}
|
||||
convert(node, def) {
|
||||
// not an element; return text content
|
||||
if (node.nodeType === 3) return this.doc.createTextNode(node.textContent)
|
||||
if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent)
|
||||
if (node.nodeType === 8) return this.doc.createComment(node.textContent)
|
||||
|
||||
const d = def?.[node.nodeName]
|
||||
if (!d) return null
|
||||
if (typeof d === 'string') return this[d](node)
|
||||
|
||||
const [name, opts, attrs] = d
|
||||
const el = this.doc.createElement(name)
|
||||
|
||||
// copy the ID, and set class name from original element name
|
||||
if (node.id) el.id = node.id
|
||||
el.classList.add(node.nodeName)
|
||||
|
||||
// copy attributes
|
||||
if (Array.isArray(attrs)) for (const attr of attrs) {
|
||||
const value = node.getAttribute(attr)
|
||||
if (value) el.setAttribute(attr, value)
|
||||
}
|
||||
|
||||
// process child elements recursively
|
||||
const childDef = opts === 'self' ? def : opts
|
||||
let child = node.firstChild
|
||||
while (child) {
|
||||
const childEl = this.convert(child, childDef)
|
||||
if (childEl) el.append(childEl)
|
||||
child = child.nextSibling
|
||||
}
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
const parseXML = async blob => {
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const str = new TextDecoder('utf-8').decode(buffer)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(str, MIME.XML)
|
||||
const encoding = doc.xmlEncoding
|
||||
// `Document.xmlEncoding` is deprecated, and already removed in Firefox
|
||||
// so parse the XML declaration manually
|
||||
|| str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1]
|
||||
if (encoding && encoding.toLowerCase() !== 'utf-8') {
|
||||
const str = new TextDecoder(encoding).decode(buffer)
|
||||
return parser.parseFromString(str, MIME.XML)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
const style = URL.createObjectURL(new Blob([`
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
body > img, section > img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
.title h1 {
|
||||
text-align: center;
|
||||
}
|
||||
body > section > .title, body.notesBodyType > .title {
|
||||
margin: 3em 0;
|
||||
}
|
||||
body.notesBodyType > section .title h1 {
|
||||
text-align: start;
|
||||
}
|
||||
body.notesBodyType > section .title {
|
||||
margin: 1em 0;
|
||||
}
|
||||
p {
|
||||
text-indent: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
:not(p) + p, p:first-child {
|
||||
text-indent: 0;
|
||||
}
|
||||
.poem p {
|
||||
text-indent: 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.text-author, .date {
|
||||
text-align: end;
|
||||
}
|
||||
.text-author:before {
|
||||
content: "—";
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td, th {
|
||||
padding: .25em;
|
||||
}
|
||||
a[epub|type~="noteref"] {
|
||||
font-size: .75em;
|
||||
vertical-align: super;
|
||||
}
|
||||
body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph {
|
||||
margin: 3em 0;
|
||||
}
|
||||
`], { type: 'text/css' }))
|
||||
|
||||
const template = html => `<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><link href="${style}" rel="stylesheet" type="text/css"/></head>
|
||||
<body>${html}</body>
|
||||
</html>`
|
||||
|
||||
// name of custom ID attribute for TOC items
|
||||
const dataID = 'data-foliate-id'
|
||||
|
||||
export const makeFB2 = async blob => {
|
||||
const book = {}
|
||||
const doc = await parseXML(blob)
|
||||
const converter = new FB2Converter(doc)
|
||||
|
||||
const $ = x => doc.querySelector(x)
|
||||
const $$ = x => [...doc.querySelectorAll(x)]
|
||||
const getPerson = el => {
|
||||
const nick = getElementText(el.querySelector('nickname'))
|
||||
if (nick) return nick
|
||||
const first = getElementText(el.querySelector('first-name'))
|
||||
const middle = getElementText(el.querySelector('middle-name'))
|
||||
const last = getElementText(el.querySelector('last-name'))
|
||||
const name = [first, middle, last].filter(x => x).join(' ')
|
||||
const sortAs = last
|
||||
? [last, [first, middle].filter(x => x).join(' ')].join(', ')
|
||||
: null
|
||||
return { name, sortAs }
|
||||
}
|
||||
const getDate = el => el?.getAttribute('value') ?? getElementText(el)
|
||||
const annotation = $('title-info annotation')
|
||||
book.metadata = {
|
||||
title: getElementText($('title-info book-title')),
|
||||
identifier: getElementText($('document-info id')),
|
||||
language: getElementText($('title-info lang')),
|
||||
author: $$('title-info author').map(getPerson),
|
||||
translator: $$('title-info translator').map(getPerson),
|
||||
contributor: $$('document-info author').map(getPerson)
|
||||
// techincially the program probably shouldn't get the `bkp` role
|
||||
// but it has been so used by calibre, so ¯\_(ツ)_/¯
|
||||
.concat($$('document-info program-used').map(getElementText))
|
||||
.map(x => Object.assign(typeof x === 'string' ? { name: x } : x,
|
||||
{ role: 'bkp' })),
|
||||
publisher: getElementText($('publish-info publisher')),
|
||||
published: getDate($('title-info date')),
|
||||
modified: getDate($('document-info date')),
|
||||
description: annotation ? converter.convert(annotation,
|
||||
{ annotation: ['div', SECTION] }).innerHTML : null,
|
||||
subject: $$('title-info genre').map(getElementText),
|
||||
}
|
||||
if ($('coverpage image')) {
|
||||
const src = converter.getImageSrc($('coverpage image'))
|
||||
book.getCover = () => fetch(src).then(res => res.blob())
|
||||
} else book.getCover = () => null
|
||||
|
||||
// get convert each body
|
||||
const bodyData = Array.from(doc.querySelectorAll('body'), body => {
|
||||
const converted = converter.convert(body, { body: ['body', BODY] })
|
||||
return [Array.from(converted.children, el => {
|
||||
// get list of IDs in the section
|
||||
const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id)
|
||||
return { el, ids }
|
||||
}), converted]
|
||||
})
|
||||
|
||||
const urls = []
|
||||
const sectionData = bodyData[0][0]
|
||||
// make a separate section for each section in the first body
|
||||
.map(({ el, ids }) => {
|
||||
// set up titles for TOC
|
||||
const titles = Array.from(
|
||||
el.querySelectorAll(':scope > section > .title'),
|
||||
(el, index) => {
|
||||
el.setAttribute(dataID, index)
|
||||
return { title: getElementText(el), index }
|
||||
})
|
||||
return { ids, titles, el }
|
||||
})
|
||||
// for additional bodies, only make one section for each body
|
||||
.concat(bodyData.slice(1).map(([sections, body]) => {
|
||||
const ids = sections.map(s => s.ids).flat()
|
||||
body.classList.add('notesBodyType')
|
||||
return { ids, el: body, linear: 'no' }
|
||||
}))
|
||||
.map(({ ids, titles, el, linear }) => {
|
||||
const str = template(el.outerHTML)
|
||||
const blob = new Blob([str], { type: MIME.XHTML })
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.push(url)
|
||||
const title = normalizeWhitespace(
|
||||
el.querySelector('.title, .subtitle, p')?.textContent
|
||||
?? (el.classList.contains('title') ? el.textContent : ''))
|
||||
return {
|
||||
ids, title, titles, load: () => url,
|
||||
createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML),
|
||||
// doo't count image data as it'd skew the size too much
|
||||
size: blob.size - Array.from(el.querySelectorAll('[src]'),
|
||||
el => el.getAttribute('src')?.length ?? 0)
|
||||
.reduce((a, b) => a + b, 0),
|
||||
linear,
|
||||
}
|
||||
})
|
||||
|
||||
const idMap = new Map()
|
||||
book.sections = sectionData.map((section, index) => {
|
||||
const { ids, load, createDocument, size, linear } = section
|
||||
for (const id of ids) if (id) idMap.set(id, index)
|
||||
return { id: index, load, createDocument, size, linear }
|
||||
})
|
||||
|
||||
book.toc = sectionData.map(({ title, titles }, index) => {
|
||||
const id = index.toString()
|
||||
return {
|
||||
label: title,
|
||||
href: id,
|
||||
subitems: titles?.length ? titles.map(({ title, index }) => ({
|
||||
label: title,
|
||||
href: `${id}#${index}`,
|
||||
})) : null,
|
||||
}
|
||||
}).filter(item => item)
|
||||
|
||||
book.resolveHref = href => {
|
||||
const [a, b] = href.split('#')
|
||||
return a
|
||||
// the link is from the TOC
|
||||
? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) }
|
||||
// link from within the page
|
||||
: { index: idMap.get(b), anchor: doc => doc.getElementById(b) }
|
||||
}
|
||||
book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? []
|
||||
book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`)
|
||||
book.isExternal = uri => /^\w+:/i.test(uri)
|
||||
|
||||
book.destroy = () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url)
|
||||
}
|
||||
return book
|
||||
}
|
||||
319
static/vendor/foliate-js/fixed-layout.js
vendored
Normal file
319
static/vendor/foliate-js/fixed-layout.js
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
const parseViewport = str => str
|
||||
?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
|
||||
?.filter(x => x)
|
||||
?.map(x => x.split('=').map(x => x.trim()))
|
||||
|
||||
const getViewport = (doc, viewport) => {
|
||||
// use `viewBox` for SVG
|
||||
if (doc.documentElement.localName === 'svg') {
|
||||
const [, , width, height] = doc.documentElement
|
||||
.getAttribute('viewBox')?.split(/\s/) ?? []
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
// get `viewport` `meta` element
|
||||
const meta = parseViewport(doc.querySelector('meta[name="viewport"]')
|
||||
?.getAttribute('content'))
|
||||
if (meta) return Object.fromEntries(meta)
|
||||
|
||||
// fallback to book's viewport
|
||||
if (typeof viewport === 'string') return parseViewport(viewport)
|
||||
if (viewport?.width && viewport.height) return viewport
|
||||
|
||||
// if no viewport (possibly with image directly in spine), get image size
|
||||
const img = doc.querySelector('img')
|
||||
if (img) return { width: img.naturalWidth, height: img.naturalHeight }
|
||||
|
||||
// just show *something*, i guess...
|
||||
console.warn(new Error('Missing viewport properties'))
|
||||
return { width: 1000, height: 2000 }
|
||||
}
|
||||
|
||||
export class FixedLayout extends HTMLElement {
|
||||
static observedAttributes = ['zoom']
|
||||
#root = this.attachShadow({ mode: 'closed' })
|
||||
#observer = new ResizeObserver(() => this.#render())
|
||||
#spreads
|
||||
#index = -1
|
||||
defaultViewport
|
||||
spread
|
||||
#portrait = false
|
||||
#left
|
||||
#right
|
||||
#center
|
||||
#side
|
||||
#zoom
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
const sheet = new CSSStyleSheet()
|
||||
this.#root.adoptedStyleSheets = [sheet]
|
||||
sheet.replaceSync(`:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
}`)
|
||||
|
||||
this.#observer.observe(this)
|
||||
}
|
||||
attributeChangedCallback(name, _, value) {
|
||||
switch (name) {
|
||||
case 'zoom':
|
||||
this.#zoom = value !== 'fit-width' && value !== 'fit-page'
|
||||
? parseFloat(value) : value
|
||||
this.#render()
|
||||
break
|
||||
}
|
||||
}
|
||||
async #createFrame({ index, src: srcOption }) {
|
||||
const srcOptionIsString = typeof srcOption === 'string'
|
||||
const src = srcOptionIsString ? srcOption : srcOption?.src
|
||||
const onZoom = srcOptionIsString ? null : srcOption?.onZoom
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute('dir', 'ltr')
|
||||
const iframe = document.createElement('iframe')
|
||||
element.append(iframe)
|
||||
Object.assign(iframe.style, {
|
||||
border: '0',
|
||||
display: 'none',
|
||||
overflow: 'hidden',
|
||||
})
|
||||
// `allow-scripts` is needed for events because of WebKit bug
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=218086
|
||||
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
|
||||
iframe.setAttribute('scrolling', 'no')
|
||||
iframe.setAttribute('part', 'filter')
|
||||
this.#root.append(element)
|
||||
if (!src) return { blank: true, element, iframe }
|
||||
return new Promise(resolve => {
|
||||
iframe.addEventListener('load', () => {
|
||||
const doc = iframe.contentDocument
|
||||
this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } }))
|
||||
const { width, height } = getViewport(doc, this.defaultViewport)
|
||||
resolve({
|
||||
element, iframe,
|
||||
width: parseFloat(width),
|
||||
height: parseFloat(height),
|
||||
onZoom,
|
||||
})
|
||||
}, { once: true })
|
||||
iframe.src = src
|
||||
})
|
||||
}
|
||||
#render(side = this.#side) {
|
||||
if (!side) return
|
||||
const left = this.#left ?? {}
|
||||
const right = this.#center ?? this.#right ?? {}
|
||||
const target = side === 'left' ? left : right
|
||||
const { width, height } = this.getBoundingClientRect()
|
||||
const portrait = this.spread !== 'both' && this.spread !== 'portrait'
|
||||
&& height > width
|
||||
this.#portrait = portrait
|
||||
const blankWidth = left.width ?? right.width ?? 0
|
||||
const blankHeight = left.height ?? right.height ?? 0
|
||||
|
||||
const scale = typeof this.#zoom === 'number' && !isNaN(this.#zoom)
|
||||
? this.#zoom
|
||||
: (this.#zoom === 'fit-width'
|
||||
? (portrait || this.#center
|
||||
? width / (target.width ?? blankWidth)
|
||||
: width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)))
|
||||
: (portrait || this.#center
|
||||
? Math.min(
|
||||
width / (target.width ?? blankWidth),
|
||||
height / (target.height ?? blankHeight))
|
||||
: Math.min(
|
||||
width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)),
|
||||
height / Math.max(
|
||||
left.height ?? blankHeight,
|
||||
right.height ?? blankHeight)))
|
||||
) || 1
|
||||
|
||||
const transform = frame => {
|
||||
let { element, iframe, width, height, blank, onZoom } = frame
|
||||
if (!iframe) return
|
||||
if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale })
|
||||
const iframeScale = onZoom ? scale : 1
|
||||
Object.assign(iframe.style, {
|
||||
width: `${width * iframeScale}px`,
|
||||
height: `${height * iframeScale}px`,
|
||||
transform: onZoom ? 'none' : `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
display: blank ? 'none' : 'block',
|
||||
})
|
||||
Object.assign(element.style, {
|
||||
width: `${(width ?? blankWidth) * scale}px`,
|
||||
height: `${(height ?? blankHeight) * scale}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
flexShrink: '0',
|
||||
marginBlock: 'auto',
|
||||
})
|
||||
if (portrait && frame !== target) {
|
||||
element.style.display = 'none'
|
||||
}
|
||||
}
|
||||
if (this.#center) {
|
||||
transform(this.#center)
|
||||
} else {
|
||||
transform(left)
|
||||
transform(right)
|
||||
}
|
||||
}
|
||||
async #showSpread({ left, right, center, side }) {
|
||||
this.#root.replaceChildren()
|
||||
this.#left = null
|
||||
this.#right = null
|
||||
this.#center = null
|
||||
if (center) {
|
||||
this.#center = await this.#createFrame(center)
|
||||
this.#side = 'center'
|
||||
this.#render()
|
||||
} else {
|
||||
this.#left = await this.#createFrame(left)
|
||||
this.#right = await this.#createFrame(right)
|
||||
this.#side = this.#left.blank ? 'right'
|
||||
: this.#right.blank ? 'left' : side
|
||||
this.#render()
|
||||
}
|
||||
}
|
||||
#goLeft() {
|
||||
if (this.#center || this.#left?.blank) return
|
||||
if (this.#portrait && this.#left?.element?.style?.display === 'none') {
|
||||
this.#side = 'left'
|
||||
this.#render()
|
||||
this.#reportLocation('page')
|
||||
return true
|
||||
}
|
||||
}
|
||||
#goRight() {
|
||||
if (this.#center || this.#right?.blank) return
|
||||
if (this.#portrait && this.#right?.element?.style?.display === 'none') {
|
||||
this.#side = 'right'
|
||||
this.#render()
|
||||
this.#reportLocation('page')
|
||||
return true
|
||||
}
|
||||
}
|
||||
open(book) {
|
||||
this.book = book
|
||||
const { rendition } = book
|
||||
this.spread = rendition?.spread
|
||||
this.defaultViewport = rendition?.viewport
|
||||
|
||||
const rtl = book.dir === 'rtl'
|
||||
const ltr = !rtl
|
||||
this.rtl = rtl
|
||||
|
||||
if (rendition?.spread === 'none')
|
||||
this.#spreads = book.sections.map(section => ({ center: section }))
|
||||
else this.#spreads = book.sections.reduce((arr, section, i) => {
|
||||
const last = arr[arr.length - 1]
|
||||
const { pageSpread } = section
|
||||
const newSpread = () => {
|
||||
const spread = {}
|
||||
arr.push(spread)
|
||||
return spread
|
||||
}
|
||||
if (pageSpread === 'center') {
|
||||
const spread = last.left || last.right ? newSpread() : last
|
||||
spread.center = section
|
||||
}
|
||||
else if (pageSpread === 'left') {
|
||||
const spread = last.center || last.left || ltr && i ? newSpread() : last
|
||||
spread.left = section
|
||||
}
|
||||
else if (pageSpread === 'right') {
|
||||
const spread = last.center || last.right || rtl && i ? newSpread() : last
|
||||
spread.right = section
|
||||
}
|
||||
else if (ltr) {
|
||||
if (last.center || last.right) newSpread().left = section
|
||||
else if (last.left || !i) last.right = section
|
||||
else last.left = section
|
||||
}
|
||||
else {
|
||||
if (last.center || last.left) newSpread().right = section
|
||||
else if (last.right || !i) last.left = section
|
||||
else last.right = section
|
||||
}
|
||||
return arr
|
||||
}, [{}])
|
||||
}
|
||||
get index() {
|
||||
const spread = this.#spreads[this.#index]
|
||||
const section = spread?.center ?? (this.#side === 'left'
|
||||
? spread.left ?? spread.right : spread.right ?? spread.left)
|
||||
return this.book.sections.indexOf(section)
|
||||
}
|
||||
#reportLocation(reason) {
|
||||
this.dispatchEvent(new CustomEvent('relocate', { detail:
|
||||
{ reason, range: null, index: this.index, fraction: 0, size: 1 } }))
|
||||
}
|
||||
getSpreadOf(section) {
|
||||
const spreads = this.#spreads
|
||||
for (let index = 0; index < spreads.length; index++) {
|
||||
const { left, right, center } = spreads[index]
|
||||
if (left === section) return { index, side: 'left' }
|
||||
if (right === section) return { index, side: 'right' }
|
||||
if (center === section) return { index, side: 'center' }
|
||||
}
|
||||
}
|
||||
async goToSpread(index, side, reason) {
|
||||
if (index < 0 || index > this.#spreads.length - 1) return
|
||||
if (index === this.#index) {
|
||||
this.#render(side)
|
||||
return
|
||||
}
|
||||
this.#index = index
|
||||
const spread = this.#spreads[index]
|
||||
if (spread.center) {
|
||||
const index = this.book.sections.indexOf(spread.center)
|
||||
const src = await spread.center?.load?.()
|
||||
await this.#showSpread({ center: { index, src } })
|
||||
} else {
|
||||
const indexL = this.book.sections.indexOf(spread.left)
|
||||
const indexR = this.book.sections.indexOf(spread.right)
|
||||
const srcL = await spread.left?.load?.()
|
||||
const srcR = await spread.right?.load?.()
|
||||
const left = { index: indexL, src: srcL }
|
||||
const right = { index: indexR, src: srcR }
|
||||
await this.#showSpread({ left, right, side })
|
||||
}
|
||||
this.#reportLocation(reason)
|
||||
}
|
||||
async select(target) {
|
||||
await this.goTo(target)
|
||||
// TODO
|
||||
}
|
||||
async goTo(target) {
|
||||
const { book } = this
|
||||
const resolved = await target
|
||||
const section = book.sections[resolved.index]
|
||||
if (!section) return
|
||||
const { index, side } = this.getSpreadOf(section)
|
||||
await this.goToSpread(index, side)
|
||||
}
|
||||
async next() {
|
||||
const s = this.rtl ? this.#goLeft() : this.#goRight()
|
||||
if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page')
|
||||
}
|
||||
async prev() {
|
||||
const s = this.rtl ? this.#goRight() : this.#goLeft()
|
||||
if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page')
|
||||
}
|
||||
getContents() {
|
||||
return Array.from(this.#root.querySelectorAll('iframe'), frame => ({
|
||||
doc: frame.contentDocument,
|
||||
// TODO: index, overlayer
|
||||
}))
|
||||
}
|
||||
destroy() {
|
||||
this.#observer.unobserve(this)
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('foliate-fxl', FixedLayout)
|
||||
103
static/vendor/foliate-js/footnotes.js
vendored
Normal file
103
static/vendor/foliate-js/footnotes.js
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
const getTypes = el => new Set(el?.getAttributeNS?.('http://www.idpf.org/2007/ops', 'type')?.split(' '))
|
||||
const getRoles = el => new Set(el?.getAttribute?.('role')?.split(' '))
|
||||
|
||||
const isSuper = el => {
|
||||
if (el.matches('sup')) return true
|
||||
const { verticalAlign } = getComputedStyle(el)
|
||||
return verticalAlign === 'super'
|
||||
|| verticalAlign === 'top'
|
||||
|| verticalAlign === 'text-top'
|
||||
|| /^\d/.test(verticalAlign)
|
||||
}
|
||||
|
||||
const refTypes = ['biblioref', 'glossref', 'noteref']
|
||||
const refRoles = ['doc-biblioref', 'doc-glossref', 'doc-noteref']
|
||||
const isFootnoteReference = a => {
|
||||
const types = getTypes(a)
|
||||
const roles = getRoles(a)
|
||||
return {
|
||||
yes: refRoles.some(r => roles.has(r)) || refTypes.some(t => types.has(t)),
|
||||
maybe: () => !types.has('backlink') && !roles.has('doc-backlink')
|
||||
&& (isSuper(a) || a.children.length === 1 && isSuper(a.children[0])
|
||||
|| isSuper(a.parentElement)),
|
||||
}
|
||||
}
|
||||
|
||||
const getReferencedType = el => {
|
||||
const types = getTypes(el)
|
||||
const roles = getRoles(el)
|
||||
return roles.has('doc-biblioentry') || types.has('biblioentry') ? 'biblioentry'
|
||||
: roles.has('definition') || types.has('glossdef') ? 'definition'
|
||||
: roles.has('doc-endnote') || types.has('endnote') || types.has('rearnote') ? 'endnote'
|
||||
: roles.has('doc-footnote') || types.has('footnote') ? 'footnote'
|
||||
: roles.has('note') || types.has('note') ? 'note' : null
|
||||
}
|
||||
|
||||
const isInline = 'a, span, sup, sub, em, strong, i, b, small, big'
|
||||
const extractFootnote = (doc, anchor) => {
|
||||
let el = anchor(doc)
|
||||
const target = el
|
||||
while (el.matches(isInline)) {
|
||||
const parent = el.parentElement
|
||||
if (!parent) break
|
||||
el = parent
|
||||
}
|
||||
if (el === doc.body) {
|
||||
const sibling = target.nextElementSibling
|
||||
if (sibling && !sibling.matches(isInline)) return sibling
|
||||
throw new Error('Failed to extract footnote')
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
export class FootnoteHandler extends EventTarget {
|
||||
detectFootnotes = true
|
||||
#showFragment(book, { index, anchor }, href) {
|
||||
const view = document.createElement('foliate-view')
|
||||
return new Promise((resolve, reject) => {
|
||||
view.addEventListener('load', e => {
|
||||
try {
|
||||
const { doc } = e.detail
|
||||
const el = anchor(doc)
|
||||
const type = getReferencedType(el)
|
||||
const hidden = el?.matches?.('aside') && type === 'footnote'
|
||||
if (el) {
|
||||
const range = el.startContainer ? el : doc.createRange()
|
||||
if (!el.startContainer) {
|
||||
if (el.matches('li, aside')) range.selectNodeContents(el)
|
||||
else range.selectNode(el)
|
||||
}
|
||||
const frag = range.extractContents()
|
||||
doc.body.replaceChildren()
|
||||
doc.body.appendChild(frag)
|
||||
}
|
||||
const detail = { view, href, type, hidden, target: el }
|
||||
this.dispatchEvent(new CustomEvent('render', { detail }))
|
||||
resolve()
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
view.open(book)
|
||||
.then(() => this.dispatchEvent(new CustomEvent('before-render', { detail: { view } })))
|
||||
.then(() => view.goTo(index))
|
||||
.catch(reject)
|
||||
})
|
||||
}
|
||||
handle(book, e) {
|
||||
const { a, href } = e.detail
|
||||
const { yes, maybe } = isFootnoteReference(a)
|
||||
if (yes) {
|
||||
e.preventDefault()
|
||||
return Promise.resolve(book.resolveHref(href)).then(target =>
|
||||
this.#showFragment(book, target, href))
|
||||
}
|
||||
else if (this.detectFootnotes && maybe()) {
|
||||
e.preventDefault()
|
||||
return Promise.resolve(book.resolveHref(href)).then(({ index, anchor }) => {
|
||||
const target = { index, anchor: doc => extractFootnote(doc, anchor) }
|
||||
return this.#showFragment(book, target, href)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
1238
static/vendor/foliate-js/mobi.js
vendored
Normal file
1238
static/vendor/foliate-js/mobi.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
289
static/vendor/foliate-js/opds.js
vendored
Normal file
289
static/vendor/foliate-js/opds.js
vendored
Normal file
@@ -0,0 +1,289 @@
|
||||
const NS = {
|
||||
ATOM: 'http://www.w3.org/2005/Atom',
|
||||
OPDS: 'http://opds-spec.org/2010/catalog',
|
||||
THR: 'http://purl.org/syndication/thread/1.0',
|
||||
DC: 'http://purl.org/dc/elements/1.1/',
|
||||
DCTERMS: 'http://purl.org/dc/terms/',
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
ATOM: 'application/atom+xml',
|
||||
OPDS2: 'application/opds+json',
|
||||
}
|
||||
|
||||
export const REL = {
|
||||
ACQ: 'http://opds-spec.org/acquisition',
|
||||
FACET: 'http://opds-spec.org/facet',
|
||||
GROUP: 'http://opds-spec.org/group',
|
||||
COVER: [
|
||||
'http://opds-spec.org/image',
|
||||
'http://opds-spec.org/cover', // ManyBooks legacy, not in spec
|
||||
'x-stanza-cover-image', // Lexcycle Stanza legacy
|
||||
],
|
||||
THUMBNAIL: [
|
||||
'http://opds-spec.org/image/thumbnail',
|
||||
'http://opds-spec.org/thumbnail', // ManyBooks legacy, not in spec
|
||||
'x-stanza-cover-image-thumbnail', // Lexcycle Stanza legacy
|
||||
],
|
||||
}
|
||||
|
||||
export const SYMBOL = {
|
||||
SUMMARY: Symbol('summary'),
|
||||
CONTENT: Symbol('content'),
|
||||
}
|
||||
|
||||
const FACET_GROUP = Symbol('facetGroup')
|
||||
|
||||
const groupByArray = (arr, f) => {
|
||||
const map = new Map()
|
||||
if (arr) for (const el of arr) {
|
||||
const keys = f(el)
|
||||
for (const key of [keys].flat()) {
|
||||
const group = map.get(key)
|
||||
if (group) group.push(el)
|
||||
else map.set(key, [el])
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1
|
||||
const parseMediaType = str => {
|
||||
if (!str) return null
|
||||
const [mediaType, ...ps] = str.split(/ *; */)
|
||||
return {
|
||||
mediaType: mediaType.toLowerCase(),
|
||||
parameters: Object.fromEntries(ps.map(p => {
|
||||
const [name, val] = p.split('=')
|
||||
return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')]
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const isOPDSCatalog = str => {
|
||||
const parsed = parseMediaType(str)
|
||||
if (!parsed) return false
|
||||
const { mediaType, parameters } = parsed
|
||||
if (mediaType === MIME.OPDS2) return true
|
||||
return mediaType === MIME.ATOM && parameters.profile?.toLowerCase() === 'opds-catalog'
|
||||
}
|
||||
|
||||
// ignore the namespace if it doesn't appear in document at all
|
||||
const useNS = (doc, ns) =>
|
||||
doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) ? ns : null
|
||||
|
||||
const filterNS = ns => ns
|
||||
? name => el => el.namespaceURI === ns && el.localName === name
|
||||
: name => el => el.localName === name
|
||||
|
||||
const getContent = el => {
|
||||
if (!el) return
|
||||
const type = el.getAttribute('type') ?? 'text'
|
||||
const value = type === 'xhtml' ? el.innerHTML
|
||||
: type === 'html' ? el.textContent
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
: el.textContent
|
||||
return { value, type }
|
||||
}
|
||||
|
||||
const getTextContent = el => {
|
||||
const content = getContent(el)
|
||||
if (content?.type === 'text') return content?.value
|
||||
}
|
||||
|
||||
const getSummary = (a, b) => getTextContent(a) ?? getTextContent(b)
|
||||
|
||||
const getPrice = link => {
|
||||
const price = link.getElementsByTagNameNS(NS.OPDS, 'price')[0]
|
||||
return price ? {
|
||||
currency: price.getAttribute('currencycode'),
|
||||
value: price.textContent,
|
||||
} : null
|
||||
}
|
||||
|
||||
const getIndirectAcquisition = el => {
|
||||
const ia = el.getElementsByTagNameNS(NS.OPDS, 'indirectAcquisition')[0]
|
||||
if (!ia) return []
|
||||
return [{ type: ia.getAttribute('type') }, ...getIndirectAcquisition(ia)]
|
||||
}
|
||||
|
||||
const getLink = link => {
|
||||
const obj = {
|
||||
rel: link.getAttribute('rel')?.split(/ +/),
|
||||
href: link.getAttribute('href'),
|
||||
type: link.getAttribute('type'),
|
||||
title: link.getAttribute('title'),
|
||||
properties: {
|
||||
price: getPrice(link),
|
||||
indirectAcquisition: getIndirectAcquisition(link),
|
||||
numberOfItems: link.getAttributeNS(NS.THR, 'count'),
|
||||
},
|
||||
[FACET_GROUP]: link.getAttributeNS(NS.OPDS, 'facetGroup'),
|
||||
}
|
||||
if (link.getAttributeNS(NS.OPDS, 'activeFacet') === 'true')
|
||||
obj.rel = [obj.rel ?? []].flat().concat('self')
|
||||
return obj
|
||||
}
|
||||
|
||||
const getPerson = person => {
|
||||
const NS = person.namespaceURI
|
||||
const uri = person.getElementsByTagNameNS(NS, 'uri')[0]?.textContent
|
||||
return {
|
||||
name: person.getElementsByTagNameNS(NS, 'name')[0]?.textContent ?? '',
|
||||
links: uri ? [{ href: uri }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
export const getPublication = entry => {
|
||||
const filter = filterNS(useNS(entry.ownerDocument, NS.ATOM))
|
||||
const children = Array.from(entry.children)
|
||||
const filterDCEL = filterNS(NS.DC)
|
||||
const filterDCTERMS = filterNS(NS.DCTERMS)
|
||||
const filterDC = x => {
|
||||
const a = filterDCEL(x), b = filterDCTERMS(x)
|
||||
return y => a(y) || b(y)
|
||||
}
|
||||
const links = children.filter(filter('link')).map(getLink)
|
||||
const linksByRel = groupByArray(links, link => link.rel)
|
||||
return {
|
||||
metadata: {
|
||||
title: children.find(filter('title'))?.textContent ?? '',
|
||||
author: children.filter(filter('author')).map(getPerson),
|
||||
contributor: children.filter(filter('contributor')).map(getPerson),
|
||||
publisher: children.find(filterDC('publisher'))?.textContent,
|
||||
published: (children.find(filterDCTERMS('issued'))
|
||||
?? children.find(filterDC('date')))?.textContent,
|
||||
language: children.find(filterDC('language'))?.textContent,
|
||||
identifier: children.find(filterDC('identifier'))?.textContent,
|
||||
subject: children.filter(filter('category')).map(category => ({
|
||||
name: category.getAttribute('label'),
|
||||
code: category.getAttribute('term'),
|
||||
scheme: category.getAttribute('scheme'),
|
||||
})),
|
||||
rights: children.find(filter('rights'))?.textContent ?? '',
|
||||
[SYMBOL.CONTENT]: getContent(children.find(filter('content'))
|
||||
?? children.find(filter('summary'))),
|
||||
},
|
||||
links,
|
||||
images: REL.COVER.concat(REL.THUMBNAIL)
|
||||
.map(R => linksByRel.get(R)?.[0]).filter(x => x),
|
||||
}
|
||||
}
|
||||
|
||||
export const getFeed = doc => {
|
||||
const ns = useNS(doc, NS.ATOM)
|
||||
const filter = filterNS(ns)
|
||||
const children = Array.from(doc.documentElement.children)
|
||||
const entries = children.filter(filter('entry'))
|
||||
const links = children.filter(filter('link')).map(getLink)
|
||||
const linksByRel = groupByArray(links, link => link.rel)
|
||||
|
||||
const groupedItems = new Map([[null, []]])
|
||||
const groupLinkMap = new Map()
|
||||
for (const entry of entries) {
|
||||
const children = Array.from(entry.children)
|
||||
const links = children.filter(filter('link')).map(getLink)
|
||||
const linksByRel = groupByArray(links, link => link.rel)
|
||||
const isPub = [...linksByRel.keys()]
|
||||
.some(rel => rel?.startsWith(REL.ACQ) || rel === 'preview')
|
||||
|
||||
const groupLinks = linksByRel.get(REL.GROUP) ?? linksByRel.get('collection')
|
||||
const groupLink = groupLinks?.length
|
||||
? groupLinks.find(link => groupedItems.has(link.href)) ?? groupLinks[0] : null
|
||||
if (groupLink && !groupLinkMap.has(groupLink.href))
|
||||
groupLinkMap.set(groupLink.href, groupLink)
|
||||
|
||||
const item = isPub
|
||||
? getPublication(entry)
|
||||
: Object.assign(links.find(link => isOPDSCatalog(link.type)) ?? links[0] ?? {}, {
|
||||
title: children.find(filter('title'))?.textContent,
|
||||
[SYMBOL.SUMMARY]: getSummary(children.find(filter('summary')),
|
||||
children.find(filter('content'))),
|
||||
})
|
||||
|
||||
const arr = groupedItems.get(groupLink?.href ?? null)
|
||||
if (arr) arr.push(item)
|
||||
else groupedItems.set(groupLink.href, [item])
|
||||
}
|
||||
const [items, ...groups] = Array.from(groupedItems, ([key, items]) => {
|
||||
const itemsKey = items[0]?.metadata ? 'publications' : 'navigation'
|
||||
if (key == null) return { [itemsKey]: items }
|
||||
const link = groupLinkMap.get(key)
|
||||
return {
|
||||
metadata: {
|
||||
title: link.title,
|
||||
numberOfItems: link.properties.numberOfItems,
|
||||
},
|
||||
links: [{ rel: 'self', href: link.href, type: link.type }],
|
||||
[itemsKey]: items,
|
||||
}
|
||||
})
|
||||
return {
|
||||
metadata: {
|
||||
title: children.find(filter('title'))?.textContent,
|
||||
subtitle: children.find(filter('subtitle'))?.textContent,
|
||||
},
|
||||
links,
|
||||
...items,
|
||||
groups,
|
||||
facets: Array.from(
|
||||
groupByArray(linksByRel.get(REL.FACET) ?? [], link => link[FACET_GROUP]),
|
||||
([facet, links]) => ({ metadata: { title: facet }, links })),
|
||||
}
|
||||
}
|
||||
|
||||
export const getSearch = async link => {
|
||||
const { replace, getVariables } = await import('./uri-template.js')
|
||||
return {
|
||||
metadata: {
|
||||
title: link.title,
|
||||
},
|
||||
search: map => replace(link.href, map.get(null)),
|
||||
params: Array.from(getVariables(link.href), name => ({ name })),
|
||||
}
|
||||
}
|
||||
|
||||
export const getOpenSearch = doc => {
|
||||
const defaultNS = doc.documentElement.namespaceURI
|
||||
const filter = filterNS(defaultNS)
|
||||
const children = Array.from(doc.documentElement.children)
|
||||
|
||||
const $$urls = children.filter(filter('Url'))
|
||||
const $url = $$urls.find(url => isOPDSCatalog(url.getAttribute('type'))) ?? $$urls[0]
|
||||
if (!$url) throw new Error('document must contain at least one Url element')
|
||||
|
||||
const regex = /{(?:([^}]+?):)?(.+?)(\?)?}/g
|
||||
const defaultMap = new Map([
|
||||
['count', '100'],
|
||||
['startIndex', $url.getAttribute('indexOffset') ?? '0'],
|
||||
['startPage', $url.getAttribute('pageOffset') ?? '0'],
|
||||
['language', '*'],
|
||||
['inputEncoding', 'UTF-8'],
|
||||
['outputEncoding', 'UTF-8'],
|
||||
])
|
||||
|
||||
const template = $url.getAttribute('template')
|
||||
return {
|
||||
metadata: {
|
||||
title: (children.find(filter('LongName')) ?? children.find(filter('ShortName')))?.textContent,
|
||||
description: children.find(filter('Description'))?.textContent,
|
||||
},
|
||||
search: map => template.replace(regex, (_, prefix, param) => {
|
||||
const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null
|
||||
const ns = namespace === defaultNS ? null : namespace
|
||||
const val = map.get(ns)?.get(param)
|
||||
return encodeURIComponent(val ? val : (!ns ? defaultMap.get(param) ?? '' : ''))
|
||||
}),
|
||||
params: Array.from(template.matchAll(regex), ([, prefix, param, optional]) => {
|
||||
const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null
|
||||
const ns = namespace === defaultNS ? null : namespace
|
||||
return {
|
||||
ns, name: param,
|
||||
required: !optional,
|
||||
value: ns && ns !== defaultNS ? '' : defaultMap.get(param) ?? '',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
175
static/vendor/foliate-js/overlayer.js
vendored
Normal file
175
static/vendor/foliate-js/overlayer.js
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
const createSVGElement = tag =>
|
||||
document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||
|
||||
export class Overlayer {
|
||||
#svg = createSVGElement('svg')
|
||||
#map = new Map()
|
||||
constructor() {
|
||||
Object.assign(this.#svg.style, {
|
||||
position: 'absolute', top: '0', left: '0',
|
||||
width: '100%', height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
get element() {
|
||||
return this.#svg
|
||||
}
|
||||
add(key, range, draw, options) {
|
||||
if (this.#map.has(key)) this.remove(key)
|
||||
if (typeof range === 'function') range = range(this.#svg.getRootNode())
|
||||
const rects = range.getClientRects()
|
||||
const element = draw(rects, options)
|
||||
this.#svg.append(element)
|
||||
this.#map.set(key, { range, draw, options, element, rects })
|
||||
}
|
||||
remove(key) {
|
||||
if (!this.#map.has(key)) return
|
||||
this.#svg.removeChild(this.#map.get(key).element)
|
||||
this.#map.delete(key)
|
||||
}
|
||||
redraw() {
|
||||
for (const obj of this.#map.values()) {
|
||||
const { range, draw, options, element } = obj
|
||||
this.#svg.removeChild(element)
|
||||
const rects = range.getClientRects()
|
||||
const el = draw(rects, options)
|
||||
this.#svg.append(el)
|
||||
obj.element = el
|
||||
obj.rects = rects
|
||||
}
|
||||
}
|
||||
hitTest({ x, y }) {
|
||||
const arr = Array.from(this.#map.entries())
|
||||
// loop in reverse to hit more recently added items first
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const [key, obj] = arr[i]
|
||||
for (const { left, top, right, bottom } of obj.rects)
|
||||
if (top <= y && left <= x && bottom > y && right > x)
|
||||
return [key, obj.range]
|
||||
}
|
||||
return []
|
||||
}
|
||||
static underline(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 2, writingMode } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', color)
|
||||
if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr')
|
||||
for (const { right, top, height } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', right - strokeWidth)
|
||||
el.setAttribute('y', top)
|
||||
el.setAttribute('height', height)
|
||||
el.setAttribute('width', strokeWidth)
|
||||
g.append(el)
|
||||
}
|
||||
else for (const { left, bottom, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left)
|
||||
el.setAttribute('y', bottom - strokeWidth)
|
||||
el.setAttribute('height', strokeWidth)
|
||||
el.setAttribute('width', width)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static strikethrough(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 2, writingMode } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', color)
|
||||
if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr')
|
||||
for (const { right, left, top, height } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', (right + left) / 2)
|
||||
el.setAttribute('y', top)
|
||||
el.setAttribute('height', height)
|
||||
el.setAttribute('width', strokeWidth)
|
||||
g.append(el)
|
||||
}
|
||||
else for (const { left, top, bottom, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left)
|
||||
el.setAttribute('y', (top + bottom) / 2)
|
||||
el.setAttribute('height', strokeWidth)
|
||||
el.setAttribute('width', width)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static squiggly(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 2, writingMode } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', 'none')
|
||||
g.setAttribute('stroke', color)
|
||||
g.setAttribute('stroke-width', strokeWidth)
|
||||
const block = strokeWidth * 1.5
|
||||
if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr')
|
||||
for (const { right, top, height } of rects) {
|
||||
const el = createSVGElement('path')
|
||||
const n = Math.round(height / block / 1.5)
|
||||
const inline = height / n
|
||||
const ls = Array.from({ length: n },
|
||||
(_, i) => `l${i % 2 ? -block : block} ${inline}`).join('')
|
||||
el.setAttribute('d', `M${right} ${top}${ls}`)
|
||||
g.append(el)
|
||||
}
|
||||
else for (const { left, bottom, width } of rects) {
|
||||
const el = createSVGElement('path')
|
||||
const n = Math.round(width / block / 1.5)
|
||||
const inline = width / n
|
||||
const ls = Array.from({ length: n },
|
||||
(_, i) => `l${inline} ${i % 2 ? block : -block}`).join('')
|
||||
el.setAttribute('d', `M${left} ${bottom}${ls}`)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static highlight(rects, options = {}) {
|
||||
const { color = 'red' } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', color)
|
||||
g.style.opacity = 'var(--overlayer-highlight-opacity, .3)'
|
||||
g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)'
|
||||
for (const { left, top, height, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left)
|
||||
el.setAttribute('y', top)
|
||||
el.setAttribute('height', height)
|
||||
el.setAttribute('width', width)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
static outline(rects, options = {}) {
|
||||
const { color = 'red', width: strokeWidth = 3, radius = 3 } = options
|
||||
const g = createSVGElement('g')
|
||||
g.setAttribute('fill', 'none')
|
||||
g.setAttribute('stroke', color)
|
||||
g.setAttribute('stroke-width', strokeWidth)
|
||||
for (const { left, top, height, width } of rects) {
|
||||
const el = createSVGElement('rect')
|
||||
el.setAttribute('x', left)
|
||||
el.setAttribute('y', top)
|
||||
el.setAttribute('height', height)
|
||||
el.setAttribute('width', width)
|
||||
el.setAttribute('rx', radius)
|
||||
g.append(el)
|
||||
}
|
||||
return g
|
||||
}
|
||||
// make an exact copy of an image in the overlay
|
||||
// one can then apply filters to the entire element, without affecting them;
|
||||
// it's a bit silly and probably better to just invert images twice
|
||||
// (though the color will be off in that case if you do heu-rotate)
|
||||
static copyImage([rect], options = {}) {
|
||||
const { src } = options
|
||||
const image = createSVGElement('image')
|
||||
const { left, top, height, width } = rect
|
||||
image.setAttribute('href', src)
|
||||
image.setAttribute('x', left)
|
||||
image.setAttribute('y', top)
|
||||
image.setAttribute('height', height)
|
||||
image.setAttribute('width', width)
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
1130
static/vendor/foliate-js/paginator.js
vendored
Normal file
1130
static/vendor/foliate-js/paginator.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
180
static/vendor/foliate-js/pdf.js
vendored
Normal file
180
static/vendor/foliate-js/pdf.js
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
const pdfjsPath = path => new URL(`vendor/pdfjs/${path}`, import.meta.url).toString()
|
||||
|
||||
import './vendor/pdfjs/pdf.mjs'
|
||||
const pdfjsLib = globalThis.pdfjsLib
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath('pdf.worker.mjs')
|
||||
|
||||
const fetchText = async url => await (await fetch(url)).text()
|
||||
|
||||
// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/text_layer_builder.css
|
||||
const textLayerBuilderCSS = await fetchText(pdfjsPath('text_layer_builder.css'))
|
||||
|
||||
// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/annotation_layer_builder.css
|
||||
const annotationLayerBuilderCSS = await fetchText(pdfjsPath('annotation_layer_builder.css'))
|
||||
|
||||
const render = async (page, doc, zoom) => {
|
||||
const scale = zoom * devicePixelRatio
|
||||
doc.documentElement.style.transform = `scale(${1 / devicePixelRatio})`
|
||||
doc.documentElement.style.transformOrigin = 'top left'
|
||||
doc.documentElement.style.setProperty('--scale-factor', scale)
|
||||
const viewport = page.getViewport({ scale })
|
||||
|
||||
// the canvas must be in the `PDFDocument`'s `ownerDocument`
|
||||
// (`globalThis.document` by default); that's where the fonts are loaded
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
await page.render({ canvasContext, viewport }).promise
|
||||
doc.querySelector('#canvas').replaceChildren(doc.adoptNode(canvas))
|
||||
|
||||
const container = doc.querySelector('.textLayer')
|
||||
const textLayer = new pdfjsLib.TextLayer({
|
||||
textContentSource: await page.streamTextContent(),
|
||||
container, viewport,
|
||||
})
|
||||
await textLayer.render()
|
||||
|
||||
// hide "offscreen" canvases appended to docuemnt when rendering text layer
|
||||
// https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/pdf_viewer.css#L51-L58
|
||||
for (const canvas of document.querySelectorAll('.hiddenCanvasElement'))
|
||||
Object.assign(canvas.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
display: 'none',
|
||||
})
|
||||
|
||||
// fix text selection
|
||||
// https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.js#L105-L107
|
||||
const endOfContent = document.createElement('div')
|
||||
endOfContent.className = 'endOfContent'
|
||||
container.append(endOfContent)
|
||||
// TODO: this only works in Firefox; see https://github.com/mozilla/pdf.js/pull/17923
|
||||
container.onpointerdown = () => container.classList.add('selecting')
|
||||
container.onpointerup = () => container.classList.remove('selecting')
|
||||
|
||||
const div = doc.querySelector('.annotationLayer')
|
||||
const linkService = {
|
||||
goToDestination: () => {},
|
||||
getDestinationHash: dest => JSON.stringify(dest),
|
||||
addLinkAttributes: (link, url) => link.href = url,
|
||||
}
|
||||
await new pdfjsLib.AnnotationLayer({ page, viewport, div, linkService })
|
||||
.render({ annotations: await page.getAnnotations() })
|
||||
}
|
||||
|
||||
const renderPage = async (page, getImageBlob) => {
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
if (getImageBlob) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
await page.render({ canvasContext, viewport }).promise
|
||||
return new Promise(resolve => canvas.toBlob(resolve))
|
||||
}
|
||||
const src = URL.createObjectURL(new Blob([`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=${viewport.width}, height=${viewport.height}">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/*
|
||||
https://github.com/mozilla/pdf.js/commit/bd05b255fabfc313b194bfe9a17ccded4d90fb5a
|
||||
*/
|
||||
:root {
|
||||
--user-unit: 1;
|
||||
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
|
||||
--scale-round-x: 1px;
|
||||
--scale-round-y: 1px;
|
||||
}
|
||||
${textLayerBuilderCSS}
|
||||
${annotationLayerBuilderCSS}
|
||||
</style>
|
||||
<div id="canvas"></div>
|
||||
<div class="textLayer"></div>
|
||||
<div class="annotationLayer"></div>
|
||||
`], { type: 'text/html' }))
|
||||
const onZoom = ({ doc, scale }) => render(page, doc, scale)
|
||||
return { src, onZoom }
|
||||
}
|
||||
|
||||
const makeTOCItem = item => ({
|
||||
label: item.title,
|
||||
href: JSON.stringify(item.dest),
|
||||
subitems: item.items.length ? item.items.map(makeTOCItem) : null,
|
||||
})
|
||||
|
||||
export const makePDF = async file => {
|
||||
const transport = new pdfjsLib.PDFDataRangeTransport(file.size, [])
|
||||
transport.requestDataRange = (begin, end) => {
|
||||
file.slice(begin, end).arrayBuffer().then(chunk => {
|
||||
transport.onDataRange(begin, chunk)
|
||||
})
|
||||
}
|
||||
const pdf = await pdfjsLib.getDocument({
|
||||
range: transport,
|
||||
cMapUrl: pdfjsPath('cmaps/'),
|
||||
standardFontDataUrl: pdfjsPath('standard_fonts/'),
|
||||
isEvalSupported: false,
|
||||
}).promise
|
||||
|
||||
const book = { rendition: { layout: 'pre-paginated' } }
|
||||
|
||||
const { metadata, info } = await pdf.getMetadata() ?? {}
|
||||
// TODO: for better results, parse `metadata.getRaw()`
|
||||
book.metadata = {
|
||||
title: metadata?.get('dc:title') ?? info?.Title,
|
||||
author: metadata?.get('dc:creator') ?? info?.Author,
|
||||
contributor: metadata?.get('dc:contributor'),
|
||||
description: metadata?.get('dc:description') ?? info?.Subject,
|
||||
language: metadata?.get('dc:language'),
|
||||
publisher: metadata?.get('dc:publisher'),
|
||||
subject: metadata?.get('dc:subject'),
|
||||
identifier: metadata?.get('dc:identifier'),
|
||||
source: metadata?.get('dc:source'),
|
||||
rights: metadata?.get('dc:rights'),
|
||||
}
|
||||
|
||||
const outline = await pdf.getOutline()
|
||||
book.toc = outline?.map(makeTOCItem)
|
||||
|
||||
const cache = new Map()
|
||||
book.sections = Array.from({ length: pdf.numPages }).map((_, i) => ({
|
||||
id: i,
|
||||
load: async () => {
|
||||
const cached = cache.get(i)
|
||||
if (cached) return cached
|
||||
const url = await renderPage(await pdf.getPage(i + 1))
|
||||
cache.set(i, url)
|
||||
return url
|
||||
},
|
||||
size: 1000,
|
||||
}))
|
||||
book.isExternal = uri => /^\w+:/i.test(uri)
|
||||
book.resolveHref = async href => {
|
||||
const parsed = JSON.parse(href)
|
||||
const dest = typeof parsed === 'string'
|
||||
? await pdf.getDestination(parsed) : parsed
|
||||
const index = await pdf.getPageIndex(dest[0])
|
||||
return { index }
|
||||
}
|
||||
book.splitTOCHref = async href => {
|
||||
const parsed = JSON.parse(href)
|
||||
const dest = typeof parsed === 'string'
|
||||
? await pdf.getDestination(parsed) : parsed
|
||||
const index = await pdf.getPageIndex(dest[0])
|
||||
return [index, null]
|
||||
}
|
||||
book.getTOCFragment = doc => doc.documentElement
|
||||
book.getCover = async () => renderPage(await pdf.getPage(1), true)
|
||||
book.destroy = () => pdf.destroy()
|
||||
return book
|
||||
}
|
||||
113
static/vendor/foliate-js/progress.js
vendored
Normal file
113
static/vendor/foliate-js/progress.js
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
// assign a unique ID for each TOC item
|
||||
const assignIDs = toc => {
|
||||
let id = 0
|
||||
const assignID = item => {
|
||||
item.id = id++
|
||||
if (item.subitems) for (const subitem of item.subitems) assignID(subitem)
|
||||
}
|
||||
for (const item of toc) assignID(item)
|
||||
return toc
|
||||
}
|
||||
|
||||
const flatten = items => items
|
||||
.map(item => item.subitems?.length
|
||||
? [item, flatten(item.subitems)].flat()
|
||||
: item)
|
||||
.flat()
|
||||
|
||||
export class TOCProgress {
|
||||
async init({ toc, ids, splitHref, getFragment }) {
|
||||
assignIDs(toc)
|
||||
const items = flatten(toc)
|
||||
const grouped = new Map()
|
||||
for (const [i, item] of items.entries()) {
|
||||
const [id, fragment] = await splitHref(item?.href) ?? []
|
||||
const value = { fragment, item }
|
||||
if (grouped.has(id)) grouped.get(id).items.push(value)
|
||||
else grouped.set(id, { prev: items[i - 1], items: [value] })
|
||||
}
|
||||
const map = new Map()
|
||||
for (const [i, id] of ids.entries()) {
|
||||
if (grouped.has(id)) map.set(id, grouped.get(id))
|
||||
else map.set(id, map.get(ids[i - 1]))
|
||||
}
|
||||
this.ids = ids
|
||||
this.map = map
|
||||
this.getFragment = getFragment
|
||||
}
|
||||
getProgress(index, range) {
|
||||
if (!this.ids) return
|
||||
const id = this.ids[index]
|
||||
const obj = this.map.get(id)
|
||||
if (!obj) return null
|
||||
const { prev, items } = obj
|
||||
if (!items) return prev
|
||||
if (!range || items.length === 1 && !items[0].fragment) return items[0].item
|
||||
|
||||
const doc = range.startContainer.getRootNode()
|
||||
for (const [i, { fragment }] of items.entries()) {
|
||||
const el = this.getFragment(doc, fragment)
|
||||
if (!el) continue
|
||||
if (range.comparePoint(el, 0) > 0)
|
||||
return (items[i - 1]?.item ?? prev)
|
||||
}
|
||||
return items[items.length - 1].item
|
||||
}
|
||||
}
|
||||
|
||||
export class SectionProgress {
|
||||
constructor(sections, sizePerLoc, sizePerTimeUnit) {
|
||||
this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0)
|
||||
this.sizePerLoc = sizePerLoc
|
||||
this.sizePerTimeUnit = sizePerTimeUnit
|
||||
this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0)
|
||||
this.sectionFractions = this.#getSectionFractions()
|
||||
}
|
||||
#getSectionFractions() {
|
||||
const { sizeTotal } = this
|
||||
const results = [0]
|
||||
let sum = 0
|
||||
for (const size of this.sizes) results.push((sum += size) / sizeTotal)
|
||||
return results
|
||||
}
|
||||
// get progress given index of and fractions within a section
|
||||
getProgress(index, fractionInSection, pageFraction = 0) {
|
||||
const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this
|
||||
const sizeInSection = sizes[index] ?? 0
|
||||
const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0)
|
||||
const size = sizeBefore + fractionInSection * sizeInSection
|
||||
const nextSize = size + pageFraction * sizeInSection
|
||||
const remainingTotal = sizeTotal - size
|
||||
const remainingSection = (1 - fractionInSection) * sizeInSection
|
||||
return {
|
||||
fraction: nextSize / sizeTotal,
|
||||
section: {
|
||||
current: index,
|
||||
total: sizes.length,
|
||||
},
|
||||
location: {
|
||||
current: Math.floor(size / sizePerLoc),
|
||||
next: Math.floor(nextSize / sizePerLoc),
|
||||
total: Math.ceil(sizeTotal / sizePerLoc),
|
||||
},
|
||||
time: {
|
||||
section: remainingSection / sizePerTimeUnit,
|
||||
total: remainingTotal / sizePerTimeUnit,
|
||||
},
|
||||
}
|
||||
}
|
||||
// the inverse of `getProgress`
|
||||
// get index of and fraction in section based on total fraction
|
||||
getSection(fraction) {
|
||||
if (fraction <= 0) return [0, 0]
|
||||
if (fraction >= 1) return [this.sizes.length - 1, 1]
|
||||
fraction = fraction + Number.EPSILON
|
||||
const { sizeTotal } = this
|
||||
let index = this.sectionFractions.findIndex(x => x > fraction) - 1
|
||||
if (index < 0) return [0, 0]
|
||||
while (!this.sizes[index]) index++
|
||||
const fractionInSection = (fraction - this.sectionFractions[index])
|
||||
/ (this.sizes[index] / sizeTotal)
|
||||
return [index, fractionInSection]
|
||||
}
|
||||
}
|
||||
86
static/vendor/foliate-js/quote-image.js
vendored
Normal file
86
static/vendor/foliate-js/quote-image.js
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
|
||||
// bisect
|
||||
const fit = (el, a = 1, b = 50) => {
|
||||
const c = Math.floor(a + (b - a) / 2)
|
||||
el.style.fontSize = `${c}px`
|
||||
if (b - a === 1) return
|
||||
if (el.scrollHeight > el.clientHeight
|
||||
|| el.scrollWidth > el.clientWidth) fit(el, a, c)
|
||||
else fit(el, c, b)
|
||||
}
|
||||
|
||||
const width = 540
|
||||
const height = 540
|
||||
const pixelRatio = 2
|
||||
|
||||
const html = `<style>
|
||||
:host {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
<main style="width: ${width}px; height: ${height}px; overflow: hidden; display: flex; flex-direction: column; justify-content: center; text-align: center; background: #fff; color: #000; font: 16pt serif">
|
||||
<style>
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
<div style="font-size: min(4em, 8rem); line-height: 1; margin-bottom: -.5em">“</div>
|
||||
<div id="text" style="margin: 1em; text-wrap: balance"></div>
|
||||
<div style="margin: 0 1em">
|
||||
<div style="font-size: min(.8em, 1.25rem); font-family: sans-serif">
|
||||
<div style="display: block; font-weight: bold; margin-bottom: .25em">
|
||||
<span id="author" class="ellipsis"></span>
|
||||
</div>
|
||||
<div style="display: block; text-wrap: balance">
|
||||
<cite id="title" class="ellipsis"></cite>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height: 1em"> </div>
|
||||
</main>`
|
||||
|
||||
// TODO: lang, vertical writing
|
||||
customElements.define('foliate-quoteimage', class extends HTMLElement {
|
||||
#root = this.attachShadow({ mode: 'closed' })
|
||||
constructor() {
|
||||
super()
|
||||
this.#root.innerHTML = html
|
||||
}
|
||||
async getBlob({ title, author, text }) {
|
||||
this.#root.querySelector('#title').textContent = title
|
||||
this.#root.querySelector('#author').textContent = author
|
||||
this.#root.querySelector('#text').innerText = text
|
||||
|
||||
fit(this.#root.querySelector('main'))
|
||||
|
||||
const img = document.createElement('img')
|
||||
return new Promise(resolve => {
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = pixelRatio * width
|
||||
canvas.height = pixelRatio * height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
canvas.toBlob(resolve)
|
||||
}
|
||||
const doc = document.implementation.createDocument(SVG_NS, 'svg')
|
||||
doc.documentElement.setAttribute('viewBox', `0 0 ${width} ${height}`)
|
||||
const obj = doc.createElementNS(SVG_NS, 'foreignObject')
|
||||
obj.setAttribute('width', width)
|
||||
obj.setAttribute('height', height)
|
||||
obj.append(doc.importNode(this.#root.querySelector('main'), true))
|
||||
doc.documentElement.append(obj)
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,'
|
||||
+ new XMLSerializer().serializeToString(doc)
|
||||
})
|
||||
}
|
||||
})
|
||||
130
static/vendor/foliate-js/search.js
vendored
Normal file
130
static/vendor/foliate-js/search.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
// length for context in excerpts
|
||||
const CONTEXT_LENGTH = 50
|
||||
|
||||
const normalizeWhitespace = str => str.replace(/\s+/g, ' ')
|
||||
|
||||
const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => {
|
||||
const start = strs[startIndex]
|
||||
const end = strs[endIndex]
|
||||
const match = start === end
|
||||
? start.slice(startOffset, endOffset)
|
||||
: start.slice(startOffset)
|
||||
+ strs.slice(start + 1, end).join('')
|
||||
+ end.slice(0, endOffset)
|
||||
const trimmedStart = normalizeWhitespace(start.slice(0, startOffset)).trimStart()
|
||||
const trimmedEnd = normalizeWhitespace(end.slice(endOffset)).trimEnd()
|
||||
const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
|
||||
const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
|
||||
const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
|
||||
const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
|
||||
return { pre, match, post }
|
||||
}
|
||||
|
||||
const simpleSearch = function* (strs, query, options = {}) {
|
||||
const { locales = 'en', sensitivity } = options
|
||||
const matchCase = sensitivity === 'variant'
|
||||
const haystack = strs.join('')
|
||||
const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales)
|
||||
const needle = matchCase ? query : query.toLocaleLowerCase(locales)
|
||||
const needleLength = needle.length
|
||||
let index = -1
|
||||
let strIndex = -1
|
||||
let sum = 0
|
||||
do {
|
||||
index = lowerHaystack.indexOf(needle, index + 1)
|
||||
if (index > -1) {
|
||||
while (sum <= index) sum += strs[++strIndex].length
|
||||
const startIndex = strIndex
|
||||
const startOffset = index - (sum - strs[strIndex].length)
|
||||
const end = index + needleLength
|
||||
while (sum <= end) sum += strs[++strIndex].length
|
||||
const endIndex = strIndex
|
||||
const endOffset = end - (sum - strs[strIndex].length)
|
||||
const range = { startIndex, startOffset, endIndex, endOffset }
|
||||
yield { range, excerpt: makeExcerpt(strs, range) }
|
||||
}
|
||||
} while (index > -1)
|
||||
}
|
||||
|
||||
const segmenterSearch = function* (strs, query, options = {}) {
|
||||
const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options
|
||||
let segmenter, collator
|
||||
try {
|
||||
segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity })
|
||||
collator = new Intl.Collator(locales, { sensitivity })
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
segmenter = new Intl.Segmenter('en', { usage: 'search', granularity })
|
||||
collator = new Intl.Collator('en', { sensitivity })
|
||||
}
|
||||
const queryLength = Array.from(segmenter.segment(query)).length
|
||||
|
||||
const substrArr = []
|
||||
let strIndex = 0
|
||||
let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
|
||||
main: while (strIndex < strs.length) {
|
||||
while (substrArr.length < queryLength) {
|
||||
const { done, value } = segments.next()
|
||||
if (done) {
|
||||
// the current string is exhausted
|
||||
// move on to the next string
|
||||
strIndex++
|
||||
if (strIndex < strs.length) {
|
||||
segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
|
||||
continue
|
||||
} else break main
|
||||
}
|
||||
const { index, segment } = value
|
||||
// ignore formatting characters
|
||||
if (!/[^\p{Format}]/u.test(segment)) continue
|
||||
// normalize whitespace
|
||||
if (/\s/u.test(segment)) {
|
||||
if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment))
|
||||
substrArr.push({ strIndex, index, segment: ' ' })
|
||||
continue
|
||||
}
|
||||
value.strIndex = strIndex
|
||||
substrArr.push(value)
|
||||
}
|
||||
const substr = substrArr.map(x => x.segment).join('')
|
||||
if (collator.compare(query, substr) === 0) {
|
||||
const endIndex = strIndex
|
||||
const lastSeg = substrArr[substrArr.length - 1]
|
||||
const endOffset = lastSeg.index + lastSeg.segment.length
|
||||
const startIndex = substrArr[0].strIndex
|
||||
const startOffset = substrArr[0].index
|
||||
const range = { startIndex, startOffset, endIndex, endOffset }
|
||||
yield { range, excerpt: makeExcerpt(strs, range) }
|
||||
}
|
||||
substrArr.shift()
|
||||
}
|
||||
}
|
||||
|
||||
export const search = (strs, query, options) => {
|
||||
const { granularity = 'grapheme', sensitivity = 'base' } = options
|
||||
if (!Intl?.Segmenter || granularity === 'grapheme'
|
||||
&& (sensitivity === 'variant' || sensitivity === 'accent'))
|
||||
return simpleSearch(strs, query, options)
|
||||
return segmenterSearch(strs, query, options)
|
||||
}
|
||||
|
||||
export const searchMatcher = (textWalker, opts) => {
|
||||
const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, acceptNode } = opts
|
||||
return function* (doc, query) {
|
||||
const iter = textWalker(doc, function* (strs, makeRange) {
|
||||
for (const result of search(strs, query, {
|
||||
locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en',
|
||||
granularity: matchWholeWords ? 'word' : 'grapheme',
|
||||
sensitivity: matchDiacritics && matchCase ? 'variant'
|
||||
: matchDiacritics && !matchCase ? 'accent'
|
||||
: !matchDiacritics && matchCase ? 'case'
|
||||
: 'base',
|
||||
})) {
|
||||
const { startIndex, startOffset, endIndex, endOffset } = result.range
|
||||
result.range = makeRange(startIndex, startOffset, endIndex, endOffset)
|
||||
yield result
|
||||
}
|
||||
}, acceptNode)
|
||||
for (const result of iter) yield result
|
||||
}
|
||||
}
|
||||
43
static/vendor/foliate-js/text-walker.js
vendored
Normal file
43
static/vendor/foliate-js/text-walker.js
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
const walkRange = (range, walker) => {
|
||||
const nodes = []
|
||||
for (let node = walker.currentNode; node; node = walker.nextNode()) {
|
||||
const compare = range.comparePoint(node, 0)
|
||||
if (compare === 0) nodes.push(node)
|
||||
else if (compare > 0) break
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
const walkDocument = (_, walker) => {
|
||||
const nodes = []
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
||||
nodes.push(node)
|
||||
return nodes
|
||||
}
|
||||
|
||||
const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
|
||||
| NodeFilter.SHOW_CDATA_SECTION
|
||||
|
||||
const acceptNode = node => {
|
||||
if (node.nodeType === 1) {
|
||||
const name = node.tagName.toLowerCase()
|
||||
if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
|
||||
export const textWalker = function* (x, func, filterFunc) {
|
||||
const root = x.commonAncestorContainer ?? x.body ?? x
|
||||
const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode })
|
||||
const walk = x.commonAncestorContainer ? walkRange : walkDocument
|
||||
const nodes = walk(x, walker)
|
||||
const strs = nodes.map(node => node.nodeValue ?? '')
|
||||
const makeRange = (startIndex, startOffset, endIndex, endOffset) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(nodes[startIndex], startOffset)
|
||||
range.setEnd(nodes[endIndex], endOffset)
|
||||
return range
|
||||
}
|
||||
for (const match of func(strs, makeRange)) yield match
|
||||
}
|
||||
278
static/vendor/foliate-js/tts.js
vendored
Normal file
278
static/vendor/foliate-js/tts.js
vendored
Normal file
@@ -0,0 +1,278 @@
|
||||
const NS = {
|
||||
XML: 'http://www.w3.org/XML/1998/namespace',
|
||||
SSML: 'http://www.w3.org/2001/10/synthesis',
|
||||
}
|
||||
|
||||
const blockTags = new Set([
|
||||
'article', 'aside', 'audio', 'blockquote', 'caption',
|
||||
'details', 'dialog', 'div', 'dl', 'dt', 'dd',
|
||||
'figure', 'footer', 'form', 'figcaption',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
|
||||
'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr',
|
||||
])
|
||||
|
||||
const getLang = el => {
|
||||
const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang')
|
||||
return x ? x : el.parentElement ? getLang(el.parentElement) : null
|
||||
}
|
||||
|
||||
const getAlphabet = el => {
|
||||
const x = el?.getAttributeNS?.(NS.XML, 'lang')
|
||||
return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null
|
||||
}
|
||||
|
||||
const getSegmenter = (lang = 'en', granularity = 'word') => {
|
||||
const segmenter = new Intl.Segmenter(lang, { granularity })
|
||||
const granularityIsWord = granularity === 'word'
|
||||
return function* (strs, makeRange) {
|
||||
const str = strs.join('')
|
||||
let name = 0
|
||||
let strIndex = -1
|
||||
let sum = 0
|
||||
for (const { index, segment, isWordLike } of segmenter.segment(str)) {
|
||||
if (granularityIsWord && !isWordLike) continue
|
||||
while (sum <= index) sum += strs[++strIndex].length
|
||||
const startIndex = strIndex
|
||||
const startOffset = index - (sum - strs[strIndex].length)
|
||||
const end = index + segment.length - 1
|
||||
if (end < str.length) while (sum <= end) sum += strs[++strIndex].length
|
||||
const endIndex = strIndex
|
||||
const endOffset = end - (sum - strs[strIndex].length) + 1
|
||||
yield [(name++).toString(),
|
||||
makeRange(startIndex, startOffset, endIndex, endOffset)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fragmentToSSML = (fragment, inherited) => {
|
||||
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
|
||||
const { lang } = inherited
|
||||
if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang)
|
||||
|
||||
const convert = (node, parent, inheritedAlphabet) => {
|
||||
if (!node) return
|
||||
if (node.nodeType === 3) return ssml.createTextNode(node.textContent)
|
||||
if (node.nodeType === 4) return ssml.createCDATASection(node.textContent)
|
||||
if (node.nodeType !== 1 && node.nodeType !== 11) return
|
||||
|
||||
let el
|
||||
const nodeName = node.nodeName.toLowerCase()
|
||||
if (nodeName === 'foliate-mark') {
|
||||
el = ssml.createElementNS(NS.SSML, 'mark')
|
||||
el.setAttribute('name', node.dataset.name)
|
||||
}
|
||||
else if (nodeName === 'br')
|
||||
el = ssml.createElementNS(NS.SSML, 'break')
|
||||
else if (nodeName === 'em' || nodeName === 'strong')
|
||||
el = ssml.createElementNS(NS.SSML, 'emphasis')
|
||||
|
||||
const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang')
|
||||
if (lang) {
|
||||
if (!el) el = ssml.createElementNS(NS.SSML, 'lang')
|
||||
el.setAttributeNS(NS.XML, 'lang', lang)
|
||||
}
|
||||
|
||||
const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet
|
||||
if (!el) {
|
||||
const ph = node.getAttributeNS?.(NS.SSML, 'ph')
|
||||
if (ph) {
|
||||
el = ssml.createElementNS(NS.SSML, 'phoneme')
|
||||
if (alphabet) el.setAttribute('alphabet', alphabet)
|
||||
el.setAttribute('ph', ph)
|
||||
}
|
||||
}
|
||||
|
||||
if (!el) el = parent
|
||||
|
||||
let child = node.firstChild
|
||||
while (child) {
|
||||
const childEl = convert(child, el, alphabet)
|
||||
if (childEl && el !== childEl) el.append(childEl)
|
||||
child = child.nextSibling
|
||||
}
|
||||
return el
|
||||
}
|
||||
convert(fragment, ssml.documentElement, inherited.alphabet)
|
||||
return ssml
|
||||
}
|
||||
|
||||
const getFragmentWithMarks = (range, textWalker, granularity) => {
|
||||
const lang = getLang(range.commonAncestorContainer)
|
||||
const alphabet = getAlphabet(range.commonAncestorContainer)
|
||||
|
||||
const segmenter = getSegmenter(lang, granularity)
|
||||
const fragment = range.cloneContents()
|
||||
|
||||
// we need ranges on both the original document (for highlighting)
|
||||
// and the document fragment (for inserting marks)
|
||||
// so unfortunately need to do it twice, as you can't copy the ranges
|
||||
const entries = [...textWalker(range, segmenter)]
|
||||
const fragmentEntries = [...textWalker(fragment, segmenter)]
|
||||
|
||||
for (const [name, range] of fragmentEntries) {
|
||||
const mark = document.createElement('foliate-mark')
|
||||
mark.dataset.name = name
|
||||
range.insertNode(mark)
|
||||
}
|
||||
const ssml = fragmentToSSML(fragment, { lang, alphabet })
|
||||
return { entries, ssml }
|
||||
}
|
||||
|
||||
const rangeIsEmpty = range => !range.toString().trim()
|
||||
|
||||
function* getBlocks(doc) {
|
||||
let last
|
||||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT)
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const name = node.tagName.toLowerCase()
|
||||
if (blockTags.has(name)) {
|
||||
if (last) {
|
||||
last.setEndBefore(node)
|
||||
if (!rangeIsEmpty(last)) yield last
|
||||
}
|
||||
last = doc.createRange()
|
||||
last.setStart(node, 0)
|
||||
}
|
||||
}
|
||||
if (!last) {
|
||||
last = doc.createRange()
|
||||
last.setStart(doc.body.firstChild ?? doc.body, 0)
|
||||
}
|
||||
last.setEndAfter(doc.body.lastChild ?? doc.body)
|
||||
if (!rangeIsEmpty(last)) yield last
|
||||
}
|
||||
|
||||
class ListIterator {
|
||||
#arr = []
|
||||
#iter
|
||||
#index = -1
|
||||
#f
|
||||
constructor(iter, f = x => x) {
|
||||
this.#iter = iter
|
||||
this.#f = f
|
||||
}
|
||||
current() {
|
||||
if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index])
|
||||
}
|
||||
first() {
|
||||
const newIndex = 0
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
}
|
||||
prev() {
|
||||
const newIndex = this.#index - 1
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
}
|
||||
next() {
|
||||
const newIndex = this.#index + 1
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = this.#iter.next()
|
||||
if (done) break
|
||||
this.#arr.push(value)
|
||||
if (this.#arr[newIndex]) {
|
||||
this.#index = newIndex
|
||||
return this.#f(this.#arr[newIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
find(f) {
|
||||
const index = this.#arr.findIndex(x => f(x))
|
||||
if (index > -1) {
|
||||
this.#index = index
|
||||
return this.#f(this.#arr[index])
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = this.#iter.next()
|
||||
if (done) break
|
||||
this.#arr.push(value)
|
||||
if (f(value)) {
|
||||
this.#index = this.#arr.length - 1
|
||||
return this.#f(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TTS {
|
||||
#list
|
||||
#ranges
|
||||
#lastMark
|
||||
#serializer = new XMLSerializer()
|
||||
constructor(doc, textWalker, highlight, granularity) {
|
||||
this.doc = doc
|
||||
this.highlight = highlight
|
||||
this.#list = new ListIterator(getBlocks(doc), range => {
|
||||
const { entries, ssml } = getFragmentWithMarks(range, textWalker, granularity)
|
||||
this.#ranges = new Map(entries)
|
||||
return [ssml, range]
|
||||
})
|
||||
}
|
||||
#getMarkElement(doc, mark) {
|
||||
if (!mark) return null
|
||||
return doc.querySelector(`mark[name="${CSS.escape(mark)}"`)
|
||||
}
|
||||
#speak(doc, getNode) {
|
||||
if (!doc) return
|
||||
if (!getNode) return this.#serializer.serializeToString(doc)
|
||||
const ssml = document.implementation.createDocument(NS.SSML, 'speak')
|
||||
ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true))
|
||||
let node = getNode(ssml)?.previousSibling
|
||||
while (node) {
|
||||
const next = node.previousSibling ?? node.parentNode?.previousSibling
|
||||
node.parentNode.removeChild(node)
|
||||
node = next
|
||||
}
|
||||
return this.#serializer.serializeToString(ssml)
|
||||
}
|
||||
start() {
|
||||
this.#lastMark = null
|
||||
const [doc] = this.#list.first() ?? []
|
||||
if (!doc) return this.next()
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
|
||||
}
|
||||
resume() {
|
||||
const [doc] = this.#list.current() ?? []
|
||||
if (!doc) return this.next()
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
|
||||
}
|
||||
prev(paused) {
|
||||
this.#lastMark = null
|
||||
const [doc, range] = this.#list.prev() ?? []
|
||||
if (paused && range) this.highlight(range.cloneRange())
|
||||
return this.#speak(doc)
|
||||
}
|
||||
next(paused) {
|
||||
this.#lastMark = null
|
||||
const [doc, range] = this.#list.next() ?? []
|
||||
if (paused && range) this.highlight(range.cloneRange())
|
||||
return this.#speak(doc)
|
||||
}
|
||||
from(range) {
|
||||
this.#lastMark = null
|
||||
const [doc] = this.#list.find(range_ =>
|
||||
range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0)
|
||||
let mark
|
||||
for (const [name, range_] of this.#ranges.entries())
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) {
|
||||
mark = name
|
||||
break
|
||||
}
|
||||
return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark))
|
||||
}
|
||||
setMark(mark) {
|
||||
const range = this.#ranges.get(mark)
|
||||
if (range) {
|
||||
this.#lastMark = mark
|
||||
this.highlight(range.cloneRange())
|
||||
}
|
||||
}
|
||||
}
|
||||
42
static/vendor/foliate-js/ui/menu.js
vendored
Normal file
42
static/vendor/foliate-js/ui/menu.js
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
const createMenuItemRadioGroup = (label, arr, onclick) => {
|
||||
const group = document.createElement('ul')
|
||||
group.setAttribute('role', 'group')
|
||||
group.setAttribute('aria-label', label)
|
||||
const map = new Map()
|
||||
const select = value => {
|
||||
onclick(value)
|
||||
const item = map.get(value)
|
||||
for (const child of group.children)
|
||||
child.setAttribute('aria-checked', child === item ? 'true' : 'false')
|
||||
}
|
||||
for (const [label, value] of arr) {
|
||||
const item = document.createElement('li')
|
||||
item.setAttribute('role', 'menuitemradio')
|
||||
item.innerText = label
|
||||
item.onclick = () => select(value)
|
||||
map.set(value, item)
|
||||
group.append(item)
|
||||
}
|
||||
return { element: group, select }
|
||||
}
|
||||
|
||||
export const createMenu = arr => {
|
||||
const groups = {}
|
||||
const element = document.createElement('ul')
|
||||
element.setAttribute('role', 'menu')
|
||||
const hide = () => element.classList.remove('show')
|
||||
const hideAnd = f => (...args) => (hide(), f(...args))
|
||||
for (const { name, label, type, items, onclick } of arr) {
|
||||
const widget = type === 'radio'
|
||||
? createMenuItemRadioGroup(label, items, hideAnd(onclick))
|
||||
: null
|
||||
if (name) groups[name] = widget
|
||||
element.append(widget.element)
|
||||
}
|
||||
// TODO: keyboard events
|
||||
window.addEventListener('blur', () => hide())
|
||||
window.addEventListener('click', e => {
|
||||
if (!element.parentNode.contains(e.target)) hide()
|
||||
})
|
||||
return { element, groups }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user