56 Commits

Author SHA1 Message Date
69c69ab5bc books-ingest: safer title/ISBN matching, junk-author filter, GB quota breaker 2026-10-24 01:18:43 +00:00
8c65ae6e96 books: immersive reader route, PDF resume fix, scalable placeholder covers, read-only books mount in prod deploy, README 2026-10-24 01:18:17 +00:00
364c7ee132 books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests 2026-10-24 01:06:33 +00:00
2ca2d0d5a4 books: schema, migration and ingest script (rebased onto main; migration regenerated after money_bank_sync) 2026-10-24 01:05:01 +00:00
e4de2dcec3 Add privacy and terms pages
All checks were successful
Deploy production / deploy (push) Successful in 2m28s
2026-10-24 00:50:46 +00:00
00c1e8b1e7 money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements 2026-10-24 00:50:46 +00:00
9a1b21ec39 money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed) 2026-10-24 00:50:45 +00:00
b1ce715ec7 money: bank-sync schema (account scope/iban/external uid, transaction external id/source, bank_sessions) 2026-10-24 00:50:45 +00:00
064d41595f preflight: verify health grants and energy data
All checks were successful
Deploy production / deploy (push) Successful in 2m20s
2026-10-24 00:27:29 +00:00
fa2456b99e Merge branch 'energy-tracker' into energy-merge 2026-10-24 00:25:34 +00:00
8aa689af64 health: drop hair_progress; owner = Stan's account email only
All checks were successful
Deploy production / deploy (push) Successful in 2m22s
Regenerated the (not yet deployed) health migration without hair_progress.
2026-10-24 00:22:24 +00:00
8275f1fb22 Add private health tracker (owner-only)
- health schema: vaccinations, body_measurements, workouts, nutrition_daily,
  sleep (generated hours), medications, hair_progress, appointments
- migration with updated_at triggers; grants to personal_app only
- /trackers/health overview with add/edit/delete, owner-gated (404 otherwise),
  noindex + private no-store, hidden from listings for non-owners
- owner-only JSON/CSV import endpoint and scripts/health-import.ts
2026-10-24 00:14:07 +00:00
0cf5d44062 Add energy tracker (/projects/energy)
Public, read-only page over the ned-collector energy schema: latest-hour
mix and per-source utilization, generation vs consumption with CO2
intensity (day/week/month/12 months/all years), NED forecast, gas storage
by year and per site, and gas-fired power share vs storage fill.
Incomplete hours (sources not summing to the NED total) are left out and
partial days/months/years are marked.
2026-10-23 23:11:27 +00:00
88837af624 Rename repo/image path to stan/website; retag legacy images on rollback
All checks were successful
Deploy production / deploy (push) Successful in 2m23s
2026-10-23 22:53:23 +00:00
45f2bbb198 db-refresh-dev: own dev DB as personal_dev, no prod grants; PG_CONTAINER mode
All checks were successful
Deploy production / deploy (push) Successful in 3m49s
2026-10-23 22:27:17 +00:00
36af5675e9 Merge db-schemas: Drizzle schemas, prod-only pipeline, trackers
Some checks failed
Deploy production / deploy (push) Failing after 14s
2026-10-23 22:09:54 +00:00
7e69d1e888 ui: separate trackers from projects; keep header dropdown on top
- /trackers index, Trackers section on the homepage and in the header nav;
  /projects lists only projects (tracker URLs unchanged)
- header is its own z-50 layer and <main> is isolated, so maps, charts and
  sticky tables no longer paint over the nav dropdowns
2026-10-23 22:03:59 +00:00
2915135ed8 deploy: prod-only pipeline with Drizzle migrations; dev refresh + preview
- push to main builds, backs up prod (pg_dump 17), runs db-migrate + seeds,
  preflights and deploys; rollback = workflow_dispatch with rollback_tag
- staging workflow, promote step and migrate-*.ts / old SQL removed
- DB_URL = app role, DB_MIGRATE_URL = owner role
- scripts/db-refresh-dev.sh (prod dump -> local dev DB), scripts/preview.sh <branch>
2026-10-23 22:03:59 +00:00
4cfe6bad6c app: lists on meta tables, schema-qualified SQL, typed columns
- lists read/write real lists.* tables via meta.tables/meta.columns and the
  SECURITY DEFINER meta functions; typed extra columns in the item forms
- old numeric list URLs 308-redirect to the slug
- include_recurring is boolean, osu accuracy/pp/rate numeric
- raw SQL qualified with the new schemas
2026-10-23 22:03:42 +00:00
64fb8df262 db: Drizzle schemas on domain Postgres schemas, baseline migration, reorg SQL
- pgSchema tables for auth, money, house, ref, games, cs, animaldex, site, meta
  (lists excluded via schemaFilter; runtime list tables are managed by meta.*)
- drizzle/ baseline matches the reorged DB exactly (catalog diff = 0 on rehearsal)
- db/reorg: one-time SQL that moves the flat public schema (rehearsed on a prod copy)
- scripts/db-migrate.ts: migrator with --init to record the baseline and a guard
  against running the baseline on a non-empty, unrecorded database
2026-10-23 22:03:42 +00:00
f94e3dc8ac fix: use namespace zod import in house tracker types
All checks were successful
Test and deploy staging / staging (push) Successful in 2m46s
`import { z } from 'zod'` is undefined when vitest runs under Bun (as in the
Docker build), matching the earlier fix in eca43c8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:20:22 +00:00
54924b39f0 feat: upgrade to SvelteKit 3 and adapter-bun
Some checks failed
Test and deploy staging / staging (push) Failing after 43s
Run the sveltekit-3 migration and finish its manual tasks:

- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path

Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:30 +00:00
b52e9188f4 house worker 2026-10-11 22:05:05 +00:00
ecd49fc410 fix: parse real Pararius search titles correctly 2026-10-11 21:38:47 +00:00
4ea0ba2d48 fix: enforce house worker request spacing and ignore local secrets 2026-10-11 21:14:41 +00:00
0846cf7d77 feat: house tracker with home worker, alerts and listing map
Collects Pararius rent/buy listings into Postgres via a worker that runs on a
residential connection, sends Discord/ntfy alerts for new listings matching
rules, and shows an admin-only map, market stats and worker log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HfF5tnChi8tkCx9svJP6D
2026-10-11 20:57:20 +00:00
0ee9ed0abb feat: roomier BO2 companion with +/- rank stepping
All checks were successful
Test and deploy staging / staging (push) Successful in 2m29s
- Scale the COD page to the screen: larger gun art, text and progress
  ribbon, sized from one viewport unit so it fills a MacBook Pro 16"
  and big monitors and still fits 1280x720 without scrolling.
- Five columns on wide screens and four below, picked by CSS, so the
  server-rendered page already has the final layout.
- Replace the tabs/filters/overview rows with one toolbar of matching
  controls.
- Player and weapon ranks step with + and -: level 55 + 1 is the next
  prestige at level 1, up to max prestige. Presses show at once, batch
  while a save is running, and stepRank does the arithmetic in SQL.
- Clicking a rank still opens exact entry for big jumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:50:19 +00:00
8eec207f2d fix: create deployment backups with allowed Docker permissions
All checks were successful
Test and deploy staging / staging (push) Successful in 2m26s
2026-10-08 21:10:21 +00:00
c2bc9050c2 feat: build compact BO2 grind companion with cumulative ranks
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
2026-10-08 21:06:19 +00:00
c2ba8febb4 Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
2026-09-25 22:43:04 +00:00
f6b34150d4 add ai slop warnings
All checks were successful
Test and deploy staging / staging (push) Successful in 12m6s
2026-09-25 16:41:45 +00:00
abac077541 ui changes and cs prices
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
2026-09-25 00:44:06 +00:00
c757d0882a add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
2026-09-24 19:24:09 +00:00
a10585a4d2 add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:18 +00:00
438623d857 add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Dutch and Korean (explained in English) and English (explained in Dutch)
from A1 to C1, with a Hangul level for Korean. Lessons with notes, words
and sentences generate multiple-choice, typing, sentence building, cloze,
listening and matching exercises. Endless challenges: a seeded daily
challenge, sprint, survival, mixed practice, level tests, spaced-repetition
reviews and generated drills (numbers, clock times, de/het, plurals, Dutch
and Korean conjugation, particles, Hangul reading, irregular verbs).
Progress is stored in localStorage with export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:34:56 +00:00
afb1f191df add world opinions project
All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
World map with switchable layers (what "liberal" means, what left vs right
is about, traditional/secular and survival/self-expression values), country
cards, a shared-scale party compass, a values scatter and national
narratives of major events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:24:40 +00:00
499dd94cbb update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
2026-09-23 18:06:20 +00:00
2466db74cd fix csv import for money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s
2026-09-23 17:23:14 +00:00
0638ead87f add money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s
2026-09-23 17:02:32 +00:00
753b2e5d6a add animal modal and page
All checks were successful
Test and deploy staging / staging (push) Successful in 1m54s
2026-09-23 16:56:19 +00:00
3691a85fdf add projects dropdown
All checks were successful
Test and deploy staging / staging (push) Successful in 1m48s
2026-09-23 16:44:21 +00:00
4b63e9ca11 remove emojis with real icons
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s
2026-09-23 16:12:41 +00:00
f1cd0ef849 add catchemall progress bars and hide uncatchable pokemon by default
All checks were successful
Test and deploy staging / staging (push) Successful in 28s
2026-09-23 04:59:22 +00:00
eca43c82d0 fix zod imports in bun test build
Some checks failed
Test and deploy staging / staging (push) Failing after 1m39s
2026-09-23 04:47:27 +00:00
1d0b96e7bc optimize building and deploying
Some checks failed
Test and deploy staging / staging (push) Failing after 13s
2026-09-23 04:36:58 +00:00
b8b366b542 Merge pull request 'staging' (#42) from staging into main
All checks were successful
build / build (push) Successful in 34s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#42
2026-09-22 13:51:54 +00:00
6ed508702e Merge pull request 'staging' (#38) from staging into main
All checks were successful
build / build (push) Successful in 37s
build / deploy (push) Successful in 3s
Reviewed-on: stan/personal-website#38
2026-09-20 02:26:53 +00:00
46ab4a0adb Merge pull request 'staging' (#34) from staging into main
All checks were successful
build / build (push) Successful in 36s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#34
2026-09-17 05:47:09 +00:00
08ab1f2716 Merge pull request 'staging' (#31) from staging into main
All checks were successful
build / build (push) Successful in 36s
build / deploy (push) Successful in 3s
Reviewed-on: stan/personal-website#31
2026-09-17 05:03:40 +00:00
e039f69afd Merge pull request 'staging' (#29) from staging into main
All checks were successful
build / build (push) Successful in 32s
build / deploy (push) Successful in 12s
Reviewed-on: stan/personal-website#29
2026-09-17 03:24:39 +00:00
1ff6bb859e Merge pull request 'staging' (#27) from staging into main
All checks were successful
build / build (push) Successful in 32s
build / deploy (push) Successful in 3s
Reviewed-on: stan/personal-website#27
2026-09-17 03:19:35 +00:00
997c54133f Merge pull request 'staging' (#21) from staging into main
All checks were successful
build / build (push) Successful in 33s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#21
2026-09-01 22:00:25 +00:00
bdc8ac24ac Merge pull request 'staging' (#15) from staging into main
All checks were successful
build / build (push) Successful in 41s
build / deploy (push) Successful in 14s
Reviewed-on: stan/personal-website#15
2026-09-01 11:22:34 +00:00
c688b874c8 Merge pull request 'staging' (#12) from staging into main
All checks were successful
build / build (push) Successful in 31s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#12
2026-08-31 16:43:42 +00:00
74f64c7457 Merge pull request 'base website' (#10) from staging into main
All checks were successful
build / build (push) Successful in 40s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#10
2026-08-31 14:47:50 +00:00
245137c65d Merge pull request 'chore: add basic readme' (#1) from staging into main
All checks were successful
build / build (push) Successful in 41s
build / deploy (push) Successful in 17s
Reviewed-on: stan/personal-website#1
2026-08-26 04:36:48 +00:00
4590 changed files with 218561 additions and 3993 deletions

View File

@@ -1,5 +1,9 @@
# PostgreSQL connection (server only) # PostgreSQL connections (server only). Never commit real passwords.
DB_URL=postgresql://postgres:postgres@localhost:5432/personal_dev # DB_URL: the app role (personal_app: DML only, no DDL).
DB_URL=postgresql://personal_app:CHANGE_ME@localhost:5432/personal_dev
# DB_MIGRATE_URL: the schema owner (personal), used only by drizzle-kit,
# scripts/db-migrate.ts, the seed scripts and deploy backups.
DB_MIGRATE_URL=postgresql://personal:CHANGE_ME@localhost:5432/personal_dev
# Use the exact public origin, including scheme and port. Set the same value for both. # Use the exact public origin, including scheme and port. Set the same value for both.
ORIGIN=http://localhost:5173 ORIGIN=http://localhost:5173
@@ -28,3 +32,16 @@ R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY= R2_SECRET_ACCESS_KEY=
R2_BUCKET= R2_BUCKET=
R2_PUBLIC_URL= R2_PUBLIC_URL=
# House tracker: shared secret for the home worker (openssl rand -hex 32), and
# where alerts go (Discord webhook and/or ntfy topic URL). See docs/house-tracker.md.
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

View File

@@ -1,52 +0,0 @@
name: Test and deploy staging
on:
push:
branches: [staging]
jobs:
staging:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0
- name: Install and test
run: |
bun install --frozen-lockfile
bun run test:unit -- --run
# Build once. The same SHA-tagged image stays on the deployment host for
# one-click promotion to production; the production workflow never rebuilds it.
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
build-args: |
GIT_SHA=${{ github.sha }}
- name: Configure deployment SSH key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
set -euo pipefail
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: Transfer tested image
run: |
set -euo pipefail
image="git.stanrunge.dev/stan/personal-website:${{ github.sha }}"
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
- name: Deploy staging
run: |
set -euo pipefail
ssh -o BatchMode=yes deploy@172.17.0.1 \
"bash -s -- '${{ github.sha }}' '/opt/personal-website-staging' 'https://staging.stanrunge.dev'" \
< scripts/deploy-remote.sh

View File

@@ -1,41 +0,0 @@
name: Deploy production
on:
workflow_dispatch:
jobs:
promote:
runs-on: ubuntu-latest
steps:
# Always test the staging branch, regardless of which branch the button
# appears on in Gitea's Actions UI.
- uses: actions/checkout@v4
with:
ref: staging
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0
- name: Check staging source
run: |
bun install --frozen-lockfile
bun run test:unit -- --run
- name: Configure deployment SSH key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
set -euo pipefail
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: Promote the exact staging image
run: |
set -euo pipefail
commit=$(git rev-parse HEAD)
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$commit'" \
< scripts/verify-staging-image.sh
ssh -o BatchMode=yes deploy@172.17.0.1 \
"bash -s -- '$commit' '/opt/personal-website' 'https://stanrunge.dev'" \
< scripts/deploy-remote.sh

View File

@@ -0,0 +1,70 @@
name: Deploy production
# Two environments only: local dev (see README "Development") and prod.
# A push/merge to main builds and tests the image, backs up prod (pg_dump via
# postgres:17-alpine), runs Drizzle migrations, then switches the container.
# Pushes to any other branch (e.g. db-schemas) do not match and run nothing.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
rollback_tag:
description: 'Roll back: redeploy this earlier image SHA (skips migrations/seeds)'
required: true
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
# Belt and braces: a manual dispatch from another branch does nothing either.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure deployment SSH key
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
set -euo pipefail
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
# The Dockerfile runs bun install --frozen-lockfile, unit tests and the build,
# so a failing test never produces a deployable image.
- if: github.event_name == 'push'
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: git.stanrunge.dev/stan/website:${{ github.sha }}
build-args: |
GIT_SHA=${{ github.sha }}
- name: Transfer image
if: github.event_name == 'push'
run: |
set -euo pipefail
image="git.stanrunge.dev/stan/website:${{ github.sha }}"
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
- name: Back up, migrate and deploy
if: github.event_name == 'push'
run: |
set -euo pipefail
ssh -o BatchMode=yes deploy@172.17.0.1 \
"bash -s -- '${{ github.sha }}' deploy" < scripts/deploy-remote.sh
- name: Roll back to an earlier image
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ inputs.rollback_tag }}
run: |
set -euo pipefail
case "$TAG" in *[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;; esac
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$TAG' rollback" < scripts/deploy-remote.sh

4
.gitignore vendored
View File

@@ -16,6 +16,10 @@ Thumbs.db
!.env.example !.env.example
!.env.test !.env.test
# Home worker credentials and browser cookies
/house.env
/.house-worker-profile/
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*

View File

@@ -5,3 +5,23 @@ yarn.lock
# Generated pinned catalog # Generated pinned catalog
data/pokemon-catalog.json data/pokemon-catalog.json
# Generated Pokémon locations and encounters
data/pokemon-locations.json
# Generated Pokémon research and map coordinates
data/pokemon-research.json
data/pokemon-atlas.json
# Attributed generated challenge snapshot
data/cod-challenges.json
# Generated local image download manifest
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

View File

@@ -3,6 +3,7 @@ WORKDIR /app
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .
RUN bun run test:unit -- --run
RUN bun run build RUN bun run build
FROM oven/bun:1.4.0 FROM oven/bun:1.4.0
@@ -12,11 +13,13 @@ ENV APP_VERSION=$GIT_SHA
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build COPY --from=builder /app/build ./build
COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/data ./data COPY --from=builder /app/data ./data
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
COPY --from=builder /app/src/lib/cs-prices ./src/lib/cs-prices
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
COPY package.json ./ COPY package.json ./
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV BODY_SIZE_LIMIT=6M ENV BODY_SIZE_LIMIT=6M
CMD ["bun", "build/index.js"] CMD ["bun", "./build"]

457
README.md Executable file → Normal file
View File

@@ -6,3 +6,460 @@ This is my personal website, including:
- About me - About me
- Blog posts + comments - Blog posts + comments
- Projects like Animaldex, CatchEmAll - Projects like Animaldex, CatchEmAll
## House tracker
Available at `/projects/house-tracker` (admin only). Listings come from a worker
that runs at home; setup, settings and the ban-avoidance rules are in
[docs/house-tracker.md](docs/house-tracker.md).
## osu! Map Finder
Available at `/projects/spotify-beatmap-finder`. Built with the existing Svelte 5,
SvelteKit 3, Tailwind 4, and adapter-bun setup. Uses server-only direct `fetch`
calls; no Spotify/osu! SDK or additional services.
Set these private variables in your local `.env` or deployment environment:
```text
SPOTIFY_CLIENT_ID
SPOTIFY_CLIENT_SECRET
OSU_CLIENT_ID
OSU_CLIENT_SECRET
```
Never prefix these with `PUBLIC_` or commit `.env`. Providers read
`$app/env/private`; the browser receives only display metadata and results.
The other projects on this personal site retain their own environment requirements.
Supported inputs: song text (`Deftones Change`), Spotify track, playlist, and album
URLs, and `spotify:track:ID`, `spotify:playlist:ID`, `spotify:album:ID` URIs.
URLs allow HTTP/HTTPS, `www`, locale prefixes, query parameters and trailing slashes.
Artist, episode, show and other resources receive a validation error.
Playlists and albums preserve order and process up to **50 usable unique tracks**
(`MAX_PLAYLIST_TRACKS`), scanning at most **10 pages** (`MAX_PLAYLIST_PAGES`).
Null, unavailable, local, malformed and episode entries are skipped. Truncation is
shown explicitly. Album tracks inherit the album artwork/name. Each match has its
own error state; an osu! failure cannot erase other completed tracks. The enhanced
form shows a pending state and renders the complete batch; native form submission
also works without JavaScript. Filters require JavaScript.
Spotify playlist availability depends on application quota/access. Both legacy
`tracks/track` and newer `items/item` payloads are supported. Spotify's
[2026 development-mode changes](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide)
restrict playlist access; client credentials cannot unlock private/user-authorized
playlists. Such responses produce an access error. This project intentionally does
not add user authentication.
Matching is pure and testable in `src/lib/map-finder/matching.ts`: Unicode and
punctuation normalization, diacritic removal, common annotation cleanup, title and
artist token overlap, Unicode metadata alternatives, and version penalties. Title
contributes 65 points and artist 35. High confidence starts at 88, medium at 72;
weak artist evidence is capped below medium, and a different version costs 24.
These are heuristics, not probabilities. Candidates below 40 are omitted; up to six
are sorted and deduplicated by beatmapset ID. Low-confidence candidates are collapsed.
Search first uses artist + title, then one cleaned-title fallback if no medium/high
match exists. Duration is omitted because osu! map length may reflect a song cut.
Tokens are reused until 30 seconds before expiry with a shared refresh promise.
Matching results cache for five minutes (maximum 500 entries), including no-match
results, and identical pending searches are coalesced. Three concurrent osu!
matching jobs are allowed per process. The site allows one active search per IP,
up to six active searches globally, and 12 budget units per IP per 10 minutes:
a single track costs one, a playlist/album six. Expired records are cleaned on
requests; the limiter has a 10,000-record ceiling. It uses `getClientAddress()`,
never arbitrary forwarded headers; unsupported adapters share a conservative key.
Configure adapter-bun's trusted proxy/client-address settings (`ADDRESS_HEADER`, `XFF_DEPTH`) correctly at deployment.
Requests have a 12-second timeout and at most two retries for network errors,
429s or server errors. Backoff includes jitter. `Retry-After` is honored; delays
above four seconds return a helpful error and retain a provider cooldown rather
than holding the request open. All caches, cooldowns, concurrency and rate limits
are per process and reset on restart; multiple instances do not share them.
Local commands (npm scripts and existing tooling; no new dependencies):
```sh
npm install
npm run dev
npm run test:unit -- --run
npm run test:integration
npm run check
npm run lint
npm run build
```
Finder unit tests mock upstream APIs. Browser tests cover initial layout and
validation with and without enhancement, and do not call Spotify or osu!.
## Language learner
`/projects/language-learner` teaches Dutch and Korean (explained in English) and
English (explained in Dutch), from A1 to C1. Korean starts with a Hangul level.
It runs entirely in the browser: no database tables, migrations or API keys.
- Course content lives in `src/lib/language-learner/courses/<lang>/<level>.ts` as
compact `target | translation | note` lines, plus markdown notes and
multiple-choice grammar questions (`prompt | answer | wrong; wrong | explanation`).
Exercises (choice, typing, sentence building, fill-in-the-blank, listening,
matching) are generated from that data. `content.test.ts` validates every lesson.
- Endless drills are generated by code in `src/lib/language-learner/drills/`:
number words, Dutch/English clock times, de/het, plurals, Dutch and Korean
conjugation (a rule-based Korean conjugator handling the ㅂ/ㄷ/르/ㅅ/ㅎ
irregulars), Korean particles and counters, Hangul reading, English irregular
verbs, comparatives and a/an.
- Challenges: a seeded daily challenge (first score of the day counts), 60-second
sprint, survival with three lives, mixed practice, level tests that mark a level
as done at 80%, and spaced-repetition reviews (simplified SM-2).
- Audio uses the browser's speech synthesis, so listening exercises appear only
when the device has a voice for the language. Romanization of Korean is
approximate Revised Romanization.
- Progress, streaks and settings are stored in `localStorage`
(`language-learner-v1`), per browser. Export/import on the overview page moves
them between devices.
## Shared accounts and permissions
`/login`, `/register`, and `/account` are the central account pages. Better Auth
handles email/password credentials and database-backed sessions for the entire
site. Google and osu! buttons appear when the corresponding client ID and secret
are configured. The header exposes account access everywhere.
Copy the auth settings from `.env.example`. `BETTER_AUTH_SECRET` must be at least
32 random characters; there is no runtime fallback secret. Set `ORIGIN` and
`BETTER_AUTH_URL` to the same public origin (for example,
`https://dev.stanrunge.dev` behind the local proxy). Register these exact callbacks:
- Google: `<BETTER_AUTH_URL>/api/auth/callback/google`
- osu!: `<BETTER_AUTH_URL>/api/auth/callback/osu`
osu! uses the `identify` scope and a stable numeric account ID. Its API does not
supply an email address, so these accounts use a reserved, unverified
`<id>@osu.invalid` internal identifier, hidden from the account UI. Credential
registration cannot claim that namespace. Provider accounts are linked explicitly
from `/account` while logged in; there is no automatic merging by email. Link a
provider **before** using it to create a separate account if you want shared
progress. Existing separate accounts are not automatically merged.
Every new user has role `user`. The shared server guards enforce administrator
access for blog drafts/writes, list creation/editing, Animaldex moderation, and
Progress edits. Legacy `ADMIN_SECRET`, `AUTH_TOKEN`, `admin_token`, and `token`
logins no longer authorize anything. To grant yourself admin access, register,
copy the account ID shown on `/account`, then run on the server:
```sh
npm run auth:role -- <user-id> admin
# To revoke access:
npm run auth:role -- <user-id> user
```
Role changes take effect on the next request; roles cannot be set through signup
or profile updates. Public registrations are never made admin automatically.
Future projects should reference the shared `user.id`, use `requireUser(event)`,
and scope **both reads and writes** to the session user's ID. Use
`requireAdmin(event)` for site-wide administration; never accept an owner ID from
a form or infer permission from the presence of a cookie.
Email/password registration works without a mail service. Email verification and
password recovery emails are **not configured** yet. Connect a transactional mail
sender and Better Auth's verification/reset hooks before offering those flows.
Better Auth's rate limiting is enabled and currently uses per-process memory;
a multi-instance deployment needs shared rate-limit storage. Account pages and
saved progress require a working database. Authentication forms require JavaScript.
## Game tracker
`/projects/game-tracker` is the hub for completionist progress. Catchemall lives at
`/projects/game-tracker/pokemon` (the old `/projects/catch-em-all` URL redirects),
with location pages under `/projects/game-tracker/pokemon/locations`. Call of Duty
games live at `/projects/game-tracker/call-of-duty/<slug>`. All catalog data is
owned by the site: it is checked in under `data/` and seeded into Postgres, and
nothing calls PokéAPI or Steam at runtime.
### Pokémon catalog
`npm run catalog:build` rebuilds two files from the pinned PokéAPI CSV snapshot
(revision recorded inside them):
- `data/pokemon-catalog.json`: **1,025 species, 53 game versions/expansions and
27,267 species/game entries**, plus each species' evolution chain and the
evolution method that applies **in each game** (Magnezone needs Mt. Coronet in
Platinum but a Thunder Stone in Sword).
- `data/pokemon-locations.json`: 1,104 locations and about 67,000 encounters
(area, method, levels, rarity, form and conditions such as time of day, season
or swarm). Max Raid dens are folded per location.
Each entry has one availability:
- `catchable`: a documented encounter (wild, gift, static, in-game trade).
- `evolve`: a pre-evolution is obtainable in that game and the method exists
there, e.g. Blastoise in Red is “Evolve Wartortle: Level 36+”.
- `breed`: an obtainable relative can breed it (not in Gen 1, Let’s Go,
Colosseum/XD or the Legends games); incense babies name their incense.
- `event_only`: a mythical in the game with no other route.
- `unknown`: in the game's Pokédex data, but no confirmed route.
`catchable`, `evolve` and `breed` can be ticked and count toward progress.
PokéAPI has **no encounter data** for Brilliant Diamond/Shining Pearl, Legends:
Arceus, Scarlet/Violet and their DLC, or Legends: Z-A, so those games stay
`unknown` until another source is added. Missing pairs are not proof of absence.
Administrators can correct any entry from the Pokémon detail dialog and add notes
and a picture (JPEG/PNG/WebP, 5 MB, stored in R2 under `locations/`) on any
location page. Rows carry a `source`: seeding refreshes `pokeapi` and `derived`
rows and never touches `manual` ones. Location notes and pictures always survive
reseeding, and user progress is keyed by entry, so it survives too.
### Call of Duty catalog
`data/call-of-duty.json` covers Black Ops, Black Ops II, Ghosts, Advanced
Warfare, Black Ops III, Infinite Warfare and WWII. Weapon classes, post-launch
(DLC/update) weapons and camo rules are **hand-maintained** from the Call of Duty
Wiki (CC BY-SA). `npm run catalog:cod` refreshes only the achievement lists from
the public Steam Community pages. Users tick per-weapon milestones (bought or
unlocked, max level, Gold, Gold Cheetah); class and overall camos (Diamond, Dark
Matter, Solar, Black Sky, Chrome, Chrome Tiger) are derived in
`src/lib/game-tracker/mastery.ts`, and post-launch weapons never gate them.
Weapon progress is keyed by a slug of the weapon name, so renaming a weapon in
the JSON starts its progress over; the seed keeps removed items that already have
progress and warns about them.
### Database
The schema is managed by Drizzle (`src/lib/server/db/schema/`, migrations in
`drizzle/`). Postgres schemas: `auth`, `money`, `house`, `ref`, `games`, `cs`,
`animaldex`, `site`, `meta` (Drizzle-managed) and `lists` (one real table per list,
created at runtime through the `meta.create_list` family of SQL functions, so it is
excluded from Drizzle via `schemaFilter`). See [Development](#development) for the
local database and migrations, and `db/reorg/README.md` for the one-time move of the
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
are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns.
Transactions can be entered manually or imported from a CSV or tab-separated
export with `date,account,description,amount` headers. Dates accept
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
such as `-€1,234.50`. Quoted fields and comma, semicolon, or tab delimiters
are supported, and empty rows are skipped. Imported entries are stored in date
order. `Start balance` rows are imported as ordinary transactions, so leave
the account opening balance at zero for those accounts. Import creates
missing accounts and adds every row in one database transaction. It does not
deduplicate repeat uploads, so import each file once. Accounts have an editable
opening balance for money held before the first recorded transaction.
The current balance and history stop at today; future dated transactions feed the
wealth projection. The projection invests spendable money above the configured
reserve in a modeled balance, even without an existing investment account.
An existing investment account can optionally be selected as the starting
balance for growth. The projection treats recurring entries as monthly
equivalents from next month and can disable them to avoid double counting entries
already recorded as future transactions. Its reserve, hypothetical return,
contribution rule, and end date are editable. Wish list priority runs from tier 5
(highest) to tier 1 (lowest), with cumulative estimated costs on the overview.
The utility comparison uses meter readings within the configured billing period,
the current per-unit rate, and prorated monthly advances. It excludes fixed
charges, taxes, and past rate changes. Set the billing start to a recorded opening
meter reading for a meaningful annual comparison.
Seeding runs in one transaction and is repeatable. It preserves row IDs, existing
catches, selected games, and hand-maintained availability notes. Pokémon are keyed
by national number, games by PokéAPI version ID, and entries by Pokémon/game pair.
Existing games with exactly matching names are adopted to preserve their IDs.
The old broken reset and auth-schema overwrite commands have been removed.
Availability is deliberately conservative:
- `catchable`: PokéAPI supplies an encounter record. Conditions or a particular
form may be required. These entries contribute to the catch checklist.
- `unknown` (`?`): a Pokédex or game-index record exists, but the acquisition method
has not been confirmed. Membership alone never becomes “catchable”.
- A dash: no source record; this does **not** prove the Pokémon is unavailable.
- Manually maintained trade/transfer/event entries remain supported.
Encounter coverage varies by game and is especially incomplete for newer titles.
The import does not invent evolution, breeding, gift, trade, transfer, or event
rules from missing data. The page explains these limits. Catalog refreshes are
explicit: update `REVISION` in `scripts/build-pokemon-catalog.py`, run
`npm run catalog:build`, review the snapshot, and rerun the seed. Source:
[PokéAPI data](https://github.com/PokeAPI/pokeapi/tree/master/data/v2/csv).
Catches and selected games are stored server-side under the authenticated user's
ID. Repeated catch requests are idempotent, writes validate catalog IDs, and an
explicit empty game selection survives reloads. The UI rolls back failed saves
and prevents overlapping changes to the same catch.
`npm run test:unit -- --run` covers authorization, safe return paths, osu! identity
mapping, and catalog integrity alongside existing tests. `npm run test:auth` runs
browser/database checks against a migrated, seeded **development** database; it
creates temporary test accounts/content and removes them afterward. It checks
registration, login/logout, cookie replay, role escalation attempts, cross-user
progress isolation, admin routes, game selection, and OAuth authorization startup.
Completing Google/osu! consent still requires real provider credentials and the
registered callback URL. The integration follows the installed Better Auth APIs;
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
## Development
There are two environments: local dev and production. Prod is the source of truth
for data; the dev database is a disposable copy of it.
```sh
cp .env.example .env # DB_URL (app role) + DB_MIGRATE_URL (owner role) -> local personal_dev
scripts/db-refresh-dev.sh # fresh prod dump -> personal_dev (sessions cleared)
bun run db:migrate # apply this branch's pending Drizzle migrations
bun run dev
```
`db-refresh-dev.sh` reads `PROD_DB_URL` (a role that can read everything, e.g.
`personal`) and `DEV_ADMIN_URL` (superuser on the dev cluster) from the environment
or `.env.dev-refresh` (git-ignored, never commit it). It refuses to overwrite the
source database or any database named `personal`. `PG_DOCKER=1` uses the
`postgres:17-alpine` image when local `pg_dump` 17 isn't installed.
Schema changes: edit `src/lib/server/db/schema/`, run `bun run db:generate --name
<change>`, review the SQL in `drizzle/`, then `bun run db:migrate`. Never edit an
applied migration; never use `db:push` against prod.
`scripts/preview.sh <branch> [port]` runs a branch against a throwaway copy of the
dev database (migrated with that branch's migrations) and cleans everything up on
Ctrl-C.
### Database roles
| env var | role | used for |
| ---------------- | -------------- | --------------------------------------------------------------- |
| `DB_URL` | `personal_app` | the app at runtime: DML only, calls the `meta.*` list functions |
| `DB_MIGRATE_URL` | `personal` | Drizzle migrations, seeds, backups (`pg_dump`) |
`personal_ddl` owns the `lists` schema (only reachable through the SECURITY DEFINER
`meta.*` functions); `personal_ro` is read-only. The deploy preflight fails if
`DB_URL` can create objects, i.e. if it isn't the app role.
## Production
Push or merge to `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) builds a
SHA-tagged image (unit tests + production build run inside the Docker build),
transfers it, and runs `scripts/deploy-remote.sh <sha> deploy` on the host:
1. checks the image and that `DB_MIGRATE_URL` is set in `/etc/personal-website/prod.env`;
2. `pg_dump` of prod (postgres:17-alpine) into the backup directory;
3. `bun scripts/db-migrate.ts` (Drizzle migrations) and the catalog seeds;
4. preflight (tables, column types, app-role privileges), switch the container, health checks.
A failed health check restores the previous image tag automatically. Manual
rollback: Gitea Actions → **Deploy production** → **Run workflow** with
`rollback_tag=<previous sha>`; this redeploys that image without migrating or
seeding. Database changes are not rolled back with the image; restore the
pre-deploy dump if a migration has to be undone. Only `main` deploys; other
branches never trigger the workflow. The runner needs Docker CLI/socket access
and the `DEPLOY_KEY` secret.
`/etc/personal-website/prod.env` needs `DB_URL`, `DB_MIGRATE_URL`, a unique random
`BETTER_AUTH_SECRET` (32+ characters), and `ORIGIN`/`BETTER_AUTH_URL` set to
`https://stanrunge.dev`, plus the Google and osu! client ID/secret pairs and the five
`R2_*` values from `.env.example`. Picture uploads (lists, Animaldex) go through the
app server to R2 (5 MB per image; the container sets `BODY_SIZE_LIMIT=6M`). OAuth
callback URLs:
```text
https://stanrunge.dev/api/auth/callback/google
https://stanrunge.dev/api/auth/callback/osu
```
After registering, promote your account (ID shown on `/account`):
```sh
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
git.stanrunge.dev/stan/website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin
```
No public signup grants admin privileges.
# CS prices
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
individual listing. The separate `cs.item_dimensions` table makes it possible to
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
additional imported dimensions such as collection or rarity. Group chart points
average the available per-listing prices equally within each time bucket. Raw
observations retain their source, currency, timestamp, kind, price in cents, and
volume. Chart responses use up to roughly 240 time buckets per series and allow
up to 1,000 selected series; actual response time still depends on database size
and hardware.
With `DB_URL` pointed at a migrated database:
```sh
npm run cs-prices:ingest -- catalog
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
```
The catalog pass pages through Counter-Strike market listings and saves a current
lowest-listing snapshot. The history pass visits each catalog item and stores
Steam's available historical sale-price and volume points. It needs a Steam web
session, may take many hours for the full catalog, and can be resumed with
`--start=<item offset>` and bounded with `--limit=<items>`. Requests pause for two
seconds by default (`--delay-ms=...`, minimum one second), retry temporary
failures, and upsert observations safely. Run catalog regularly for new listings
and snapshots; run history regularly for new sale points. Steam's market web
endpoints are undocumented and may change or limit access. The historical
granularity and completeness are bounded by what Steam returns; running this
collector cannot reconstruct older high-frequency prices that Steam no longer
exposes.
For another provider or a prepared archive, import JSON Lines instead:
```sh
npm run cs-prices:ingest -- import ./prices.jsonl
```
Each line needs `marketHashName`, `source`, `currency` (three-letter ISO code),
`observedAt` (ISO timestamp), `priceCents`, and `kind`. Optional `name`, `volume`,
and `dimensions` add detail. For example:
```json
{
"marketHashName": "AK-47 | Redline (Field-Tested)",
"source": "archive",
"currency": "USD",
"observedAt": "2026-09-24T12:00:00Z",
"priceCents": 1850,
"volume": 42,
"kind": "sale-median",
"dimensions": { "collection": "Phoenix", "rarity": "classified" }
}
```
Imported sources and price kinds appear in the page's source and kind controls.

182
bun.lock
View File

@@ -9,32 +9,44 @@
"@aws-sdk/client-s3": "^3.1121.0", "@aws-sdk/client-s3": "^3.1121.0",
"@aws-sdk/s3-request-presigner": "^3.1121.0", "@aws-sdk/s3-request-presigner": "^3.1121.0",
"@maptiler/sdk": "^4.1.0", "@maptiler/sdk": "^4.1.0",
"@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85", "ai": "^7.0.85",
"d3-geo": "^3.1.1",
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4", "zod": "^4.5.4",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/simple-icons": "^1.2.94", "@iconify-json/simple-icons": "^1.2.94",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@sveltejs/adapter-bun": "^1.0.0",
"@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3", "@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7", "@typescript/native": "npm:typescript@7",
"better-auth": "^1.7.2", "better-auth": "^1.7.2",
"bits-ui": "^2.19.0", "bits-ui": "^2.19.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk-sv": "^0.0.19", "cmdk-sv": "^0.0.19",
"cn": "^0.4.0",
"drizzle-kit": "^1.0.0-rc.4", "drizzle-kit": "^1.0.0-rc.4",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -43,11 +55,13 @@
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"prettier": "3.9.6", "prettier": "3.9.6",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.57.0", "shadcn-svelte": "^1.7.0",
"svelte": "^5.57.1",
"svelte-check": "4.7.6", "svelte-check": "4.7.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1", "tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~6", "typescript": "~6",
"typescript-eslint": "8.68.0", "typescript-eslint": "8.68.0",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^23.0.1",
@@ -201,6 +215,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@fontsource-variable/montserrat": ["@fontsource-variable/montserrat@5.3.0", "", {}, "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@@ -281,6 +297,8 @@
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@lucide/svelte": ["@lucide/svelte@1.48.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw=="],
"@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="], "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="],
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
@@ -309,7 +327,29 @@
"@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=="], "@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/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], "@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/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
@@ -355,66 +395,8 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
"@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
"@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="],
@@ -431,11 +413,11 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="], "@sveltejs/adapter-bun": ["@sveltejs/adapter-bun@1.0.0", "", { "dependencies": { "bun-types": "^1.3.14" }, "peerDependencies": { "@sveltejs/kit": "^3.0.0-next.0" } }, "sha512-CUfFNxrWrKk/0VqSM1DrXCAwpY3tWocaFwBohuBeyxKx07859shLqPdT9oo00lDti9PvVwhQMDS6bKQylTyv0g=="],
"@sveltejs/enhanced-img": ["@sveltejs/enhanced-img@0.11.0", "", { "dependencies": { "magic-string": "^0.30.5", "sharp": "^0.34.1", "svelte-parse-markup": "^0.1.5", "vite-imagetools": "^9.0.3", "zimmerframe": "^1.1.2" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", "svelte": "^5.0.0", "vite": "^6.3.0 || >=7.0.0" } }, "sha512-TN7VzGoqwFqvA4Faj1brUPHLtcEkyFa08nrn9bfm98yVrzr0g4bOZHjlBoPYHHX+sZNGE+cxznhTVgarECPQsA=="], "@sveltejs/enhanced-img": ["@sveltejs/enhanced-img@0.11.0", "", { "dependencies": { "magic-string": "^0.30.5", "sharp": "^0.34.1", "svelte-parse-markup": "^0.1.5", "vite-imagetools": "^9.0.3", "zimmerframe": "^1.1.2" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", "svelte": "^5.0.0", "vite": "^6.3.0 || >=7.0.0" } }, "sha512-TN7VzGoqwFqvA4Faj1brUPHLtcEkyFa08nrn9bfm98yVrzr0g4bOZHjlBoPYHHX+sZNGE+cxznhTVgarECPQsA=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="], "@sveltejs/kit": ["@sveltejs/kit@3.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "@standard-schema/spec": "^1.1.0", "cookie": "^2.0.1", "devalue": "^5.9.4", "esm-env": "^1.2.2", "magic-string": "^1.2.3", "mrmime": "^2.0.1", "sirv": "^3.0.2" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "svelte": "^5.57.1", "typescript": "^6.0.0", "vite": "^8.0.12" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JTWlq2HQlkqx6vIoxhC0R6sc4WVqbQoRyEmr5YlqKmiYbzY1U3awtSWTmjMLlS056a49AmkQR6rnNANXIE8B0Q=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="], "@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
@@ -477,7 +459,7 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -495,7 +477,9 @@
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/topojson-client": ["@types/topojson-client@3.1.5", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw=="],
"@types/topojson-specification": ["@types/topojson-specification@1.0.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
@@ -609,6 +593,8 @@
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": "cli.js" }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": "cli.js" }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@@ -619,18 +605,24 @@
"cmdk-sv": ["cmdk-sv@0.0.19", "", { "dependencies": { "bits-ui": "^0.21.12", "nanoid": "^5.0.7" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" } }, "sha512-Vm6+/up5nQbwGmNw0uYFbXTrbpF460/FhMohTskzzszMtIR/u/78haucSmJWjjp9bW0wxpHXZWa6fk+Lj1tKnA=="], "cmdk-sv": ["cmdk-sv@0.0.19", "", { "dependencies": { "bits-ui": "^0.21.12", "nanoid": "^5.0.7" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" } }, "sha512-Vm6+/up5nQbwGmNw0uYFbXTrbpF460/FhMohTskzzszMtIR/u/78haucSmJWjjp9bW0wxpHXZWa6fk+Lj1tKnA=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], "cn": ["cn@0.4.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -643,7 +635,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="], "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA=="], "drizzle-kit": ["drizzle-kit@1.0.0-rc.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA=="],
@@ -651,12 +643,12 @@
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="], "earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
"echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.416", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="], "electron-to-chromium": ["electron-to-chromium@1.5.416", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
@@ -721,8 +713,6 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
"gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
@@ -733,8 +723,6 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="], "highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -747,14 +735,12 @@
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -781,8 +767,6 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
@@ -857,6 +841,8 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="], "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
@@ -873,12 +859,12 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="], "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-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
@@ -935,16 +921,12 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": "bin/cli.mjs" }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": "bin/cli.mjs" }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], "runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
@@ -955,6 +937,8 @@
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"shadcn-svelte": ["shadcn-svelte@1.7.0", "", { "dependencies": { "cn": "^0.3.0", "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-AK3ZsYOL8PN/RxcEBCHGvQj2TrLfaaBsbw0QohyEb3nn/yPNDRC/+9iSjnTp/uKEe+tAi0tBxB/Yhzno0N7EEg=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -973,9 +957,7 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "svelte": ["svelte@5.57.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ=="],
"svelte": ["svelte@5.57.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": "bin/svelte-check" }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": "bin/svelte-check" }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
@@ -1005,12 +987,16 @@
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -1051,6 +1037,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
@@ -1061,16 +1049,16 @@
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], "@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="],
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@sveltejs/kit/magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="], "@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1087,6 +1075,8 @@
"cmdk-sv/bits-ui": ["bits-ui@0.21.16", "", { "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", "nanoid": "^5.0.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.118" } }, "sha512-XFZ7/bK7j/K+5iktxX/ZpmoFHjYjpPzP5EOO/4bWiaFg5TG1iMcfjDhlBTQnJxD6BoVoHuqeZPHZvaTgF4Iv3Q=="], "cmdk-sv/bits-ui": ["bits-ui@0.21.16", "", { "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", "nanoid": "^5.0.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.118" } }, "sha512-XFZ7/bK7j/K+5iktxX/ZpmoFHjYjpPzP5EOO/4bWiaFg5TG1iMcfjDhlBTQnJxD6BoVoHuqeZPHZvaTgF4Iv3Q=="],
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"eslint-plugin-compat/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], "eslint-plugin-compat/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
@@ -1103,7 +1093,7 @@
"postcss/nanoid": ["nanoid@3.3.18", "", { "bin": "bin/nanoid.cjs" }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "postcss/nanoid": ["nanoid@3.3.18", "", { "bin": "bin/nanoid.cjs" }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"rollup/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "shadcn-svelte/cn": ["cn@0.3.3", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-OEiyJkSpYm2kOuNOKQ+c2wmzZRG8orrb9fnObtHTbCGNx/E9KLnGcmcocItOzREKk5KzQN5I2e3BUCj563KEpw=="],
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
@@ -1113,6 +1103,10 @@
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="], "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],

View File

@@ -2,7 +2,7 @@
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": { "tailwind": {
"css": "src/app.css", "css": "src/app.css",
"baseColor": "slate" "baseColor": "neutral"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
@@ -12,5 +12,9 @@
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/default" "registry": "https://shadcn-svelte.com/registry",
"style": "vega",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
} }

5166
data/call-of-duty.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,620 @@
{
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f84ba26b1844819ea9e52aef0d0570559b57a75a.jpg": "/game-art/call-of-duty/achievements/238b785e1fca8f84bbee.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e3b18cd1c090368b0386de8cc46f5a169c4038e9.jpg": "/game-art/call-of-duty/achievements/668be15f0347459b94f2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0a2812d34a7fa98e20285368bc1a763bd792f889.jpg": "/game-art/call-of-duty/achievements/5cd7a661f6d3e7e6e1bb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/584c74c05982e10c4441dd65324f277b7539eae2.jpg": "/game-art/call-of-duty/achievements/3f54bba6d8f7de2e2284.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b1b1349e22f48972109b743d520183ba70caad3c.jpg": "/game-art/call-of-duty/achievements/a9236535546a8dbc79b5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/aa159223f265ab937d7d5b6690ddcf49020a2cbc.jpg": "/game-art/call-of-duty/achievements/7535dcc4298af27198c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f2edbcfaed969439c62f7a4e6f1eb068b413e158.jpg": "/game-art/call-of-duty/achievements/8852bd4a99d6ccc75845.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e9d07584f66650764670664404079276b4a3406b.jpg": "/game-art/call-of-duty/achievements/d49877c3e2ce9ca95bd9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5b0accacfbcfa0396cb41f268b5ad8d9a65c613.jpg": "/game-art/call-of-duty/achievements/1826f646d000287dc1f6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e8d0caf4122d896eb05024593bb3b66dadf4e88d.jpg": "/game-art/call-of-duty/achievements/652149e0966e1eae6125.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/066c88a94e5a4b27555d5c643a08859ddf915d0f.jpg": "/game-art/call-of-duty/achievements/090152a9c71b3a45e225.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/8557b021165d25955cb8c9fef04bde10d9c73a99.jpg": "/game-art/call-of-duty/achievements/19009908c84ec2cafc6d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e63f09bb9b40effe11802afce07b6e9a90571724.jpg": "/game-art/call-of-duty/achievements/e20d161a6df6dc45f756.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/77911ac60f573a18fe2b136fe366c6eed30fe108.jpg": "/game-art/call-of-duty/achievements/3805fbfc6e08a1eeab92.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/8fb4508be92820c78ff69f0e69546de0d9b76be7.jpg": "/game-art/call-of-duty/achievements/a647123583f71660a8df.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/052772410645d83c3fce7b03c9467b66ccdf0213.jpg": "/game-art/call-of-duty/achievements/2bf1bc39e772950369fa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f6e25d8f4ebbe3166dfc3b58c34867f268f2d033.jpg": "/game-art/call-of-duty/achievements/566ea9ce791bf9abe5a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/54d9340f91dbdb3e22ff5fc256ce30d0fa6a963c.jpg": "/game-art/call-of-duty/achievements/fdfdce2ff535cf32cd0a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/82c5e869cf17efa57d7c2a6d19903cb2aee7ff0d.jpg": "/game-art/call-of-duty/achievements/c3e15cc571084bd8e032.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/19dcec07495607501ec6b251e2b822be1b60f0fd.jpg": "/game-art/call-of-duty/achievements/3a814d01d4b72ed78b86.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/2af436c800d1b2343c79bb8cfec3517a896b2074.jpg": "/game-art/call-of-duty/achievements/ca1b094aada4c4d3e3bf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/6e962666da615b8cb3821e60f3f104b270338e04.jpg": "/game-art/call-of-duty/achievements/e09047b22965850447f9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d013df6d049ec65340b44f297ae5e181d10e4b49.jpg": "/game-art/call-of-duty/achievements/04af8a32026bacfca67c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/5e4644a4b38f3fc47114804ec397ea4700629285.jpg": "/game-art/call-of-duty/achievements/b9c0bf5f5afcc39213c9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/75798a0d173926d2ee97d71bca3ec380927d104d.jpg": "/game-art/call-of-duty/achievements/02bc128c11c94faf1c9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5feb12f310b6feb51db21f92cf62e53ba3f6521.jpg": "/game-art/call-of-duty/achievements/edb0787878d0b6e8e696.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/4a90e8dda5c440eb25d33a6f495edbed0140d7af.jpg": "/game-art/call-of-duty/achievements/a57766605831b6778bf0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/35e478ea8071694ca9be384f361aea9c2928c420.jpg": "/game-art/call-of-duty/achievements/350f4b45235fdc221371.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7047eecc9292d4c18462340cb7cbeb417f35b0f7.jpg": "/game-art/call-of-duty/achievements/68dc121e074880e68595.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c51d6a0a1a54e2b152d000fea518a0e6359dac4b.jpg": "/game-art/call-of-duty/achievements/5297acb4f4da5e454251.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b6bedf6b9fb9a8dc846b8a0d178bde44ab140f35.jpg": "/game-art/call-of-duty/achievements/40ebfc363b9df8683469.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7d2b7d0909fed0934b8958181b8e6e3834c31698.jpg": "/game-art/call-of-duty/achievements/5772b839a7ac1b2099a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c531aac734c62f81aea8b4a3cd0d393b3da7e4a4.jpg": "/game-art/call-of-duty/achievements/3db466d8d7ea4e0ce404.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/a00d833009c4c2f5390f3f6750a341c3ded2e3f4.jpg": "/game-art/call-of-duty/achievements/8a1dc6e2c75c02ca2d9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/904509f71a94d27bd02c8c59f735ce9625cc1c49.jpg": "/game-art/call-of-duty/achievements/078f7c246f6c0bdd0193.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/c6bbe0d3d22db00af800f931976e8003be50ee06.jpg": "/game-art/call-of-duty/achievements/80472754bf73ca7d58e6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/21177bc1209e70be184fd480004bb2082c27e428.jpg": "/game-art/call-of-duty/achievements/bec752b67dc9ab1ca236.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/2f1b3a3deb73182554d30c5e6240bf2abb7e9137.jpg": "/game-art/call-of-duty/achievements/4ad516df8a73d5904575.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/414eea359802851cd50bf577d6be3d814988a88a.jpg": "/game-art/call-of-duty/achievements/9989fb508e8136957f1a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/1e1797ea80faffaebab8aac35df9b5f737a3a740.jpg": "/game-art/call-of-duty/achievements/013fa5da7e485ac3242c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/f8d225e74c13b54a4be33d1db176424518cae516.jpg": "/game-art/call-of-duty/achievements/1e08a1136a06591bcd6f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/294382b96591ade0f7a3dd35f69aa64cf8e0d14d.jpg": "/game-art/call-of-duty/achievements/f56176dafd96a78a3c03.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/6cfdcdff1c60ba4087436650c376379668611765.jpg": "/game-art/call-of-duty/achievements/907d50d6eb676aad489f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/db3ebdb04f6d8dc9208cb41d65bf0dde71e48ee6.jpg": "/game-art/call-of-duty/achievements/1af5f1612db6eb49ec8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3b4ddc46340c4ee914c43232708e4f0b99d1f433.jpg": "/game-art/call-of-duty/achievements/6ffbbc679ea915877be5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/93c7772577b5cbf3385f4b505017684f0d27a77e.jpg": "/game-art/call-of-duty/achievements/cb00bccd7155c5c79939.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/7d430d7cb0e27f1357148e8c57c1ff53969e9789.jpg": "/game-art/call-of-duty/achievements/5d0dc7bf0c89d2cb7a4e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0fd21bfea7ae6cea7d19beacda813546b8aa2aac.jpg": "/game-art/call-of-duty/achievements/dbaad790ad126bced253.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/9dad5b5851b3b41c654b5b64bf17289c2bd25e5c.jpg": "/game-art/call-of-duty/achievements/74b743415e4823b9afbd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/fab6950999928104e43aafbed1b875bd8fa535ae.jpg": "/game-art/call-of-duty/achievements/afdea16426b4c756e4bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/b5d3e15aeb110c232b25d35badd27d3723cfc79f.jpg": "/game-art/call-of-duty/achievements/34bd87a05e2d5c87308d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/e3008c9f8bc996aab7ccd1c03252eb489eb2a961.jpg": "/game-art/call-of-duty/achievements/8e7cbdfd2248aa26d16e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3e370768bce2246e4a34e2b95a63894a8f3ad300.jpg": "/game-art/call-of-duty/achievements/af00a2e00f4d494e1bc7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/221f9b2e1a6562e0f2dc99518bc133b6d9e28845.jpg": "/game-art/call-of-duty/achievements/c45cafdc867922a8fe07.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0703c58b9cdbc32a607b198dd6df75aebbda4e0f.jpg": "/game-art/call-of-duty/achievements/8878c7ee8bd4313bb88c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/dfffdca7f1eec0f51c4d1ba6c7794a98d234ef36.jpg": "/game-art/call-of-duty/achievements/ef3dc83102c12eda7a11.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3924b4c5269c1db5e3fe069ef1b14bbdcdd03b7b.jpg": "/game-art/call-of-duty/achievements/e8811318dbb354abc472.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d959ef75d60450e2eeb53099cd3ac21ed8543d89.jpg": "/game-art/call-of-duty/achievements/a8f55324896c83dac16c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/aaf807e4357992e7926fa8e37d994dea52716f5d.jpg": "/game-art/call-of-duty/achievements/ef83905f63cb07adf3b4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/d8f4344e51350ddbcf8594090fb5fe716a1cf87e.jpg": "/game-art/call-of-duty/achievements/18074a3c513c6266b0e2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/a2f171ee14933001a4126270eedbfbbe51f41c0e.jpg": "/game-art/call-of-duty/achievements/7767a2b17c42543b9a15.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/945ede3b335f123ae7f556d8125a0f957c04ad30.jpg": "/game-art/call-of-duty/achievements/10f6dc026886bf6d8fed.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/3aa709991192b7df667d76aeade231ac5d5a491e.jpg": "/game-art/call-of-duty/achievements/586ae3efa7b170f43654.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/ae1ea1ba275ee59ba9b86bef5b2d22488f9b5c8a.jpg": "/game-art/call-of-duty/achievements/8623c2c97d51409a3fcf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/33bf5941093863c085a3cba80d03004258b2bdc8.jpg": "/game-art/call-of-duty/achievements/4bb2e298d674f00435e7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/4b1a75db52ecb1484674cb4c8fd10ae4028d9b0e.jpg": "/game-art/call-of-duty/achievements/4558c32b182fb8e840ef.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/0ad5d6255b43a6d24b4ecc7ac5d4b87289e24ab7.jpg": "/game-art/call-of-duty/achievements/8bc453728da64b88935a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42700/12112302162a4ca970c616a9b4d6c777d0e7e623.jpg": "/game-art/call-of-duty/achievements/12bc6d970ab690b96ee4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/02c69139d83d887e8bbad07330f7c076e4181a28.jpg": "/game-art/call-of-duty/achievements/bc3aa66672504284d00e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/f84f81208278703664dbd530e5e3c6c5c71daf13.jpg": "/game-art/call-of-duty/achievements/613c8f97eb5108156e44.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/42710/0dd80a8bb848350d4b8a0a18dafb270652068384.jpg": "/game-art/call-of-duty/achievements/e2769289d1b1467be1b9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/0133fda3a5fa19b1165259600644d25d981bf479.jpg": "/game-art/call-of-duty/achievements/334bacf4dad73d50f310.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/ab8d4ccecae718e5314300a6716d0c3623ce0dc5.jpg": "/game-art/call-of-duty/achievements/2d642533372b4304001b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/0980082fe85ff0d25840700b6ed2e52504956d1c.jpg": "/game-art/call-of-duty/achievements/4470c1371d884636304f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/3bef8dba7289f3eb05a69fcb97e252b13a44a492.jpg": "/game-art/call-of-duty/achievements/dd9e2b94ec07c8fabd4d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/910637c7094876116abbb2e786652120fb4f65f3.jpg": "/game-art/call-of-duty/achievements/a142154c721fce62166a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/df51dbfc2880d1d6053175b6160efb42a71d0a44.jpg": "/game-art/call-of-duty/achievements/5c7e46bb2e598bd62c8f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/f43d08d5e78681254c6d1d83edea8a0a3ed6d50b.jpg": "/game-art/call-of-duty/achievements/00e8802dfb3a6e23b510.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/eb2f8a890a5a1c09082c7f31d6907ad9852b8d6b.jpg": "/game-art/call-of-duty/achievements/a6a776d25ae0dd14f3f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/1814c8da565a05aeb72c3a260dcbb53722b7349c.jpg": "/game-art/call-of-duty/achievements/0292942faf8b9048ae7b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/69f41d5d43ccfe5b5491392eae1b1ec9e2eec557.jpg": "/game-art/call-of-duty/achievements/18bdee8e74450d4081c5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/7f6c52357f7ea9a087edf50427d002b2544c2321.jpg": "/game-art/call-of-duty/achievements/7344321d980fae59ea9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/bdb4750ed6445a7fb6e9a588ad7c7f25595bbfe3.jpg": "/game-art/call-of-duty/achievements/64144e08856de27591ec.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/6a8c99571f5eda2d2e43696189f23ce5fea933b3.jpg": "/game-art/call-of-duty/achievements/babce0e82a2404d14780.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/774b2da404ddc3a9056c8dff17cf67aa8f8abf6c.jpg": "/game-art/call-of-duty/achievements/6635d754150bed1f4a52.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a9e6270ce3479af37f5ace927b7eac23f014ad19.jpg": "/game-art/call-of-duty/achievements/7f8eaceb05beacb38982.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/2958048adcec6bf1ff5ada6285f9525e76edaf03.jpg": "/game-art/call-of-duty/achievements/f2c28703a829c38a7ef7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/d17d6491ab01329bc3585da8b36dc354308983d3.jpg": "/game-art/call-of-duty/achievements/26bdf2f4f35b581412fb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/3bdf965dafb49cb647a31cc21baf86572df84cfa.jpg": "/game-art/call-of-duty/achievements/18e91bf129656ecfa143.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/e27810ec54b75392b3539ddddfc6ada5de42cc36.jpg": "/game-art/call-of-duty/achievements/65b1a4886f0cefe57fcd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/03ee61e8a78cbd8ec3b9dfa592d20d3433ef8759.jpg": "/game-art/call-of-duty/achievements/f1e208e8d69e884977d3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/5d8f8186eb5516caaad9138a3c7cb9b85e0059e0.jpg": "/game-art/call-of-duty/achievements/536203b9c317cef8f921.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a064792dbe470c7ebf93c07df2c0125b8199f68f.jpg": "/game-art/call-of-duty/achievements/f43afe71b1b2f4c99be6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/1dbf014f724756b8ea85f94879089ce63344b453.jpg": "/game-art/call-of-duty/achievements/0a02f70e810fd5c73ed1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c5ca4e900ec711fbbaa15322bcacca74057e92ab.jpg": "/game-art/call-of-duty/achievements/c46c3d53d7617ce2df29.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/2e406d05afd666007c927c29c7cb8df66dcf125a.jpg": "/game-art/call-of-duty/achievements/4c1ee54bbdc0de51a397.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/9dfd28b4500bdc6a148469ad3576fca079768bc8.jpg": "/game-art/call-of-duty/achievements/30101749dcc9e8f00f0e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/53173f0fc6c55779ed242f82ea899194c58e1b6b.jpg": "/game-art/call-of-duty/achievements/cf15357082a813202247.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/f6699b8c585d8e0f74d946382dc9a6b944668e1d.jpg": "/game-art/call-of-duty/achievements/4d80ad746889b6e51b11.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/6952628963694dbdb8c8b2149bd5d0b13d25a512.jpg": "/game-art/call-of-duty/achievements/f5e0217080c8e78a385b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/a452e8f7a3986dfe5ee2dcfd5997db72e36b1908.jpg": "/game-art/call-of-duty/achievements/3ced3d4600e56bd1b8f5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/5ced4efe9b5745469076c965e0fa878c1579b388.jpg": "/game-art/call-of-duty/achievements/68b751f778d9075b3438.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/dc92dcfcab652a8d51315a3e9abc8a2b87db664f.jpg": "/game-art/call-of-duty/achievements/687438517f482f07c3cd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c2a4e5c077eca0eb3913fccdf1cd502444859717.jpg": "/game-art/call-of-duty/achievements/9ac358819352bb811725.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/c1683a5aaa4459a4c30a64c7114581b5e5d535db.jpg": "/game-art/call-of-duty/achievements/f579808080eac5263a19.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202970/4c18e7babfce7b1e9a87a91ee8f3d906336f0d66.jpg": "/game-art/call-of-duty/achievements/34fdac009785f70f3a10.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/81c1462e80a491628924060da29d6b28fd04eab1.jpg": "/game-art/call-of-duty/achievements/d1df330a62288f93daee.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/d17a0f39120c0fe05b991f50c8158ccc3a0519db.jpg": "/game-art/call-of-duty/achievements/35b09deb66bebeaffe24.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/cfffa291c82382e008c2b1102864d8efb2bb54b5.jpg": "/game-art/call-of-duty/achievements/9cf4c992ca6581f3188e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/f05286d7cbdebfe28a34155a8c3a87158555f940.jpg": "/game-art/call-of-duty/achievements/a6c2e17cef39da2ae9f2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/202990/0124448f02f1564f79b5b0db570b1f736a6e10c0.jpg": "/game-art/call-of-duty/achievements/2ec7b277532d83f8bc00.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/9cb9fed7bd89695d81b2df2a78e54e7496ff7811.jpg": "/game-art/call-of-duty/achievements/414383f0814d172be947.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/5ef50a6c4cb152dd5a599d9edbfcb8b01800b208.jpg": "/game-art/call-of-duty/achievements/9001d16125fc11453433.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/3e1149b43b4a4f9173e3a5bc8b8eb6020745430c.jpg": "/game-art/call-of-duty/achievements/ec4fa15d8342ffa088be.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b4149289f16f86509f23d4a64d3305c8949b639a.jpg": "/game-art/call-of-duty/achievements/07109eeca8b226f66582.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/4198c3c5a45727f7fbd1b56a0550b39bd2c7be41.jpg": "/game-art/call-of-duty/achievements/5e6d7390b0040d17f382.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/aa3095124501a0269a16d1ca1d43ce7a576dd097.jpg": "/game-art/call-of-duty/achievements/fe71b44a31aa3bffaba1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b5e4c73252d3bcf7adc023659eef75227d469a4f.jpg": "/game-art/call-of-duty/achievements/ee23c7268ede05849376.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8688428ee57a31db06a01f617b27fa594b2e1768.jpg": "/game-art/call-of-duty/achievements/e55f4f23ab46a034b0e5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ffd7006549012bd3446a63cfb6a5ab02ce9c4b23.jpg": "/game-art/call-of-duty/achievements/d0628ddac4b96e0acbff.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/44b401b61ef76da507f4f68ba70529b79813c434.jpg": "/game-art/call-of-duty/achievements/4bdfcb1087e540bf298b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/58b1b329fc69b4575f51890b299a613a66168b9f.jpg": "/game-art/call-of-duty/achievements/ed3aa2c7c084d6206576.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ba3c0b4a33c13a614aabcf3689a5de688fc2ce8c.jpg": "/game-art/call-of-duty/achievements/b7a6b37b1a42b24dbcc8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/014b27d6529a2962da3b0885a0f381386d315fb9.jpg": "/game-art/call-of-duty/achievements/92fc55d6e72e1a9562af.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/57137bee776f973ac60b5b78f12368a40eb668a1.jpg": "/game-art/call-of-duty/achievements/39917528f97b83a77af7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/402d492afcb268e1c782a2d9b0efaf6a888ac043.jpg": "/game-art/call-of-duty/achievements/d9369ed45d2404a7042e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/7654f81ef6a8904b079d0efc8979bfb70fe84031.jpg": "/game-art/call-of-duty/achievements/21a06303c02c87795486.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c115776df2f978c2fefeaac726905b4562b1f2bc.jpg": "/game-art/call-of-duty/achievements/aafd004c36a22f406f02.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/20e2eaed04069ab2d120e3ad59ff5962811f873b.jpg": "/game-art/call-of-duty/achievements/5718a95c950469faebf5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/9024a82b5900604aae70224d142aba561b03f32d.jpg": "/game-art/call-of-duty/achievements/94a082f60893d4cac5a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b1255b28f31764890bd8e091fbbc8e6cc733b8ff.jpg": "/game-art/call-of-duty/achievements/ecf176d404db072b1191.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b4fa1dc3da619e91ee1a9451bee10f76fb1f2fff.jpg": "/game-art/call-of-duty/achievements/d1b71242d74e7049b1d9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/88516669f90dbf453e51ef71a80283c816ce8e39.jpg": "/game-art/call-of-duty/achievements/95151875ccbdf29fc0b3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8870a661c34b3f30442b5b739fe0efe4cf6e7c88.jpg": "/game-art/call-of-duty/achievements/acc3d4a27a712aa33d5e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ff6956ca387b85abc2f8fd9d556d3171f365bec2.jpg": "/game-art/call-of-duty/achievements/a296ebf4142d08eb8171.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/affa528c8efa5ace55e526fe1056f1c0c880b3a3.jpg": "/game-art/call-of-duty/achievements/34faa2286f859e4ef883.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/e8624092dbc511ce22738709002767687b1318ef.jpg": "/game-art/call-of-duty/achievements/5ab29e7e5d2638bfd973.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/2d084cd19f869118a54e873560b7cc72f5958ecf.jpg": "/game-art/call-of-duty/achievements/68e944579f95a6efbce8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/008cfb3cb67e60096605cca5a345940459dfaef0.jpg": "/game-art/call-of-duty/achievements/d4ffdfc1a9c180cbbec9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/23c6604893406d0c1de71cf902b83036d5195cba.jpg": "/game-art/call-of-duty/achievements/49c5e3803cd325081584.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/78c51356d1ec5d11a5859b571406cc13a0437f81.jpg": "/game-art/call-of-duty/achievements/da67236546bab6d710ad.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/bdb32d53b37dfb7ee8697f33d6d8b562f6d9bb19.jpg": "/game-art/call-of-duty/achievements/a04d785f7384b8ec08de.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/6e23e3219dac8f448d9771f0c85252c4e51b53ea.jpg": "/game-art/call-of-duty/achievements/845f6e30ece4d5830bc7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c6bd87838a42ad9105417c8206ef03befc063e1c.jpg": "/game-art/call-of-duty/achievements/c07284fe0ce0ae0cf020.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/add7001a22ffe44d0eb1a231ba8b3678bf607cb0.jpg": "/game-art/call-of-duty/achievements/fe8363fccefe3e208c56.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/60a410c18c0961922c0b6184b6cf560f9a104e9c.jpg": "/game-art/call-of-duty/achievements/04536f0a02897beda365.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8d9968d9592f55f89feae4de7eec43727232a39e.jpg": "/game-art/call-of-duty/achievements/a8de98585c8315e0d03c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/080951c14ab06932c4f5c1bd8d3b3e6c1bf39714.jpg": "/game-art/call-of-duty/achievements/be8130070b07f1d7de8d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/87d54d65d1e289e2067c9c3242826efc9386c284.jpg": "/game-art/call-of-duty/achievements/597da19410c37ba79471.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/bd13209f20a3a91977efed0be33feb57f475b0e6.jpg": "/game-art/call-of-duty/achievements/f52e922e57b3e985f75e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/c512a6fdd4046539d974a46fa800c78bffb386be.jpg": "/game-art/call-of-duty/achievements/08063cb881c78706d4da.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/13dfac69945f5592f310d1c8691ce77efe65cc43.jpg": "/game-art/call-of-duty/achievements/09d0c54a2098a7c62b55.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/d52adc9ffe0a7e4afdf34377bfe630f5974ae46e.jpg": "/game-art/call-of-duty/achievements/1f03769c740daab1c248.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/74d98845ae2afd34468df4636dcfc8e7ae3b01ff.jpg": "/game-art/call-of-duty/achievements/0a97ffde899d5d8a3892.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/b83aab19a451e06dcf837b7c714250d8bdc0f2e1.jpg": "/game-art/call-of-duty/achievements/93f9eff015cb69ddb6d8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/4fde690288c1729e309bb6aa8d699d3ee183efee.jpg": "/game-art/call-of-duty/achievements/6c16d165aeeee4f7f8d5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/608f862c91acbd5070827e16a74acd7e8301dc1b.jpg": "/game-art/call-of-duty/achievements/46401b6250fb1b2d8743.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/0c6091bc69bc52636473f15b8846c3121e4f9692.jpg": "/game-art/call-of-duty/achievements/1c25fab35bd9506e3ec2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/ebfb4b1a4669815e2abde15a1f5bb133b05a9ab3.jpg": "/game-art/call-of-duty/achievements/b9e3bae0f6ed760e6ec2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/25c2af163b6af528fae74383bfd03d9a2a8517ae.jpg": "/game-art/call-of-duty/achievements/fc49cf28f27efa9eb0d7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/212910/8df32a41f76f24ac6e8e7b324ef4e9fa9f827fee.jpg": "/game-art/call-of-duty/achievements/31c5acd1d552c3ada03e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/938412147809b3b8d782a0c6b1354980b7a76466.jpg": "/game-art/call-of-duty/achievements/33c4b3d1c6340964894f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/02389e50db94d8103d71e3e26532659526caf88c.jpg": "/game-art/call-of-duty/achievements/f38810da50f22d46bcb8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a398ddce474468a1e86dd574eb2ea0d4376c3054.jpg": "/game-art/call-of-duty/achievements/de27cce95c4d870ba405.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bc18770b2f678ce079eb645b346bc382b1413701.jpg": "/game-art/call-of-duty/achievements/b518642efce084c12125.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/eee8e470d8814bcd7656b725fd69a2befa9b85aa.jpg": "/game-art/call-of-duty/achievements/76bb5e0379a74272a2b8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bd9bc54978cc4e496bd56b01b9310558b445ee66.jpg": "/game-art/call-of-duty/achievements/aa77b61181dd612fdc28.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/92780cf10aadd423c0220c712bda43d771c28895.jpg": "/game-art/call-of-duty/achievements/211aa8aae0f177b5e529.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a50d913586131c5361a061484ab1af89a55bf500.jpg": "/game-art/call-of-duty/achievements/55259b5c8a012ee36742.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/43116761fcecd5ee2e779fcf4a44671d059dea36.jpg": "/game-art/call-of-duty/achievements/66cab33804509ca93ec5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/53aa6769167cb791c8937d914eea6187b457206c.jpg": "/game-art/call-of-duty/achievements/774f9f97189ec253aae2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/bfa0592d8e8afd7223ce09077e90eb96f18ef54b.jpg": "/game-art/call-of-duty/achievements/fa8d7a05d505c8a169cb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a24743f65dd411c593ce4aeea2c0ddca531b2f97.jpg": "/game-art/call-of-duty/achievements/c27becc53b17266aa07a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/c33f19060684bf97b90c7a2b5e4acd45282a4f41.jpg": "/game-art/call-of-duty/achievements/f8f9105abc5a19505251.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/9fae3eaaeb9592ab9abb923853648db26f060727.jpg": "/game-art/call-of-duty/achievements/d8713fdad3cce9149fbc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/d2309eb8d02d6cba6d5c9066bc290f81507724d6.jpg": "/game-art/call-of-duty/achievements/f78b311c634d024f7212.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/58a5787ec19de789f22118238c219166b5879c2a.jpg": "/game-art/call-of-duty/achievements/fc0511b8a8f451e31b96.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a4db0a6179ec7e43ab2e9d528dd800193f3a3f2e.jpg": "/game-art/call-of-duty/achievements/a8d8e785ea66bfd50395.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7df87b2e05cecca9b64b72379999f3ec684c90e4.jpg": "/game-art/call-of-duty/achievements/5a05b2edc9a6cf26c2b7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/847f129434ca57fea22fad195dd050f7ac84b26d.jpg": "/game-art/call-of-duty/achievements/05dc7fab4c1945a38c89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/3f2ce46fe0875b0e20bcda66dda170cfaa2bd326.jpg": "/game-art/call-of-duty/achievements/a184150a8351d169bea0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/b1f82e4fdd456c755e74831dbd9656f8513cb02b.jpg": "/game-art/call-of-duty/achievements/ea8506d6d5ecb4d83d41.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1df05bcc60677d4079a0477f358ed2969bfbc9c5.jpg": "/game-art/call-of-duty/achievements/62a4e28a78e2fbc6620b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/ba24d697d0b40477fbe3c8f910b998709e571c51.jpg": "/game-art/call-of-duty/achievements/518c2e90a89ad4e18f89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/0273d8ae279400fdfc70a04193f38a5761aa0c6c.jpg": "/game-art/call-of-duty/achievements/6867da51392b2a7cbbcb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/b22e38d57625347eab217d729128eeb4db0b4b42.jpg": "/game-art/call-of-duty/achievements/042c2e99d89b11b11ce2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/4268c8d61c9b7ba0f3e9d30f5c1b8710eeffbe8a.jpg": "/game-art/call-of-duty/achievements/f1cb256ef89049cee39b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/d631ddab876e49701f239d48c3c726b920e4afb1.jpg": "/game-art/call-of-duty/achievements/05ddeeac3170749eabb7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/35978e3bee44c5f47960bf6402d8017f80e621eb.jpg": "/game-art/call-of-duty/achievements/a6a8081342baa4827e01.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7649a95fc52417c32bb68ba9b6e2b5afbb8ed356.jpg": "/game-art/call-of-duty/achievements/5b8bdad757dcbb5910df.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/350975aca49822ad5333b344c09fe5731ca09a50.jpg": "/game-art/call-of-duty/achievements/88eaa22dcd5346871a17.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/9a1954acd31b60a475590b67bdd5076b1a9031ed.jpg": "/game-art/call-of-duty/achievements/189eba245560bf593d4c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1366d36a636b584ef7ada54a563ce7d0bfe9ce46.jpg": "/game-art/call-of-duty/achievements/cd69584aaea92839e157.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/7d4e5fbb281349fdfdac177573815bf29b11c0da.jpg": "/game-art/call-of-duty/achievements/e75c0a817729b4502215.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/a3f98881e75528718bb396a93f5eda22e4bf89ce.jpg": "/game-art/call-of-duty/achievements/d465f49420763f09c12b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/527e6f6f7a6985ccd146095c86a95a07972cc5f3.jpg": "/game-art/call-of-duty/achievements/be3c201d42ccf528c417.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/e2f9814f32a4f19b0662cacf9e60c7f050aaf788.jpg": "/game-art/call-of-duty/achievements/982416111ad59fb0ee5c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/166d66a24f482cc50fe730defb60b5b594cd942e.jpg": "/game-art/call-of-duty/achievements/28b83c917f642ebf63bc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/2dbc27cc5cc21467c3721ca8b8ec7367bdb73626.jpg": "/game-art/call-of-duty/achievements/edef7fdb97fe10818f0a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/f538ea6d78ea1645dc54c04af031ccdee6568984.jpg": "/game-art/call-of-duty/achievements/270e2a421a005b5dddfe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209160/1f92129f1dd043e20858106244a5fc272f686b86.jpg": "/game-art/call-of-duty/achievements/92e43364427d5e9059b6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/6d7a6e51581af0ffe24116942136f2d81912fd5f.jpg": "/game-art/call-of-duty/achievements/881a6dcdb647a5456dbe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/bf9871d14aa2080c53c6ad7bd8b44fb698ce9858.jpg": "/game-art/call-of-duty/achievements/cbf2768728500d2aea68.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/be9985fb5b0b3ec7f6090fd6e9bcc6999ff47c39.jpg": "/game-art/call-of-duty/achievements/5e2480d30de157fa3fc9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/2a65ab1924a9aa0973853c89cbf6de1b6a3f1b56.jpg": "/game-art/call-of-duty/achievements/cb01eaaed54970697e75.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/b8d2965a03da8768a690adf601a6125275c65954.jpg": "/game-art/call-of-duty/achievements/5442fbdf2f6974acd90d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/92c128fb406db30bdb68b246b95f73e980bd0007.jpg": "/game-art/call-of-duty/achievements/7c4f4b2fc6b80acf9e3a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/dfffd590f99c950a8f42737602bf5bbcde05ef51.jpg": "/game-art/call-of-duty/achievements/c114604b3ea2245bd5ff.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c26807e278a807bfa9a9e194132debb994e700d3.jpg": "/game-art/call-of-duty/achievements/c720a215a01a41843917.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/f86efa50cdb567bf7fe1b3d732bfc90f28c2d85c.jpg": "/game-art/call-of-duty/achievements/2e69343cde4ac31be831.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/57083ea13b16f8cddc531bbceb75d6497690eb82.jpg": "/game-art/call-of-duty/achievements/1ee9ce141d49f68403b1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/68cff2f93656ee35858621a27f45df5d637d6897.jpg": "/game-art/call-of-duty/achievements/92dd45baf84e04330fea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c5c38a113aa37738d0238d463fa76db953406524.jpg": "/game-art/call-of-duty/achievements/cb6f70177e029d0bb502.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/3d5e62983b187417073ceef554847f9d00ce394a.jpg": "/game-art/call-of-duty/achievements/ba6f3cd833455f3b90bb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/029d30e48640e9e588e7c254d42d735760f2e01f.jpg": "/game-art/call-of-duty/achievements/3a2f2380e76d6d2d4d3c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/40389e331060b203357e57dd61a3f363b9979475.jpg": "/game-art/call-of-duty/achievements/b47151eba05bf44da97f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/7ff96353c673619e2d2842385084d09868959e91.jpg": "/game-art/call-of-duty/achievements/561a880efddcee091658.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/ccb127bdaa673e31efb9d70454119e02ae2ca766.jpg": "/game-art/call-of-duty/achievements/cae6882a0941fa16921a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/087aed50312fb462e05211ad90905406b2409b5f.jpg": "/game-art/call-of-duty/achievements/4804a593f80db9e96f61.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/d72dc9e5f3b89f6a69e288de7f51fadf101ad876.jpg": "/game-art/call-of-duty/achievements/914c34dc12a4487979fa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/6f52f66ffde12ccab6fd9dbe2a0a6e352b691b46.jpg": "/game-art/call-of-duty/achievements/06e0022dd9d28a55dd89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/0c41ca34b961eae7baed5cd9fa22206f7196da5a.jpg": "/game-art/call-of-duty/achievements/c723c6e6e732d58c5c93.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/fbf0db3c197a2865ff8a546cd12950491b59eab7.jpg": "/game-art/call-of-duty/achievements/f6881f12ab401ed7ed80.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/91ca2c866c87108ee73388103a4e14c95e9e5d45.jpg": "/game-art/call-of-duty/achievements/2f9a0970dc33a4e8f3e7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/0246b25415a4eb83d6dae41bbefe5800e9c758b9.jpg": "/game-art/call-of-duty/achievements/90438c063f19119ea25d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a2d770c6dbc5f58106ba15255c1815e2fa6bfb65.jpg": "/game-art/call-of-duty/achievements/c85019f07f9c65e315dd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/51f85202f9407ac9c89c1a23c62717454aeed483.jpg": "/game-art/call-of-duty/achievements/b99c72128425236b1035.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/d946bb02b2001926c36d1c8b4bcf2e9da2a7df9f.jpg": "/game-art/call-of-duty/achievements/2a5be22b7dc548a2822c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/ca842a5338ed929ed9e82bfa95c3dd371d2197a4.jpg": "/game-art/call-of-duty/achievements/0bcdb9a4c85d98296f13.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/b9cc357bf7dff52609164ffc6ef9357fc29e49b7.jpg": "/game-art/call-of-duty/achievements/14804cc8bd7397a0ff89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/be864d18461b9998bbbb0a8d3d16a6327c151222.jpg": "/game-art/call-of-duty/achievements/4adda62a9920bf5ed7db.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/eb0573e8cc9f1a3878d7ef7ec4ac80dfa1761b61.jpg": "/game-art/call-of-duty/achievements/9aea21d05fe9092fd238.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/84caa17a06d06d31adc9931bcb6db33e62c47670.jpg": "/game-art/call-of-duty/achievements/a6d5ae65a7085ade450a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/99a4e0e015ae9be3b8ca65867bbab6650e2be4a7.jpg": "/game-art/call-of-duty/achievements/5aebe7390c44348243a4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/86064965b478de57183d1b43d122f8dcb55bf933.jpg": "/game-art/call-of-duty/achievements/baf8d5dac758f4d4e27d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/647e830027c5674a640ac6e4cd15c2ed182f40ee.jpg": "/game-art/call-of-duty/achievements/917e8bb7b9071f135d07.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a5c165e92b5b46b90a7bd9e6ef1f0e8142c9a9fe.jpg": "/game-art/call-of-duty/achievements/34e5afd793ffa563ff78.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/c83af569c6421ec90065f01e5f6ca3bbbfed12e3.jpg": "/game-art/call-of-duty/achievements/6504e1d1cf9bd7c5e023.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/298570669846c5b34f22843c132d7ab3282f28ff.jpg": "/game-art/call-of-duty/achievements/aa7ec52c45ac218e3b5e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/522d635afe5643642ff92e4e847cc2134924ea49.jpg": "/game-art/call-of-duty/achievements/891c8afc4c4d72e9052d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/35e201ad8332ddfbcacceec0661498f86ea7920d.jpg": "/game-art/call-of-duty/achievements/58117f46b67e5f8319bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/81ba45eba34f972948c411f8fe449149cebdd6a7.jpg": "/game-art/call-of-duty/achievements/ea2c5010e5df6266508c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/698088f494c39ba52fa3901a40ed972197065b12.jpg": "/game-art/call-of-duty/achievements/7e8aff9916e8753b6017.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a62c206a46f6961b66eaa42b93465b4a9acb90d3.jpg": "/game-art/call-of-duty/achievements/f8e7e9067abe8bd97043.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/63b91d1c9f1057775175185dd7ed32a85d7c4024.jpg": "/game-art/call-of-duty/achievements/eb5e344cbc69c8be20c2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a445134fc4d8ebcd88c4509a2e7f4c0e4b4cf935.jpg": "/game-art/call-of-duty/achievements/73aaa55d00fc669decb6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/a013f9c37c35c49595a007006016d74058191cc9.jpg": "/game-art/call-of-duty/achievements/68a76bf623fbaaffa1de.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/9b30e1a1fbb4b87626c730dffbabf4b29a12111d.jpg": "/game-art/call-of-duty/achievements/93be31b31e10c70d69da.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/2fb09ee55aa0815adf363172bdd1ecab23f724b2.jpg": "/game-art/call-of-duty/achievements/3e3fa6bfd93d450030f2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/3a2fa1c8efe6e383a891ae866aa8fdaa1841679a.jpg": "/game-art/call-of-duty/achievements/d76d9073568fdb7f1e22.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/5f0d23a405f525db66d5de98217fe178f3085a31.jpg": "/game-art/call-of-duty/achievements/05a8efb5e1e15477d51c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209170/20755ba1aed7213aafb9678daa6718fbd654fefe.jpg": "/game-art/call-of-duty/achievements/3e8f3d7cb413572aee1a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/050bae232fd990eb93163372db049de1e1667220.jpg": "/game-art/call-of-duty/achievements/a3fa1324f9bfa48e7ffd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/ad68409f7ee2029e76ffc99a24431817e479e15c.jpg": "/game-art/call-of-duty/achievements/0a790da83079d22ff365.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/77c17d48b3bb8f201f1c70106830ac2c93cc2bec.jpg": "/game-art/call-of-duty/achievements/1f569f12ef6bf02e2f60.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/9876a14b1eb1e169200e97ad975ba8a3f30f9dc1.jpg": "/game-art/call-of-duty/achievements/27832d705a5816832e31.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/de9c3a1e8c1e223cc764433967c2f4d6f957e56c.jpg": "/game-art/call-of-duty/achievements/db531ff792ba71a88bd2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/818648ba1376faae9caed0a37b39ee62c4cfdac1.jpg": "/game-art/call-of-duty/achievements/21351a3cca37d9104b8b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/6d61fe4bfae30b393c098e533598dcae13d54e70.jpg": "/game-art/call-of-duty/achievements/e467ca6eec81624f607b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/13e2d09ec3fd46b409918f9e78e08e016028d662.jpg": "/game-art/call-of-duty/achievements/4407c5769f18a5653760.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/a863a128014547ec4ffee3151c84a0f0217a3673.jpg": "/game-art/call-of-duty/achievements/6c1736bbf16d4782813f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/a6103cf19e593a4f72dba733a9587a8b11afdd56.jpg": "/game-art/call-of-duty/achievements/d63505f2c28cd355246c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/3c2cf01172a6ebe981d788ae2a93169befaff5e8.jpg": "/game-art/call-of-duty/achievements/81358b11c5b0cddbed0e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/2e11039befd3a7ce82b8f2a7f082bcbe7fc2bd48.jpg": "/game-art/call-of-duty/achievements/88da698e6c00ecffc4ad.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/fc28df331e97465a79a21e474913c33fd9ad7b36.jpg": "/game-art/call-of-duty/achievements/fd8dea35848fe13e55d4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/686c5d0f87a272597a0b20d9532375fc9e5b7d12.jpg": "/game-art/call-of-duty/achievements/61884f34fcc524b656f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e52fc0cbbc3344a95f9221fdb4cfe83dc3309f8b.jpg": "/game-art/call-of-duty/achievements/6323591518b8852fecf8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bc113dc8c9b656e3c2d0126ad847a518d8e71f43.jpg": "/game-art/call-of-duty/achievements/8893e5ac9a39df8861ba.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/f58a4c733358e524682fe4bff82e2b1a1bab647b.jpg": "/game-art/call-of-duty/achievements/579c1482e528f86abea0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/4920ae39511c42ad9a0e07493ef88aa63c60b25f.jpg": "/game-art/call-of-duty/achievements/e50138224f098ff2b0e9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/2edc4b06d9a471fee962c1e88ed15af793182854.jpg": "/game-art/call-of-duty/achievements/c2c0bf56904af06f44af.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/6d36e7c98bc7caa26d3c02d4ded63a2a0b3e5f3a.jpg": "/game-art/call-of-duty/achievements/6a5efa3bd9334d5d867b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e0f85322f92ba1ff6567bbc23dfbf89dff58f650.jpg": "/game-art/call-of-duty/achievements/290296290c6b8566569a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/ad615c659c19ba7050c7d8b226d6dfdc19cfa062.jpg": "/game-art/call-of-duty/achievements/57922440c12a64977ca8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/28711f22e4d02183fa3b9ddee726798870d3294c.jpg": "/game-art/call-of-duty/achievements/7614725d8df2f9e2deaa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/1adeb3c1d0616ed493b878064414ba475a3fc970.jpg": "/game-art/call-of-duty/achievements/68a83b461dbf8944ba1b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/5e1ff8cee3a47d7259264b3e0d672e0c7358f6e2.jpg": "/game-art/call-of-duty/achievements/e1a62db91c1bbee7d6bc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bfc8834dad242fa7b4e54f965e52e3af764c648f.jpg": "/game-art/call-of-duty/achievements/fb9e970b872359137afb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/adc0de3da240048a1c3ef711e8935298e7048d71.jpg": "/game-art/call-of-duty/achievements/18938e8b05c43850a1d1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/37f79052f673cd66e823b8a2ac0edf46361e9086.jpg": "/game-art/call-of-duty/achievements/de047bf800f352c3ccda.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/90bd08a0ca63a5f797a632c37360fcd7417d670d.jpg": "/game-art/call-of-duty/achievements/cc9a0743649ab4c59526.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/f21d7109756d613aae0c1f5939edd05ff11f497a.jpg": "/game-art/call-of-duty/achievements/f544fe840003238312be.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/c710f5068bd6fcc2e003c9687bf35a4e9eb6ec75.jpg": "/game-art/call-of-duty/achievements/470a0f7cb6ebca39d5a2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/142953876ef39884da73128e38f1e1031da26075.jpg": "/game-art/call-of-duty/achievements/4a5a93323ab645faf0bf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/44a46d2994af6d8908b1b1a8db56f63e93ebb9d9.jpg": "/game-art/call-of-duty/achievements/bc64976f8e558446785b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/3605bded742f799361ad78d90b6e69a1b2ed4396.jpg": "/game-art/call-of-duty/achievements/9f300fde5a055be0a2f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/16b7df11cfbc7e897f6b166c2f6af9a572f148bf.jpg": "/game-art/call-of-duty/achievements/c6f06ebc885130e0722e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/4ab8c81fbd79007c23a981e4b44212e3d91ff1d9.jpg": "/game-art/call-of-duty/achievements/9e2b0d60184c6df69f4a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/17fad7e3884b0a6d22d9186521159c8a48f99710.jpg": "/game-art/call-of-duty/achievements/d9668edb4a10ec5afb29.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/439b0eb7928b4b7b7e4e6fb1b9f4e78454f6129c.jpg": "/game-art/call-of-duty/achievements/39a710368dfcc33592fd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/95855c51876cd96887b8044dac125939defb8756.jpg": "/game-art/call-of-duty/achievements/9c8dab60983dd5f09299.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/339a36f54f554c4bd428b34c6ab57ca302a33148.jpg": "/game-art/call-of-duty/achievements/33c148406570b01ac65e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/e847bc992c94bb85aa7d877cca66d7a304202c38.jpg": "/game-art/call-of-duty/achievements/2b4d4ce264f28bf49169.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bc7b95aca3dd892e3af7bb8855609e5deffc640c.jpg": "/game-art/call-of-duty/achievements/ddf6fde35a75833626cf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/bdc263e06fd24394a7b3f556e6383b5cf2a5bd8e.jpg": "/game-art/call-of-duty/achievements/822d7187b609f4026d01.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/57f801ff4e86a62c29137529be436dd0c67e801a.jpg": "/game-art/call-of-duty/achievements/a7e8830d01f2edb54842.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209650/d1d328cd4b41675b205aa41b179f39cdef652162.jpg": "/game-art/call-of-duty/achievements/8e54b7d6307955986b96.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/52da6d72be62dd5a6c4e06ac0a4b6caa8e77109c.jpg": "/game-art/call-of-duty/achievements/4d410e57cd4155a21ff5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/bb758e805f9d2da1053db7db6518605b8b74acfd.jpg": "/game-art/call-of-duty/achievements/9f5847949abb34cbf970.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5b120f358bd219fa1830106aff9713e3e6888210.jpg": "/game-art/call-of-duty/achievements/ddb5a6e3ba7e9cf06f1a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/b3c22eab9f1e84f9d73392731db39fc30d0396d1.jpg": "/game-art/call-of-duty/achievements/6566800a8a3788ec7255.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/6467e3d1e0d8a819cc4e064b3451e2519c2a8c21.jpg": "/game-art/call-of-duty/achievements/d519143240b213146571.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/96bb5a6ddaed3705e0fb1b04cf889dc484e48348.jpg": "/game-art/call-of-duty/achievements/fe5d49946f714a41def4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/47cc1f734302c3acd1a15c365de915d8c6bbad73.jpg": "/game-art/call-of-duty/achievements/588e3e026859e6b20c4e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/236c9cf40603e1c35cace7ad3b923005d8313c07.jpg": "/game-art/call-of-duty/achievements/d4b4f64c747f8cce5747.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/861a3a694b818be9072467dd283c028371b2e063.jpg": "/game-art/call-of-duty/achievements/ef0937348329fb2b5b7f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e90c82ba064b78420e1ed4bad078158bfb708985.jpg": "/game-art/call-of-duty/achievements/e744091006695c8e43f0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/756b0d4a29fbc2ebbf236daf1e8a030e346041bc.jpg": "/game-art/call-of-duty/achievements/75446a54b94fe2cca1ba.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7fa120b283d4bdd452167da70156378588dd63d7.jpg": "/game-art/call-of-duty/achievements/7d76c465d77c0c4ed74d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/421be651f3b3faa73f873b1aff31f918b6d33faf.jpg": "/game-art/call-of-duty/achievements/356e1b587d1aa06e229f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5c09f121abcde97031674a6224f3cc20f50fea2f.jpg": "/game-art/call-of-duty/achievements/6b7a793e48a2806701b1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/2b42df8394c11a52ffd953d12fdae05b9fdc5b83.jpg": "/game-art/call-of-duty/achievements/50eed7e3271e385ba055.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7e6b3e6c827e209091223bcc22237a2904cf765d.jpg": "/game-art/call-of-duty/achievements/77f0a50f6e76f0895d78.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/4ca7533816c1d6901b77bfee63e5d8b8be38dc45.jpg": "/game-art/call-of-duty/achievements/10b81c1bf5661167f7e9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ce04968c413bfeca4b66ec2368a65da39a4b6f46.jpg": "/game-art/call-of-duty/achievements/3e1b28577bacd3294557.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/9b16bf7dab254eed651d2a78a41f3e8cd98fd52c.jpg": "/game-art/call-of-duty/achievements/48ec62405747e62c1221.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/9808074d5ee0e3fed290a3871963fc27f702a494.jpg": "/game-art/call-of-duty/achievements/140c3fc9803cc12de9e8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/52f205ce172e2455709b9c56d9a91ac9f83c2cc4.jpg": "/game-art/call-of-duty/achievements/f8471d3a20bca44f2c6d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/6bc4563784f085681652b5cf1938417d092ea225.jpg": "/game-art/call-of-duty/achievements/fbb12b75d252fcf4c473.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/50039d61608edf89dea7a3661a44d20fe2ef5316.jpg": "/game-art/call-of-duty/achievements/a3e82b8ab886ea64042b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/642be79721bbdd465e8cd02abc08e3dfedbf0f16.jpg": "/game-art/call-of-duty/achievements/4844d808b7dcf60ab4ea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/f47dbd16bfda8265ffed525c4020176a536f03a2.jpg": "/game-art/call-of-duty/achievements/7fd6c2e9a3fc8d1013b7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7bad50fee59ad5bc0c3ca3bf18b1982199386110.jpg": "/game-art/call-of-duty/achievements/4249be6f37e30ef940ec.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/1c883dc3d1adc6a5ec710f76191277b769ea76e9.jpg": "/game-art/call-of-duty/achievements/f741557d449ea7cde80a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/dfb171eac5a5c121258d4e05475c5d2e24f7cd1c.jpg": "/game-art/call-of-duty/achievements/17e4f46fee28877750f3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/022fb52a500d56c8437262d50fa280c2714bb42f.jpg": "/game-art/call-of-duty/achievements/8132fe0e13988bd24fd1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/5f8e24e7491983f80a7fa0ff69e0493e07d0be0d.jpg": "/game-art/call-of-duty/achievements/e42ff6122323ed9a3395.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/af2e18e12cb9755a12a35732a6f22d4544f5c3cc.jpg": "/game-art/call-of-duty/achievements/127c266e7e0fc974c8b8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/58ec162ca04995a17db5193753a6ab4d72a549fe.jpg": "/game-art/call-of-duty/achievements/90e6a8e1e520abcf4a56.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/7c38971df65317d11d36d0bc83446007b7e82d3e.jpg": "/game-art/call-of-duty/achievements/d57ea860dac87b16eed1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e3d2196805f3364ccf63b11af82bf78f26fe367f.jpg": "/game-art/call-of-duty/achievements/055fa62b0ec398270f1f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/c055805d0b537d1f5592821c4957a05c56c035e0.jpg": "/game-art/call-of-duty/achievements/c141e7cd14b49ac24e14.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ff3828ee10a3dd90c39d4ba03e4c80318f41df3a.jpg": "/game-art/call-of-duty/achievements/26ce14a18ff0d3ca4630.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/19725429483357cf9923024eb3b01571cb590964.jpg": "/game-art/call-of-duty/achievements/c222d739e10080d08e71.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/374aef06c38570f275550d5ec112dadbdafe2474.jpg": "/game-art/call-of-duty/achievements/65d6c7d09869e3d44d9a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/4d1a14f525f9eee3732db407b0b00e8776b7da80.jpg": "/game-art/call-of-duty/achievements/ce5174efcfa4cd484408.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/e64fa2ec9f5b716db5aadab888dfbed2897cfb57.jpg": "/game-art/call-of-duty/achievements/3649dd52dcfcbb203aed.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/c74fb5b0b7a56ebc7f963c1d30caa069820d2540.jpg": "/game-art/call-of-duty/achievements/35d06b424797d3dec59e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/35a53e8e4094957bfa43273c524cf45133da03df.jpg": "/game-art/call-of-duty/achievements/8d292c25901c52b93b82.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/ffdbc4312f0f2420a218acd67ad92df364910a6b.jpg": "/game-art/call-of-duty/achievements/d8fad671953c03f693d8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/b544bd18fb12b0b37891c889a51755e59318404a.jpg": "/game-art/call-of-duty/achievements/3140fc6f7f446c716a1b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/209660/67a2a2b17f8736eec7743f0df0c1c96a8f545272.jpg": "/game-art/call-of-duty/achievements/7ef86a67871720d7252c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/be09a9092346a464996c7df633d6f9f953b008d6.jpg": "/game-art/call-of-duty/achievements/197b69315304f7e48443.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1d4a50459abf5d2364a5d98606f6262a6fab9213.jpg": "/game-art/call-of-duty/achievements/223cbe388d22b5576646.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d6b5ee282b0916528cb140ccbef2612cf1e21d4b.jpg": "/game-art/call-of-duty/achievements/e735731d7451544af1a6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/db0d1b5f92f92cbd3d80842d57943d8d5be8d5cb.jpg": "/game-art/call-of-duty/achievements/e76bb42ca3475d9b0892.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/bfd15017d6af1bc8d41067ac2c572955c217493d.jpg": "/game-art/call-of-duty/achievements/ae01cdcbada7670a3afa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8b244c1fea2b4fdea2623a6b02b8679306b02b56.jpg": "/game-art/call-of-duty/achievements/03b4a6ad35e8d85f2257.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/254dec333adbadd7753e20a030fba050bdd03a78.jpg": "/game-art/call-of-duty/achievements/73502027f0cfafd4b5c0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8f9d970c9cfbf6a836e459d3ac8e15f1e38930ba.jpg": "/game-art/call-of-duty/achievements/8856e5ac6156c0c7ab33.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/25ed74dddf450df12f377e5342c81713b3129d2a.jpg": "/game-art/call-of-duty/achievements/4f967cbfd21a42fbaa16.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f825b53bb58668624201e12ed4eabd033efcd585.jpg": "/game-art/call-of-duty/achievements/b10628c3d94d45f19de9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a737ecf9f6d5e3953b1bf4c50aae4cec5e9e06bb.jpg": "/game-art/call-of-duty/achievements/2e67b31daa3d79a4df8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/9e7be918ab894a01f7728744ba615eac1820817c.jpg": "/game-art/call-of-duty/achievements/3a0fba9657b38aa35e43.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f21271c5b4fe81a8c2dacb3124dd38d5bf8f2a66.jpg": "/game-art/call-of-duty/achievements/be14e6541017097851a9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/fcc3e34ba09068858def201bcdf9ceb789eb61bc.jpg": "/game-art/call-of-duty/achievements/6c614d52d10224d2fa8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/30de18e9078ef6b63b3299eae374845469d095d5.jpg": "/game-art/call-of-duty/achievements/46af7819bce39547f361.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c62e9ba3af62855025c2b3b17a91ef02e2528cd2.jpg": "/game-art/call-of-duty/achievements/bc5360cb1b7898b761ea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d36b6dfff0667595586a4ddbf1d7c3788acbf38b.jpg": "/game-art/call-of-duty/achievements/acf446664a46e34baee6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/936a1c5bd72edb720616e40eab136d672d607a4a.jpg": "/game-art/call-of-duty/achievements/4c88c48b2a35e2ea664f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/86f552ab60ab431d435dc70606adfc1212a6744b.jpg": "/game-art/call-of-duty/achievements/b175cc13888dd81b6a9e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/381722b8ae8a5f47100470a2d2cb419c872da647.jpg": "/game-art/call-of-duty/achievements/6ad5ee337862d9c49188.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/41169a20ac367bd8a588ace5b5d39ad139c92b78.jpg": "/game-art/call-of-duty/achievements/4895825fc7ac017a9c81.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/6348a58f593d9f44d2cedbdca5f221d0042b679d.jpg": "/game-art/call-of-duty/achievements/cfcac47d7797bdb2a9b7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/26745f61f56cbf73a45b7ef9b1948e492148a5b4.jpg": "/game-art/call-of-duty/achievements/2ce53b47a9e930cd0f78.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/6090b4e792a1023679013b471e2aa71ecd5d4f99.jpg": "/game-art/call-of-duty/achievements/74b955b39ea1ed52fc64.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/def3fc3485640dd6edae975f87139aeef4d4fbe1.jpg": "/game-art/call-of-duty/achievements/f54072dc01e0c107a2bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/68f16e67a78e2b087c70ba959b216b49c16c2abf.jpg": "/game-art/call-of-duty/achievements/ca18fcccac729d9d7db5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/92a8876b6145ad17c8f0404f86d01bf26a010334.jpg": "/game-art/call-of-duty/achievements/3cc75903f021d64740bb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e71deeb01ca53bb6bf6a7e6973efe9b82a0f8665.jpg": "/game-art/call-of-duty/achievements/388fce23aa91b4901180.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/474b8a9fb5b38c894cd1f1957a63b679bb771216.jpg": "/game-art/call-of-duty/achievements/e773ca386395ded5b66f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c41b5d959900613a8cc39386742f7b22291f7999.jpg": "/game-art/call-of-duty/achievements/60b0825c4351c069e7cb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/d4a3b90d95dc9533703d69aade0ebed45b0b8693.jpg": "/game-art/call-of-duty/achievements/f4973928757861b05492.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/4a856c1791fd65d131bdb4fd173acc78aee409a1.jpg": "/game-art/call-of-duty/achievements/06254bba874e7fd01696.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e53cf9bba3fac118dda21382e0e22df8eb24194b.jpg": "/game-art/call-of-duty/achievements/ddf914f209f0775a6681.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e24f7b67ee2e209383ad61e5db207235e5c1b36c.jpg": "/game-art/call-of-duty/achievements/c891f4cb387f443cf069.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1937a0f8698adcadc42a0ace8b4462977a760d3d.jpg": "/game-art/call-of-duty/achievements/09de527af050eb1ea6db.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/caaa4c120cc31a3b3844078901690924622eefec.jpg": "/game-art/call-of-duty/achievements/100ea250a4b8821ed763.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8264162bd63715cce724a4c0e43200863e9db401.jpg": "/game-art/call-of-duty/achievements/245b5e76379398c97e60.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/3b7c4ba4b7813850520cb9490ad83b5053674cf3.jpg": "/game-art/call-of-duty/achievements/caf98f2b75941adb9458.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/889a2ef104e08f5c7202344b4002c92360a198d7.jpg": "/game-art/call-of-duty/achievements/47b989f34d821c69c13c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5be087b25337e1be80fc34fab49516a9bc77b6b6.jpg": "/game-art/call-of-duty/achievements/c4dfbcc832173be93b88.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8b85c34b1028b1a36a15b1a2a5163c887f127b43.jpg": "/game-art/call-of-duty/achievements/134ff04d96b5e2117659.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e3c00a88baa550f02cb701bd54a48a75584884ec.jpg": "/game-art/call-of-duty/achievements/691f0231b7479088e835.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/cb1ff523e32e0c9c4d71db0f03ba939783e2c65e.jpg": "/game-art/call-of-duty/achievements/076ad1effb6e1150ee58.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/13a1311666c200fe0d063946a86ff12eccafdacc.jpg": "/game-art/call-of-duty/achievements/b2aad82684e6f9525b74.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/24f298d0cad725a3a2a6d6c157ba8184954bd51d.jpg": "/game-art/call-of-duty/achievements/1f401a4643636b7e09ea.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/db45274296fa6e35c811ad848ae8ad626135d5e4.jpg": "/game-art/call-of-duty/achievements/6513f9d21ce06e1c34a4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a0c4ad945aba7cb59e1cd01286a298bc0319048b.jpg": "/game-art/call-of-duty/achievements/e7cf115da299db339ab8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2391734766760a40fb7938f2f70273c4d3d63525.jpg": "/game-art/call-of-duty/achievements/901df0e5c0d48e171f53.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ec08f0773c6d0e51642dde798ec750e9aff7d6e9.jpg": "/game-art/call-of-duty/achievements/54ddc40c8fce36f87df2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/de077255c5d4e52e0f6f8f00fb2e9dbca80a64d1.jpg": "/game-art/call-of-duty/achievements/c0ae7942b7a2eb803695.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e014cca3b1ca0ac9b9252f8a4541a80a07972956.jpg": "/game-art/call-of-duty/achievements/07de5ba81b60ceeb4adb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/668440cb517ae4f0bb5de178bb65482af2f9352c.jpg": "/game-art/call-of-duty/achievements/36735e3cc67ff6aefd7d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8a237750c9186100f4bf102ce1958334d8c94a5f.jpg": "/game-art/call-of-duty/achievements/394689ecddb3bb3c6ae7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1087feeae28b52a8691214007e0e4b55193c57b6.jpg": "/game-art/call-of-duty/achievements/64727141ed121cf93c42.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2ba51a64811bce2e9d987100c8de9057edefddd0.jpg": "/game-art/call-of-duty/achievements/632a1fcb4fedf10658d6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/4290a97f840765a0a8eaad905cdc3944c524b6dd.jpg": "/game-art/call-of-duty/achievements/ca5280a764455c85011e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c9c5c6ad44fd794cca4f1dc884f38f5d04bdc49c.jpg": "/game-art/call-of-duty/achievements/e0b4aa8da3809ac08f42.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b518c5c6818a1dd05389dc4d00d68ec6d1c295aa.jpg": "/game-art/call-of-duty/achievements/bd4b2888b439fa2fdb0e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e70609d0d14761efad36689c6164748b9ab906dc.jpg": "/game-art/call-of-duty/achievements/2a6bea06204ff2131803.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/98a9190243d1e999f28e52cff2f41eacf2bea14a.jpg": "/game-art/call-of-duty/achievements/29d44f556a418702ab3f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/858fa9620a0b3f18af5202262cd01014111fa142.jpg": "/game-art/call-of-duty/achievements/f0c8abc666782ed1b999.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/41e444d3d97c38c8c9745eb9812270071366d034.jpg": "/game-art/call-of-duty/achievements/5dfbb5d9c9e6761f5421.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/33c0c6c42510a5740d92052401cfa3e022041aee.jpg": "/game-art/call-of-duty/achievements/cbbf8d2b5b17d9049b8f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ad607c6979932af3f628a2f49ab7861768317271.jpg": "/game-art/call-of-duty/achievements/b4664863a39199d14aed.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b21d4dba771c1b995b823ae472ad2449ede75e96.jpg": "/game-art/call-of-duty/achievements/2595dd69394a004aa9b3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/a8691d946c8df1cbe15a5b5a844ce67afe44edec.jpg": "/game-art/call-of-duty/achievements/a33c85c2eac09eee12d3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/94ec5a16bec33a09a7e6d46c59228dc2bfe39036.jpg": "/game-art/call-of-duty/achievements/6ec3b50ebd02d0735b93.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f4eecc8ddd7ee7293fa25832335188b015ffb83d.jpg": "/game-art/call-of-duty/achievements/e630036163d8caf0debb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/7f11480479347fcea37cc1cfae939aeba0310c02.jpg": "/game-art/call-of-duty/achievements/4836d8a31e08f9d59a2b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/f92300994fb718a533ca129c8ff923a926ab45a3.jpg": "/game-art/call-of-duty/achievements/acec1c540085bf43a216.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2064b041848207e9152648f9b04b10c0eb4a5ea6.jpg": "/game-art/call-of-duty/achievements/add0b1b64943198d70f3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/0cd1b2512a34e0cdaccffdedc50a3922b97928f9.jpg": "/game-art/call-of-duty/achievements/57f87a62c441cc00fee3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/016736a5e36aa630ef854098bcf8b46a0ed090ce.jpg": "/game-art/call-of-duty/achievements/5ad988718dfbdf86382d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/32576cd779cc6194a221fa52d0fcd1ab42a3d3c2.jpg": "/game-art/call-of-duty/achievements/6081ccee396297fcf8f0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/2eb29923c09df13e6d1459d86a81fb0578304ab4.jpg": "/game-art/call-of-duty/achievements/ccecd58a309d11d1175b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ba1e4900d687debb7029236ccc1faa64db1180eb.jpg": "/game-art/call-of-duty/achievements/061b960e95b7d94f8f43.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5cadec3374b283cb8f9dc854701bc822bdcce372.jpg": "/game-art/call-of-duty/achievements/ecdf86fbec69e35d181d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/25a6f8d781a75b9c5a2836a6143654af206e7ed2.jpg": "/game-art/call-of-duty/achievements/99ec70d1d7557298d43a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/0c3c4505562edca7e9b5c2f33461bed0d024cd9e.jpg": "/game-art/call-of-duty/achievements/f876467edbcda81649b4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/8dedc819af9d9899a880116f1c5749157a41331b.jpg": "/game-art/call-of-duty/achievements/31c585a4c4713380fb47.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/880481de0edbb3b6c8106b91619047765c0438a4.jpg": "/game-art/call-of-duty/achievements/fe2f56321b012cbac9a5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/b25f0928717fdc0078a4b8235d4f8090f9347b2a.jpg": "/game-art/call-of-duty/achievements/a7d36705f1683cc0f51f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/33cbc57d8d5d260b2a029f72aa840e6f777a5fcc.jpg": "/game-art/call-of-duty/achievements/5a3e56b651582cd37b0b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/00f4b4482ffe5af8e36fc29ad10184fabb796b2e.jpg": "/game-art/call-of-duty/achievements/f9484a145c44b5d0cbd1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/5b6d4af29251e8c797ea6c8df0b5856a338147f7.jpg": "/game-art/call-of-duty/achievements/a14a5d22282b82e02b4e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/67cc050f25ae01778daa55c135f8935f0745ea5d.jpg": "/game-art/call-of-duty/achievements/2e87d420089b4f2be57f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/559bc3b290bc195d1285c286f1cde1c24335391b.jpg": "/game-art/call-of-duty/achievements/3c6074ea42bb0c376cb8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/782ab1a29161fcea2ee6637a6c0965e074e0bc35.jpg": "/game-art/call-of-duty/achievements/aac4dce9aa2be9c78d9b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ecf93da66531bf5795410c7401216bc46c96377f.jpg": "/game-art/call-of-duty/achievements/27bc37653fd38aff2e63.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/c25d1ef82dc9ae885afc75fb7db37722039a5cfa.jpg": "/game-art/call-of-duty/achievements/ed46540b107123ccac71.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/61e6b6f00e56022f96fff4ea74393b211d87333c.jpg": "/game-art/call-of-duty/achievements/1189b4ced39f5b73873d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/68cf892bf027d8f5b337c878809441595772c462.jpg": "/game-art/call-of-duty/achievements/42184870c69db3836487.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/1c86b033653580d08f213eda50989d7a7284ae57.jpg": "/game-art/call-of-duty/achievements/28d3a3ca08a7bccc03fc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/cf50aefce11bd69bc0e7aaa776cc8cfbe32c1843.jpg": "/game-art/call-of-duty/achievements/cabc845fe34956e075a8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/e1cda475ce180a6b18d5707c3e43f791890a8a0a.jpg": "/game-art/call-of-duty/achievements/594e4fdf8acf6f23526d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/7cec40e8b161cad9671f4a0e6a3c7b753312bbae.jpg": "/game-art/call-of-duty/achievements/b73876cee3831c464041.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/fed3d249602647c0793d1054b15a88b61e532fcd.jpg": "/game-art/call-of-duty/achievements/88afe330f75fb3f138a5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/311210/ca4a4b6a35ed886f3a38de19ac74cc1d79cf9b24.jpg": "/game-art/call-of-duty/achievements/73c21ba0cdda98cc9bf6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a4ea4b980d2ecaf4e2cd586d771d4a8ea67f0fb1.jpg": "/game-art/call-of-duty/achievements/0eea8fd2286728848ad9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/9d54b20f3f810ba5350c026dd5ea10fb5219eb9d.jpg": "/game-art/call-of-duty/achievements/307d65e590b40d9b2ed7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/476ebb35cfa9b4c0b1cfdc764396347cf8a956a1.jpg": "/game-art/call-of-duty/achievements/821090905722714275bc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/4d7838ed2db245e8dc1efd9c33bfb484d0987f5b.jpg": "/game-art/call-of-duty/achievements/9949c131335becbb7bde.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6f53e953c713a7cb5ccb5f927324289297424f53.jpg": "/game-art/call-of-duty/achievements/bf2530311d97100ba635.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0da3fabafb959cabc6450cdf61b380ec186a0742.jpg": "/game-art/call-of-duty/achievements/b6595861a5baa64dd7ca.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/1d1d49d31480100db0d8d3ff4ebe60600c548c33.jpg": "/game-art/call-of-duty/achievements/a993f3d757f6e294b0ce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/364a213998f2261a3bc4c79ebe3d0b2786fcef6d.jpg": "/game-art/call-of-duty/achievements/b1b2c8cca82ab7fef281.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e41644864b89f501bb6b4e81f58d34e6f7a692cc.jpg": "/game-art/call-of-duty/achievements/24f087406bd644483bc0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/7cb49cf9cc0bbbb0d345993b1251fe0ad5115b97.jpg": "/game-art/call-of-duty/achievements/7ea2fbd55068b35f2b52.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/503545f86c912ce5f504001f50f0187dbda22e6f.jpg": "/game-art/call-of-duty/achievements/5e04d92c3fba71824fae.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/68a5bbfd6300ee0f9ab5dcd3f1abbc1158f3a4fa.jpg": "/game-art/call-of-duty/achievements/6ecc81eb586e45d95d80.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/eaa5797d0d8aa6892b2cd89342afb9e20854cf90.jpg": "/game-art/call-of-duty/achievements/d4d590aa6e41f28ba0c9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/914889189cdea483f10e7911c0ec21c743bb7ee7.jpg": "/game-art/call-of-duty/achievements/22dc6c8473596aeb227f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/57b930b1ea0cf6e610139859dc2e6d56226a2633.jpg": "/game-art/call-of-duty/achievements/bcd92f34c188b2c94e19.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/efbe0632197664b2c0228426c8df13097631a37d.jpg": "/game-art/call-of-duty/achievements/4ae83a4491c70c5a7a73.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f4b3adcedec5e27d71b7af7e9ee0428cd1dcf400.jpg": "/game-art/call-of-duty/achievements/5dd6624c4ec7afaadf27.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c17c0c50abc6e6d9a9aa85e458f54608b099df9b.jpg": "/game-art/call-of-duty/achievements/1a88c2ae4f55f28fe1cb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/be10a6dca336bcffc3db6e100cc84e2afc39a04e.jpg": "/game-art/call-of-duty/achievements/dda424d5bf63271f6a83.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d36e209bce7aeee14bcd7421ec5989c3d6766335.jpg": "/game-art/call-of-duty/achievements/ea4c12853a21dcc53f2d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/45373ed485cfa11602295fc21cb619facf1b587a.jpg": "/game-art/call-of-duty/achievements/050347b46f0d8368c7c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5fd86313adb4952d991ea88fd25c264b4cf3e4d0.jpg": "/game-art/call-of-duty/achievements/a50db8da0e01557fbd8e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/abfde4e6cf3c3461caf90d5830c2984fbaf4bc68.jpg": "/game-art/call-of-duty/achievements/48753b740d2a2cdc0ba2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fe8f95d54e14d3186cbcb91c422943ed386f4668.jpg": "/game-art/call-of-duty/achievements/85ab28ed3bd4013da705.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/ed8e02846abd5ed0be162106597261e0323ca74b.jpg": "/game-art/call-of-duty/achievements/4ee5e40d6a92ccf126ce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c53a03bdce2b1ca3aa4a7c1c51b2fae47aec2417.jpg": "/game-art/call-of-duty/achievements/15f92324581bf6590bf6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/69206aa25a08794251623f894f522dd974094996.jpg": "/game-art/call-of-duty/achievements/a07c68c98dc2d336adda.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/8fd625f315d38418c48ebc2e525ee9a132562029.jpg": "/game-art/call-of-duty/achievements/38e607282e37825c2269.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/01d0cc9c538cf12c4a2604f31f9d44b0a6b81bc6.jpg": "/game-art/call-of-duty/achievements/ff3a9b02aa377eed93fe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/7783476c4c94f89e01165466eb82d896e0247c45.jpg": "/game-art/call-of-duty/achievements/8a2840e5b9584aa1546b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/48f63f1800eccb761142db00dab66bb306409774.jpg": "/game-art/call-of-duty/achievements/4b5e9de3363f43ea5dac.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/191459e1e92933065eb06461831ec18c8748aac5.jpg": "/game-art/call-of-duty/achievements/09cbb08ecadeced19848.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/bc5c4bfac95f698f5339a3458aa43b8765802e7f.jpg": "/game-art/call-of-duty/achievements/ffb4db59b87d8527049c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d3f4ef642be6bc91a1f93bc0f079ef3b18ce8b05.jpg": "/game-art/call-of-duty/achievements/9b4b9d4026c4db6643b2.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e174be71bda7e57d4c4fe7993ca4a958f3352648.jpg": "/game-art/call-of-duty/achievements/f90d041f942f984f6c15.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c52901981e755d5553186c5b77f6d59348a50f95.jpg": "/game-art/call-of-duty/achievements/d29610fabecd57672df4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d20c0b3c00365f3ec8bc9d24682c20aa487cc8ae.jpg": "/game-art/call-of-duty/achievements/31432873709e76e4e1a3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/583e60eb79cd565e85aa1bd4c59bec3aa32e4434.jpg": "/game-art/call-of-duty/achievements/63dfa9a75717ece9f7fe.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/809188e0d91acbef64b5f99a05bdce0e76a48300.jpg": "/game-art/call-of-duty/achievements/2be108c182fe9595ccb9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/322e0ab6895d3f97a06244de57038145321ba78f.jpg": "/game-art/call-of-duty/achievements/55f264e040cb5f11dd6c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e0d98de5d187d767c9c29ed3fefa5e94fb5d2a71.jpg": "/game-art/call-of-duty/achievements/da40d7b9be06fd8c6981.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fad7498a85145e17041e7a42a2ac239c9a836940.jpg": "/game-art/call-of-duty/achievements/3cdbcc76097e36b877ae.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/cca04d8cb905c1a79dd337fb637db65753b1eb74.jpg": "/game-art/call-of-duty/achievements/0316533b014576c6f9d4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e4be11b62681fe629ce889e408cd33d7c9ce2d82.jpg": "/game-art/call-of-duty/achievements/99974045c45ca4746967.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a23d7843879e2533cd5ca05cdbb12768421f5200.jpg": "/game-art/call-of-duty/achievements/d9687e41518b77bf05ba.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/2140d0ad44a74cf73dfa8e5eecff94545dbec800.jpg": "/game-art/call-of-duty/achievements/fb7536a485a7fcd2102e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/41eb949d9e5ebded26f444accfbbf776e423d0c1.jpg": "/game-art/call-of-duty/achievements/5d97318fb384c8043818.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5e903ad5b14acac2a3ce30b241eca02318efe08c.jpg": "/game-art/call-of-duty/achievements/c7ac6468f1a9dd020ad9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/190cc9b3a7a4738b2c0c0aa5b3581c3d13d0fc67.jpg": "/game-art/call-of-duty/achievements/ee8e525ffbd4aad55d05.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f26e0102562e48114cfdc47063638c0ff13ae369.jpg": "/game-art/call-of-duty/achievements/2598397bd2d8472b6ffa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/4779b58576c848e74e133b602c4febb942a486c0.jpg": "/game-art/call-of-duty/achievements/6c22b7ab6d07086e064f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/851be884cab04c069af4b95853b679e5783c0a2d.jpg": "/game-art/call-of-duty/achievements/8e7820b5324e90ea2e19.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e51e3ab96ef6d0c2a029724865f2db73074f879e.jpg": "/game-art/call-of-duty/achievements/fe0bca15248c5db77368.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/73263340a8746250ab3a8f1186f10c238cb98481.jpg": "/game-art/call-of-duty/achievements/51155702084575df7717.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0ce7057339b8725b96f9ecca6f9a244456e050c3.jpg": "/game-art/call-of-duty/achievements/eb67cbdf0f2bf1e2e392.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6091d5200a8aed5d2bf8079cd8e5f331b25a6d6a.jpg": "/game-art/call-of-duty/achievements/c14b826b7ceee473d291.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/565514b78a058199b82113be663edafbfcfaeaa6.jpg": "/game-art/call-of-duty/achievements/0e4f0bf6cff2c371105d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/232be3c5fc55ef830f30fe8e4505373482dba0ed.jpg": "/game-art/call-of-duty/achievements/875fa76fce401a711538.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/abe4a2fcc597ac2f024aeaeac6e4aa6f8a989951.jpg": "/game-art/call-of-duty/achievements/c9eece5fc57482ab737d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d2fca5a28ce5c89ec913731cf05742e5a8ec5afc.jpg": "/game-art/call-of-duty/achievements/0151aa252dbe2c64fa5b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/ce1781c257f210cf1e816d4704030365abc75058.jpg": "/game-art/call-of-duty/achievements/ab8c01332d03579e6fc0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f737b9b719c85a924574939fd4d188c1817b9619.jpg": "/game-art/call-of-duty/achievements/5f0fd6350175e1f60148.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fa839e5d6e9db883809176ac97e5e1e1fb364e50.jpg": "/game-art/call-of-duty/achievements/98a68729deb6696212f7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/a47053b4181e3d73780e2aa08454f29883e5b4ea.jpg": "/game-art/call-of-duty/achievements/bd970842650295550c88.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e787578182bf475de654ff869d7a76204d7358c0.jpg": "/game-art/call-of-duty/achievements/5d72276ec7235d317174.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/0174240168f53900f133adf792fe1435ee1b0ea1.jpg": "/game-art/call-of-duty/achievements/7cde51a094d2ba37d596.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/c7abcb4db7eae619b4b94affd18b152fb039fe1f.jpg": "/game-art/call-of-duty/achievements/2776bc87b100fcf92325.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/fdf433700a6079bd2af957abe935150557992f31.jpg": "/game-art/call-of-duty/achievements/59803f6ef69ae9c0b923.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6a11b9adabb47de14ec6e99d57cd08fd05c7638b.jpg": "/game-art/call-of-duty/achievements/43bf5dd8b3e0c059e922.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f7c7b9405060e89852c1fdd1b98cd99fd4556ee6.jpg": "/game-art/call-of-duty/achievements/51ad1c409df815178212.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/d1cd031ca9d9664353b45bfea4fd6be7da9c0498.jpg": "/game-art/call-of-duty/achievements/14e430fa2934e973aa36.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e7e98e8582812646a25139106f785cbd75520f63.jpg": "/game-art/call-of-duty/achievements/2b8350b0eae8221c10f8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/9f05aef05e025f2b3562f02a3879a150188beaa9.jpg": "/game-art/call-of-duty/achievements/9fc62e9028597c00592e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/65010f7eb16e517591dc2b62da2a907475033cdd.jpg": "/game-art/call-of-duty/achievements/326861e9806de8f193a8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/13c39f9153e906d20d6fd4526884deb3b08a13a1.jpg": "/game-art/call-of-duty/achievements/ed1fc961b8cae53a42df.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e4b6c8ab4e761956616be5b79ec00f3df015162d.jpg": "/game-art/call-of-duty/achievements/1884707001ee9093877b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/df54112c58d577e0c021905122dcd40b2f6e7a98.jpg": "/game-art/call-of-duty/achievements/1afc6d1d8574b1961ca8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e794b4b7a3adcaf08c50323b1a019fa34eed3632.jpg": "/game-art/call-of-duty/achievements/fae378553e0ee2f50a9d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/eb1ca331fcaaa3e1ddf481375bebff75a3ee8c76.jpg": "/game-art/call-of-duty/achievements/b1771805712e9d7dca3f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/f6db79f0db66c4ff5998b54f392bc5f6671e1e62.jpg": "/game-art/call-of-duty/achievements/033de94bcc4b798ef4c6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/804df5f215f8b28b76e370f636664b803e9cca52.jpg": "/game-art/call-of-duty/achievements/754dbd600cc2e348d91e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/2264003a9af6d55e694d9ebfb8c4be90678c9a18.jpg": "/game-art/call-of-duty/achievements/7a11fd527ab181f42e46.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/afdb278ecf2d8812f2eb4e2ba1916746fac6b715.jpg": "/game-art/call-of-duty/achievements/8a30f1adc1bc6d98f62a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/5aa426a557d386edacc8c53a16f31538643fc52a.jpg": "/game-art/call-of-duty/achievements/ed424ec96f90c53b8412.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/1f55129c4a789aeac4909e1d61618b5ea67a6c95.jpg": "/game-art/call-of-duty/achievements/0d96a1f789220f3ee488.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/e33837825d9424f56fcef9fe51e4f7cd8c7ad661.jpg": "/game-art/call-of-duty/achievements/692b9a85693cd1405c98.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/b829035e663ee531e0603a0506f2247cc13e5990.jpg": "/game-art/call-of-duty/achievements/a4dac4e7d2f8011e0a34.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/292730/6b396a5ecb13f571bac9d485356927f286048727.jpg": "/game-art/call-of-duty/achievements/b601313d7027944c14ff.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/5f5204549ec2b2350fe2abb39f7b31106f0eee9d.jpg": "/game-art/call-of-duty/achievements/5423e6e0220bf8389f93.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/6d40e8236f865b88e62f5b8f81683733b190de10.jpg": "/game-art/call-of-duty/achievements/464c02401563b468757a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/444ca3ad0ffcd78d48869b996ac0c3f28e8a3892.jpg": "/game-art/call-of-duty/achievements/f21af58534176e7cc2ae.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/9de932c337c236172603c5380673c6c02be47e56.jpg": "/game-art/call-of-duty/achievements/87efada8bf8cd21d007e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/81828d6e7db5cd1f3e05061f8cb2c2ff212bf021.jpg": "/game-art/call-of-duty/achievements/00fe9ca1edc2bf6ef2c7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/a3a798f1259d54c816478e6636dfb812faa38cd3.jpg": "/game-art/call-of-duty/achievements/017d310d77f9ce71f34d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d3fd3f4a6d1160e7fca344c7153a3c1c3790e20d.jpg": "/game-art/call-of-duty/achievements/89bc701b69649a38c36c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/97caaed86660711ef4d92f431057ed5f0ed37ab2.jpg": "/game-art/call-of-duty/achievements/e9040935e077d69a190a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/9a09587009ade2752f099e19c947c7ba6b3edbf4.jpg": "/game-art/call-of-duty/achievements/516471bdf6e23a7c3525.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d24857c1f2837e608c8dc19bdd780cf11dceb234.jpg": "/game-art/call-of-duty/achievements/f8d28a760c778fb16f3b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/d4cd1139dbfda102e4b2865ca8fdd2d2f46b1e3a.jpg": "/game-art/call-of-duty/achievements/0a0f8b7255d63297ad28.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/5f51760b958da5c541c968359c4904ae65b577eb.jpg": "/game-art/call-of-duty/achievements/304f33ab3c88f3c4bb61.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/1f41f3bdd8749e0f4893ba61eed72f52d5cacb6f.jpg": "/game-art/call-of-duty/achievements/a22e8f846d1302897f3b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/14db187aa9805219b5e7665953e2ff5370eb4bf1.jpg": "/game-art/call-of-duty/achievements/34a87af7e5ed992e5f56.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/fe4b494bd12470168e58611cc5848fad5af4c556.jpg": "/game-art/call-of-duty/achievements/47f3c124d30542618467.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/cd0fd8249e28ab27c9ad41bda381164ebb24bdbe.jpg": "/game-art/call-of-duty/achievements/6e28b230d5c7f0ccfd98.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/b77eee595f7c4d33efbbd156cd05b91f2137d504.jpg": "/game-art/call-of-duty/achievements/8078c43246474ae807e8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/8c0b886c75e4c8aa9c7140f7138b1139625a14d4.jpg": "/game-art/call-of-duty/achievements/1e444cfdba5372cf8d80.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f0d225a5898fbdbf0320519a2e9269232ee0b2a1.jpg": "/game-art/call-of-duty/achievements/52cb1a51cb4f0b4a579c.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/bd1fa843cba1aeaea05401a2012de9f626cdea56.jpg": "/game-art/call-of-duty/achievements/173b27753257df49245a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/fe251e95bb30bf3e566dba7a9a2764588aca5303.jpg": "/game-art/call-of-duty/achievements/325c34e0be02eb788bce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/4d0bb5694f2137cfa4bd6b45c50dfde39974e797.jpg": "/game-art/call-of-duty/achievements/91a62cd332f49a9dbcbb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/b11c309f8f71d56743791e9066bf507bce47089d.jpg": "/game-art/call-of-duty/achievements/7bc5d0d30105499b4787.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/c83c48ee231f23865e1564bd9dd609d159e2dc78.jpg": "/game-art/call-of-duty/achievements/786a6fbe1c805696cbeb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f46558af9e3f8d4c34e6d910ddcd06899d7668dd.jpg": "/game-art/call-of-duty/achievements/46d491e0a9b3ef87b4c3.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/f1bd0dc5211c6469c2eb8914a76376b7c9587da2.jpg": "/game-art/call-of-duty/achievements/35bbd7b64edea5f0dbcc.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/e415053365c5cd2a794422e6e6e82b7447695978.jpg": "/game-art/call-of-duty/achievements/c5b97fe5c00980e17812.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/bf5061bb9b4d88a78819244cc9cb4fee608d8e4c.jpg": "/game-art/call-of-duty/achievements/e38ea5af67911aaa1672.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/ce15e88ab93d982b480395308ff8d3a51f4bfd09.jpg": "/game-art/call-of-duty/achievements/0d6e0f9af9b044891c85.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/53e20712d27d0fa30f6389683703fec90785b9f2.jpg": "/game-art/call-of-duty/achievements/fde64e9607353ca56656.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/3aab6e911ce76a3ae861515b15eab3880fbed914.jpg": "/game-art/call-of-duty/achievements/9fa641633b88a769124a.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/0ceaafe4e56dc6a5be562d6695fd556c896f1a6e.jpg": "/game-art/call-of-duty/achievements/dd4cde7eaa47be497800.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/1eeeda536f7fcca06176e03a8c8d0d673f93db81.jpg": "/game-art/call-of-duty/achievements/5e6c05ca59d43672b9e6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/062937d06c763afe5c917ce8c6ff2a3d8c1a10c7.jpg": "/game-art/call-of-duty/achievements/dcd3cfe2c0164cda451f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476600/ac5a08806508f32eaa1e8ee3b630e28c34fe8a8e.jpg": "/game-art/call-of-duty/achievements/26a745ed804f7c028b9e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/fafcdac24818ab3ea5fb9b062822e315636d7ec1.jpg": "/game-art/call-of-duty/achievements/96ad629fae03dc395189.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/3da38c567f9e948f20dc472fdb59f407b96dc8b2.jpg": "/game-art/call-of-duty/achievements/5324eff1e27445cda337.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6a78ffd1b01c0db1b7efc5d4be32d98eea5c4e8e.jpg": "/game-art/call-of-duty/achievements/845b6b03c0a95993479f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/c56dac0afdaf369cc301c0067d1988bb74b67618.jpg": "/game-art/call-of-duty/achievements/47d8dd228ad855fcea24.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5b2a1593fe4240b9c6a6f4dd69965e33d5f0abe1.jpg": "/game-art/call-of-duty/achievements/73a2389b03f7587fc0e5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d1f38005260fe5598668227f322d4fbfc955a590.jpg": "/game-art/call-of-duty/achievements/aadf5c23e728238bb9c5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8007c03be77aba99d7b1cc88e02a020118047bde.jpg": "/game-art/call-of-duty/achievements/1c4e1eb7189ca710164b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/3e8727718b0f19b5faec8c42fab0b862b4b63481.jpg": "/game-art/call-of-duty/achievements/413f83285d53ffcae2fd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/e7405eabbe8111af18d2edf6ff029482b2b700fb.jpg": "/game-art/call-of-duty/achievements/26b06ce878eff10b73bf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6930db97368989cd6fd99cc8875511008f80f9ab.jpg": "/game-art/call-of-duty/achievements/6e906dc87a721d88e3bd.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/066c58519c94120a7586b048285be0e03f2eac8f.jpg": "/game-art/call-of-duty/achievements/5b39f17436deb4386859.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9f473cb021b4e2a466eaabdd43e70b0d4bcc33c6.jpg": "/game-art/call-of-duty/achievements/06adf873abc0e85ec5c0.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/4ea7184e640b125cf66a0b41f50782e20141d149.jpg": "/game-art/call-of-duty/achievements/0f588f009483c1119c89.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/0d7fb5796c7fed3697cf1e005f9cf771d8c54d83.jpg": "/game-art/call-of-duty/achievements/fcb7c46cd797564a698f.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/dd125e0c672897afd49f58d916a6b30508d5cb8f.jpg": "/game-art/call-of-duty/achievements/8bf89d4fa5608647b7fa.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/6215cea00d06af99fc481233e5ae205d2fd4cecd.jpg": "/game-art/call-of-duty/achievements/0923efbeb3f152d328be.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/4ed6a97188c019b9c5ad79ec923756f29ba6e4c4.jpg": "/game-art/call-of-duty/achievements/5155ebec3a8d0596e4db.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/b10776bf82f624ffa3266e371074ac5f990a2f21.jpg": "/game-art/call-of-duty/achievements/ab9164941f8672bc8abb.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d92a57a9bdb0215bbf025f1ebfeef31b86808b74.jpg": "/game-art/call-of-duty/achievements/741578149080d68ea0cf.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/35abebfdd66507398c87c1fb106bd847f29cd103.jpg": "/game-art/call-of-duty/achievements/60315f55775a3fce1077.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/714ebbc054d4a315f36b603047be752e3f03d6e0.jpg": "/game-art/call-of-duty/achievements/df765b3d7d4bf9e8daf4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/e001bbd75e459f1a3698594ae4d906e637e85505.jpg": "/game-art/call-of-duty/achievements/6b6d3959856e3f9ca287.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/27b2f66216c7326455b475b4968a371b579ee57c.jpg": "/game-art/call-of-duty/achievements/e61c3692670ed08908c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9bd50348aa430df867060e24d3770e48b09672dd.jpg": "/game-art/call-of-duty/achievements/c7b703f4789fdf88f2e8.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/fcf0b94a7b7a9c7bc86fc5b797308a6139b7ef54.jpg": "/game-art/call-of-duty/achievements/d4451dddcf7b12a1e957.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/642bb5bf764fe94d8b8ed64221f81ea418dd67f8.jpg": "/game-art/call-of-duty/achievements/b98dcb94df80f1203e69.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/2830777cd6e2ca860803a580a507927315639bdb.jpg": "/game-art/call-of-duty/achievements/e7684dc386168bb855ce.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/f317858e66eae25497d0038dc802403ca25a6d69.jpg": "/game-art/call-of-duty/achievements/e3d5603aaef9f9181396.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/77d79b47cc9d7a1f75250e9b568e35d6aa9c5b0d.jpg": "/game-art/call-of-duty/achievements/3606dac327d5e7c2f002.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/9a2332cefa0811232da7eb65cce6babdd53e17aa.jpg": "/game-art/call-of-duty/achievements/373fb8fa42b25f2ff2f5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/dfbef312f1d2fcfeb4003204c0318041cf40bbac.jpg": "/game-art/call-of-duty/achievements/d2fa391ff5e1ba047d69.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8c653f390740da89eea0a708fea7a073f6042dc8.jpg": "/game-art/call-of-duty/achievements/6f0bcb55520c5f4ee6d6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/143591c3bd783549dec12e594ca66eae0a7ab3b9.jpg": "/game-art/call-of-duty/achievements/5bb20f6fc96f66d387da.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/a46c13da4c9b6ed4e1477231c8b36349fc808f0b.jpg": "/game-art/call-of-duty/achievements/ffaffffefe23de4af17e.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/a0f7fc7d8cf212d04fa86a806791f43ee8bb7c10.jpg": "/game-art/call-of-duty/achievements/7b4336d95ad38bdceb14.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8e0e68c0f8fc9c69b0e5fd7dc50fdd9c6b2335b5.jpg": "/game-art/call-of-duty/achievements/8ebc6e71dbc8afa226c6.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/d2bcd4fcee0e57475afca3dcba7809540521ec20.jpg": "/game-art/call-of-duty/achievements/178528d63e0791dfa11d.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/146e6d1394fea265d9850d6a482d643fa55cf8bd.jpg": "/game-art/call-of-duty/achievements/c0fdcae2d2e23d569eb4.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/725b6f81036fe2e27e105e7f2d5fa569af5a0300.jpg": "/game-art/call-of-duty/achievements/9179e207bd077972ff97.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/8a0ee0fc42268e7308d0cbe121944d95647baa9a.jpg": "/game-art/call-of-duty/achievements/f26378d314c0b4bf71f5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/20e57a0d1b40c952c8af700c8b5cee0cc12b4cd8.jpg": "/game-art/call-of-duty/achievements/807b6d5288ea4e99c540.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/531e5e8aab594ff1e740f96f1063186c3e0f9567.jpg": "/game-art/call-of-duty/achievements/8a6ee3f02bbce9526be7.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/adef655eb9161e85b9b75c5fe4341238df00ed1b.jpg": "/game-art/call-of-duty/achievements/c9e7867e6868bbdbd283.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5d099ed40cadcfa7996e9dc2bac903bbc5427830.jpg": "/game-art/call-of-duty/achievements/21dd1009bcc8472b26d1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/0874326e2469ef5053d713487d2774adf4a83794.jpg": "/game-art/call-of-duty/achievements/50b2a42d676828bf2407.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/14c155b68e14d342bb1807ac44d7f96cf40d5066.jpg": "/game-art/call-of-duty/achievements/954434d2dbdb5fcc4343.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/31381253ea5db5f151e6b2ed375edbd17031b9de.jpg": "/game-art/call-of-duty/achievements/4738f4e73fdec7acb4c1.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/18e378dd7b85bb39e37a3b2c94b5673b2bc192ab.jpg": "/game-art/call-of-duty/achievements/923bcc83b36645e6ef85.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/15d7872c42fcbf3a829021296824cca0a505c6d2.jpg": "/game-art/call-of-duty/achievements/8f7ce98dd2e8abf78bd9.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/1b97bf2a964c883d01b91d830d953078f731413a.jpg": "/game-art/call-of-duty/achievements/f9fe1161efe00ae8c571.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/70dab8649791478c2d9877465550534656917c0f.jpg": "/game-art/call-of-duty/achievements/048b05607127d4ee5b7b.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/89b9d0d811451df6f7c57c25183ada2ee1ddc402.jpg": "/game-art/call-of-duty/achievements/73a862019bb136eb7a86.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/244a19d76e4c44187b88a0d30f2883631e7c82f5.jpg": "/game-art/call-of-duty/achievements/0f898edbf93d8f89dace.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/5b505784913a7dd3d8fd62174cdd76a0fb49c151.jpg": "/game-art/call-of-duty/achievements/8ca100618c6de814d6d5.webp",
"https://shared.akamai.steamstatic.com/community_assets/images/apps/476620/bc21875d3703b16313741bed719261fbb2d1ffdf.jpg": "/game-art/call-of-duty/achievements/f3d9e7b5a4fd35172686.webp"
}

1874
data/cod-art.json Normal file

File diff suppressed because it is too large Load Diff

36766
data/cod-challenges.json Normal file

File diff suppressed because it is too large Load Diff

90
data/cod-rank-art.json Normal file
View File

@@ -0,0 +1,90 @@
[
{
"game": "black-ops-2",
"prestige": 1,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-1.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804661214/9FD6B03A8AB4ABE1D52ADEB9A14D8DBB72054910/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 2,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-2.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804661581/1D2C4EAD9F7EBCE0955B6BBE0FEA78A643B817C7/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 3,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-3.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804661865/38F7010DD1543AA9A08C584D1B90DD7C9C138BA1/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 4,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-4.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804662398/E36D618159E6474536E21AFD5873BB652658C010/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 5,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-5.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804662706/45B72C58EB62C84E20A50AEDF2539069BFBF4531/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 6,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-6.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804663180/AB4D41F43A0A68C3417F5DBB1ADA54365F217A49/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 7,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-7.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804668104/BC9E371A435F8CCD160A22ED70AABB4E747E78E4/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 8,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-8.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804668443/6985C3A4733C6CC5E19EEDC53E95FBE705752C70/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 9,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-9.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804669343/BCBD2BC8411DA714684CE5E01C24F4E3DA948F5F/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": 10,
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-10.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804670653/CD1052286A32FDA566E1849D3B6F02ADB22C45CC/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
},
{
"game": "black-ops-2",
"prestige": "master",
"path": "/game-art/call-of-duty/ranks/black-ops-2-prestige-11.webp",
"url": "https://images.steamusercontent.com/ugc/2506892135804671070/305118EBAF387C239C6CBF2D8C808E1E6305D3E7/",
"source": "https://steamcommunity.com/sharedfiles/filedetails/?id=3231054009",
"credit": "Activision / Treyarch; prestige emblem via Steam Community"
}
]

39352
data/game-images.json Normal file

File diff suppressed because it is too large Load Diff

5568
data/pokemon-atlas.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3894
data/pokemon-map-art.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
{
"note": "First release of each title; Red and Blue refer to the international versions. Japan versions are separate catalog entries.",
"source": "https://bulbapedia.bulbagarden.net/wiki/Pok%C3%A9mon_games",
"dates": {
"red-japan": "1996-02-27",
"green-japan": "1996-02-27",
"blue-japan": "1996-10-15",
"red": "1998-09-28",
"blue": "1998-09-28",
"yellow": "1998-09-12",
"gold": "1999-11-21",
"silver": "1999-11-21",
"crystal": "2000-12-14",
"ruby": "2002-11-21",
"sapphire": "2002-11-21",
"colosseum": "2003-11-21",
"firered": "2004-01-29",
"leafgreen": "2004-01-29",
"emerald": "2004-09-16",
"xd": "2005-08-04",
"diamond": "2006-09-28",
"pearl": "2006-09-28",
"platinum": "2008-09-13",
"heartgold": "2009-09-12",
"soulsilver": "2009-09-12",
"black": "2010-09-18",
"white": "2010-09-18",
"black-2": "2012-06-23",
"white-2": "2012-06-23",
"x": "2013-10-12",
"y": "2013-10-12",
"omega-ruby": "2014-11-21",
"alpha-sapphire": "2014-11-21",
"sun": "2016-11-18",
"moon": "2016-11-18",
"ultra-sun": "2017-11-17",
"ultra-moon": "2017-11-17",
"lets-go-pikachu": "2018-11-16",
"lets-go-eevee": "2018-11-16",
"sword": "2019-11-15",
"shield": "2019-11-15",
"the-isle-of-armor-sword": "2020-06-17",
"the-isle-of-armor-shield": "2020-06-17",
"the-crown-tundra-sword": "2020-10-22",
"the-crown-tundra-shield": "2020-10-22",
"brilliant-diamond": "2021-11-19",
"shining-pearl": "2021-11-19",
"legends-arceus": "2022-01-28",
"scarlet": "2022-11-18",
"violet": "2022-11-18",
"the-teal-mask-scarlet": "2023-09-13",
"the-teal-mask-violet": "2023-09-13",
"the-indigo-disk-scarlet": "2023-12-14",
"the-indigo-disk-violet": "2023-12-14",
"legends-za": "2025-10-16",
"mega-dimension": "2025-12-10",
"champions": "2026-04-08"
}
}

File diff suppressed because one or more lines are too long

17
db/reorg/01_roles.sql Normal file
View File

@@ -0,0 +1,17 @@
-- 01_roles.sql: cluster-level roles. Run as a superuser (postgres). Idempotent.
-- In prod: needs `sudo docker exec -i <pg-container> psql -U postgres`; set passwords separately (\password), never in this file.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
CREATE ROLE personal_app LOGIN; -- app, ned-collector, house worker
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ddl') THEN
CREATE ROLE personal_ddl NOLOGIN; -- owns schema lists; only reachable via SECURITY DEFINER fns
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
CREATE ROLE personal_ro LOGIN; -- read-only for audits/agents/pgAdmin
END IF;
END $$;
ALTER ROLE personal_ro SET default_transaction_read_only = on;
ALTER ROLE personal_app SET search_path = '$user'; -- force schema-qualified names in app SQL
ALTER ROLE personal_ro SET search_path = '$user';

View File

@@ -0,0 +1,142 @@
-- 02_schemas_move.sql: create schemas, snapshot counts, move + rename all 58 public tables.
SET ROLE personal;
CREATE SCHEMA auth; CREATE SCHEMA money; CREATE SCHEMA house; CREATE SCHEMA ref;
CREATE SCHEMA games; CREATE SCHEMA cs; CREATE SCHEMA animaldex; CREATE SCHEMA site;
CREATE SCHEMA meta; CREATE SCHEMA hardware; CREATE SCHEMA energy; CREATE SCHEMA legacy;
COMMENT ON SCHEMA legacy IS 'Pre-reorg list tables + reorg bookkeeping. Drop 30 days after cutover.';
COMMENT ON SCHEMA ref IS 'Seeded third-party catalog data (Pokemon, CoD tracker definitions). Read-only in the GUI; seeders own it.';
COMMENT ON SCHEMA games IS 'Stan''s own game progress.';
-- exact row counts of every public table before anything moves
CREATE TABLE legacy.reorg_counts_before (table_name text PRIMARY KEY, n bigint NOT NULL);
DO $$
DECLARE t text; c bigint;
BEGIN
FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE format('SELECT count(*) FROM public.%I', t) INTO c;
INSERT INTO legacy.reorg_counts_before VALUES (t, c);
END LOOP;
END $$;
CREATE TABLE legacy.reorg_table_map (
old_name text PRIMARY KEY,
old_prefix text NOT NULL, -- prefix used by its index/constraint/sequence names
new_schema text NOT NULL,
new_name text NOT NULL,
UNIQUE (new_schema, new_name)
);
INSERT INTO legacy.reorg_table_map (old_name, old_prefix, new_schema, new_name) VALUES
('user','user','auth','user'), ('session','session','auth','session'),
('account','account','auth','account'), ('verification','verification','auth','verification'),
('money_accounts','money_accounts','money','accounts'), ('money_transactions','money_transactions','money','transactions'),
('money_recurring','money_recurring','money','recurring'), ('money_forecasts','money_forecasts','money','forecasts'),
('money_insurances','money_insurances','money','insurances'), ('money_wishlist','money_wishlist','money','wishlist'),
('money_projection_settings','money_projection_settings','money','projection_settings'),
('money_utility_settings','money_utility_settings','money','utility_settings'),
('money_utility_readings','money_utility_readings','money','utility_readings'),
('house_listings','house_listings','house','listings'), ('house_price_history','house_price_history','house','price_history'),
('house_searches','house_searches','house','searches'), ('house_worker_runs','house_worker_runs','house','worker_runs'),
('house_alert_rules','house_alert_rules','house','alert_rules'),
('pokemon','pokemon','ref','pokemon'), ('games','game','ref','pokemon_games'),
('pokemon_game_entries','pokemon_game_entry','ref','pokemon_game_entries'),
('pokemon_locations','pokemon_locations','ref','pokemon_locations'), ('pokemon_encounters','pokemon_encounters','ref','pokemon_encounters'),
('pokemon_evolutions','pokemon_evolutions','ref','pokemon_evolutions'), ('pokemon_dex_numbers','pokemon_dex_numbers','ref','pokemon_dex_numbers'),
('pokemon_moves','pokemon_moves','ref','pokemon_moves'), ('pokemon_learnsets','pokemon_learnsets','ref','pokemon_learnsets'),
('pokemon_research','pokemon_research','ref','pokemon_research'), ('pokemon_game_research','pokemon_game_research','ref','pokemon_game_research'),
('tracker_games','tracker_games','ref','tracker_games'), ('tracker_items','tracker_items','ref','tracker_items'),
('tracker_challenges','tracker_challenges','ref','tracker_challenges'),
('user_games','user_game','games','catch_em_all_games'), ('user_entries','user_entry','games','catch_em_all_entries'),
('catch_em_all_settings','catch_em_all_settings','games','catch_em_all_settings'),
('pokemon_journeys','pokemon_journeys','games','pokemon_journeys'), ('pokemon_seen','pokemon_seen','games','pokemon_seen'),
('pokemon_catches','pokemon_catches','games','pokemon_catches'), ('pokemon_milestones','pokemon_milestones','games','pokemon_milestones'),
('tracker_progress','tracker_progress','games','tracker_progress'), ('tracker_ranks','tracker_ranks','games','tracker_ranks'),
('tracker_weapon_ranks','tracker_weapon_ranks','games','tracker_weapon_ranks'),
('tracker_challenge_progress','tracker_challenge_progress','games','tracker_challenge_progress'),
('osu_entries','osu_entries','games','osu_entries'),
('cs_market_items','cs_market_items','cs','items'), ('cs_price_observations','cs_price_observations','cs','price_observations'),
('cs_item_dimensions','cs_item_dimensions','cs','item_dimensions'), ('cs_price_streams','cs_price_streams','cs','price_streams'),
('animals','animals','animaldex','animals'), ('sightings','sightings','animaldex','sightings'),
('photos','photos','animaldex','sighting_photos'),
('blog_articles','blog_articles','site','blog_articles'), ('tasks','tasks','site','tasks'),
('topics','topics','site','topics'), ('tasks_to_topics','tasks_to_topics','site','task_topics'),
('lists','lists','legacy','lists'), ('list_items','list_items','legacy','list_items'),
('list_item_completions','list_item_completions','legacy','list_item_completions');
DO $$
DECLARE
missing text;
BEGIN
SELECT string_agg(tablename, ', ') INTO missing FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT IN (SELECT old_name FROM legacy.reorg_table_map);
IF missing IS NOT NULL THEN RAISE EXCEPTION 'unmapped public tables: %', missing; END IF;
END $$;
-- types + shared trigger function
ALTER TYPE public.availability SET SCHEMA ref;
ALTER TYPE public.blog_article_status SET SCHEMA site;
ALTER FUNCTION public.site_touch_updated_at() SET SCHEMA meta;
ALTER FUNCTION meta.site_touch_updated_at() RENAME TO touch_updated_at;
-- Duplicate indexes (hand SQL + Drizzle both created one). Keep Drizzle's `_index` / constraint-backed `_key`.
DROP INDEX public.games_source_id_unique; -- dup of constraint games_source_id_key
DROP INDEX public.pokemon_number_unique; -- dup of constraint pokemon_number_key
DROP INDEX public.catch_em_all_settings_user_id_unique;-- dup of constraint ..._user_id_key
DROP INDEX public.money_accounts_user_id_idx;
DROP INDEX public.money_forecasts_user_date_idx;
DROP INDEX public.money_insurances_user_id_idx;
DROP INDEX public.money_recurring_user_id_idx;
DROP INDEX public.money_transactions_account_id_idx;
DROP INDEX public.money_transactions_user_date_idx;
DROP INDEX public.money_wishlist_user_id_idx;
DROP INDEX public.lists_slug_unique; -- dup of lists_slug_key (legacy table, tidy anyway)
-- Move + rename tables; rename their constraints, indexes and owned sequences to the new prefix.
CREATE FUNCTION pg_temp.reprefix(name text, old_name text, old_prefix text, new_name text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT CASE
WHEN name LIKE old_name || '\_%' THEN new_name || substr(name, length(old_name) + 1)
WHEN name LIKE old_prefix || '\_%' THEN new_name || substr(name, length(old_prefix) + 1)
ELSE NULL END
$$;
DO $$
DECLARE
m record; r record; tbl regclass; newn text;
BEGIN
FOR m IN SELECT * FROM legacy.reorg_table_map ORDER BY old_name LOOP
EXECUTE format('ALTER TABLE public.%I SET SCHEMA %I', m.old_name, m.new_schema);
IF m.old_name <> m.new_name THEN
EXECUTE format('ALTER TABLE %I.%I RENAME TO %I', m.new_schema, m.old_name, m.new_name);
END IF;
tbl := format('%I.%I', m.new_schema, m.new_name)::regclass;
-- constraints (renaming a pk/unique constraint renames its index too)
FOR r IN SELECT conname AS n FROM pg_constraint WHERE conrelid = tbl LOOP
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
IF newn IS NOT NULL AND newn <> r.n THEN
EXECUTE format('ALTER TABLE %s RENAME CONSTRAINT %I TO %I', tbl, r.n, newn);
END IF;
END LOOP;
-- remaining plain indexes
FOR r IN SELECT i.relname AS n FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid
WHERE x.indrelid = tbl
AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = x.indexrelid AND c.conrelid = tbl) LOOP
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
IF newn IS NOT NULL AND newn <> r.n THEN
EXECUTE format('ALTER INDEX %I.%I RENAME TO %I', m.new_schema, r.n, newn);
END IF;
END LOOP;
-- owned sequences
FOR r IN SELECT s.relname AS n FROM pg_class s JOIN pg_depend d ON d.objid = s.oid AND d.deptype IN ('a','i')
WHERE s.relkind = 'S' AND d.refobjid = tbl LOOP
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
IF newn IS NOT NULL AND newn <> r.n THEN
EXECUTE format('ALTER SEQUENCE %I.%I RENAME TO %I', m.new_schema, r.n, newn);
END IF;
END LOOP;
END LOOP;
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN
RAISE EXCEPTION 'public still has tables';
END IF;
END $$;
RESET ROLE;

32
db/reorg/03_fixes.sql Normal file
View File

@@ -0,0 +1,32 @@
-- 03_fixes.sql: missing PKs, type fixes, CHECKs, updated_at columns.
SET ROLE personal;
-- missing primary keys (promote the existing unique indexes)
ALTER TABLE cs.item_dimensions ADD CONSTRAINT item_dimensions_pkey PRIMARY KEY USING INDEX item_dimensions_unique_idx;
ALTER TABLE cs.price_streams ADD CONSTRAINT price_streams_pkey PRIMARY KEY USING INDEX price_streams_unique_idx;
ALTER TABLE house.price_history ADD CONSTRAINT price_history_pkey PRIMARY KEY (listing_id, observed_at);
DROP INDEX house.price_history_listing_idx; -- now covered by the PK
-- include_recurring was an int used as a boolean (app code: charts.ts `!== 0`)
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring DROP DEFAULT;
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring TYPE boolean USING include_recurring <> 0;
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring SET DEFAULT true;
-- osu numbers stored as text (0 rows in prod)
ALTER TABLE games.osu_entries ALTER COLUMN pp TYPE numeric USING nullif(pp, '')::numeric;
ALTER TABLE games.osu_entries ALTER COLUMN accuracy TYPE numeric USING nullif(replace(accuracy, '%', ''), '')::numeric;
ALTER TABLE games.osu_entries ALTER COLUMN rate DROP DEFAULT;
ALTER TABLE games.osu_entries ALTER COLUMN rate TYPE numeric(4,2) USING nullif(rate, '')::numeric;
ALTER TABLE games.osu_entries ALTER COLUMN rate SET DEFAULT 1.00;
-- CHECKs matching the values the app already enforces (src/lib/server/money-tracker.ts, money page)
ALTER TABLE money.recurring ADD CONSTRAINT recurring_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
ALTER TABLE money.insurances ADD CONSTRAINT insurances_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
ALTER TABLE money.accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('spendable','investment'));
ALTER TABLE money.utility_settings ADD CONSTRAINT utility_settings_kind_check CHECK (kind IN ('electricity','gas','water'));
ALTER TABLE money.utility_readings ADD CONSTRAINT utility_readings_kind_check CHECK (kind IN ('electricity','gas','water'));
ALTER TABLE money.projection_settings ADD CONSTRAINT projection_settings_contribution_mode_check CHECK (contribution_mode IN ('excess','fixed'));
-- user-editable tables without updated_at
ALTER TABLE house.alert_rules ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
RESET ROLE;

224
db/reorg/04_meta.sql Normal file
View File

@@ -0,0 +1,224 @@
-- 04_meta.sql: GUI registry (meta.tables / meta.columns), runtime-DDL functions, schema `lists`.
SET ROLE personal;
CREATE TABLE meta.tables (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
schema_name text NOT NULL,
table_name text NOT NULL,
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
display_name text NOT NULL,
description text,
icon text,
picture_url text,
kind text NOT NULL CHECK (kind IN ('list','core','ref')),
editable boolean NOT NULL DEFAULT true,
has_done boolean NOT NULL DEFAULT false,
default_sort text,
sort_order integer NOT NULL DEFAULT 0,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (schema_name, table_name)
);
COMMENT ON TABLE meta.tables IS 'One row per GUI-visible table (runtime lists + core + ref). Structure truth stays in pg_catalog; this is presentation.';
CREATE TABLE meta.columns (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
table_id integer NOT NULL REFERENCES meta.tables(id) ON DELETE CASCADE,
column_name text NOT NULL,
label text,
ui_type text NOT NULL CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json')),
options jsonb NOT NULL DEFAULT '{}'::jsonb,
position integer NOT NULL DEFAULT 0,
hidden boolean NOT NULL DEFAULT false,
help_text text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (table_id, column_name)
);
CREATE INDEX columns_table_id_index ON meta.columns (table_id);
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.tables FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.columns FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
RESET ROLE;
-- schema for runtime lists, owned by personal_ddl (not by the migration owner)
CREATE SCHEMA lists AUTHORIZATION personal_ddl;
COMMENT ON SCHEMA lists IS 'Runtime-created user lists. Never touched by Drizzle migrations (excluded via schemaFilter). Create/alter only via meta.* functions.';
GRANT USAGE, CREATE ON SCHEMA meta TO personal_ddl;
GRANT SELECT, INSERT, UPDATE, DELETE ON meta.tables, meta.columns TO personal_ddl;
SET ROLE personal_ddl;
-- guess a ui_type from a column's catalog type (format_type output) and name
CREATE FUNCTION meta.guess_ui_type(p_type text, p_col text, p_is_enum boolean DEFAULT false) RETURNS text
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
SELECT CASE
WHEN p_is_enum THEN 'select'
WHEN p_type LIKE '%[]' THEN 'multiselect'
WHEN p_col IN ('picture_url','image_url','icon_url','image') THEN 'image'
WHEN p_col LIKE '%\_url' OR p_col = 'url' THEN 'url'
WHEN p_col LIKE '%\_cents' THEN 'cents'
WHEN p_col IN ('notes','description','content','note') THEN 'longtext'
WHEN p_type IN ('smallint','integer','bigint','real','double precision') OR p_type LIKE 'numeric%' THEN 'number'
WHEN p_type = 'boolean' THEN 'bool'
WHEN p_type = 'date' THEN 'date'
WHEN p_type LIKE 'timestamp%' THEN 'datetime'
WHEN p_type IN ('json','jsonb') THEN 'json'
ELSE 'text' END
$$;
-- reconcile meta.columns with pg_catalog for one table (or all registered tables).
-- Uses pg_attribute (not information_schema, which hides tables the caller has no grants on).
CREATE FUNCTION meta.sync(p_table_id integer DEFAULT NULL) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE k integer;
BEGIN
IF to_regclass('pg_temp._meta_cols') IS NULL THEN CREATE TEMP TABLE _meta_cols ON COMMIT DROP AS SELECT 1::integer AS table_id, ''::text AS col, ''::text AS typ, false AS is_enum, 0::integer AS pos, NULL::jsonb AS vals LIMIT 0; END IF;
TRUNCATE _meta_cols;
INSERT INTO _meta_cols
SELECT t.id, a.attname, format_type(a.atttypid, a.atttypmod), ty.typtype = 'e', a.attnum,
CASE WHEN ty.typtype = 'e' THEN (SELECT jsonb_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = ty.oid) END
FROM meta.tables t
JOIN pg_class c ON c.relname = t.table_name
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.schema_name
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
JOIN pg_type ty ON ty.oid = a.atttypid
WHERE p_table_id IS NULL OR t.id = p_table_id;
DELETE FROM meta.columns mc
WHERE (p_table_id IS NULL OR mc.table_id = p_table_id)
AND NOT EXISTS (SELECT 1 FROM _meta_cols x WHERE x.table_id = mc.table_id AND x.col = mc.column_name);
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position, hidden)
SELECT x.table_id, x.col, initcap(replace(x.col, '_', ' ')), meta.guess_ui_type(x.typ, x.col, x.is_enum),
CASE WHEN x.vals IS NOT NULL THEN jsonb_build_object('values', x.vals) ELSE '{}'::jsonb END,
x.pos, x.col IN ('id','created_at','updated_at','user_id','sort_order')
FROM _meta_cols x
ON CONFLICT (table_id, column_name) DO NOTHING;
GET DIAGNOSTICS k = ROW_COUNT;
RETURN k;
END $$;
-- allowed column types for runtime lists -> SQL type
CREATE FUNCTION meta.sql_type(p_ui_type text) RETURNS text
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
SELECT CASE p_ui_type
WHEN 'text' THEN 'text' WHEN 'longtext' THEN 'text' WHEN 'url' THEN 'text' WHEN 'image' THEN 'text'
WHEN 'select' THEN 'text' WHEN 'number' THEN 'numeric' WHEN 'cents' THEN 'integer'
WHEN 'date' THEN 'date' WHEN 'datetime' THEN 'timestamptz' WHEN 'bool' THEN 'boolean'
WHEN 'multiselect' THEN 'text[]' WHEN 'json' THEN 'jsonb' END
$$;
CREATE FUNCTION meta.assert_ident(p text) RETURNS void
LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog AS $$
BEGIN
IF p IS NULL OR p !~ '^[a-z][a-z0-9_]{0,62}$' THEN
RAISE EXCEPTION 'invalid identifier: %', p USING ERRCODE = '22023';
END IF;
END $$;
-- add one column to a runtime list. p_col: {"name","ui_type","label"?,"options"?,"default"?}
CREATE FUNCTION meta.add_column(p_slug text, p_col jsonb) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE
t meta.tables; c text := p_col->>'name'; u text := coalesce(p_col->>'ui_type', 'text');
st text := meta.sql_type(u); vals jsonb := p_col->'options'->'values'; ddl text;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
PERFORM meta.assert_ident(c);
IF c IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
RAISE EXCEPTION 'column % is a standard column', c; END IF;
IF st IS NULL THEN RAISE EXCEPTION 'unsupported ui_type %', u; END IF;
ddl := format('ALTER TABLE lists.%I ADD COLUMN %I %s', t.table_name, c, st);
IF p_col ? 'default' THEN ddl := ddl || format(' DEFAULT %L', p_col->>'default'); END IF;
IF u = 'select' AND jsonb_typeof(vals) = 'array' THEN
ddl := ddl || format(' CONSTRAINT %I CHECK (%I IN (%s))', t.table_name || '_' || c || '_check', c,
(SELECT string_agg(quote_literal(v), ',') FROM jsonb_array_elements_text(vals) v));
END IF;
EXECUTE ddl;
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position)
VALUES (t.id, c, coalesce(p_col->>'label', initcap(replace(c, '_', ' '))), u,
coalesce(p_col->'options', '{}'::jsonb),
(SELECT coalesce(max(position), 0) + 1 FROM meta.columns WHERE table_id = t.id));
END $$;
-- create a runtime list table with the standard columns + extras
CREATE FUNCTION meta.create_list(
p_slug text, p_table text, p_display_name text,
p_has_done boolean DEFAULT true, p_columns jsonb DEFAULT '[]'::jsonb,
p_picture_url text DEFAULT NULL, p_description text DEFAULT NULL, p_icon text DEFAULT NULL,
p_sort_order integer DEFAULT 0
) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE tid integer; col jsonb;
BEGIN
PERFORM meta.assert_ident(p_table);
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'lists' AND tablename = p_table) THEN
RAISE EXCEPTION 'lists.% already exists', p_table; END IF;
EXECUTE format($f$CREATE TABLE lists.%1$I (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL CHECK (btrim(name) <> ''),
notes text,
picture_url text,
%2$s
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now())$f$,
p_table, CASE WHEN p_has_done THEN 'done_at timestamptz,' ELSE '' END);
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON lists.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', p_table);
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, description, icon, picture_url, kind, editable, has_done, default_sort, sort_order)
VALUES ('lists', p_table, p_slug, p_display_name, p_description, p_icon, p_picture_url, 'list', true, p_has_done,
CASE WHEN p_has_done THEN 'done_at nulls first, sort_order, name' ELSE 'sort_order, name' END, p_sort_order)
RETURNING id INTO tid;
PERFORM meta.sync(tid);
UPDATE meta.columns SET label = 'Done', ui_type = 'datetime' WHERE table_id = tid AND column_name = 'done_at';
FOR col IN SELECT * FROM jsonb_array_elements(p_columns) LOOP
PERFORM meta.add_column(p_slug, col);
END LOOP;
RETURN tid;
END $$;
CREATE FUNCTION meta.rename_column(p_slug text, p_old text, p_new text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE t meta.tables;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
PERFORM meta.assert_ident(p_new);
IF p_old IN ('id','name','done_at','created_at','updated_at') THEN RAISE EXCEPTION 'standard column'; END IF;
EXECUTE format('ALTER TABLE lists.%I RENAME COLUMN %I TO %I', t.table_name, p_old, p_new);
UPDATE meta.columns SET column_name = p_new WHERE table_id = t.id AND column_name = p_old;
END $$;
CREATE FUNCTION meta.drop_column(p_slug text, p_col text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE t meta.tables;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
IF p_col IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
RAISE EXCEPTION 'standard column'; END IF;
EXECUTE format('ALTER TABLE lists.%I DROP COLUMN %I', t.table_name, p_col);
DELETE FROM meta.columns WHERE table_id = t.id AND column_name = p_col;
END $$;
CREATE FUNCTION meta.archive_list(p_slug text) RETURNS void
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
UPDATE meta.tables SET archived_at = now() WHERE slug = p_slug AND kind = 'list' AND archived_at IS NULL
$$;
-- real DROP only after 7 days in the archive (backups cover the rest)
CREATE FUNCTION meta.drop_list(p_slug text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE t meta.tables;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
IF t.archived_at IS NULL OR t.archived_at > now() - interval '7 days' THEN
RAISE EXCEPTION 'archive % first and wait 7 days', p_slug; END IF;
EXECUTE format('DROP TABLE lists.%I', t.table_name);
DELETE FROM meta.tables WHERE id = t.id;
END $$;
RESET ROLE;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA meta FROM PUBLIC;
GRANT EXECUTE ON FUNCTION meta.touch_updated_at() TO PUBLIC; -- trigger fn, must stay callable

176
db/reorg/05_lists.sql Normal file
View File

@@ -0,0 +1,176 @@
-- 05_lists.sql: the 22 legacy lists become real tables in schema `lists`.
-- legacy.* stays untouched (rollback reference); typo fixes + dedupes are applied to a temp working copy.
-- Runs as superuser (inserts into personal_ddl-owned tables); tables are created via meta.create_list.
CREATE TEMP TABLE src ON COMMIT DROP AS
SELECT i.id, i.list_id, btrim(i.name) AS name, nullif(btrim(i.description), '') AS notes, i.picture_url,
c.created_at AS done_at, i.created_at, i.updated_at
FROM legacy.list_items i
LEFT JOIN legacy.list_item_completions c ON c.item_id = i.id;
CREATE TEMP TABLE fix_log (item_id integer, list_id integer, action text, before text, after text) ON COMMIT DROP;
-- ---- dedupes: keep the lower id, carry over done_at / picture / notes, delete the other ----
CREATE TEMP TABLE dedupe (keep_id integer, drop_id integer, new_name text) ON COMMIT DROP;
INSERT INTO dedupe VALUES
(165, 323, NULL), -- places: New York x2
(168, 331, NULL), -- places: France x2
(156, 271, 'Advanced Programming in the UNIX Environment'), -- books: "...UNIX® Environment" dup
(139, 287, 'Building Knowledge Graphs: A Practitioner''s Guide'); -- books: short + full title
UPDATE src k SET done_at = coalesce(k.done_at, d.done_at), picture_url = coalesce(k.picture_url, d.picture_url),
notes = coalesce(k.notes, d.notes), name = coalesce(x.new_name, k.name)
FROM dedupe x JOIN src d ON d.id = x.drop_id WHERE k.id = x.keep_id;
INSERT INTO fix_log SELECT s.id, s.list_id, 'dedupe-drop', s.name, 'merged into ' || x.keep_id FROM src s JOIN dedupe x ON x.drop_id = s.id;
DELETE FROM src WHERE id IN (SELECT drop_id FROM dedupe);
-- ---- typo / naming fixes ----
CREATE TEMP TABLE renames (id integer PRIMARY KEY, new_name text NOT NULL) ON COMMIT DROP;
INSERT INTO renames VALUES
-- vinyls (kept as "Artist - Album" here; split below)
(343, 'Pearl Jam - Ten'), (346, 'Amy Winehouse - Back to Black'),
(27, 'Slipknot - Vol. 3: (The Subliminal Verses)'), (6, 'Them Crooked Vultures - Them Crooked Vultures'),
(39, 'Various Artists - Katamari Damacy (OST)'),
(40, 'Gojira - The Way of All Flesh'), (370, 'Gojira - The Link'),
(80, 'Deftones - Private Music'), (308, 'Kendrick Lamar - DAMN.'),
-- movies/shows
(98, 'Kim''s Convenience'), (394, 'Azuki-chan'), (111, 'A Minecraft Movie'),
-- books
(150, 'Domain-Driven Design: Tackling Complexity in the Heart of Software'),
(285, 'Molecular Biology of the Cell'), (288, 'Building Microservices'),
(147, 'Scrum: The Art of Doing Twice the Work in Half the Time'),
-- places
(325, 'Curaçao'),
-- cars
(322, 'Mazda MX-5 Miata'),
-- leetcode
(217, 'Two Sum'),
-- foods
(221, 'Sotteok Sotteok'), (230, 'Bibim Naengmyeon'), (240, 'Doenjang Jjigae'), (241, 'Kimchi Jjigae'),
-- papers
(190, 'Order Matters: Sequence to Sequence for Sets'),
(200, 'GPipe: Easy Scaling with Micro-Batch Pipeline Parallelism'),
(203, 'Attention Is All You Need'),
(201, 'Deep Speech 2: End-to-End Speech Recognition in English and Mandarin'),
-- games
(385, 'Chrono Trigger');
DO $$ BEGIN
IF (SELECT count(*) FROM renames r JOIN src s ON s.id = r.id) <> (SELECT count(*) FROM renames) THEN
RAISE EXCEPTION 'rename list references missing item ids'; END IF;
END $$;
INSERT INTO fix_log SELECT s.id, s.list_id, 'rename', s.name, r.new_name FROM src s JOIN renames r ON r.id = s.id WHERE s.name <> r.new_name;
UPDATE src s SET name = r.new_name FROM renames r WHERE r.id = s.id;
-- helper: copy standard columns for one list into lists.<t> and return the row count
CREATE FUNCTION pg_temp.copy_std(p_list integer, p_table text, p_has_done boolean) RETURNS bigint LANGUAGE plpgsql AS $$
DECLARE n bigint;
BEGIN
EXECUTE format('INSERT INTO lists.%I (id, name, notes, picture_url, %s sort_order, created_at, updated_at)
SELECT id, name, notes, picture_url, %s row_number() OVER (ORDER BY id), created_at, updated_at
FROM src WHERE list_id = %s', p_table,
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END,
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END, p_list);
GET DIAGNOSTICS n = ROW_COUNT;
EXECUTE format('SELECT setval(pg_get_serial_sequence(%L, ''id''), (SELECT max(id) FROM lists.%I))', 'lists.' || p_table, p_table);
RETURN n;
END $$;
CREATE TEMP TABLE list_map (list_id integer PRIMARY KEY, tbl text NOT NULL, has_done boolean NOT NULL, cols jsonb NOT NULL) ON COMMIT DROP;
INSERT INTO list_map VALUES
(1, 'vinyls', true, '[{"name":"artist","ui_type":"text"}]'),
(2, 'watchlist', true, '[{"name":"kind","ui_type":"select","options":{"values":["movie","show","anime","documentary"]}}]'),
(3, 'books', true, '[{"name":"author","ui_type":"text"}]'),
(4, 'side_projects', false, '[{"name":"status","ui_type":"select","default":"idea","options":{"values":["idea","active","paused","done","dropped"]}}]'),
(5, 'artists_to_see', true, '[]'),
(6, 'osu_maps', true, '[{"name":"mods","ui_type":"text"},{"name":"goal","ui_type":"select","options":{"values":["pass","fc"]}}]'),
(7, 'animals_to_spot', true, '[]'),
(8, 'vehicles_to_spot', true, '[{"name":"kind","ui_type":"select","default":"car","options":{"values":["car","train","plane"]}}]'),
(9, 'leetcode', true, '[{"name":"kind","ui_type":"select","default":"problem","options":{"values":["problem","topic"]}},{"name":"url","ui_type":"url"}]'),
(10, 'restaurants', true, '[{"name":"city","ui_type":"text"}]'),
(11, 'recipes', true, '[{"name":"cuisine","ui_type":"text"},{"name":"recipe_url","ui_type":"url"}]'),
(12, 'places', true, '[{"name":"kind","ui_type":"select","options":{"values":["country","region","city","island"]}}]'),
(13, 'papers', false, '[{"name":"authors","ui_type":"text"},{"name":"year","ui_type":"number"},{"name":"url","ui_type":"url"}]'),
(14, 'piano_songs', true, '[{"name":"artist","ui_type":"text"},{"name":"reference_url","ui_type":"url"}]'),
(15, 'games_to_complete', true, '[{"name":"platform","ui_type":"text"}]'),
(22, 'things_to_learn', true, '[]'),
(23, 'build_from_scratch', true, '[]'),
(24, 'tools_to_try', true, '[{"name":"url","ui_type":"url"}]'),
(25, 'setup_upgrades', true, '[]'),
(26, 'bucket_list', true, '[]'),
(27, 'docs_to_read', true, '[{"name":"rfc_number","ui_type":"number"},{"name":"topic","ui_type":"text"},{"name":"starter_pack","ui_type":"bool","default":"false"},{"name":"url","ui_type":"url"}]'),
(28, 'banger_songs', false, '[{"name":"artist","ui_type":"text"},{"name":"kind","ui_type":"select","default":"song","options":{"values":["song","playlist"]}},{"name":"url","ui_type":"url"}]');
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM legacy.lists l WHERE NOT EXISTS (SELECT 1 FROM list_map m WHERE m.list_id = l.id))
OR EXISTS (SELECT 1 FROM list_map m JOIN legacy.lists l ON l.id = m.list_id WHERE l.is_progress <> m.has_done AND m.list_id NOT IN (4)) THEN
RAISE EXCEPTION 'list_map does not cover legacy.lists / has_done mismatch';
END IF;
END $$;
-- create tables (as personal_ddl via SECURITY DEFINER) and copy standard columns
DO $$
DECLARE m record; l record; n bigint;
BEGIN
FOR m IN SELECT * FROM list_map ORDER BY list_id LOOP
SELECT * INTO l FROM legacy.lists WHERE id = m.list_id;
PERFORM meta.create_list(l.slug, m.tbl, l.name, m.has_done, m.cols, l.picture_url, NULL, NULL, m.list_id);
UPDATE meta.tables SET created_at = l.created_at WHERE slug = l.slug;
n := pg_temp.copy_std(m.list_id, m.tbl, m.has_done);
RAISE NOTICE 'lists.%: % rows', m.tbl, n;
END LOOP;
END $$;
-- parsing must not bump updated_at: suppress user triggers for these UPDATEs (superuser only)
SET LOCAL session_replication_role = replica;
-- ---- per-list parsing into the extra columns (only where it parses cleanly) ----
-- vinyls: "Artist - Album"
UPDATE lists.vinyls SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
WHERE strpos(name, ' - ') > 0;
-- books: "Author - Title" only for the 3 personal-name ones (others contain ':' subtitles, never ' - ')
UPDATE lists.books SET author = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
WHERE id IN (28, 29, 306);
-- side projects: everything starts as an idea
UPDATE lists.side_projects SET status = 'idea';
-- osu: trailing mods
UPDATE lists.osu_maps SET name = 'Lonely Go', mods = 'HR' WHERE id = 310;
UPDATE lists.osu_maps SET name = 'Imagination', mods = 'HR' WHERE id = 317;
-- vehicles: all current entries are cars
UPDATE lists.vehicles_to_spot SET kind = 'car';
-- leetcode
UPDATE lists.leetcode SET kind = CASE WHEN name IN ('Two Sum','3Sum') THEN 'problem' ELSE 'topic' END;
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/two-sum/' WHERE name = 'Two Sum';
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/3sum/' WHERE name = '3Sum';
-- restaurants: "Name (City)"
UPDATE lists.restaurants SET city = substring(name from '\(([^)]+)\)\s*$'), name = btrim(regexp_replace(name, '\s*\([^)]+\)\s*$', ''))
WHERE name ~ '\([^)]+\)\s*$';
-- recipes
UPDATE lists.recipes SET cuisine = CASE WHEN name = 'Yogurt, honey & cruesli' THEN NULL ELSE 'korean' END;
-- places
UPDATE lists.places SET kind = CASE
WHEN name IN ('London','Seattle','Miami','Dubai','New York') THEN 'city'
WHEN name IN ('California','Texas') THEN 'region'
WHEN name IN ('Curaçao','Aruba','Bonaire') THEN 'island'
ELSE 'country' END;
-- piano: "artist - title" + URL in notes
UPDATE lists.piano_songs SET artist = 'ryo (supercell)', name = 'odds & ends', reference_url = notes, notes = NULL
WHERE name = 'ryo/supercell - odds & ends';
-- tools: notes that are a bare URL become url
UPDATE lists.tools_to_try SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
-- docs/RFCs: "RFC n – Title", notes "Topic · starter pack"
UPDATE lists.docs_to_read SET
rfc_number = substring(name from '^RFC (\d+)')::integer,
topic = btrim(split_part(notes, '·', 1)),
starter_pack = notes LIKE '%starter pack%',
url = 'https://www.rfc-editor.org/rfc/rfc' || substring(name from '^RFC (\d+)'),
notes = NULL
WHERE name ~ '^RFC \d+';
-- banger songs: "Artist - Title" + URL notes; the 2 YT mixes are playlists
UPDATE lists.banger_songs SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
UPDATE lists.banger_songs SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3), kind = 'song'
WHERE strpos(name, ' - ') > 0;
UPDATE lists.banger_songs SET kind = 'playlist' WHERE artist IS NULL;
SET LOCAL session_replication_role = origin;
-- persist the fix log for review (legacy schema, dropped with it)
CREATE TABLE legacy.reorg_list_fixes AS SELECT * FROM fix_log;
ALTER TABLE legacy.reorg_list_fixes OWNER TO personal; -- so `personal` can pg_dump everything

View File

@@ -0,0 +1,148 @@
-- 06_hardware_energy_cs.sql: bring hardware + energy + CS prices over from dev.
-- Expects schemas import_hardware / import_energy / import_cs, staged by run.sh from dumps/dev-import-staged.dump.
SET ROLE personal;
-- ---------- energy (DDL identical to ned-collector/collect.py so its IF NOT EXISTS is a no-op) ----------
CREATE TABLE energy.gas_storage_daily (
gas_day date NOT NULL,
site text NOT NULL,
point_name text,
volume_twh numeric(12,4),
fill_pct numeric(7,3),
source text NOT NULL DEFAULT 'ned-json',
first_seen timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (gas_day, site)
);
CREATE TABLE energy.ned_utilization (
valid_from timestamptz NOT NULL, valid_to timestamptz NOT NULL,
point text NOT NULL, type text NOT NULL, activity text NOT NULL, classification text NOT NULL, granularity text NOT NULL,
capacity_kw numeric, volume_kwh numeric, percentage numeric, emission_kg numeric, emission_factor numeric,
source_file text,
first_seen timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (valid_from, point, type, activity, classification, granularity)
);
CREATE INDEX ned_utilization_type_time ON energy.ned_utilization (type, granularity, valid_from DESC);
CREATE TABLE energy.collector_runs (
id bigserial PRIMARY KEY,
started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
mode text NOT NULL, ok boolean, rows_upserted integer, errors text
);
CREATE VIEW energy.gas_storage_latest AS
SELECT DISTINCT ON (site) site, gas_day, volume_twh, fill_pct, updated_at
FROM energy.gas_storage_daily ORDER BY site, gas_day DESC;
CREATE VIEW energy.electricity_hourly AS
SELECT valid_from AT TIME ZONE 'Europe/Amsterdam' AS hour_local, type, classification,
round(volume_kwh / 1000.0, 1) AS mwh, percentage, emission_factor AS kg_co2_per_kwh
FROM energy.ned_utilization WHERE granularity = 'Hour';
INSERT INTO energy.gas_storage_daily SELECT * FROM import_energy.gas_storage_daily;
INSERT INTO energy.ned_utilization SELECT * FROM import_energy.ned_utilization;
INSERT INTO energy.collector_runs SELECT * FROM import_energy.collector_runs;
SELECT setval('energy.collector_runs_id_seq', coalesce((SELECT max(id) FROM energy.collector_runs), 1));
-- ---------- hardware (prices -> integer cents) ----------
CREATE TABLE hardware.devices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
kind text NOT NULL CHECK (kind IN ('desktop','laptop','phone','vm','monitor')),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE hardware.components (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device_id bigint NOT NULL REFERENCES hardware.devices(id) ON DELETE CASCADE,
category text NOT NULL CHECK (category IN ('case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','monitor','lighting','accessory')),
brand text,
model text NOT NULL,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity > 0),
specs jsonb NOT NULL DEFAULT '{}'::jsonb,
unit_price_cents integer CHECK (unit_price_cents >= 0),
total_price_cents integer CHECK (total_price_cents >= 0),
shop text,
order_number text,
order_date date,
warranty_until date,
invoice_number text,
status text NOT NULL DEFAULT 'in_use' CHECK (status IN ('in_use','retired','sold')),
verified boolean NOT NULL DEFAULT true,
notes text,
source text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX components_category_idx ON hardware.components (category);
CREATE INDEX components_device_idx ON hardware.components (device_id);
CREATE UNIQUE INDEX components_dedupe_uq ON hardware.components (device_id, model, order_number) NULLS NOT DISTINCT;
CREATE INDEX components_specs_gin ON hardware.components USING gin (specs);
COMMENT ON SCHEMA hardware IS 'Stan''s hardware inventory: devices (PCs, laptops, phones, VMs, monitors) and their components/peripherals with purchase + warranty details.';
COMMENT ON TABLE hardware.devices IS 'A physical or virtual machine Stan owns/uses (e.g. stan-pc). Components hang off a device.';
COMMENT ON TABLE hardware.components IS 'One line item (part or peripheral) belonging to a device, with purchase, price and warranty info.';
COMMENT ON COLUMN hardware.components.unit_price_cents IS 'Price per unit in EUR cents incl. VAT.';
COMMENT ON COLUMN hardware.components.total_price_cents IS 'unit price * quantity in EUR cents incl. VAT (excl. shipping).';
COMMENT ON COLUMN hardware.components.verified IS 'false = purchase is real but unconfirmed whether it is (still) part of this setup, or some details are guessed. See notes.';
COMMENT ON COLUMN hardware.components.source IS 'Where the data came from: user_list, gmail, manual.';
INSERT INTO hardware.devices (id, name, kind, notes, created_at, updated_at) OVERRIDING SYSTEM VALUE
SELECT id, name, kind, notes, created_at, created_at FROM import_hardware.devices;
INSERT INTO hardware.components (id, device_id, category, brand, model, quantity, specs, unit_price_cents, total_price_cents,
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at)
OVERRIDING SYSTEM VALUE
SELECT id, device_id, category, brand, model, quantity, specs, round(unit_price_eur * 100)::integer, round(total_price_eur * 100)::integer,
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at
FROM import_hardware.components;
SELECT setval(pg_get_serial_sequence('hardware.devices', 'id'), (SELECT max(id) FROM hardware.devices));
SELECT setval(pg_get_serial_sequence('hardware.components', 'id'), (SELECT max(id) FROM hardware.components));
DO $$ BEGIN
IF (SELECT sum(total_price_cents) FROM hardware.components) <> (SELECT round(sum(total_price_eur) * 100) FROM import_hardware.components) THEN
RAISE EXCEPTION 'hardware cents conversion mismatch'; END IF;
END $$;
CREATE VIEW hardware.setup_overview AS
SELECT d.name AS device, d.kind AS device_kind, c.category, c.brand, c.model, c.quantity,
c.total_price_cents, round(c.total_price_cents / 100.0, 2) AS total_price_eur,
c.shop, c.order_date, c.warranty_until, (c.warranty_until >= CURRENT_DATE) AS under_warranty,
c.status, c.verified, c.specs, c.notes, c.order_number, c.invoice_number, c.source, c.id AS component_id
FROM hardware.components c JOIN hardware.devices d ON d.id = c.device_id
ORDER BY d.name, array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','accessory'], c.category),
c.order_date, c.model;
COMMENT ON VIEW hardware.setup_overview IS 'Flat view of every component per device, build order, live under_warranty flag. Filter status = ''in_use'' for the current setup.';
-- ---------- CS prices (prod tables are empty; copy dev) ----------
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM cs.items) OR EXISTS (SELECT 1 FROM cs.price_observations)
OR EXISTS (SELECT 1 FROM cs.item_dimensions) OR EXISTS (SELECT 1 FROM cs.price_streams) THEN
RAISE EXCEPTION 'cs.* not empty in target; refusing to merge dev data'; END IF;
END $$;
INSERT INTO cs.items (id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at)
SELECT id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at FROM import_cs.cs_market_items;
INSERT INTO cs.item_dimensions (item_id, key, value) SELECT item_id, key, value FROM import_cs.cs_item_dimensions;
INSERT INTO cs.price_streams (source, currency, kind) SELECT source, currency, kind FROM import_cs.cs_price_streams;
INSERT INTO cs.price_observations (id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at) OVERRIDING SYSTEM VALUE
SELECT id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at FROM import_cs.cs_price_observations;
SELECT setval(pg_get_serial_sequence('cs.items', 'id'), coalesce((SELECT max(id) FROM cs.items), 1));
SELECT setval(pg_get_serial_sequence('cs.price_observations', 'id'), coalesce((SELECT max(id) FROM cs.price_observations), 1));
CREATE TABLE legacy.reorg_imports (dst text PRIMARY KEY, src text NOT NULL, n bigint NOT NULL);
-- ---------- verify imports against the staged dev copies, then drop staging ----------
RESET ROLE;
DO $$
DECLARE r record; a bigint; b bigint;
BEGIN
FOR r IN SELECT * FROM (VALUES
('energy.gas_storage_daily','import_energy.gas_storage_daily'), ('energy.ned_utilization','import_energy.ned_utilization'),
('energy.collector_runs','import_energy.collector_runs'), ('hardware.devices','import_hardware.devices'),
('hardware.components','import_hardware.components'), ('cs.items','import_cs.cs_market_items'),
('cs.item_dimensions','import_cs.cs_item_dimensions'), ('cs.price_streams','import_cs.cs_price_streams'),
('cs.price_observations','import_cs.cs_price_observations')) v(dst, srct) LOOP
EXECUTE 'SELECT count(*) FROM ' || r.dst INTO a;
EXECUTE 'SELECT count(*) FROM ' || r.srct INTO b;
IF a <> b THEN RAISE EXCEPTION '% has % rows, dev % has %', r.dst, a, r.srct, b; END IF;
INSERT INTO legacy.reorg_imports VALUES (r.dst, r.srct, a);
END LOOP;
END $$;
DROP SCHEMA import_energy CASCADE;
DROP SCHEMA import_hardware CASCADE;
DROP SCHEMA import_cs CASCADE;

View File

@@ -0,0 +1,79 @@
-- 07_triggers_meta_grants.sql: updated_at triggers everywhere, register core tables in meta, grants.
SET ROLE personal;
-- 1) updated_at trigger on every table that has the column but no trigger (27 pre-existing + new ones)
DO $$
DECLARE r record; n integer := 0;
BEGIN
FOR r IN
SELECT n.nspname AS s, c.relname AS t
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
WHERE c.relkind = 'r'
AND n.nspname IN ('auth','money','house','ref','games','cs','animaldex','site','hardware','energy')
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND NOT tg.tgisinternal
AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure)
LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', r.s, r.t);
n := n + 1;
END LOOP;
RAISE NOTICE 'added updated_at triggers: %', n;
END $$;
-- the 16 old triggers were all named site_touch_updated_at; normalise the name
DO $$
DECLARE r record;
BEGIN
FOR r IN SELECT tg.tgname, tg.tgrelid::regclass AS rel FROM pg_trigger tg WHERE tg.tgname = 'site_touch_updated_at' LOOP
EXECUTE format('ALTER TRIGGER site_touch_updated_at ON %s RENAME TO touch_updated_at', r.rel);
END LOOP;
END $$;
RESET ROLE;
-- 2) register core + ref tables in meta (GUI visibility); auth + bookkeeping are not exposed
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
SELECT n.nspname, c.relname,
replace(n.nspname || '-' || c.relname, '_', '-'),
initcap(replace(c.relname, '_', ' ')),
CASE WHEN n.nspname = 'ref' THEN 'ref' ELSE 'core' END,
n.nspname <> 'ref', false, 1000
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','v')
AND n.nspname IN ('money','house','ref','games','cs','animaldex','site','hardware','energy')
ON CONFLICT DO NOTHING;
UPDATE meta.tables SET editable = false WHERE kind = 'core' AND (schema_name, table_name) IN
(SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'v');
SELECT meta.sync();
-- 3) grants
GRANT USAGE ON SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_app, personal_ro;
-- app: full DML on its own data, read-only on ref
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
GRANT SELECT ON ALL TABLES IN SCHEMA ref, meta TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
-- app may edit presentation fields of the registry directly
GRANT UPDATE (slug, display_name, description, icon, picture_url, default_sort, sort_order) ON meta.tables TO personal_app; -- slug: URL only, table name stays
GRANT UPDATE (label, ui_type, options, position, hidden, help_text) ON meta.columns TO personal_app;
GRANT EXECUTE ON FUNCTION meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer),
meta.add_column(text, jsonb), meta.rename_column(text, text, text), meta.drop_column(text, text),
meta.archive_list(text), meta.drop_list(text), meta.sync(integer) TO personal_app;
-- ro: everything readable
GRANT SELECT ON ALL TABLES IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_ro;
-- future objects
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA ref, meta GRANT SELECT ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy
GRANT SELECT ON TABLES TO personal_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal_ro;
-- migration owner can read the runtime lists, so deploy backups (pg_dump as `personal`) cover everything
GRANT USAGE ON SCHEMA lists TO personal;
GRANT SELECT ON ALL TABLES IN SCHEMA lists TO personal;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA lists TO personal;
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal;
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON SEQUENCES TO personal;
-- nobody creates in public anymore
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

68
db/reorg/08_verify.sql Normal file
View File

@@ -0,0 +1,68 @@
-- 08_verify.sql: hard assertions; any failure aborts the whole transaction.
DO $$
DECLARE r record; a bigint; bad text := '';
BEGIN
-- every moved table keeps its exact row count
-- expected = prod count before + rows imported from dev (cs.* only; prod cs was empty)
FOR r IN SELECT m.*, b.n + coalesce(i.n, 0) AS n FROM legacy.reorg_table_map m
JOIN legacy.reorg_counts_before b ON b.table_name = m.old_name
LEFT JOIN legacy.reorg_imports i ON i.dst = m.new_schema || '.' || m.new_name LOOP
EXECUTE format('SELECT count(*) FROM %I.%I', r.new_schema, r.new_name) INTO a;
IF a <> r.n THEN bad := bad || format('%s.%s %s<>%s; ', r.new_schema, r.new_name, a, r.n); END IF;
END LOOP;
IF bad <> '' THEN RAISE EXCEPTION 'row count mismatch: %', bad; END IF;
IF (SELECT count(*) FROM legacy.reorg_counts_before) <> 58 THEN RAISE EXCEPTION 'expected 58 source tables'; END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN RAISE EXCEPTION 'public not empty'; END IF;
END $$;
-- lists: every legacy item is in exactly one lists.* table, except the 4 merged duplicates; done count preserved
DO $$
DECLARE t record; n bigint; total bigint := 0; done bigint := 0; d bigint;
dropped bigint := (SELECT count(*) FROM legacy.reorg_list_fixes WHERE action = 'dedupe-drop');
BEGIN
FOR t IN SELECT * FROM meta.tables WHERE kind = 'list' LOOP
EXECUTE format('SELECT count(*) FROM lists.%I', t.table_name) INTO n;
total := total + n;
IF t.has_done THEN EXECUTE format('SELECT count(*) FROM lists.%I WHERE done_at IS NOT NULL', t.table_name) INTO d; done := done + d; END IF;
END LOOP;
IF total + dropped <> (SELECT count(*) FROM legacy.list_items) THEN
RAISE EXCEPTION 'lists total % + % dropped <> legacy %', total, dropped, (SELECT count(*) FROM legacy.list_items); END IF;
-- expected done = legacy completions minus dedupe pairs where BOTH copies were done (they collapse to one)
d := (SELECT count(*) FROM legacy.list_item_completions)
- (SELECT count(*) FROM dedupe x
WHERE EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.keep_id)
AND EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.drop_id));
IF done <> d THEN RAISE EXCEPTION 'done count % <> expected %', done, d; END IF;
IF (SELECT count(*) FROM meta.tables WHERE kind = 'list') <> (SELECT count(*) FROM legacy.lists) THEN
RAISE EXCEPTION 'list count mismatch'; END IF;
RAISE NOTICE 'lists OK: % rows (+% merged dupes), % done', total, dropped, done;
END $$;
-- every table with updated_at has the trigger
DO $$
DECLARE missing text;
BEGIN
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure);
IF missing IS NOT NULL THEN RAISE EXCEPTION 'missing updated_at trigger: %', missing; END IF;
-- every table has a PK
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p');
IF missing IS NOT NULL THEN RAISE EXCEPTION 'tables without PK: %', missing; END IF;
END $$;
-- list rows keep their original timestamps
DO $$
DECLARE t record; n bigint; bad bigint := 0;
BEGIN
FOR t IN SELECT table_name FROM meta.tables WHERE kind = 'list' LOOP
EXECUTE format('SELECT count(*) FROM lists.%I x JOIN legacy.list_items i ON i.id = x.id WHERE x.updated_at <> i.updated_at OR x.created_at <> i.created_at', t.table_name) INTO n;
bad := bad + n;
END LOOP;
IF bad > 0 THEN RAISE EXCEPTION '% list rows lost their original timestamps', bad; END IF;
END $$;

View File

@@ -0,0 +1,58 @@
-- 09_drizzle_names.sql: rename constraints/indexes to the names Drizzle generates, so future
-- drizzle-kit migrations (which address objects by name) match the database. Pure renames, no data change.
-- Generated from a diff of the rehearsed DB against the Drizzle baseline (2026-10-23).
SET ROLE personal;
ALTER TABLE cs.item_dimensions RENAME CONSTRAINT item_dimensions_item_id_cs_market_items_id_fkey TO item_dimensions_item_id_items_id_fkey;
ALTER TABLE cs.price_observations RENAME CONSTRAINT price_observations_item_id_cs_market_items_id_fkey TO price_observations_item_id_items_id_fkey;
ALTER TABLE games.catch_em_all_entries RENAME CONSTRAINT catch_em_all_entries_pokemon_game_entry_id_pokemon_game_entry_i TO "catch_em_all_entries_HeABXvGNiB6D_fkey";
ALTER TABLE games.catch_em_all_games RENAME CONSTRAINT catch_em_all_games_game_id_game_id_fkey TO catch_em_all_games_game_id_pokemon_games_id_fkey;
ALTER TABLE games.catch_em_all_settings RENAME CONSTRAINT catch_em_all_settings_user_id_fkey TO catch_em_all_settings_user_id_user_id_fkey;
ALTER TABLE games.osu_entries RENAME CONSTRAINT osu_entries_user_id_fkey TO osu_entries_user_id_user_id_fkey;
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_game_id_fkey TO pokemon_catches_game_id_pokemon_games_id_fkey;
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_pokemon_id_fkey TO pokemon_catches_pokemon_id_pokemon_id_fkey;
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_fkey TO pokemon_catches_user_id_user_id_fkey;
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_game_id_party_slot_key TO pokemon_catches_user_id_game_id_party_slot_unique;
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_game_id_fkey TO pokemon_journeys_game_id_pokemon_games_id_fkey;
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_fkey TO pokemon_journeys_user_id_user_id_fkey;
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_game_id_key TO pokemon_journeys_user_id_game_id_unique;
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_game_id_fkey TO pokemon_milestones_game_id_pokemon_games_id_fkey;
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_fkey TO pokemon_milestones_user_id_user_id_fkey;
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_game_id_key_key TO pokemon_milestones_user_id_game_id_key_unique;
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_game_id_fkey TO pokemon_seen_game_id_pokemon_games_id_fkey;
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_pokemon_id_fkey TO pokemon_seen_pokemon_id_pokemon_id_fkey;
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_fkey TO pokemon_seen_user_id_user_id_fkey;
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_game_id_pokemon_id_key TO pokemon_seen_user_id_game_id_pokemon_id_unique;
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_challenge_id_fkey TO "tracker_challenge_progress_HtzyFtOyUAhk_fkey";
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_challenge_id_key TO tracker_challenge_progress_user_id_challenge_id_unique;
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_fkey TO tracker_challenge_progress_user_id_user_id_fkey;
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_game_id_fkey TO tracker_ranks_game_id_tracker_games_id_fkey;
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_fkey TO tracker_ranks_user_id_user_id_fkey;
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_game_id_key TO tracker_ranks_user_id_game_id_unique;
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_item_id_fkey TO tracker_weapon_ranks_item_id_tracker_items_id_fkey;
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_fkey TO tracker_weapon_ranks_user_id_user_id_fkey;
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_item_id_key TO tracker_weapon_ranks_user_id_item_id_unique;
ALTER TABLE house.price_history RENAME CONSTRAINT price_history_listing_id_fkey TO price_history_listing_id_listings_id_fkey;
ALTER TABLE meta.columns RENAME CONSTRAINT columns_table_id_fkey TO columns_table_id_tables_id_fkey;
ALTER TABLE money.forecasts RENAME CONSTRAINT forecasts_account_id_money_accounts_id_fkey TO forecasts_account_id_accounts_id_fkey;
ALTER TABLE money.projection_settings RENAME CONSTRAINT "projection_settings_fqvzThdinM8E_fkey" TO projection_settings_investment_account_id_accounts_id_fkey;
ALTER TABLE money.recurring RENAME CONSTRAINT recurring_account_id_money_accounts_id_fkey TO recurring_account_id_accounts_id_fkey;
ALTER TABLE money.transactions RENAME CONSTRAINT transactions_account_id_money_accounts_id_fkey TO transactions_account_id_accounts_id_fkey;
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_game_id_fkey TO pokemon_dex_numbers_game_id_pokemon_games_id_fkey;
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_fkey TO pokemon_dex_numbers_pokemon_id_pokemon_id_fkey;
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_game_id_dex_key TO pokemon_dex_numbers_pokemon_id_game_id_dex_unique;
ALTER TABLE ref.pokemon_encounters RENAME CONSTRAINT pokemon_encounters_game_id_games_id_fkey TO pokemon_encounters_game_id_pokemon_games_id_fkey;
ALTER TABLE ref.pokemon_evolutions RENAME CONSTRAINT pokemon_evolutions_game_id_games_id_fkey TO pokemon_evolutions_game_id_pokemon_games_id_fkey;
ALTER TABLE ref.pokemon_game_entries RENAME CONSTRAINT pokemon_game_entries_game_id_game_id_fkey TO pokemon_game_entries_game_id_pokemon_games_id_fkey;
ALTER TABLE ref.pokemon_game_research RENAME CONSTRAINT pokemon_game_research_game_id_fkey TO pokemon_game_research_game_id_pokemon_games_id_fkey;
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_fkey TO pokemon_learnsets_pokemon_id_pokemon_id_fkey;
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_version_group_key TO pokemon_learnsets_pokemon_id_version_group_unique;
ALTER TABLE ref.pokemon_research RENAME CONSTRAINT pokemon_research_pokemon_id_fkey TO pokemon_research_pokemon_id_pokemon_id_fkey;
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_fkey TO tracker_challenges_game_id_tracker_games_id_fkey;
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_key_key TO tracker_challenges_game_id_key_unique;
ALTER TABLE site.blog_articles RENAME CONSTRAINT blog_articles_slug_unique TO blog_articles_slug_key;
ALTER INDEX games.osu_entries_owner_idx RENAME TO osu_entries_user_id_kind_index;
ALTER INDEX games.pokemon_catches_owner_idx RENAME TO pokemon_catches_user_id_game_id_pokemon_id_index;
ALTER INDEX games.pokemon_seen_user_idx RENAME TO pokemon_seen_user_id_index;
ALTER INDEX money.utility_readings_user_kind_date_idx RENAME TO utility_readings_user_id_kind_date_index;
ALTER INDEX ref.pokemon_dex_game_idx RENAME TO pokemon_dex_numbers_game_id_index;
RESET ROLE;

21
db/reorg/README.md Normal file
View File

@@ -0,0 +1,21 @@
# One-time schema reorg (2026-10)
Hand-written, reviewed SQL that moved the old flat `public.*` tables into the
domain schemas (`auth`, `money`, `house`, `ref`, `games`, `cs`, `animaldex`,
`site`, `meta`, `lists`, `hardware`, `energy`, `legacy`), created the roles
(`personal`, `personal_app`, `personal_ddl`, `personal_ro`), the `meta.*`
registry/API and the 22 runtime lists. It is the state that the Drizzle baseline
in `drizzle/` describes.
These files are kept for the record and for the cutover only. They were
rehearsed on a copy of prod (PG17) and run in one transaction (`run.sh`,
which refuses the real database names unless `ALLOW_REAL=yes`). After the
cutover, record the baseline with `bun scripts/db-migrate.ts --init`; from then
on every schema change is a normal Drizzle migration (`bun run db:generate`).
Passwords are never in these files; set them with `\password` in psql.
`09_drizzle_names.sql` runs just before `08_verify.sql` and only renames 48
constraints and 5 indexes to Drizzle's generated names, so a catalog diff of the
reorged DB against a DB built from the baseline is empty (columns, defaults,
constraints, indexes, enums in the nine Drizzle schemas).

24
db/reorg/run.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# run.sh <target-db> : stage the dev import, then apply 01..08 in ONE transaction as superuser.
# Rehearsal: runs against the private PG17 cluster in ~/dev/setup/db/rehearsal (port 5441, unix socket only).
# Prod: same files, but psql = `sudo docker exec -i <pg-container> psql -U postgres -d personal` (see PLAN.md §4c).
set -euo pipefail
DB=${1:?target db}
case "$DB" in personal|personal_dev|personal_staging) [ "${ALLOW_REAL:-}" = yes ] || { echo "refusing $DB without ALLOW_REAL=yes"; exit 3; };; esac
D=~/dev/setup/db
export LD_LIBRARY_PATH=$D/tools/root/usr/lib/x86_64-linux-gnu
BIN=$D/tools/root/usr/lib/postgresql/17/bin
CONN=(-h "${PGSOCK:-$D/rehearsal/sock}" -p "${PGPORT:-5441}" -U postgres)
STAGED=${STAGED:-$D/dumps/dev-import-staged.dump}
# 1) stage dev data as import_* schemas (owned by personal so 06 can read it)
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 -qc "DROP SCHEMA IF EXISTS import_energy, import_hardware, import_cs CASCADE"
$BIN/pg_restore "${CONN[@]}" -d "$DB" --no-owner --role=personal --single-transaction --exit-on-error "$STAGED"
# 2) the migration, all-or-nothing
cd "$D/migration"
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 --single-transaction \
-c "SET lock_timeout = '5s'" -c "SET statement_timeout = '10min'" \
-f 01_roles.sql -f 02_schemas_move.sql -f 03_fixes.sql -f 04_meta.sql -f 05_lists.sql \
-f 06_hardware_energy_cs.sql -f 07_triggers_meta_grants.sql -f 09_drizzle_names.sql -f 08_verify.sql
echo "migration committed on $DB"

View 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

View 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

132
docs/game-tracker.md Normal file
View File

@@ -0,0 +1,132 @@
# Game tracker
The development database has the journal migration and catalog imports applied. Production and staging need the same additive migration and seeds before deploying this code:
```sh
bun run db:migrate:tracker-journal
bun run db:seed:research
bun run db:seed:challenges
bun run db:seed:art
```
These commands require `DB_URL`. They build on the existing Pokémon and Call of Duty catalogs. They preserve user catches, teams, ranks and challenge progress. Removed challenge definitions are retired only if nobody has saved progress for them. Deployment preflight checks the new tables and populated research catalogs.
## Local images and library layout
The tracker includes locally stored WebP pictures for **all 53 Pokémon entries, all seven COD entries and osu!**, plus the series logos. Most games use box art; expansions use their promotional artwork. Covers keep their original aspect ratio. No supplied files or remote image hotlinks are required. Replace a WebP at the same path to use your own art.
`data/game-images.json` records download URLs, original sources and credits. `data/pokemon-releases.json` records chronology. Game rights remain with Nintendo/The Pokémon Company, Activision, and ppy; these assets are not claimed as site-owned or openly licensed. The public `/projects/game-tracker/credits` page links the sources.
```sh
# Pillow required; map rebuilding also needs beautifulsoup4.
npm run catalog:images
npm run catalog:map-art
npm run check:images
```
The downloader preserves transparency/aspect ratio, writes WebP atomically and reuses valid existing files. PokéAPI sprite URLs and Libretro box-art URLs are pinned to source commits. The three Scarlet/Violet maps are assembled from the original regional map tiles by `build-pokemon-map-art.py`. Map downloads are cached in `/tmp/game-map-art`.
The library has one column per series on desktop. Pokémon has a main journal link and only the games explicitly chosen in rules, arranged chronologically in exactly two columns; COD is chronological and osu! is a single entry. Large collections can scroll vertically so covers and names remain readable. At mobile sizes or high browser zoom, the series flow vertically. In the Pokémon journal, cover-only buttons stay visible across the header. They use one row for small selections and two rows for larger selections; the active cover is highlighted. Narrow screens can scroll the two-row cover strip horizontally.
## Pokémon
Open `/projects/game-tracker/pokemon?game=platinum`. A playthrough remembers its stage and selected location when **Save my place** is pressed. The adventure view hides encounters requiring radar, swarms, another cartridge or similar special conditions by default; these can be revealed with a checkbox. This is a convenience filter, not a full simulation of story gates or encounter eligibility.
The Pokédex defaults to a regional dex where the source has one. National order remains available. Map encounters show methods, areas, levels, rates and conditions. Species details include evolution information, stats, abilities, biology and searchable game-specific learnsets.
The companion layout keeps the current team and an animated completion ribbon above tabs for the map, Pokédex, PC and milestones. Dex and PC pages show smaller batches appropriate to the device. Desktop shows the map beside a fitted grid containing every species matching the selected area and filters, without an encounter-list scrollbar. On smaller screens, switch between the region map and area Pokémon. **Expand encounters** opens a larger grid; tapping any portrait opens encounter details and catch registration. Broad locations with hundreds of species use smaller portraits; area/floor filters and the expanded view make these easier to explore.
Register each individual catch with a nickname, current level, four moves, party slot, shiny flag, acquisition method, location, date and notes. The PC is sorted in National Pokédex order and shows the **first registered record** per species, even if a later record has an earlier catch date. Open species research to browse all duplicates. Assigning an occupied team slot moves the previous occupant to the PC. The six slots and caught checkmark update in one transaction; catch and checkmark mutations use the same per-user/game lock.
Existing caught checkmarks remain intact. A checkmark without an individual record offers **Add catch details**. Deleting a catch removes its individual details and party slot but retains the historical caught checkmark. Clear that checkmark separately after removing all records for the species. Species are separate records: after evolving a companion, record the evolved species and move it into the relevant team slot.
Badges, Elite Four, champions, selected bosses and collection milestones vary by game. Custom milestones cover personal goals and events outside the default list. Progress is split into story, postgame, and trades/transfers/events.
### Current catalog coverage
| Data | Records |
| ------------------------------------- | ------: |
| Species and species research | 1,025 |
| Game catalog | 53 |
| Regional dex/game/species assignments | 16,044 |
| Moves | 937 |
| Species/version-group learnsets | 11,331 |
| Existing encounter rows | 67,282 |
| Existing locations | 1,104 |
`data/pokemon-research.json` uses the same pinned [PokéAPI dataset](https://github.com/PokeAPI/pokeapi/tree/575291cdb197a7e3a320297be276c9de4ef8401a) as the existing catalog. Regenerate with `bun run catalog:research`, then seed. Learn methods and levels are version-specific; battle stats, move power/accuracy/PP and biology use the snapshot’s default forms and current values. Alternate forms and historical stat changes are not yet a complete catalog. Missing encounters are not evidence of unavailability, and recent titles have thinner source coverage.
### Atlas coverage
All **13 supported regions** now use local map images: Kanto (including the Sevii Islands), Johto, Hoenn, Sinnoh, Unova, Kalos, Alola, Galar (including its expansions), Hisui, Paldea, Orre, Kitakami and Blueberry Academy. The SVG schematic rendering has been removed. Maps preserve their aspect ratios and support zoom, dragging, keyboard scrolling, clickable markers and optional town labels.
`data/pokemon-map-art.json` stores image dimensions and marker positions. Kanto, Johto, Hoenn, Sinnoh, Unova and Kalos use the location coordinates associated with the [Serebii map images](https://www.serebii.net/pokearth/). Orre uses a Pokémon XD game screenshot. Paldea, Kitakami and the Terarium use assembled in-game map tiles. The remaining landmark positions are manually placed and labeled approximate. Images are regional references shared by titles in that region, not an exhaustive collection of every game's map revision or every interior map. The searchable location list continues to provide imported encounters outside the shown markers.
The original `pokemon-atlas.json` retains location and coordinate reference data from [pret](https://github.com/pret). Its build script does not control the new image backgrounds.
Pokémon sprites, shiny sprites and official artwork are stored as **3,075 local WebP images**, covering every one of the catalog's 1,025 species.
## Completion and discovery
The library and individual game pages show shadcn progress bars with two decimal places, so a single new entry remains visible even in a large catalog. Bars animate between values and respect reduced-motion preferences. **All progress** in Pokémon opens the full collection, regional dex, story, badge, league, boss, postgame and transfer/event breakdown.
Pokémon sightings are independent, per-user/per-game records. Use **seen** in the Pokédex or **Mark as seen** in an encounter dialog. Catches automatically record sightings; simply browsing the catalog never does. Historical caught checkmarks are backfilled as seen by the additive journal migration. A caught species cannot be marked unseen while its caught checkmark remains.
National caught/seen bars count each species once across the games selected in rules, against the full species catalog. Per-game collections include known acquisition methods (including trades/events/transfers) and exclude unconfirmed availability unless a real catch establishes it. In-game collection bars cover catching, evolution and breeding. Regional bars count distinct species across the imported regional dexes, without double-counting overlap.
Game completion balances collection, story, postgame and transfer/event progress equally where data exists. The story category includes a story-cleared checkpoint. **Mark story complete** records that checkpoint without inventing individual badge records; completing every default story milestone also infers it. Games completed counts cleared stories, not 100% collections. Personal milestones have a separate bar, so adding a custom goal does not change the main denominator. Changing selected games changes the scope; removing data can lower percentages.
COD completion equally weights cumulative player/weapon levels, weapon mastery, achievements and challenges where a denominator is known. Each recorded level counts immediately: `prestige × max level + current level`, out of `(max prestige + 1) × max level`. For example, M27 P0 L17 is 17/51, P1 L1 is 18/51, and P2 L17 is 51/51. Missing ranks contribute zero; launchers/melee without weapon levels and games without verified caps are excluded from rank denominators. Library and game totals share the same calculation; historical out-of-range records are bounded for display calculations, never rewritten. Each challenge contributes its fraction of a known target, or a completed/not-completed value when no target exists. Derived Diamond/Dark Matter tiers are shown separately and are not counted a second time in overall completion. Weapon cards, achievement groups, individual challenges and the full class breakdown show their own percentages. Black Ops II's catalog records a player cap of level 55 and prestige 10 (Prestige Master), a weapon prestige cap of 2, and attachment-rank caps by weapon class. Assault rifles cap at 17, SMGs and LMGs at 16, shotguns, pistols and most sniper rifles at 10, Ballista at 11 and Crossbow at 6. Launchers and the other melee specials have no attachment-rank progression. These caps are enforced by both the controls and server actions. osu! shows all-goal, pass, FC and accuracy completion from saved goals.
The BO2 cap list follows the [weapon-level unlock table](https://w.atwiki.jp/blackops2_cod/pages/47.html); the player Prestige Master threshold is documented by [Activision Support](https://support.activision.com/call-of-duty--black-ops-ii/articles/achieving-prestige-master-in-call-of-duty-black-ops-ii). The numeric player cap represents the level-55 prestige cycle; prestige 10 at level 55 displays the Master emblem.
Re-run `bun run db:migrate:tracker-journal` before deploying this update: it adds `pokemon_seen` and `pokemon_journeys.completed` without deleting existing data. Deployment preflight checks both.
## Call of Duty
Player and weapon levels/prestiges are separate from camo mastery. The challenges tab provides search, category filters, numeric progress where a source gives a target, completion checkboxes and linked requirements/rewards.
Weapon cards combine artwork, name, level, prestige and camo progress. Level and prestige **− / +** buttons save immediately, display save failures, and use atomic database increments to preserve simultaneous updates from multiple devices. Prestige + starts the next prestige at level 1. Tap the level number for direct editing. Positive prestige infers that maximum level has been reached at least once, and existing max-level checkmarks remain recognized. Black Ops II player and weapon level/prestige caps are enforced in both controls and server actions. The shared COD companion board groups weapons by class. At desktop sizes of 1280×720 and above, all 40 BO2 weapons fit on one screen; other games use 32-weapon pages. Narrow screens use a readable vertical layout. Every gun has a still image. Select a weapon name to edit camos and use full rank controls; the compact + button saves a level immediately. All ten BO2 prestige emblems and the Master emblem are served locally beside a level badge. Hide finished requires every prestige run and applicable camo milestone, not just a first-run max-level checkmark. Overall mastery statistics are available in the **Progress overview** dialog.
The imported snapshot has **3,674 challenges/operations**: Black Ops 1,489; Black Ops II 526; Ghosts 31; Advanced Warfare 75; Black Ops III 860; Infinite Warfare 329; WWII 364. This is a partial, attributed catalog, not an exhaustive inventory. Ghosts source rows with missing tier thresholds are excluded. Rewards without source text are left blank, and many emblem/calling-card pictures remain unavailable.
`scripts/build-cod-challenges.py` imports rendered, line-numbered wiki snapshots with `--snapshots <directory>`. The checked-in JSON is the deployment input; regenerating from newer research requires supplying source snapshots and reviewing ambiguous rows. Stable keys use game/category/name, so review renamed source headings before importing to avoid splitting progress.
208 locally served WebP weapon pictures are registered in `data/cod-art.json`, covering all seven games, with a larger shadcn dialog preview and original image credits. The base weapon galleries for Black Ops III and WWII are complete. Other games remain partial. Images include renders, game screenshots, named variants and real weapon reference photos, identified in the preview credits. All 618 cataloged achievement icons have local WebP copies. Add verified images and manifest entries, then run `db:seed:art`; the regular COD catalog seed also retains manifest images.
| Base weapon picture coverage | Pictures / weapons |
| ---------------------------- | -----------------: |
| Black Ops | 32 / 42 |
| Black Ops II | 38 / 39 |
| Ghosts | 20 / 40 |
| Advanced Warfare | 34 / 35 |
| Black Ops III | 31 / 31 |
| Infinite Warfare | 20 / 35 |
| WWII | 31 / 31 |
Additional pictures include BO2’s DLC Peacekeeper and Ghosts’ DLC Maverick-A2. BO2’s Combat Knife is the remaining entry without artwork; all guns have pictures. These counts do not imply complete DLC, camo, emblem or calling-card artwork.
Challenge text is adapted from **Call of Duty Wiki contributors**, with per-record source links. The adapted challenge dataset retains the source’s [CC BY-SA terms](https://www.fandom.com/licensing), per [the wiki’s copyright policy](https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights). Game artwork retains its original owners’ rights; it is not covered by the challenge-text license. `data/cod-art.json` records each image’s source, download URL and credit. Pokémon artwork/sprites are downloaded from [PokéAPI’s sprite repository](https://github.com/PokeAPI/sprites) and served locally.
## osu!
`/projects/game-tracker/osu` contains beatmap goals, memorable scores and training sessions for osu!, taiko, catch and mania. Track pass/FC/accuracy/custom goals, score links, pp, combo, misses, mods and notes. Practice records have a skill, map BPM, playback rate and duration. The chart compares qualifying sessions, with a minimum duration filter. Effective BPM is map BPM multiplied by playback rate. These are manual journal entries; there is no automatic account/score import.
## Shared components and validation
Pokémon rules/research/catches, COD ranks/artwork, osu entries, Animaldex details, money forms, article creation and list/item creation/editing use shadcn Dialog. Destructive list/article/language reset controls use Alert Dialog. Existing custom selects use Native Select. Money/list modal fields use shared inputs, textareas and checkboxes. Native disclosure sections that explain content are retained.
```sh
npm run check
npm run build
npm run test:unit -- --run
npm run test:tracker
```
Tracker browser tests use the migrated **local development** database, start their own server on port 4174, create temporary users and delete them afterward. They check Platinum routes and dex ordering, duplicate catch policy, team slot reassignment, ownership, rank/challenge persistence, osu training calculations, dialog keyboard behavior, mobile page widths, local image loading, picture-button selection, and a readable two-column 53-game Pokémon library. Companion checks cover immediate weapon adjustments, concurrent updates, failed-save recovery, prestige inference, all-species encounter grid fitting, expanded encounters and catch registration from the map. Progress checks cover persisted sightings, caught/seen deduplication, completed stories, partial COD challenges, osu! goal completion, rules-scoped totals and direct cover switching. Do not point `test:tracker` at a shared or production database.
## Deployment verification
`deploy-remote.sh` takes a PostgreSQL-version-matched custom-format backup and validates its archive before running the repeatable migrations and catalog seeds on each environment. The COD seed retains stable IDs and preserves stale items with either checklist progress **or weapon ranks**. No saved player/weapon levels are reset or clamped in storage. `audit-tracker-data.ts` prints only row counts and fingerprints for saved tracker records; compare before/after each deployment (newly added journal tables and backfilled sightings are expected). BO2 catalog caps, weapon counts and image coverage are checked by deployment preflight.
BO2 data sources: [attachment level unlocks](https://w.atwiki.jp/blackops2_cod/pages/47.html), [Peacekeeper attachments](https://callofduty.fandom.com/wiki/Peacekeeper), and [weapon prestige](https://callofduty.fandom.com/wiki/Prestige_Mode). Artwork URLs and credits are in `data/cod-art.json` and `data/cod-rank-art.json` and included in the public credits page.

178
docs/house-tracker.md Normal file
View File

@@ -0,0 +1,178 @@
# House tracker
Personal tool: collects Dutch housing listings into Postgres, shows them on
`/projects/house-tracker` (admin only) and sends an alert when a new listing
matches a rule. Only Pararius for now; other sources plug in the same way.
## How it fits together
```
home box (residential IP) stanrunge.dev (Hetzner)
┌──────────────────────────┐ POST sync ┌──────────────────────────────┐
│ scripts/house-worker.ts │ ──────────────▶ │ /projects/house-tracker/api/ │
│ polls pararius.nl │ POST details │ worker/{sync,details,report}│
│ parses HTML │ ◀────────────── │ stores, dedups, alerts │
└──────────────────────────┘ needDetails └──────────────┬───────────────┘
▼
Discord webhook / ntfy push
```
The server never talks to the listing sites. Pararius sits behind Cloudflare,
which challenges datacenter IPs and plain HTTP clients, so the worker runs at
home where traffic looks like a normal visitor.
Two lanes:
- **fast**: one filtered search (default `huurwoningen/gouda/1300-1700`), page 1
only. Every 3–5 min on weekdays 07:00–18:00 Amsterdam time, every 30–60 min
otherwise. Listings near Gouda were all published in office hours on weekdays.
Alerts are sent from the search card alone, before the detail page is fetched.
- **slow**: full, paginated sweeps of rent and buy listings in Gouda, Rotterdam,
Den Haag, Utrecht, Amsterdam and Eindhoven every 12 h. Unfiltered city sweeps
also mark listings that disappeared, which gives "days listed" in the stats.
Safety rules in the worker: one request at a time, at least 8 s apart with
jitter, a daily budget (1500 by default, the slow lane may only use 60%), and
any challenge/403/429 stops everything and backs off 15 min → 30 → 60 … 6 h.
The first poll of any search only seeds the database, so you don't get 40
alerts for listings that were already online.
## Server setup
Add to the production env file (`/etc/personal-website/prod.env`):
```text
# openssl rand -hex 32
HOUSE_WORKER_TOKEN=
# one or both
HOUSE_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
HOUSE_NTFY_URL=https://ntfy.sh/some-long-random-topic
HOUSE_NTFY_TOKEN= # only for a protected ntfy topic
PUBLIC_MAPTILER_API_KEY= # already set for Animaldex
```
The `house` schema is part of the Drizzle migrations that run on every deploy. Use
"send test alert" on the page to check the alert channel.
If Cloudflare Bot Fight Mode / WAF challenges are on for stanrunge.dev, add a
WAF skip rule for `URI Path starts with /projects/house-tracker/api/worker/`,
otherwise the worker's POSTs get challenged too. The endpoints are protected by
the bearer token.
### Development behind Cloudflare Access
For `https://dev.stanrunge.dev`, keep Access enabled. In Cloudflare Zero Trust,
go to **Access controls → Service credentials → Service Tokens** and create a
token named `house-worker`. Save its Client ID and Client Secret. In the Access
application protecting the dev hostname, add a **Service Auth** policy with
**Include → Service Token → house-worker**. Keep the existing browser login
policy. See [Cloudflare's service token documentation](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/).
Set `HOUSE_WORKER_TOKEN` in the dev website's `.env` and restart its dev server.
On the home machine, copy `house.env.example` to `house.env`, fill in the same
worker token plus the Cloudflare Client ID and Client Secret, then run:
```sh
chmod 600 house.env
bun --env-file=house.env scripts/house-worker.ts --check-api
```
This makes one authenticated GET to the website's worker health endpoint. It
does not contact Pararius, sync listings or send alerts. The expected output is
`API connection OK`. Access credentials are sent only to the website API; the
client refuses redirects and stops immediately on an authentication failure.
An HTTP 503 can mean the website's worker token is missing; an HTTP 404 can mean
the running website does not yet include the health route.
## Home worker setup
Needs a machine that stays on (old laptop, Raspberry Pi, NAS) with Bun 1.4 or
newer; older versions cannot read this repository's lockfile. Use `bun upgrade`
if needed.
```sh
git clone https://git.stanrunge.dev/stan/website ~/house-worker
cd ~/house-worker && bun install --frozen-lockfile
cat > house.env <<'EOF'
HOUSE_API_URL=https://stanrunge.dev
HOUSE_WORKER_TOKEN=same-value-as-the-server
EOF
chmod 600 house.env
# 1. check parsing without sending anything
bun --env-file=house.env scripts/house-worker.ts --dry-run --once
# 2. one real poll
bun --env-file=house.env scripts/house-worker.ts --once
```
Then run it as a service, `/etc/systemd/system/house-worker.service`:
```ini
[Unit]
Description=House tracker worker
After=network-online.target
Wants=network-online.target
[Service]
User=stan
WorkingDirectory=/home/stan/house-worker
ExecStart=/home/stan/.bun/bin/bun --env-file=house.env scripts/house-worker.ts
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target
```
```sh
sudo systemctl enable --now house-worker
journalctl -u house-worker -f
```
Or with Docker: `docker run -d --restart unless-stopped --name house-worker
--env-file house.env -v "$PWD":/app -w /app oven/bun:1.4.0 bun
scripts/house-worker.ts`.
## Worker settings
| variable | default |
| ------------------------ | ------------------------------------------------------------------------ |
| `HOUSE_FAST_SEARCHES` | `https://www.pararius.nl/huurwoningen/gouda/1300-1700` (comma separated) |
| `HOUSE_SLOW_SEARCHES` | rent + buy for the six cities above |
| `HOUSE_SLOW_EVERY_HOURS` | `12` |
| `HOUSE_SLOW_MAX_PAGES` | `80` per search |
| `HOUSE_MIN_GAP_SECONDS` | `8` between any two requests |
| `HOUSE_DAILY_BUDGET` | `1500` requests |
| `HOUSE_FETCH_MODE` | `fetch`, or `browser` (real Chromium) |
Any Pararius search URL works as a fast search: set the filters on the site
(price, radius, bedrooms) and paste the URL. Every extra fast search is one more
request per poll.
## When it gets challenged
You get one "worker was challenged" alert per 6 hours and the page header turns
red. In order:
1. Do nothing for a day. Backoff handles short blocks.
2. Slow down: raise `HOUSE_MIN_GAP_SECONDS`, lower `HOUSE_DAILY_BUDGET`, or drop
slow searches.
3. Save a page manually in your normal browser to investigate parsing offline.
Do not switch clients, use proxies or solve challenges to bypass a block.
The optional `HOUSE_FETCH_MODE=browser` uses headless Chromium with a persistent
profile (`npx playwright install chromium` once on the worker machine). It obeys
the same stop/backoff rules and is not a workaround for a challenged fetch.
## Parsers
`src/lib/house-tracker/pararius.ts`, tested against trimmed real pages in
`src/lib/house-tracker/fixtures/`. Listing pages expose an exact publish
timestamp (`odp.offered_since`), coordinates, deposit, energy label and agent.
`pararius-search-gouda.html` is a trimmed real search page saved on 2026-10-11,
with tracking and account data removed. It covers three listings, nested base
and total rent, action attributes and a lazy photo template. The synthetic
fixture still covers pagination and price on request. Rental `priceEur` is the
base/listed rent used by the search filter; a separately shown total can be
higher (Raam: €1,475 base, €1,750 total). If Pararius changes markup, the worker
reports `parse-error` in the worker log on the page.

View File

@@ -1,12 +1,32 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from 'drizzle-kit';
// Drizzle owns the core schemas only. Deliberately excluded:
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
// - legacy: pre-reorg tables kept read-only for rollback
// - energy: owned by ned-collector
// - hardware: not modelled yet
// Migrations run as the schema owner (`personal`), never as the app role.
export default defineConfig({ export default defineConfig({
dialect: 'postgresql', dialect: 'postgresql',
schema: './src/lib/server/db/schema/index.ts', schema: './src/lib/server/db/schema/index.ts',
out: './drizzle', out: './drizzle',
schemaFilter: [
'auth',
'money',
'house',
'ref',
'games',
'cs',
'animaldex',
'site',
'meta',
'health',
'books'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,
strict: true, strict: true,
dbCredentials: { dbCredentials: {
url: process.env.DB_URL url: (process.env.DB_MIGRATE_URL || process.env.DB_URL)!
} }
}); });

View File

@@ -0,0 +1,857 @@
CREATE SCHEMA "animaldex";
--> statement-breakpoint
CREATE SCHEMA "auth";
--> statement-breakpoint
CREATE SCHEMA "cs";
--> statement-breakpoint
CREATE SCHEMA "games";
--> statement-breakpoint
CREATE SCHEMA "house";
--> statement-breakpoint
CREATE SCHEMA "meta";
--> statement-breakpoint
CREATE SCHEMA "money";
--> statement-breakpoint
CREATE SCHEMA "ref";
--> statement-breakpoint
CREATE SCHEMA "site";
--> statement-breakpoint
CREATE TYPE "site"."blog_article_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TYPE "ref"."availability" AS ENUM('catchable', 'trade_only', 'transfer_only', 'event_only', 'unknown', 'evolve', 'breed');--> statement-breakpoint
CREATE TABLE "auth"."account" (
"id" text PRIMARY KEY,
"account_id" text NOT NULL,
"issuer" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp with time zone,
"refresh_token_expires_at" timestamp with time zone,
"scope" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."session" (
"id" text PRIMARY KEY,
"expires_at" timestamp with time zone NOT NULL,
"token" text NOT NULL UNIQUE,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."user" (
"id" text PRIMARY KEY,
"name" text NOT NULL,
"role" text DEFAULT 'user' NOT NULL,
"email" text NOT NULL UNIQUE,
"email_verified" boolean DEFAULT false NOT NULL,
"image" 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 "auth"."verification" (
"id" text PRIMARY KEY,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp with time zone 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 "site"."blog_articles" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"slug" text NOT NULL UNIQUE,
"content" text DEFAULT '' NOT NULL,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"status" "site"."blog_article_status" DEFAULT 'draft'::"site"."blog_article_status" 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 "site"."tasks" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"current_points" integer DEFAULT 0 NOT NULL,
"total_points" integer DEFAULT 1 NOT NULL,
"notes" 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 "site"."task_topics" (
"id" serial PRIMARY KEY,
"task_id" integer NOT NULL,
"topic_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "task_topics_task_id_topic_id_unique" UNIQUE("task_id","topic_id")
);
--> statement-breakpoint
CREATE TABLE "site"."topics" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"emoji" 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 "games"."catch_em_all_settings" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL UNIQUE,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_games" (
"id" serial PRIMARY KEY,
"source_id" integer UNIQUE,
"slug" text UNIQUE,
"name" text NOT NULL,
"generation" integer DEFAULT 1 NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"breeding" boolean DEFAULT true 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 "ref"."pokemon" (
"id" serial PRIMARY KEY,
"number" integer NOT NULL UNIQUE,
"generation" integer DEFAULT 1 NOT NULL,
"name" text NOT NULL,
"type1" text NOT NULL,
"type2" text,
"hp" integer DEFAULT 0 NOT NULL,
"attack" integer DEFAULT 0 NOT NULL,
"defense" integer DEFAULT 0 NOT NULL,
"special_attack" integer DEFAULT 0 NOT NULL,
"special_defense" integer DEFAULT 0 NOT NULL,
"speed" integer DEFAULT 0 NOT NULL,
"evolves_from" integer,
"evolution_chain" integer,
"is_baby" boolean DEFAULT false NOT NULL,
"is_legendary" boolean DEFAULT false NOT NULL,
"is_mythical" boolean DEFAULT false 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 "ref"."pokemon_encounters" (
"id" serial PRIMARY KEY,
"pokemon_id" integer NOT NULL,
"game_id" integer NOT NULL,
"location_id" integer NOT NULL,
"area" text,
"method" text NOT NULL,
"min_level" integer,
"max_level" integer,
"rarity" integer,
"form" text,
"conditions" text[] DEFAULT '{}'::text[] NOT NULL,
"note" text,
"source" text DEFAULT 'pokeapi' 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 "ref"."pokemon_evolutions" (
"id" serial PRIMARY KEY,
"from_pokemon_id" integer NOT NULL,
"to_pokemon_id" integer NOT NULL,
"game_id" integer NOT NULL,
"method" text NOT NULL,
"source" text DEFAULT 'pokeapi' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique" UNIQUE("from_pokemon_id","to_pokemon_id","game_id","method")
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_game_entries" (
"id" serial PRIMARY KEY,
"pokemon_id" integer NOT NULL,
"game_id" integer NOT NULL,
"availability" "ref"."availability" DEFAULT 'catchable'::"ref"."availability" NOT NULL,
"note" text,
"source" text DEFAULT 'pokeapi' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pokemon_game_entries_pokemon_id_game_id_unique" UNIQUE("pokemon_id","game_id")
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_locations" (
"id" serial PRIMARY KEY,
"source_id" integer UNIQUE,
"slug" text NOT NULL,
"name" text NOT NULL,
"region" text,
"description" text,
"image_key" text,
"image_credit" text,
"source" text DEFAULT 'pokeapi' 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 "games"."catch_em_all_entries" (
"id" serial PRIMARY KEY,
"pokemon_game_entry_id" integer NOT NULL,
"user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "catch_em_all_entries_pokemon_game_entry_id_user_id_unique" UNIQUE("pokemon_game_entry_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "games"."catch_em_all_games" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "catch_em_all_games_user_id_game_id_unique" UNIQUE("user_id","game_id")
);
--> statement-breakpoint
CREATE TABLE "ref"."tracker_games" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL UNIQUE,
"franchise" text NOT NULL,
"name" text NOT NULL,
"short_name" text NOT NULL,
"release_year" integer,
"sort_order" integer DEFAULT 0 NOT NULL,
"rules" jsonb 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 "ref"."tracker_items" (
"id" serial PRIMARY KEY,
"game_id" integer NOT NULL,
"key" text NOT NULL,
"kind" text NOT NULL,
"category" text NOT NULL,
"name" text NOT NULL,
"description" text,
"icon" text,
"post_launch" boolean DEFAULT false 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,
CONSTRAINT "tracker_items_game_id_key_unique" UNIQUE("game_id","key")
);
--> statement-breakpoint
CREATE TABLE "games"."tracker_progress" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"item_id" integer NOT NULL,
"milestone" text DEFAULT 'done' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tracker_progress_user_id_item_id_milestone_unique" UNIQUE("user_id","item_id","milestone")
);
--> statement-breakpoint
CREATE TABLE "games"."osu_entries" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"title" text NOT NULL,
"mode" text DEFAULT 'osu' NOT NULL,
"beatmap_url" text,
"score_url" text,
"goal" text DEFAULT 'pass' NOT NULL,
"skill" text DEFAULT 'stamina' NOT NULL,
"bpm" integer,
"duration" integer,
"accuracy" numeric,
"combo" integer,
"misses" integer,
"pp" numeric,
"mods" text DEFAULT 'NM' NOT NULL,
"rate" numeric(4,2) DEFAULT 1.00 NOT NULL,
"achieved" boolean DEFAULT false NOT NULL,
"played_on" date NOT NULL,
"notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "osu_entries_kind_check" CHECK ("kind" IN ('score','goal','training'))
);
--> statement-breakpoint
CREATE TABLE "games"."pokemon_catches" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
"pokemon_id" integer NOT NULL,
"nickname" text DEFAULT '' NOT NULL,
"level" integer DEFAULT 1 NOT NULL,
"moves" text[] DEFAULT '{}'::text[] NOT NULL,
"party_slot" integer,
"shiny" boolean DEFAULT false NOT NULL,
"nature" text,
"ability" text,
"ball" text,
"location" text,
"obtained" text DEFAULT 'caught' NOT NULL,
"stage" text DEFAULT 'story' NOT NULL,
"notes" text DEFAULT '' NOT NULL,
"caught_on" date,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pokemon_catches_user_id_game_id_party_slot_unique" UNIQUE("user_id","game_id","party_slot"),
CONSTRAINT "pokemon_catches_level_check" CHECK ("level" between 1 and 100),
CONSTRAINT "pokemon_catches_party_slot_check" CHECK ("party_slot" between 1 and 6),
CONSTRAINT "pokemon_catches_moves_check" CHECK (cardinality("moves") <= 4),
CONSTRAINT "pokemon_catches_stage_check" CHECK ("stage" IN ('story','postgame','collection'))
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_dex_numbers" (
"id" serial PRIMARY KEY,
"pokemon_id" integer NOT NULL,
"game_id" integer NOT NULL,
"dex" text NOT NULL,
"dex_name" text NOT NULL,
"number" integer NOT NULL,
CONSTRAINT "pokemon_dex_numbers_pokemon_id_game_id_dex_unique" UNIQUE("pokemon_id","game_id","dex")
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_game_research" (
"game_id" integer PRIMARY KEY,
"version_group" integer NOT NULL,
"regions" text[] NOT NULL
);
--> statement-breakpoint
CREATE TABLE "games"."pokemon_journeys" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
"stage" text DEFAULT 'story' NOT NULL,
"location" text,
"completed" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pokemon_journeys_user_id_game_id_unique" UNIQUE("user_id","game_id"),
CONSTRAINT "pokemon_journeys_stage_check" CHECK ("stage" IN ('story','postgame','collection'))
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_learnsets" (
"id" serial PRIMARY KEY,
"pokemon_id" integer NOT NULL,
"version_group" integer NOT NULL,
"moves" jsonb NOT NULL,
CONSTRAINT "pokemon_learnsets_pokemon_id_version_group_unique" UNIQUE("pokemon_id","version_group")
);
--> statement-breakpoint
CREATE TABLE "games"."pokemon_milestones" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
"key" text NOT NULL,
"title" text NOT NULL,
"category" text NOT NULL,
"stage" text DEFAULT 'story' NOT NULL,
"done" boolean DEFAULT false NOT NULL,
"notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pokemon_milestones_user_id_game_id_key_unique" UNIQUE("user_id","game_id","key")
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_moves" (
"id" integer PRIMARY KEY,
"name" text NOT NULL,
"type" text NOT NULL,
"category" text NOT NULL,
"power" integer,
"accuracy" integer,
"pp" integer,
"priority" integer NOT NULL,
"target" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ref"."pokemon_research" (
"pokemon_id" integer PRIMARY KEY,
"facts" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "games"."pokemon_seen" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
"pokemon_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pokemon_seen_user_id_game_id_pokemon_id_unique" UNIQUE("user_id","game_id","pokemon_id")
);
--> statement-breakpoint
CREATE TABLE "games"."tracker_challenge_progress" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"challenge_id" integer NOT NULL,
"current" integer DEFAULT 0 NOT NULL,
"done" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tracker_challenge_progress_user_id_challenge_id_unique" UNIQUE("user_id","challenge_id"),
CONSTRAINT "tracker_challenge_progress_current_check" CHECK ("current" >= 0)
);
--> statement-breakpoint
CREATE TABLE "ref"."tracker_challenges" (
"id" serial PRIMARY KEY,
"game_id" integer NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"category" text NOT NULL,
"requirement" text NOT NULL,
"reward" text,
"icon" text,
"target" integer,
"source_url" text NOT NULL,
CONSTRAINT "tracker_challenges_game_id_key_unique" UNIQUE("game_id","key")
);
--> statement-breakpoint
CREATE TABLE "games"."tracker_ranks" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
"level" integer DEFAULT 1 NOT NULL,
"prestige" 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,
CONSTRAINT "tracker_ranks_user_id_game_id_unique" UNIQUE("user_id","game_id"),
CONSTRAINT "tracker_ranks_level_check" CHECK ("level" between 1 and 10000),
CONSTRAINT "tracker_ranks_prestige_check" CHECK ("prestige" between 0 and 1000)
);
--> statement-breakpoint
CREATE TABLE "games"."tracker_weapon_ranks" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"item_id" integer NOT NULL,
"level" integer DEFAULT 1 NOT NULL,
"prestige" 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,
CONSTRAINT "tracker_weapon_ranks_user_id_item_id_unique" UNIQUE("user_id","item_id"),
CONSTRAINT "tracker_weapon_ranks_level_check" CHECK ("level" between 1 and 10000),
CONSTRAINT "tracker_weapon_ranks_prestige_check" CHECK ("prestige" between 0 and 1000)
);
--> statement-breakpoint
CREATE TABLE "animaldex"."animals" (
"id" serial PRIMARY KEY,
"species" text NOT NULL,
"breed" text,
"animal_name" text,
"description" text,
"ai_breed_suggestion" text,
"ai_breed_confidence" double precision,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
"accepted_at" timestamp with time zone,
"denied_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
);
--> statement-breakpoint
CREATE TABLE "animaldex"."sighting_photos" (
"id" serial PRIMARY KEY,
"sighting_id" integer NOT NULL,
"r2_key" text NOT NULL,
"url" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"uploaded_at" timestamp with time zone DEFAULT now() 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 "animaldex"."sightings" (
"id" serial PRIMARY KEY,
"animal_id" integer NOT NULL,
"reporter_name" text,
"seen_at" timestamp with time zone NOT NULL,
"lat" double precision NOT NULL,
"lng" double precision NOT NULL,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
"accepted_at" timestamp with time zone,
"denied_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
);
--> statement-breakpoint
CREATE TABLE "meta"."columns" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "meta"."columns_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"table_id" integer NOT NULL,
"column_name" text NOT NULL,
"label" text,
"ui_type" text NOT NULL,
"options" jsonb DEFAULT '{}' NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"hidden" boolean DEFAULT false NOT NULL,
"help_text" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "columns_table_id_column_name_key" UNIQUE("table_id","column_name"),
CONSTRAINT "columns_ui_type_check" CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json'))
);
--> statement-breakpoint
CREATE TABLE "meta"."tables" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "meta"."tables_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"schema_name" text NOT NULL,
"table_name" text NOT NULL,
"slug" text NOT NULL CONSTRAINT "tables_slug_key" UNIQUE,
"display_name" text NOT NULL,
"description" text,
"icon" text,
"picture_url" text,
"kind" text NOT NULL,
"editable" boolean DEFAULT true NOT NULL,
"has_done" boolean DEFAULT false NOT NULL,
"default_sort" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"archived_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 "tables_schema_name_table_name_key" UNIQUE("schema_name","table_name"),
CONSTRAINT "tables_slug_check" CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
CONSTRAINT "tables_kind_check" CHECK (kind IN ('list','core','ref'))
);
--> statement-breakpoint
CREATE TABLE "money"."accounts" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"opening_balance_cents" integer DEFAULT 0 NOT NULL,
"kind" text DEFAULT 'spendable' NOT NULL,
"color" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "accounts_user_id_name_unique" UNIQUE("user_id","name"),
CONSTRAINT "accounts_kind_check" CHECK (kind IN ('spendable','investment'))
);
--> statement-breakpoint
CREATE TABLE "money"."forecasts" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"date" date NOT NULL,
"description" text NOT NULL,
"amount_cents" integer NOT NULL,
"account_id" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "money"."insurances" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"provider" text,
"coverage" text,
"premium_cents" integer,
"cadence" text DEFAULT 'monthly' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "insurances_cadence_check" CHECK (cadence IN ('weekly','monthly','yearly'))
);
--> statement-breakpoint
CREATE TABLE "money"."projection_settings" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL UNIQUE,
"investment_account_id" integer,
"reserve_cents" integer DEFAULT 100000 NOT NULL,
"annual_return_percent" numeric(5,2) DEFAULT '5.00' NOT NULL,
"contribution_mode" text DEFAULT 'excess' NOT NULL,
"include_recurring" boolean DEFAULT true NOT NULL,
"fixed_monthly_cents" integer DEFAULT 0 NOT NULL,
"end_date" date,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "projection_settings_contribution_mode_check" CHECK (contribution_mode IN ('excess','fixed'))
);
--> statement-breakpoint
CREATE TABLE "money"."recurring" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"amount_cents" integer NOT NULL,
"cadence" text NOT NULL,
"starts_on" date,
"ends_on" date,
"account_id" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "recurring_cadence_check" CHECK (cadence IN ('weekly','monthly','yearly'))
);
--> statement-breakpoint
CREATE TABLE "money"."transactions" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"account_id" integer NOT NULL,
"date" date NOT NULL,
"description" text NOT NULL,
"amount_cents" integer 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 "money"."utility_readings" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"date" date NOT NULL,
"reading" numeric(12,3) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "utility_readings_kind_check" CHECK (kind IN ('electricity','gas','water'))
);
--> statement-breakpoint
CREATE TABLE "money"."utility_settings" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"rate_per_unit" numeric(12,4) DEFAULT '0' NOT NULL,
"monthly_advance_cents" integer DEFAULT 0 NOT NULL,
"billing_start" date,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "utility_settings_user_id_kind_unique" UNIQUE("user_id","kind"),
CONSTRAINT "utility_settings_kind_check" CHECK (kind IN ('electricity','gas','water'))
);
--> statement-breakpoint
CREATE TABLE "money"."wishlist" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"estimated_cents" integer,
"priority" integer DEFAULT 3 NOT NULL,
"notes" 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 "cs"."item_dimensions" (
"item_id" integer,
"key" text,
"value" text NOT NULL,
CONSTRAINT "item_dimensions_pkey" PRIMARY KEY("item_id","key")
);
--> statement-breakpoint
CREATE TABLE "cs"."items" (
"id" serial PRIMARY KEY,
"market_hash_name" text NOT NULL,
"name" text NOT NULL,
"icon_url" text,
"item_type" text,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cs"."price_observations" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "cs"."price_observations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"item_id" integer NOT NULL,
"source" text NOT NULL,
"currency" text NOT NULL,
"observed_at" timestamp with time zone NOT NULL,
"price_cents" integer NOT NULL,
"volume" integer,
"kind" text NOT NULL,
"imported_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cs"."price_streams" (
"source" text,
"currency" text,
"kind" text,
CONSTRAINT "price_streams_pkey" PRIMARY KEY("source","currency","kind")
);
--> statement-breakpoint
CREATE TABLE "house"."alert_rules" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"offer" text DEFAULT 'rent' NOT NULL,
"min_price" integer,
"max_price" integer,
"min_area" integer,
"min_bedrooms" integer,
"cities" text[] DEFAULT '{}'::text[] NOT NULL,
"property_types" text[] DEFAULT '{}'::text[] NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "alert_rules_offer_check" CHECK (offer IN ('rent', 'buy'))
);
--> statement-breakpoint
CREATE TABLE "house"."listings" (
"id" serial PRIMARY KEY,
"source" text NOT NULL,
"source_id" text NOT NULL,
"external_id" text,
"url" text NOT NULL,
"offer" text NOT NULL,
"property_type" text NOT NULL,
"title" text,
"street" text,
"postal_code" text,
"city" text,
"city_slug" text,
"neighbourhood" text,
"price_eur" integer,
"area_m2" integer,
"rooms" integer,
"bedrooms" integer,
"bathrooms" integer,
"construction_year" integer,
"energy_label" text,
"interior" text,
"deposit_eur" integer,
"available_from" text,
"contract_type" text,
"agent" text,
"image_url" text,
"description" text,
"features" jsonb DEFAULT '{}' NOT NULL,
"lat" double precision,
"lng" double precision,
"offered_at" timestamp with time zone,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"first_seen_lane" text DEFAULT 'slow' NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"detail_fetched_at" timestamp with time zone,
"removed_at" timestamp with time zone,
"alerted_at" timestamp with time zone,
"status" text,
"notes" text,
CONSTRAINT "listings_offer_check" CHECK (offer IN ('rent', 'buy')),
CONSTRAINT "listings_price_eur_check" CHECK (price_eur IS NULL OR price_eur >= 0)
);
--> statement-breakpoint
CREATE TABLE "house"."price_history" (
"listing_id" integer,
"observed_at" timestamp with time zone DEFAULT now(),
"price_eur" integer NOT NULL,
CONSTRAINT "price_history_pkey" PRIMARY KEY("listing_id","observed_at")
);
--> statement-breakpoint
CREATE TABLE "house"."searches" (
"search_url" text PRIMARY KEY,
"source" text NOT NULL,
"lane" text NOT NULL,
"first_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_found" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "house"."worker_runs" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "house"."worker_runs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"source" text NOT NULL,
"lane" text NOT NULL,
"url" text NOT NULL,
"status" text NOT NULL,
"http_status" integer,
"found" integer,
"duration_ms" integer,
"message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "auth"."account" ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "account_issuer_account_id_unique" ON "auth"."account" ("issuer","account_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "auth"."session" ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "auth"."verification" ("identifier");--> statement-breakpoint
CREATE INDEX "pokemon_encounters_pokemon_id_game_id_index" ON "ref"."pokemon_encounters" ("pokemon_id","game_id");--> statement-breakpoint
CREATE INDEX "pokemon_encounters_location_id_index" ON "ref"."pokemon_encounters" ("location_id");--> statement-breakpoint
CREATE INDEX "pokemon_evolutions_game_id_index" ON "ref"."pokemon_evolutions" ("game_id");--> statement-breakpoint
CREATE INDEX "pokemon_game_entries_game_id_index" ON "ref"."pokemon_game_entries" ("game_id");--> statement-breakpoint
CREATE INDEX "catch_em_all_entries_user_id_index" ON "games"."catch_em_all_entries" ("user_id");--> statement-breakpoint
CREATE INDEX "catch_em_all_games_user_id_index" ON "games"."catch_em_all_games" ("user_id");--> statement-breakpoint
CREATE INDEX "tracker_items_game_id_index" ON "ref"."tracker_items" ("game_id");--> statement-breakpoint
CREATE INDEX "tracker_progress_user_id_index" ON "games"."tracker_progress" ("user_id");--> statement-breakpoint
CREATE INDEX "osu_entries_user_id_kind_index" ON "games"."osu_entries" ("user_id","kind");--> statement-breakpoint
CREATE INDEX "pokemon_catches_user_id_game_id_pokemon_id_index" ON "games"."pokemon_catches" ("user_id","game_id","pokemon_id");--> statement-breakpoint
CREATE INDEX "pokemon_dex_numbers_game_id_index" ON "ref"."pokemon_dex_numbers" ("game_id");--> statement-breakpoint
CREATE INDEX "pokemon_seen_user_id_index" ON "games"."pokemon_seen" ("user_id");--> statement-breakpoint
CREATE INDEX "columns_table_id_index" ON "meta"."columns" ("table_id");--> statement-breakpoint
CREATE INDEX "accounts_user_id_index" ON "money"."accounts" ("user_id");--> statement-breakpoint
CREATE INDEX "forecasts_user_id_date_index" ON "money"."forecasts" ("user_id","date");--> statement-breakpoint
CREATE INDEX "insurances_user_id_index" ON "money"."insurances" ("user_id");--> statement-breakpoint
CREATE INDEX "recurring_user_id_index" ON "money"."recurring" ("user_id");--> statement-breakpoint
CREATE INDEX "transactions_user_id_date_id_index" ON "money"."transactions" ("user_id","date","id");--> statement-breakpoint
CREATE INDEX "transactions_account_id_index" ON "money"."transactions" ("account_id");--> statement-breakpoint
CREATE INDEX "utility_readings_user_id_kind_date_index" ON "money"."utility_readings" ("user_id","kind","date");--> statement-breakpoint
CREATE INDEX "wishlist_user_id_index" ON "money"."wishlist" ("user_id");--> statement-breakpoint
CREATE INDEX "item_dimensions_filter_idx" ON "cs"."item_dimensions" ("key","value","item_id");--> statement-breakpoint
CREATE UNIQUE INDEX "items_hash_idx" ON "cs"."items" ("market_hash_name");--> statement-breakpoint
CREATE INDEX "items_name_idx" ON "cs"."items" ("name");--> statement-breakpoint
CREATE UNIQUE INDEX "price_observations_unique_idx" ON "cs"."price_observations" ("item_id","source","currency","observed_at","kind");--> statement-breakpoint
CREATE INDEX "price_observations_lookup_idx" ON "cs"."price_observations" ("item_id","source","currency","observed_at");--> statement-breakpoint
CREATE INDEX "price_observations_time_idx" ON "cs"."price_observations" ("observed_at");--> statement-breakpoint
CREATE UNIQUE INDEX "listings_source_idx" ON "house"."listings" ("source","source_id");--> statement-breakpoint
CREATE INDEX "listings_active_idx" ON "house"."listings" ("offer","first_seen_at" DESC) WHERE removed_at IS NULL;--> statement-breakpoint
CREATE INDEX "listings_city_idx" ON "house"."listings" ("source","offer","city_slug");--> statement-breakpoint
CREATE INDEX "listings_details_idx" ON "house"."listings" ("first_seen_at" DESC) WHERE detail_fetched_at IS NULL;--> statement-breakpoint
CREATE INDEX "worker_runs_created_idx" ON "house"."worker_runs" ("created_at" DESC);--> statement-breakpoint
ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "site"."task_topics" ADD CONSTRAINT "task_topics_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "site"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "site"."task_topics" ADD CONSTRAINT "task_topics_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "site"."topics"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."catch_em_all_settings" ADD CONSTRAINT "catch_em_all_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_encounters" ADD CONSTRAINT "pokemon_encounters_location_id_pokemon_locations_id_fkey" FOREIGN KEY ("location_id") REFERENCES "ref"."pokemon_locations"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_from_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("from_pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_to_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("to_pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_evolutions" ADD CONSTRAINT "pokemon_evolutions_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_game_entries" ADD CONSTRAINT "pokemon_game_entries_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_game_entries" ADD CONSTRAINT "pokemon_game_entries_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."catch_em_all_entries" ADD CONSTRAINT "catch_em_all_entries_HeABXvGNiB6D_fkey" FOREIGN KEY ("pokemon_game_entry_id") REFERENCES "ref"."pokemon_game_entries"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."catch_em_all_entries" ADD CONSTRAINT "catch_em_all_entries_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."catch_em_all_games" ADD CONSTRAINT "catch_em_all_games_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."catch_em_all_games" ADD CONSTRAINT "catch_em_all_games_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."tracker_items" ADD CONSTRAINT "tracker_items_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_progress" ADD CONSTRAINT "tracker_progress_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_progress" ADD CONSTRAINT "tracker_progress_item_id_tracker_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "ref"."tracker_items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."osu_entries" ADD CONSTRAINT "osu_entries_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_catches" ADD CONSTRAINT "pokemon_catches_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_dex_numbers" ADD CONSTRAINT "pokemon_dex_numbers_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_dex_numbers" ADD CONSTRAINT "pokemon_dex_numbers_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_game_research" ADD CONSTRAINT "pokemon_game_research_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_journeys" ADD CONSTRAINT "pokemon_journeys_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_journeys" ADD CONSTRAINT "pokemon_journeys_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_learnsets" ADD CONSTRAINT "pokemon_learnsets_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_milestones" ADD CONSTRAINT "pokemon_milestones_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_milestones" ADD CONSTRAINT "pokemon_milestones_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."pokemon_research" ADD CONSTRAINT "pokemon_research_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_game_id_pokemon_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."pokemon_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pokemon_seen" ADD CONSTRAINT "pokemon_seen_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "ref"."pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_challenge_progress" ADD CONSTRAINT "tracker_challenge_progress_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_challenge_progress" ADD CONSTRAINT "tracker_challenge_progress_HtzyFtOyUAhk_fkey" FOREIGN KEY ("challenge_id") REFERENCES "ref"."tracker_challenges"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "ref"."tracker_challenges" ADD CONSTRAINT "tracker_challenges_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_ranks" ADD CONSTRAINT "tracker_ranks_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_ranks" ADD CONSTRAINT "tracker_ranks_game_id_tracker_games_id_fkey" FOREIGN KEY ("game_id") REFERENCES "ref"."tracker_games"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_weapon_ranks" ADD CONSTRAINT "tracker_weapon_ranks_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."tracker_weapon_ranks" ADD CONSTRAINT "tracker_weapon_ranks_item_id_tracker_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "ref"."tracker_items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "animaldex"."sighting_photos" ADD CONSTRAINT "sighting_photos_sighting_id_sightings_id_fkey" FOREIGN KEY ("sighting_id") REFERENCES "animaldex"."sightings"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "animaldex"."sightings" ADD CONSTRAINT "sightings_animal_id_animals_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animaldex"."animals"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "meta"."columns" ADD CONSTRAINT "columns_table_id_tables_id_fkey" FOREIGN KEY ("table_id") REFERENCES "meta"."tables"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."forecasts" ADD CONSTRAINT "forecasts_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."forecasts" ADD CONSTRAINT "forecasts_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "money"."insurances" ADD CONSTRAINT "insurances_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."projection_settings" ADD CONSTRAINT "projection_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."projection_settings" ADD CONSTRAINT "projection_settings_investment_account_id_accounts_id_fkey" FOREIGN KEY ("investment_account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "money"."recurring" ADD CONSTRAINT "recurring_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."recurring" ADD CONSTRAINT "recurring_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_id_accounts_id_fkey" FOREIGN KEY ("account_id") REFERENCES "money"."accounts"("id");--> statement-breakpoint
ALTER TABLE "money"."utility_readings" ADD CONSTRAINT "utility_readings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."utility_settings" ADD CONSTRAINT "utility_settings_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."wishlist" ADD CONSTRAINT "wishlist_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cs"."item_dimensions" ADD CONSTRAINT "item_dimensions_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cs"."price_observations" ADD CONSTRAINT "price_observations_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "house"."price_history" ADD CONSTRAINT "price_history_listing_id_listings_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "house"."listings"("id") ON DELETE CASCADE;

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View 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 $$;

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> 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
ALTER TABLE "books"."progress" ADD COLUMN "status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD COLUMN "rating" smallint;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD COLUMN "last_read_at" timestamp with time zone;--> 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
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"."progress" ADD CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5);
--> 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 $$;

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ export default [
} }
}, },
{ {
files: ['**/*.svelte'], files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
parser: ts.parser parser: ts.parser
@@ -34,6 +34,6 @@ export default [
} }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'] ignores: ['build/', '.svelte-kit/', 'dist/', 'static/vendor/']
} }
]; ];

7
house.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Copy to house.env on the HOME machine. That filename is ignored by Git.
HOUSE_API_URL=https://dev.stanrunge.dev
# Same value as HOUSE_WORKER_TOKEN in the website's .env.
HOUSE_WORKER_TOKEN=
# Cloudflare Access service token, used only for requests to HOUSE_API_URL.
HOUSE_CF_ACCESS_CLIENT_ID=
HOUSE_CF_ACCESS_CLIENT_SECRET=

975
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "bun --bun vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "npm run test:integration && npm run test:unit", "test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
@@ -15,31 +15,51 @@
"test:unit": "vitest", "test:unit": "vitest",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "bun scripts/db-migrate.ts",
"db:refresh-dev": "bash scripts/db-refresh-dev.sh",
"preview:branch": "bash scripts/preview.sh",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-catalog.ts", "db:seed": "bun scripts/seed-catalog.ts",
"db:migrate:auth": "bun scripts/migrate-auth-catalog.ts", "db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"db:migrate:foundation": "bun scripts/migrate-foundation.ts", "cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
"house:worker": "bun scripts/house-worker.ts",
"auth:role": "bun scripts/set-admin.ts", "auth:role": "bun scripts/set-admin.ts",
"catalog:build": "python3 scripts/build-pokemon-catalog.py", "catalog:build": "python3 scripts/build-pokemon-catalog.py",
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts" "catalog:cod": "python3 scripts/build-cod-catalog.py && prettier --write data/call-of-duty.json",
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts",
"catalog:research": "python3 scripts/build-pokemon-research.py",
"db:seed:research": "bun scripts/seed-pokemon-research.ts",
"db:seed:challenges": "bun scripts/seed-cod-challenges.ts",
"test:tracker": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.tracker.config.ts",
"db:seed:art": "bun scripts/seed-cod-art.ts",
"catalog:atlas": "python3 scripts/build-pokemon-atlas.py",
"catalog:images": "python3 scripts/download-game-images.py",
"catalog:map-art": "python3 scripts/build-pokemon-map-art.py",
"check:images": "python3 scripts/check-game-images.py"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/simple-icons": "^1.2.94", "@iconify-json/simple-icons": "^1.2.94",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.48.0",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@sveltejs/adapter-bun": "^1.0.0",
"@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3", "@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7", "@typescript/native": "npm:typescript@7",
"better-auth": "^1.7.2", "better-auth": "^1.7.2",
"bits-ui": "^2.19.0", "bits-ui": "^2.19.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk-sv": "^0.0.19", "cmdk-sv": "^0.0.19",
"cn": "^0.4.0",
"drizzle-kit": "^1.0.0-rc.4", "drizzle-kit": "^1.0.0-rc.4",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -48,11 +68,13 @@
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"prettier": "3.9.6", "prettier": "3.9.6",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.57.0", "shadcn-svelte": "^1.7.0",
"svelte": "^5.57.1",
"svelte-check": "4.7.6", "svelte-check": "4.7.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1", "tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~6", "typescript": "~6",
"typescript-eslint": "8.68.0", "typescript-eslint": "8.68.0",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^23.0.1",
@@ -65,14 +87,23 @@
"@aws-sdk/client-s3": "^3.1121.0", "@aws-sdk/client-s3": "^3.1121.0",
"@aws-sdk/s3-request-presigner": "^3.1121.0", "@aws-sdk/s3-request-presigner": "^3.1121.0",
"@maptiler/sdk": "^4.1.0", "@maptiler/sdk": "^4.1.0",
"@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85", "ai": "^7.0.85",
"d3-geo": "^3.1.1",
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4" "zod": "^4.5.4"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
} }
} }

View File

@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
process.env.TRACKER_E2E = '1';
export default defineConfig({
testDir: 'tests',
testMatch: 'game-tracker.test.ts',
workers: 1,
use: { baseURL: 'http://127.0.0.1:4174' },
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4174 --strictPort',
url: 'http://127.0.0.1:4174/login',
reuseExistingServer: false,
env: { ORIGIN: 'http://127.0.0.1:4174', BETTER_AUTH_URL: 'http://127.0.0.1:4174' },
timeout: 60000
}
});

View File

@@ -1,84 +0,0 @@
-- One-off: convert animaldex ids from uuid to serial, keeping existing rows.
-- Postgres can't cast uuid -> integer, so db:push can't do this itself.
--
-- Run BEFORE re-running db:push, once per database:
-- psql "$DB_URL" -v ON_ERROR_STOP=1 -f scripts/animaldex-uuid-to-serial.sql
--
-- Safe to run on an empty database too. Everything is in one transaction, so a
-- failure leaves the database untouched.
BEGIN;
-- the failed push created this before erroring out
DROP SEQUENCE IF EXISTS animals_id_seq;
-- 1. new integer ids alongside the uuid ones
ALTER TABLE animals ADD COLUMN new_id serial;
ALTER TABLE sightings ADD COLUMN new_id serial;
ALTER TABLE photos ADD COLUMN new_id serial;
-- 2. translate the foreign keys through the uuid -> integer mapping
ALTER TABLE sightings ADD COLUMN new_animal_id integer;
UPDATE sightings s SET new_animal_id = a.new_id FROM animals a WHERE a.id = s.animal_id;
ALTER TABLE photos ADD COLUMN new_sighting_id integer;
UPDATE photos p SET new_sighting_id = s.new_id FROM sightings s WHERE s.id = p.sighting_id;
-- 3. drop the old foreign keys (names vary by how they were created)
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT conrelid::regclass AS tbl, conname
FROM pg_constraint
WHERE contype = 'f' AND conrelid IN ('sightings'::regclass, 'photos'::regclass)
LOOP
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
END LOOP;
END $$;
-- 4. swap the foreign key columns
ALTER TABLE photos DROP COLUMN sighting_id;
ALTER TABLE photos RENAME COLUMN new_sighting_id TO sighting_id;
ALTER TABLE photos ALTER COLUMN sighting_id SET NOT NULL;
ALTER TABLE sightings DROP COLUMN animal_id;
ALTER TABLE sightings RENAME COLUMN new_animal_id TO animal_id;
ALTER TABLE sightings ALTER COLUMN animal_id SET NOT NULL;
-- 5. swap the primary keys
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT conrelid::regclass AS tbl, conname
FROM pg_constraint
WHERE contype = 'p'
AND conrelid IN ('animals'::regclass, 'sightings'::regclass, 'photos'::regclass)
LOOP
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
END LOOP;
END $$;
ALTER TABLE animals DROP COLUMN id;
ALTER TABLE animals RENAME COLUMN new_id TO id;
ALTER TABLE animals ADD PRIMARY KEY (id);
ALTER SEQUENCE animals_new_id_seq RENAME TO animals_id_seq;
ALTER TABLE sightings DROP COLUMN id;
ALTER TABLE sightings RENAME COLUMN new_id TO id;
ALTER TABLE sightings ADD PRIMARY KEY (id);
ALTER SEQUENCE sightings_new_id_seq RENAME TO sightings_id_seq;
ALTER TABLE photos DROP COLUMN id;
ALTER TABLE photos RENAME COLUMN new_id TO id;
ALTER TABLE photos ADD PRIMARY KEY (id);
ALTER SEQUENCE photos_new_id_seq RENAME TO photos_id_seq;
-- 6. restore the foreign keys under the names drizzle expects
ALTER TABLE sightings ADD CONSTRAINT sightings_animal_id_animals_id_fkey
FOREIGN KEY (animal_id) REFERENCES animals(id) ON DELETE CASCADE;
ALTER TABLE photos ADD CONSTRAINT photos_sighting_id_sightings_id_fkey
FOREIGN KEY (sighting_id) REFERENCES sightings(id) ON DELETE CASCADE;
COMMIT;

View File

@@ -0,0 +1,34 @@
import postgres from 'postgres';
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
// All saved progress lives in schema `games` since the schema reorg.
const tables = [
'catch_em_all_entries',
'catch_em_all_games',
'catch_em_all_settings',
'pokemon_journeys',
'pokemon_catches',
'pokemon_milestones',
'pokemon_seen',
'tracker_progress',
'tracker_ranks',
'tracker_weapon_ranks',
'tracker_challenge_progress',
'osu_entries'
];
try {
const result: Record<string, unknown> = {};
for (const table of tables) {
const [exists] = await client`select to_regclass(${'games.' + table}) is not null as present`;
if (!exists.present) continue;
const [row] = await client.unsafe(`select count(*)::int as rows,
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
from (select row_to_json(t)::text as record from games."${table}" t) s`);
result[table] = row;
}
console.log(JSON.stringify(result, null, 2));
} finally {
await client.end();
}

View File

@@ -1,18 +0,0 @@
-- Additive migration for the existing domain schema. No users or progress are removed.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user';
ALTER TABLE account ADD COLUMN IF NOT EXISTS issuer text;
-- Existing credentials use Better Auth's local namespace. Fail on unknown legacy
-- OAuth providers rather than silently changing which remote identity they trust.
UPDATE account SET issuer = 'local:credential' WHERE issuer IS NULL AND provider_id = 'credential';
UPDATE account SET issuer = 'https://accounts.google.com' WHERE issuer IS NULL AND provider_id = 'google';
UPDATE account SET issuer = 'local:oauth:github' WHERE issuer IS NULL AND provider_id = 'github';
UPDATE account SET issuer = 'local:oauth:osu' WHERE issuer IS NULL AND provider_id = 'osu';
ALTER TABLE account ALTER COLUMN issuer SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS account_issuer_account_id_unique ON account (issuer, account_id);
ALTER TABLE games ADD COLUMN IF NOT EXISTS source_id integer;
CREATE UNIQUE INDEX IF NOT EXISTS games_source_id_unique ON games (source_id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_number_unique ON pokemon (number);
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'unknown';
CREATE TABLE IF NOT EXISTS catch_em_all_settings (
user_id text PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE
);

112
scripts/bank-link.ts Normal file
View 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
View 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();
}

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

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

View File

@@ -0,0 +1,49 @@
"""Refresh the achievement lists in data/call-of-duty.json from Steam Community.
Weapons, classes and mastery rules in that file are hand-maintained; this only
replaces each game's `achievements` array with the public global achievement
pages of its Steam apps. Keys are `<app id>:<slug>` so progress survives renames
of descriptions or icons.
"""
import html
import json
from pathlib import Path
import re
import urllib.request
TARGET = Path(__file__).resolve().parent.parent / 'data/call-of-duty.json'
ROW = re.compile(r'<div class="achieveRow.*?<img src="([^"]+)".*?<h3>(.*?)</h3>\s*<h5>(.*?)</h5>', re.S)
def slug(text):
return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
def achievements(app):
request = urllib.request.Request(f"https://steamcommunity.com/stats/{app['id']}/achievements/?l=english",
headers={'User-Agent': 'stanrunge.dev game-tracker catalog builder'})
with urllib.request.urlopen(request, timeout=60) as response:
page = response.read().decode('utf-8')
rows, seen = [], {}
for icon, name, description in ROW.findall(page):
name, description = html.unescape(name).strip(), html.unescape(description).strip()
key = f"{app['id']}:{slug(name)}"
seen[key] = seen.get(key, 0) + 1
if seen[key] > 1:
key += f'-{seen[key]}'
rows.append(dict(key=key, category=app['label'], name=name, description=description or None, icon=icon))
if not rows:
raise RuntimeError(f"No achievements parsed for Steam app {app['id']}")
return rows
def main():
catalog = json.loads(TARGET.read_text())
for game in catalog['games']:
game['achievements'] = [row for app in game['steamApps'] for row in achievements(app)]
print(f"{game['shortName']}: {len(game['achievements'])} achievements")
TARGET.write_text(json.dumps(catalog, ensure_ascii=False, indent='\t') + '\n')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,91 @@
"""Import attributable Call of Duty Wiki table snapshots into a factual challenge catalog.
Input is one or more rendered, line-numbered source snapshots per game, exported
by the research tool. Ambiguous or incomplete rows are skipped. No thresholds
are invented. Keys depend on category + name, never page line numbers.
Usage: python3 scripts/build-cod-challenges.py --snapshots /tmp/cod-wiki
The checked-in JSON is the deployable snapshot; this command does not need DB access.
"""
import argparse
import json
from pathlib import Path
import re
TITLES={'black-ops':'Call_of_Duty:_Black_Ops','black-ops-2':'Call_of_Duty:_Black_Ops_II','ghosts':'Operations_(challenges)/Call_of_Duty:_Ghosts','advanced-warfare':'Call_of_Duty:_Advanced_Warfare','black-ops-3':'Call_of_Duty:_Black_Ops_III','infinite-warfare':'Call_of_Duty:_Infinite_Warfare','wwii':'Call_of_Duty:_WWII'}
VERBS=r'(?:Get|Earn|Kill|Win|Complete|Destroy|Perform|Play|Achieve|Reach|Use|Capture|Defend|Survive|Assist|Shoot|Recover|Hack|Call in|Obtain|Unlock|Steal|Equip|Finish|Plant|Defuse|Deny|Pick up|Revive|Take|Absorb|Damage|Land|Hold|Score|Purchase|Make|Eliminate|Return|Collect|Prevent|Detonate|End|Resupply)'
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-')
def clean(s):return re.sub(r'\s+',' ',s).strip().replace('[]','')
def lines_for(directory,game):
lines={}
for p in directory.glob(game+'*.txt'):
if p.name.split('--')[0].split('.txt')[0]!=game:continue
raw=p.read_text();raw=re.sub(r'cite[^†]+†([^†]+)(?:†[^]+)?',r'\1',raw)
for m in re.finditer(r'L(\d+):\s*(.*?)(?=L\d+:|$)',raw,re.S):
number=int(m[1]);text=m[2].split('-------------------------')[0].strip()
if number not in lines or len(text)>len(lines[number]):lines[number]=text
return sorted(lines.items())
def parse(directory,game):
rows=lines_for(directory,game);heads={};items={};previous='';previous_line=0
source='https://callofduty.fandom.com/wiki/'+('' if game=='ghosts' else 'Challenges/')+TITLES[game]
for number,line in rows:
line=line.replace('\xa0',' ')
heading=re.match(r'^(#{2,6})\s+(.+)',line)
if heading:
level=len(heading[1]);heads={k:v for k,v in heads.items() if k<level};heads[level]=clean(heading[2]);previous='';continue
if game=='ghosts':
# The source omits many tier thresholds. Only standalone, explicit operations
# are imported; unknown '#' counts are deliberately left out.
bullet=re.match(rf'^\s*\*\s+(.+?):\s*((?:{VERBS}|Fire|Force|Crush|Have|Be|Successfully|Infect)\b.+)$',line)
if bullet and heads and '#' not in bullet[2] and any('Operations' in h for h in heads.values()) and not any('References' in h or 'Trivia' in h for h in heads.values()):
name=clean(bullet[1]);requirement=clean(bullet[2]);category=' / '.join(heads[k] for k in sorted(heads))
key=slug(category)+':'+slug(name)
items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=None,target=None,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_'))
continue
if not heads or not line or line.startswith(('*','Note:','Challenge','Name ','Description','XP Reward','Image','This ','The ','After ','All ','I:','II:','III:','IV:','V:','VI:')):continue
if any(re.search(r'Contents|Trivia|References|Gallery|Navigation|Explore|Follow|Community|Advertise',h) for h in heads.values()):continue
# BO2 campaign objectives lack stable named table rows; exclude those.
if game=='black-ops-2' and number<705:continue
if game=='black-ops' and number<490:continue
match=re.match(rf'^(.{{2,100}}?)\s+({VERBS}\b.+)$',line)
if match:
name,rest=match.groups()
if re.match(rf'^{VERBS}\b',name) or 'Challenge How' in name:continue
elif game=='advanced-warfare' and previous and number-previous_line<5 and re.match(rf'^{VERBS}\b',line):
name,rest=previous,line
else:
if game=='advanced-warfare' and len(line)<70 and not re.search(r'\d|XP|Playercard|Emblem|Reticle',line):previous=clean(line);previous_line=number
continue
fields=re.split(r'\s{2,}',rest)
requirement=clean(fields[0]);rewards=fields[1:]
if len(fields)==1:
match_xp=re.search(r'\s+(\d[\d,]*)(?:\s*XP)?$',requirement)
if match_xp:rewards=[match_xp[1]+' XP'];requirement=requirement[:match_xp.start()]
reward=clean(' · '.join(r for r in rewards if r not in ['None','Image'] and not re.fullmatch(r'\d+',r)))
reward=re.sub(r'Image:\s*','',reward).replace('Image ','').strip(' ·')
if not reward and any('Image' in r for r in rewards):reward='Calling card (see source)'
if not requirement or len(requirement)>1000 or re.search(r'\bL\d+:',requirement):continue
# Numeric progress only when the requirement starts with an explicit count.
count=re.match(r'^(?:Get|Earn|Kill|Win|Destroy|Play|Capture|Complete|Absorb)\s+(\d[\d,]*)\s+',requirement)
target=int(count[1].replace(',','')) if count else None
category=' / '.join(heads[k] for k in sorted(heads))
if game=='black-ops':category='Multiplayer / '+category
name=clean(name).strip('* ')
if re.search(r'Image:|\bXP\b',name):continue # merged table cells, not a valid challenge name
key=slug(category)+':'+slug(name)
if key in items:continue
items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=reward or None,target=target,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_'))
return list(items.values())
def main():
parser=argparse.ArgumentParser();parser.add_argument('--snapshots',type=Path,required=True);args=parser.parse_args()
games={game:parse(args.snapshots,game) for game in TITLES}
result=dict(source=dict(name='Call of Duty Wiki contributors',license='CC BY-SA',licenseUrl='https://www.fandom.com/licensing',attribution='https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights',retrieved='2026-09-25',scope='Named challenge rows with explicit requirements. Incomplete and ambiguous source rows are excluded. Source coverage varies; these are not complete game inventories. Numeric targets are recorded only when explicit at the start of a requirement.'),games=games)
Path('data/cod-challenges.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
print({game:len(rows) for game,rows in games.items()})
if __name__=='__main__':main()

View File

@@ -0,0 +1,85 @@
"""Build the factual region atlas and authored overview maps.
Run from the repository root. Coordinate sources are cached in /tmp/pokemon-atlas.
Later regions are explicitly schematic and do not imply exact route geometry.
"""
import argparse, hashlib, json, re, urllib.request
from pathlib import Path
parser=argparse.ArgumentParser()
parser.add_argument('--cache',type=Path,default=Path('/tmp/pokemon-atlas'))
args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True)
sources={
'pokeemerald-region.json':'https://raw.githubusercontent.com/pret/pokeemerald/master/src/data/region_map/region_map_sections.json',
'pokefirered-region.json':'https://raw.githubusercontent.com/pret/pokefirered/master/src/data/region_map/region_map_sections.json',
'platinum-map.json':'https://raw.githubusercontent.com/pret/pokeplatinum/main/res/town_map/town_map_data.json',
'johto-landmarks.asm':'https://raw.githubusercontent.com/pret/pokecrystal/master/data/maps/landmarks.asm'
}
checksums={'pokeemerald-region.json': '3a5f8c07c85c17f05cec06d049305962e9a9a1d2bd6eae935f3887866a4ad491', 'pokefirered-region.json': '82e456cbe86a57ebc4bd0d88d6e000e83e28340895ac038079c38d415bc6de5f', 'platinum-map.json': 'b5f6792e7993afa57df3d5b86c0af57488d23642576c4a224ba401bd762ba6b8', 'johto-landmarks.asm': 'bf61258379901daf794555b1a8f73bcf634d50ef3ca6e067f88f599b9df9816f'}
for filename,url in sources.items():
path=args.cache/filename
if not path.exists():
with urllib.request.urlopen(url,timeout=60) as response: path.write_bytes(response.read())
if hashlib.sha256(path.read_bytes()).hexdigest()!=checksums[filename]:
raise ValueError(f'{filename}: upstream coordinates changed; review the map and update its checksum before rebuilding')
from pathlib import Path
locations=json.load(open('data/pokemon-locations.json'))['locations']
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-')
def location(key,region):
key=key.replace('eternia','eterna').replace('mount-coronet','mt-coronet').replace('tin-tower','bell-tower')
keys=[key,f'{region}-{key}',key.replace('route-','sea-route-'),f'{region}-{key.replace("route-","sea-route-")}']
return next((p for p in locations if p['slug'] in keys and (p['region'] or '').lower()==region),None)
def node(key,x,y,region,cells=None):
p=location(key,region)
return {'slug':p['slug'] if p else key,'name':p['name'] if p else key.replace('-',' ').title(),'x':x,'y':y,'kind':'town' if re.search('town|city|village|area$|league',key) else 'route' if 'route' in key else 'landmark','cells':cells or []}
regions={}
for region,file in [('hoenn',args.cache/'pokeemerald-region.json'),('kanto',args.cache/'pokefirered-region.json')]:
d=json.load(open(file))['map_sections'];nodes=[]
for p in d:
if 'x' not in p:continue
key=slug(p['name']);x=p['x'];y=p['y'];w=p['width'];h=p['height']
if region=='hoenn' and key=='none':break
if region=='kanto' and key=='one-island':break
nodes.append(node(key,x+w/2,y+h/2,region,[[x,y,w,h]]))
regions[region]={'name':region.title(),'width':32,'height':20,'nodes':nodes,'source':f'https://github.com/pret/poke{ "emerald" if region=="hoenn" else "firered" }/blob/master/src/data/region_map/region_map_sections.json','precision':'Game map coordinates'}
# Platinum town map blocks preserve branching paths and geographic placement.
d=json.load(open(args.cache/'platinum-map.json'));groups={}
for b in d['blocks']:
key=re.sub(r'_(north|south)$','',b['area'] or '').replace('_','-')
if not key:continue
groups.setdefault(key,[]).append([b['x'],b['z'],1,1])
if b.get('landmark') and b['landmark'].replace('_','-')!=key:
land=b['landmark'].replace('_','-')
if location(land,'sinnoh'):groups.setdefault(land,[]).append([b['x'],b['z'],1,1])
nodes=[node(k,sum(c[0]+.5 for c in cells)/len(cells),sum(c[1]+.5 for c in cells)/len(cells),'sinnoh',cells) for k,cells in groups.items()]
regions['sinnoh']={'name':'Sinnoh','width':32,'height':30,'nodes':nodes,'source':'https://github.com/pret/pokeplatinum/blob/main/res/town_map/town_map_data.json','precision':'Platinum town map coordinates'}
s=(args.cache/'johto-landmarks.asm').read_text();nodes=[]
for x,y,key in re.findall(r'landmark\s+(\d+),\s*(\d+),\s*(\w+)Name',s):
if key=='PalletTown':break
name=re.sub(r'([a-z])([A-Z])',r'\1-\2',key);name=re.sub(r'([A-Za-z])(\d)',r'\1-\2',name)
nodes.append(node(name.lower(),int(x)/5,int(y)/5,'johto'))
regions['johto']={'name':'Johto','width':32,'height':30,'nodes':nodes,'source':'https://github.com/pret/pokecrystal/blob/master/data/maps/landmarks.asm','precision':'Crystal landmark coordinates'}
# Later-region overview points: deliberately identified as schematic rather than exact route maps.
landmarks={
'unova':'Nuvema Town,23,26;Accumula Town,23,23;Striaton City,26,20;Nacrene City,21,20;Castelia City,16,23;Nimbasa City,16,16;Driftveil City,9,17;Mistralton City,7,10;Icirrus City,11,5;Opelucid City,20,5;Lacunosa Town,26,6;Undella Town,28,12;Black City,26,17;White Forest,28,18;Aspertia City,4,26;Floccesy Town,7,25;Virbank City,10,25;Humilau City,29,4;Pokemon League,28,1',
'kalos':'Vaniville Town,23,25;Aquacorde Town,23,22;Santalune City,24,19;Lumiose City,16,12;Camphrier Town,12,19;Ambrette Town,5,24;Cyllage City,4,18;Geosenge Town,3,12;Shalour City,6,8;Coumarine City,11,6;Laverre City,17,7;Dendemille Town,24,8;Anistar City,28,12;Couriway Town,28,20;Snowbelle City,27,25;Pokemon Village,24,28;Pokemon League,30,17',
'alola':'Iki Town,6,6;Hauoli City,3,9;Melemele Sea,6,12;Heahea City,22,10;Paniola Town,24,7;Royal Avenue,26,11;Konikoni City,21,13;Malie City,26,20;Tapu Village,22,23;Po Town,21,17;Mount Lanakila,21,21;Seafolk Village,5,23;Poni Wilds,4,20;Vast Poni Canyon,7,17;Aether Paradise,14,15',
'galar':'Postwick,15,27;Wedgehurst,15,24;Wild Area,15,20;Motostoke,15,17;Turffield,8,14;Hulbury,23,14;Hammerlocke,15,10;Stow on Side,6,10;Ballonlea,7,5;Circhester,25,8;Spikemuth,26,17;Wyndon,15,3;Isle of Armor,29,23;Crown Tundra,7,28',
'hisui':'Jubilife Village,6,15;Obsidian Fieldlands,9,22;Crimson Mirelands,19,22;Cobalt Coastlands,26,15;Coronet Highlands,16,13;Alabaster Icelands,16,5;Temple of Sinnoh,16,10;Prelude Beach,4,19',
'paldea':'Cabo Poco,17,28;Los Platos,17,24;Mesagoza,17,21;Cortondo,11,22;Artazon,24,22;Levincia,28,15;Cascarrafa,9,16;Porto Marinada,4,11;Medali,12,10;Montenevera,20,6;Alfornada,5,27;Glaseado Mountain,21,9;Casseroya Lake,9,6;Area Zero,17,15;Pokemon League,14,19',
'orre':'Phenac City,21,21;Pyrite Town,12,20;Agate Village,11,6;Mt Battle,22,4;Realgam Tower,19,15;The Under,10,23;Outskirt Stand,26,25;Snagem Hideout,27,15;Cipher Lab,27,8;Gateon Port,5,15;Pokemon HQ Lab,5,22;Rock Poke Spot,16,18;Oasis Poke Spot,23,11;Cave Poke Spot,16,10;Citadark Isle,4,28',
'kitakami':'Mossui Town,16,23;Loyalty Plaza,11,24;Apple Hills,9,21;Kitakami Hall,22,17;Oni Mountain,16,10;Crystal Pool,16,9;Timeless Woods,26,6;Fellhorn Gorge,26,14;Paradise Barrens,6,8;Revelers Road,20,21',
'blueberry':'Central Plaza,16,15;Savanna Biome,8,22;Coastal Biome,24,22;Canyon Biome,8,8;Polar Biome,24,8'
}
for region,raw in landmarks.items():
ns=[]
for point in raw.split(';'):
name,x,y=point.split(',');n=node(slug(name),int(x),int(y),region);n['name']=name.replace('Pokemon','Pokémon');ns.append(n)
regions[region]={'name':{'blueberry':'Blueberry Academy'}.get(region,region.title()),'width':32,'height':30,'nodes':ns,'source':f'https://bulbapedia.bulbagarden.net/wiki/{region.title() if region!="blueberry" else "Terarium"}','precision':'Schematic landmark overview; use the location list for routes'}
# Multiple blocks can describe one route or cave; retain one selectable marker.
for region in regions.values():
merged={}
for point in region['nodes']:
if point['slug'] in merged: merged[point['slug']]['cells'].extend(point['cells'])
else: merged[point['slug']]=point
region['nodes']=list(merged.values())
Path('data/pokemon-atlas.json').write_text(json.dumps({'source':'Coordinates adapted from pret decompilations; later-region landmark overviews are authored schematics. No game or wiki prose is reproduced.','regions':regions},ensure_ascii=False,indent=2)+'\n')
print({k:len(v['nodes']) for k,v in regions.items()})

View File

@@ -1,4 +1,15 @@
"""Build the species-level catalog from a pinned PokéAPI CSV snapshot. """Build the owned Pokémon catalog from a pinned PokéAPI CSV snapshot.
Writes two files:
data/pokemon-catalog.json species, games, per-game availability, evolutions
data/pokemon-locations.json locations and aggregated wild/gift/static encounters
Availability per species/game:
catchable documented encounter (wild, gift, static, in-game trade, ...)
evolve a pre-evolution is obtainable in that game and the method exists there
breed an obtainable relative can breed it in that game
event_only mythical Pokémon present in the game without another route
unknown present in the game's Pokédex/index data, route not confirmed
Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary
folder. Encounter absence is not evidence of trade/transfer/event exclusivity. folder. Encounter absence is not evidence of trade/transfer/event exclusivity.
@@ -8,13 +19,52 @@ import concurrent.futures
import csv import csv
import json import json
from pathlib import Path from pathlib import Path
import re
import tempfile import tempfile
import urllib.request import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a' REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types', TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types',
'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups', 'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups',
'pokemon_dex_numbers', 'pokedex_version_groups', 'pokemon_game_indices', 'encounters'] 'version_group_regions', 'regions', 'region_names', 'pokemon_dex_numbers',
'pokedex_version_groups', 'pokemon_game_indices', 'encounters', 'encounter_slots',
'encounter_methods', 'encounter_method_prose', 'encounter_condition_values',
'encounter_condition_value_prose', 'encounter_condition_value_map', 'locations',
'location_names', 'location_areas', 'location_area_prose', 'pokemon_evolution',
'evolution_trigger_prose', 'item_names', 'move_names', 'pokemon_egg_groups',
'pokemon_forms', 'pokemon_form_names']
# Version groups without a Day Care, Nursery or picnic eggs.
NO_BREEDING = {'red-blue', 'yellow', 'red-green-japan', 'blue-japan', 'colosseum', 'xd',
'lets-go-pikachu-lets-go-eevee', 'legends-arceus', 'legends-za',
'mega-dimension', 'champions'}
# Baby Pokémon that only hatch when a parent holds an incense; species number -> item.
INCENSE = {298: 'Sea Incense', 360: 'Lax Incense', 406: 'Rose Incense', 433: 'Pure Incense',
438: 'Rock Incense', 439: 'Odd Incense', 440: 'Luck Incense', 446: 'Full Incense',
458: 'Wave Incense'}
NO_EGGS = 15
METHOD_LABELS = {
'walk': 'Grass or cave', 'old-rod': 'Old Rod', 'good-rod': 'Good Rod', 'super-rod': 'Super Rod',
'surf': 'Surfing', 'rock-smash': 'Rock Smash', 'headbutt': 'Headbutt', 'dark-grass': 'Dark grass',
'grass-spots': 'Rustling grass', 'cave-spots': 'Dust cloud', 'bridge-spots': 'Bridge shadow',
'super-rod-spots': 'Super Rod (rippling water)', 'surf-spots': 'Surfing (rippling water)',
'yellow-flowers': 'Yellow flowers', 'purple-flowers': 'Purple flowers', 'red-flowers': 'Red flowers',
'rough-terrain': 'Rough terrain', 'gift': 'Gift', 'gift-egg': 'Gift egg', 'static': 'Static encounter',
'pokeflute': 'Poké Flute', 'headbutt-low': 'Headbutt (rare trees)', 'headbutt-normal': 'Headbutt (common trees)',
'headbutt-high': 'Headbutt (frequent trees)', 'squirt-bottle': 'Squirt Bottle', 'wailmer-pail': 'Wailmer Pail',
'seaweed': 'Diving in seaweed', 'roaming-grass': 'Roaming (grass)', 'roaming-water': 'Roaming (water)',
'devon-scope': 'Devon Scope', 'feebas-tile-fishing': 'Fishing on Feebas tiles', 'island-scan': 'Island Scan',
'sos': 'SOS battle', 'bubbling-spots': 'Fishing in bubbling spots', 'berry-trees': 'Berry tree',
'npc-trade': 'In-game trade', 'sos-from-bubbling-spot': 'SOS battle (bubbling spot)',
'overworld': 'Overworld', 'overworld-water': 'Overworld (water)', 'overworld-flying': 'Overworld (flying)',
'overworld-special': 'Overworld (special)', 'overworld-flying-special': 'Overworld (flying, special)',
'overworld-water-special': 'Overworld (water, special)', 'overworld-dirt': 'Overworld (dirt)',
'horde': 'Horde encounter', 'snag': 'Snag from a trainer', 'snag-rematch': 'Snag from a trainer (rematch)',
'pokespot': 'Poké Spot', 'hidden-grotto': 'Hidden Grotto', 'honey-tree': 'Honey tree',
'wanderer': 'Wanderer', 'wanderer-water': 'Wanderer (water)', 'chase-water': 'Chasing (water)',
'dynamax-adventure': 'Dynamax Adventure', 'max-raid': 'Max Raid Battle',
}
TIMES = {'day': 'during the day', 'night': 'at night', 'dusk': 'at dusk', 'full-moon': 'during a full moon'}
def build(directory): def build(directory):
@@ -22,12 +72,18 @@ def build(directory):
with (directory / f'{name}.csv').open() as f: with (directory / f'{name}.csv').open() as f:
return list(csv.DictReader(f)) return list(csv.DictReader(f))
def english(name, key, field='name'):
return {int(r[key]): r[field] for r in rows(name) if r['local_language_id'] == '9'}
def number(value):
return int(value) if value not in ('', None) else None
species = rows('pokemon_species') species = rows('pokemon_species')
pokemon = rows('pokemon') pokemon = rows('pokemon')
species_for = {int(p['id']): int(p['species_id']) for p in pokemon} species_for = {int(p['id']): int(p['species_id']) for p in pokemon}
defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'} defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'}
names = {int(p['pokemon_species_id']): p['name'] for p in rows('pokemon_species_names') if p['local_language_id'] == '9'} names = english('pokemon_species_names', 'pokemon_species_id')
type_names = {int(t['type_id']): t['name'] for t in rows('type_names') if t['local_language_id'] == '9'} type_names = english('type_names', 'type_id')
types = {} types = {}
for p in rows('pokemon_types'): for p in rows('pokemon_types'):
types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])] types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])]
@@ -35,42 +91,307 @@ def build(directory):
for p in rows('pokemon_stats'): for p in rows('pokemon_stats'):
stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat']) stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat'])
stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed'] stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed']
egg_groups = {}
for p in rows('pokemon_egg_groups'):
egg_groups.setdefault(int(p['species_id']), set()).add(int(p['egg_group_id']))
evolves_from = {int(s['id']): number(s['evolves_from_species_id']) for s in species}
chain_of = {int(s['id']): int(s['evolution_chain_id']) for s in species}
mythical = {int(s['id']) for s in species if s['is_mythical'] == '1'}
mons = [] mons = []
for s in species: for s in species:
number = int(s['id']) n = int(s['id'])
default = defaults[number] default = defaults[n]
mons.append(dict(number=number, name=names[number], generation=int(s['generation_id']), mons.append(dict(number=n, name=names[n], generation=int(s['generation_id']),
type1=types[default][1], type2=types[default].get(2), type1=types[default][1], type2=types[default].get(2),
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)})) **{name: stats[default][i + 1] for i, name in enumerate(stat_fields)},
evolvesFrom=evolves_from[n], evolutionChain=chain_of[n],
isBaby=s['is_baby'] == '1', isLegendary=s['is_legendary'] == '1',
isMythical=n in mythical))
# ── Games ────────────────────────────────────────────────────────────
groups = {int(g['id']): g for g in rows('version_groups')} groups = {int(g['id']): g for g in rows('version_groups')}
group_order = {g: int(groups[g]['order']) for g in groups}
regions_for_group = {}
for r in rows('version_group_regions'):
regions_for_group.setdefault(int(r['version_group_id']), set()).add(int(r['region_id']))
region_names = english('region_names', 'region_id')
versions = rows('versions') versions = rows('versions')
game_names = {int(v['version_id']): v['name'] for v in rows('version_names') if v['local_language_id'] == '9'} game_names = english('version_names', 'version_id')
games = [dict(sourceId=int(v['id']), name=game_names.get(int(v['id']), v['identifier'].replace('-', ' ').title()), games, version_group = [], {}
generation=int(groups[int(v['version_group_id'])]['generation_id'])) for v in versions] for v in versions:
for game, version in zip(games, versions): vid, group = int(v['id']), int(v['version_group_id'])
if version['identifier'].endswith('-japan'): version_group[vid] = group
game['name'] += ' (Japan)' name = game_names.get(vid, v['identifier'].replace('-', ' ').title())
if v['identifier'].endswith('-japan'):
name += ' (Japan)'
games.append(dict(sourceId=vid, name=name, slug=v['identifier'],
generation=int(groups[group]['generation_id']), sortOrder=group_order[group],
breeding=groups[group]['identifier'] not in NO_BREEDING))
versions_for_group = {} versions_for_group = {}
for v in versions: for v in versions:
versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id'])) versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id']))
# ── Presence (Pokédex membership and game indices) ───────────────────
groups_for_dex = {} groups_for_dex = {}
for d in rows('pokedex_version_groups'): for d in rows('pokedex_version_groups'):
groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id'])) groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id']))
entries = {} present = set()
# Membership and internal game indices establish presence, not how it is obtained.
for p in rows('pokemon_dex_numbers'): for p in rows('pokemon_dex_numbers'):
for group in groups_for_dex.get(int(p['pokedex_id']), []): for group in groups_for_dex.get(int(p['pokedex_id']), []):
for version in versions_for_group.get(group, []): for version in versions_for_group.get(group, []):
entries[(int(p['species_id']), version)] = 'unknown' present.add((int(p['species_id']), version))
for p in rows('pokemon_game_indices'): for p in rows('pokemon_game_indices'):
if int(p['game_index']) > 0: if int(p['game_index']) > 0:
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'unknown' present.add((species_for[int(p['pokemon_id'])], int(p['version_id'])))
for p in rows('encounters'):
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'catchable' # ── Locations and encounters ─────────────────────────────────────────
return dict(source={'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION, location_names = english('location_names', 'location_id')
'scope': 'All species and game versions in this snapshot; default-form types/stats. Unknown means presence without confirmed encounter data. Missing pairs have no source record, not proven absence.'}, location_rows = rows('locations')
pokemon=mons, games=games, entries=[[p, g, availability] for (p, g), availability in sorted(entries.items())]) location_region = {int(l['id']): number(l['region_id']) for l in location_rows}
locations = [dict(sourceId=int(l['id']), slug=l['identifier'],
name=location_names.get(int(l['id']), l['identifier'].replace('-', ' ').title()),
region=region_names.get(number(l['region_id']) or 0))
for l in location_rows]
location_label = {l['sourceId']: l['name'] for l in locations}
area_prose = english('location_area_prose', 'location_area_id')
areas = {}
for a in rows('location_areas'):
location = int(a['location_id'])
label = None
if a['identifier']:
prose = area_prose.get(int(a['id']), '')
inner = re.fullmatch(re.escape(location_names.get(location, '')) + r' \((.+)\)', prose)
label = inner.group(1) if inner else ' '.join(
w.upper() if re.fullmatch(r'b?\d+f', w) else w for w in a['identifier'].split('-')).capitalize()
areas[int(a['id'])] = (location, label)
method_prose = english('encounter_method_prose', 'encounter_method_id')
method_label = {int(m['id']): METHOD_LABELS.get(m['identifier'], method_prose.get(int(m['id']), m['identifier']))
for m in rows('encounter_methods')}
slots = {int(s['id']): (int(s['encounter_method_id']), number(s['rarity'])) for s in rows('encounter_slots')}
condition_prose = english('encounter_condition_value_prose', 'encounter_condition_value_id')
species_ids = {s['identifier']: int(s['id']) for s in species}
condition_text = {}
for c in rows('encounter_condition_values'):
value, kind, identifier = int(c['id']), int(c['encounter_condition_id']), c['identifier']
if kind == 15: # in-game trade: the Pokémon the NPC wants
wanted = identifier.removeprefix('trade-')
condition_text[value] = f"In exchange for {names[species_ids[wanted]] if wanted in species_ids else wanted.replace('-', ' ')}"
elif kind == 16: # Game Corner prize
condition_text[value] = f"{identifier.removeprefix('coins-')} Game Corner coins"
elif c['is_default'] != '1':
condition_text[value] = condition_prose.get(value) or re.sub(
r'^(other|story-progress|item)-', '', identifier).replace('-', ' ').capitalize()
conditions = {}
for c in rows('encounter_condition_value_map'):
value = int(c['encounter_condition_value_id'])
if value in condition_text:
conditions.setdefault(int(c['encounter_id']), set()).add(condition_text[value])
form_names = {int(f['pokemon_form_id']): f['form_name'] for f in rows('pokemon_form_names')
if f['local_language_id'] == '9' and f['form_name']}
form_for = {int(f['pokemon_id']): form_names.get(int(f['id'])) for f in rows('pokemon_forms')
if f['is_default'] == '1' and int(f['pokemon_id']) not in defaults.values()}
grouped = {}
for e in rows('encounters'):
pid, version = int(e['pokemon_id']), int(e['version_id'])
location, area = areas[int(e['location_area_id'])]
method, rarity = slots[int(e['encounter_slot_id'])]
key = (species_for[pid], version, location, area, method_label[method], form_for.get(pid),
tuple(sorted(conditions.get(int(e['id']), ()))))
low, high, total = grouped.get(key, (999, 0, 0))
grouped[key] = (min(low, int(e['min_level'])), max(high, int(e['max_level'])), total + (rarity or 0))
# Max Raid dens repeat every species per den and star level; fold them per location.
raids = {}
for key in [k for k in grouped if k[4] == METHOD_LABELS['max-raid']]:
s, v, loc, area, method, form, conds = key
low, high, _ = grouped.pop(key)
raid = raids.setdefault((s, v, loc, form), dict(dens=set(), stars=set(), beams=set(), low=999, high=0))
if area:
raid['dens'].add(area.removeprefix('Max Den '))
raid['low'], raid['high'] = min(raid['low'], low), max(raid['high'], high)
for condition in conds:
star = re.search(r'difficulty of (\d) star', condition)
if star:
raid['stars'].add(int(star.group(1)))
elif 'pillar of light' in condition:
raid['beams'].add('Wild Area News' if 'News' in condition else 'purple' if 'purple' in condition else 'red')
for (s, v, loc, form), raid in raids.items():
stars = sorted(raid['stars'])
conds = [f'{stars[0]}★' + (f'–{stars[-1]}★' if len(stars) > 1 else '') + ' raids'] if stars else []
if raid['beams'] == {'purple'}:
conds.append('Rare (purple) beam dens only')
if 'Wild Area News' in raid['beams']:
conds.append('Wild Area News event dens')
dens = sorted(raid['dens'])
area = (('Max Den ' if len(dens) == 1 else 'Max Dens ') + ', '.join(dens)) if dens else None
grouped[(s, v, loc, area, METHOD_LABELS['max-raid'], form, tuple(conds))] = (raid['low'], raid['high'], 0)
encounters = [[s, v, loc, area, method, low, high, min(total, 100) or None, form, list(conds)]
for (s, v, loc, area, method, form, conds), (low, high, total) in sorted(
grouped.items(), key=lambda item: tuple('' if x is None else str(x) for x in item[0]))]
catchable, where = set(), {}
for s, v, loc, *_ in encounters:
catchable.add((s, v))
seen = where.setdefault((s, v), [])
if location_label[loc] not in seen:
seen.append(location_label[loc])
# ── Evolution methods per game ───────────────────────────────────────
items = english('item_names', 'item_id')
moves = english('move_names', 'move_id')
triggers = english('evolution_trigger_prose', 'evolution_trigger_id')
def describe(r):
trigger = int(r['evolution_trigger_id'])
level = number(r['minimum_level'])
if trigger == 1:
text = f'Level {level}+' if level else 'Level up'
elif trigger == 2:
text = 'Trade'
elif trigger == 3:
text = f"Use a {items[int(r['trigger_item_id'])]}"
else:
text = triggers[trigger]
parts = [text]
if r['held_item_id']:
parts.append(f"holding a {items[int(r['held_item_id'])]}")
if r['known_move_id']:
parts.append(f"knowing {moves[int(r['known_move_id'])]}")
if r['known_move_type_id']:
parts.append(f"knowing a {type_names[int(r['known_move_type_id'])]}-type move")
if r['minimum_happiness']:
parts.append('with high friendship')
if r['minimum_affection']:
parts.append('with high affection')
if r['minimum_beauty']:
parts.append('with high Beauty')
if r['time_of_day']:
parts.append(TIMES.get(r['time_of_day'], r['time_of_day']))
if r['near_special_rock'] == '1':
parts.append('near the special rock')
if r['location_id']:
parts.append(f"at {location_label[int(r['location_id'])]}")
if r['party_species_id']:
parts.append(f"with {names[int(r['party_species_id'])]} in the party")
if r['party_type_id']:
parts.append(f"with a {type_names[int(r['party_type_id'])]}-type Pokémon in the party")
if r['trade_species_id']:
parts.append(f"for a {names[int(r['trade_species_id'])]}")
if r['relative_physical_stats']:
parts.append({'1': 'if Attack > Defense', '0': 'if Attack = Defense', '-1': 'if Attack < Defense'}[r['relative_physical_stats']])
if r['gender_id']:
parts.append('(female)' if r['gender_id'] == '1' else '(male)')
if r['needs_overworld_rain'] == '1':
parts.append('while it is raining')
if r['turn_upside_down'] == '1':
parts.append('holding the console upside down')
if r['used_move_id']:
parts.append(f"after using {moves[int(r['used_move_id'])]} {r['minimum_move_count'] or ''} times".replace(' ', ' '))
if r['minimum_steps']:
parts.append(f"after walking {r['minimum_steps']} steps with it")
if r['minimum_damage_taken']:
parts.append(f"after losing {r['minimum_damage_taken']}+ HP without fainting")
if r['needs_multiplayer'] == '1':
parts.append('in Union Circle')
if r['nature_bitmask']:
parts.append('(form depends on nature)')
if r['percentage_chance']:
parts.append(f"({r['percentage_chance']}% chance)")
if r['region_id']:
parts.append(f"in {region_names[int(r['region_id'])]}")
return ' '.join(parts)
evolution_rows = {}
for r in rows('pokemon_evolution'):
evolution_rows.setdefault(int(r['evolved_species_id']), []).append(r)
# Methods are dated from Red/Blue; the earlier Japanese Red/Green/Blue share them.
first_order = min(group_order[int(r['version_group_id'])] for rs in evolution_rows.values() for r in rs)
def methods(target, version):
group = version_group[version]
regions = regions_for_group.get(group, set())
valid = [r for r in evolution_rows.get(target, [])
if group_order[int(r['version_group_id'])] <= max(group_order[group], first_order)
and (not r['region_id'] or int(r['region_id']) in regions)
and (not r['location_id'] or location_region[int(r['location_id'])] in regions)]
# A regional variant (e.g. Alolan Raichu in Alola) replaces the generic method.
regional = {r['required_pokemon_form_id'] for r in valid if r['region_id']}
valid = [r for r in valid if r['region_id'] or r['required_pokemon_form_id'] not in regional]
# Each form variant uses the method from the latest version group that defines it.
latest = {}
for r in valid:
key = (r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])
latest[key] = max(latest.get(key, 0), group_order[int(r['version_group_id'])])
chosen = [describe(r) for r in valid
if group_order[int(r['version_group_id'])] == latest[(r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])]]
return list(dict.fromkeys(chosen))
# ── Availability ─────────────────────────────────────────────────────
availability, notes = {}, {}
for key in present | catchable:
availability[key] = 'catchable' if key in catchable else 'unknown'
if key in catchable:
places = where[key]
notes[key] = ', '.join(places[:3]) + (f' (+{len(places) - 3} more)' if len(places) > 3 else '')
chain_members = {}
for n, chain in chain_of.items():
chain_members.setdefault(chain, []).append(n)
obtainable = {'catchable', 'evolve', 'breed'}
evolutions = {}
for game in games:
v = game['sourceId']
changed = True
while changed:
changed = False
for n in evolves_from:
key = (n, v)
if availability.get(key) != 'unknown':
continue
parent = evolves_from[n]
if parent and availability.get((parent, v)) in obtainable:
found = methods(n, v)
if found:
availability[key], notes[key] = 'evolve', f"Evolve {names[parent]}: {' or '.join(found)}"
changed = True
continue
if game['breeding'] and not parent:
parents = [m for m in chain_members[chain_of[n]] if m != n
and availability.get((m, v)) in obtainable
and NO_EGGS not in egg_groups.get(m, {NO_EGGS})]
if parents:
incense = f' holding a {INCENSE[n]}' if n in INCENSE else ''
availability[key], notes[key] = 'breed', f'Breed {names[parents[0]]}{incense}'
changed = True
for n in mythical:
if availability.get((n, v)) == 'unknown':
availability[(n, v)] = 'event_only'
notes[(n, v)] = 'Mythical: normally only through an event distribution or special unlock'
for n, parent in evolves_from.items():
if parent and (n, v) in availability and (parent, v) in availability:
for method in methods(n, v):
evolutions.setdefault((parent, n, method), []).append(v)
entries = []
for (n, v), value in sorted(availability.items()):
entries.append([n, v, value, notes[(n, v)]] if (n, v) in notes else [n, v, value])
source = {'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION}
catalog = dict(
source=dict(source, scope='All species and game versions in this snapshot; default-form types/stats. '
'Evolve/breed/event availability is derived from evolution, egg group and mythical data. '
'Unknown means presence without a confirmed route. Missing pairs have no source record, not proven absence.'),
pokemon=mons, games=games, entries=entries,
evolutions=[[a, b, versions, method] for (a, b, method), versions in sorted(evolutions.items())])
# Methods and conditions repeat thousands of times; store them once and reference by index.
methods_used = sorted({e[4] for e in encounters})
conditions_used = sorted({c for e in encounters for c in e[9]})
for e in encounters:
e[4] = methods_used.index(e[4])
e[9] = [conditions_used.index(c) for c in e[9]]
location_catalog = dict(source=dict(source, scope='Locations and encounters aggregated per species, game, '
'area, method, form and non-default condition. Rarity is the summed slot '
'percentage. Encounter rows are [species, version, location, area, method index, '
'min level, max level, rarity, form, condition indexes].'),
locations=locations, methods=methods_used, conditions=conditions_used,
encounters=encounters)
return catalog, location_catalog
def main(): def main():
@@ -86,10 +407,16 @@ def main():
(directory / f'{table}.csv').write_bytes(response.read()) (directory / f'{table}.csv').write_bytes(response.read())
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES)) list(executor.map(download, TABLES))
catalog = build(directory) catalog, locations = build(directory)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-catalog.json' data = Path(__file__).resolve().parent.parent / 'data'
target.write_text(json.dumps(catalog, ensure_ascii=False, separators=(',', ':')) + '\n') for name, content in [('pokemon-catalog.json', catalog), ('pokemon-locations.json', locations)]:
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, {len(catalog['entries'])} entries") (data / name).write_text(json.dumps(content, ensure_ascii=False, separators=(',', ':')) + '\n')
counts = {}
for entry in catalog['entries']:
counts[entry[2]] = counts.get(entry[2], 0) + 1
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, "
f"{len(catalog['entries'])} entries {counts}, {len(catalog['evolutions'])} evolution methods, "
f"{len(locations['locations'])} locations, {len(locations['encounters'])} encounters")
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Build image-space map markers and assemble the three SV in-game maps.
Requires Pillow and beautifulsoup4. Cached source pages/tiles: /tmp/game-map-art.
The map artwork is from the games; source image-map coordinates are adapted from
Serebii with attribution. Hand-placed landmarks are explicitly approximate.
"""
from pathlib import Path
import argparse
import json
import re
import urllib.request
from PIL import Image
from bs4 import BeautifulSoup
ROOT=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cache',type=Path,default=Path('/tmp/game-map-art'))
args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True)
def fetch(url,name):
p=args.cache/name
if not p.exists():p.write_bytes(urllib.request.urlopen(url,timeout=45).read())
return p
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower().replace('é','e')).strip('-')
original=json.loads((ROOT/'data/pokemon-atlas.json').read_text())['regions']
locations=json.loads((ROOT/'data/pokemon-locations.json').read_text())['locations']
def node(name,x,y,region):
key=slug(name);aliases={'mount-coronet':'mt-coronet','pokemon-league':'sinnoh-pokemon-league','lake-of-rage':'lake-of-rage','mt-battle':'mt-battle','pokemon-hq-lab':'pokemon-hq-lab'}
key=aliases.get(key,key)
found=next((p for p in locations if slug(p['name'])==slug(name) and (p['region'] or '').lower()==region),None)
if not found:found=next((p for p in locations if p['slug'] in [key,region+'-'+key] and (p['region'] or '').lower()==region),None)
return dict(slug=found['slug'] if found else key,name=found['name'] if found else name,x=round(x,3),y=round(y,3),kind='town' if re.search(r'Town|City|Village|Area|League',name) else 'route' if 'Route' in name else 'landmark',cells=[])
maps={}
for region in original:
p=ROOT/f'static/game-art/maps/{region}.webp'
if region in ['paldea','kitakami','blueberry']:
remote='terarium' if region=='blueberry' else region
canvas=Image.new('RGB',(2048,2048))
for x in range(8):
for y in range(8):
tile=fetch(f'https://www.serebii.net/pokearth/{remote}/map/tile_2-{x}-{y}.png',f'{remote}-tile-{x}-{y}.png')
canvas.paste(Image.open(tile),(x*256,y*256))
canvas.save(p,format='WEBP',quality=90,method=6)
im=Image.open(p);w,h=im.size
source='https://www.serebii.net/pokearth/'+region+'/'
if region=='blueberry':source='https://www.serebii.net/pokearth/terarium/'
if region=='orre':source='https://pokemonrevolution.net/forum/topic/147410-orre-region/'
nodes=[]
if region in ['sinnoh','hoenn','kanto','johto','unova','kalos']:
if region in ['kalos','unova']:page=fetch('https://www.serebii.net/pokearth/','pokearth.html')
else:page=fetch(source,region+'.html')
soup=BeautifulSoup(page.read_bytes(),'html.parser')
area_map=soup.find('map',attrs={'name':re.compile('^'+region+'$',re.I)})
if area_map:
for a in area_map.find_all('area'):
values=list(map(float,re.findall(r'[\d.]+',a.get('coords',''))))
if len(values)<4:continue
xs=values[::2];ys=values[1::2]
# Bounding-box centre remains inside rectangular routes and town markers.
x=(min(xs)+max(xs))/2;y=(min(ys)+max(ys))/2
name=a.get('title','').strip()
if name and 0<=x<=w and 0<=y<=h:nodes.append(node(name,x,y,region))
nodes=list({n['slug']:n for n in nodes}.values())
maps[region]=dict(name=original[region]['name'],image=f'/game-art/maps/{region}.webp',width=w,height=h,nodes=nodes,source=source,credit='Nintendo / The Pokémon Company; map imagery and location coordinates via Serebii' if region!='orre' else 'Nintendo / Genius Sonority; Pokémon XD screenshot via Pokémon Revolution forum',precision='Regional map · location markers aligned to the source image' if nodes else 'Game map · approximate landmark markers',pixelated=w<500)
# Reviewed landmark centres as percentages of the image, not the old schematic.
manual={
'paldea':'Cabo Poco,52,93;Los Platos,51,85;Mesagoza,49,78;Cortondo,31,77;Artazon,69,78;Levincia,83,58;Cascarrafa,30,58;Porto Marinada,14,48;Medali,35,40;Montenevera,51,27;Alfornada,15,89;Glaseado Mountain,55,31;Casseroya Lake,25,28;Area Zero,48,61;Pokemon League,45,72',
'kitakami':'Mossui Town,56,77;Loyalty Plaza,43,80;Apple Hills,38,75;Kitakami Hall,67,58;Oni Mountain,49,49;Crystal Pool,48,50;Timeless Woods,75,28;Fellhorn Gorge,81,46;Paradise Barrens,27,29;Revelers Road,64,70',
'blueberry':'Central Plaza,50,50;Savanna Biome,72,70;Coastal Biome,72,30;Canyon Biome,30,70;Polar Biome,30,30',
'hisui':'Jubilife Village,31,55;Obsidian Fieldlands,27,70;Crimson Mirelands,70,68;Cobalt Coastlands,86,35;Coronet Highlands,52,43;Alabaster Icelands,37.5,18;Temple of Sinnoh,36,10;Prelude Beach,26,61',
'galar':'Postwick,27.5,68.5;Wedgehurst,27.5,63.5;Wild Area,26.1,54.2;Motostoke,25.7,46.3;Turffield,12.4,40.9;Hulbury,38.5,40.8;Hammerlocke,26.2,34.5;Stow on Side,12.6,31.3;Ballonlea,10.4,24.3;Circhester,43.3,26.5;Spikemuth,47.4,34.7;Wyndon,26.2,9.4;Isle of Armor,76.9,35.2;Crown Tundra,26.1,84.5',
'alola':'Iki Town,14.5,40.4;Hauoli City,12,48.3;Melemele Sea,18.8,55;Heahea City,30.8,32;Paniola Town,34.3,25.8;Royal Avenue,37.8,32.5;Konikoni City,26.3,28.8;Malie City,91.3,62.9;Tapu Village,78.5,73.3;Po Town,79,55;Mount Lanakila,82.8,64.6;Seafolk Village,56.8,37.9;Poni Wilds,57.8,33.3;Vast Poni Canyon,67,19.6;Aether Paradise,38.3,61.7',
'orre':'Phenac City,73,61.2;Pyrite Town,56,44;Agate Village,39,30;Mt Battle,54,19.8;Realgam Tower,59,55.6;Outskirt Stand,87.8,61.7;Snagem Hideout,84,36;Cipher Lab,73.4,27.7;Gateon Port,12.8,31.9;Pokemon HQ Lab,19.5,19.6;Rock Poke Spot,86.7,48.5;Oasis Poke Spot,57.5,31.9;Cave Poke Spot,38.3,47;Citadark Isle,31,57.7;Orre Colosseum,22.6,46.5'
}
for region,places in manual.items():
m=maps[region]
m['nodes']=[node(name,float(x)*m['width']/100,float(y)*m['height']/100,region) for name,x,y in (p.split(',') for p in places.split(';'))]
(ROOT/'data/pokemon-map-art.json').write_text(json.dumps(maps,ensure_ascii=False,indent=2)+'\n')
print({r:len(m['nodes']) for r,m in maps.items()})

View File

@@ -0,0 +1,119 @@
"""Reproducible factual PokéAPI import: regional dexes, learnsets and species biology.
Uses the same pinned revision as the encounter catalog. Reuses cached CSV files.
Never infers encounter absence from missing data.
"""
import argparse
import concurrent.futures
import csv
import json
from pathlib import Path
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_abilities', 'ability_names', 'pokemon_egg_groups',
'egg_group_prose', 'growth_rates', 'pokemon_stats', 'pokemon_dex_numbers', 'pokedex_prose',
'pokedexes', 'pokedex_version_groups', 'versions', 'version_group_regions', 'regions',
'moves', 'move_names', 'type_names', 'move_damage_class_prose', 'move_targets',
'pokemon_moves', 'pokemon_move_method_prose']
def build(directory):
def rows(name):
with (directory / f'{name}.csv').open() as f:
yield from csv.DictReader(f)
def names(table, key):
return {int(r[key]): r['name'] for r in rows(table) if r['local_language_id'] == '9'}
def integer(v):
return int(v) if v else None
defaults = {int(r['id']): r for r in rows('pokemon') if r['is_default'] == '1'}
species_for = {pid: int(r['species_id']) for pid, r in defaults.items()}
abilities, eggs, evs = {}, {}, {}
ability_names = names('ability_names', 'ability_id')
egg_names = names('egg_group_prose', 'egg_group_id')
growth = {int(r['id']): r['identifier'].replace('-', ' ') for r in rows('growth_rates')}
for r in rows('pokemon_abilities'):
if int(r['pokemon_id']) in species_for:
abilities.setdefault(species_for[int(r['pokemon_id'])], []).append(dict(name=ability_names[int(r['ability_id'])], hidden=r['is_hidden'] == '1'))
for r in rows('pokemon_egg_groups'):
eggs.setdefault(int(r['species_id']), []).append(egg_names[int(r['egg_group_id'])])
for r in rows('pokemon_stats'):
pid = int(r['pokemon_id'])
if pid in species_for:
evs.setdefault(species_for[pid], [0]*6)[int(r['stat_id'])-1] = int(r['effort'])
by_species = {int(r['species_id']): r for r in defaults.values()}
facts = []
for s in rows('pokemon_species'):
number = int(s['id'])
p = by_species[number]
facts.append(dict(number=number, facts=dict(height=int(p['height']), weight=int(p['weight']),
baseExperience=integer(p['base_experience']), captureRate=int(s['capture_rate']),
baseHappiness=integer(s['base_happiness']), growthRate=growth[int(s['growth_rate_id'])],
genderRate=int(s['gender_rate']), hatchCounter=integer(s['hatch_counter']),
abilities=abilities.get(number, []), eggGroups=eggs.get(number, []), evYield=evs.get(number, []))))
versions = list(rows('versions'))
dex_names = names('pokedex_prose', 'pokedex_id')
dex_slugs = {int(r['id']): r['identifier'] for r in rows('pokedexes')}
dex_groups = {}
for r in rows('pokedex_version_groups'):
dex_groups.setdefault(int(r['pokedex_id']), []).append(int(r['version_group_id']))
dexes = []
for r in rows('pokemon_dex_numbers'):
dex = int(r['pokedex_id'])
if dex == 1:
continue
for v in versions:
if int(v['version_group_id']) in dex_groups.get(dex, []):
dexes.append([int(r['species_id']), int(v['id']), dex_slugs[dex], dex_names[dex], int(r['pokedex_number'])])
regions = {int(r['id']): r['identifier'] for r in rows('regions')}
group_regions = {}
for r in rows('version_group_regions'):
group_regions.setdefault(int(r['version_group_id']), []).append(regions[int(r['region_id'])])
games = [dict(sourceId=int(v['id']), versionGroup=int(v['version_group_id']), regions=group_regions.get(int(v['version_group_id']), [])) for v in versions]
move_names = names('move_names', 'move_id')
type_names = names('type_names', 'type_id')
damage = names('move_damage_class_prose', 'move_damage_class_id')
targets = {int(r['id']): r['identifier'].replace('-', ' ') for r in rows('move_targets')}
moves = [dict(id=int(r['id']), name=move_names.get(int(r['id']), r['identifier']), type=type_names[int(r['type_id'])],
category=damage[int(r['damage_class_id'])], power=integer(r['power']), accuracy=integer(r['accuracy']),
pp=integer(r['pp']), priority=int(r['priority']), target=targets[int(r['target_id'])]) for r in rows('moves')]
method_names = names('pokemon_move_method_prose', 'pokemon_move_method_id')
learnsets = {}
for r in rows('pokemon_moves'):
pid = int(r['pokemon_id'])
if pid not in species_for:
continue
key = (species_for[pid], int(r['version_group_id']))
learnsets.setdefault(key, set()).add((int(r['move_id']), method_names[int(r['pokemon_move_method_id'])], int(r['level'])))
return dict(source=dict(repository='https://github.com/PokeAPI/pokeapi', revision=REVISION,
scope='Default forms; regional dex numbers, species biology and version-specific learnsets. Move battle values are current snapshot values, not historical per-generation values.'),
facts=facts, dexes=dexes, games=games, moves=moves,
learnsets=[[species, group, sorted(entries)] for (species, group), entries in sorted(learnsets.items())])
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--cache', type=Path, default=Path('/tmp/pokemon-research-' + REVISION))
args = parser.parse_args()
args.cache.mkdir(parents=True, exist_ok=True)
def download(table):
path = args.cache / f'{table}.csv'
if path.exists():
return
with urllib.request.urlopen(f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv', timeout=120) as response:
content = response.read()
path.write_bytes(content)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
result = build(args.cache)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-research.json'
target.write_text(json.dumps(result, ensure_ascii=False, separators=(',', ':')) + '\n')
print({k: len(v) for k, v in result.items() if isinstance(v, list)})
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Check picture coverage, decodability, dimensions, and manifest integrity offline."""
import json
from pathlib import Path
from PIL import Image
ROOT=Path(__file__).resolve().parents[1]
manifest=json.loads((ROOT/'data/game-images.json').read_text())
paths={r['path'] for r in manifest}
assert len(paths)==len(manifest), 'Duplicate image paths'
for game in json.loads((ROOT/'data/pokemon-catalog.json').read_text())['games']:
assert f'/game-logos/pokemon/{game["slug"]}.webp' in paths, game['slug']
for game in json.loads((ROOT/'data/call-of-duty.json').read_text())['games']:
assert f'/game-logos/cod/{game["slug"]}.webp' in paths, game['slug']
for kind in ['sprite','shiny','artwork']:
for number in range(1,1026):assert f'/game-art/pokemon/{kind}/{number}.webp' in paths
for slug in ['pokemon','call-of-duty','osu']:assert f'/game-logos/{slug}.webp' in paths
for row in json.loads((ROOT/'data/cod-art.json').read_text()):
assert row['path'] in paths, (row['game'],row['name'])
for path in json.loads((ROOT/'data/cod-achievement-art.json').read_text()).values():
assert path in paths, path
for region,m in json.loads((ROOT/'data/pokemon-map-art.json').read_text()).items():
paths.add(m['image'])
for point in m['nodes']:
assert 0 <= point['x'] <= m['width'] and 0 <= point['y'] <= m['height'], (region,point)
for_size=Image.open(ROOT/'static'/m['image'].lstrip('/'))
assert for_size.size==(m['width'],m['height']),region
for path in paths:
with Image.open(ROOT/'static'/path.lstrip('/')) as im:
assert im.format=='WEBP',path
im.load()
assert min(im.size)>0,path
print(f'{len(paths):,} local WebP images checked: all game pictures, 1,025 × 3 Pokémon images, 13 maps, and downloaded COD artwork.')

55
scripts/db-migrate.ts Normal file
View File

@@ -0,0 +1,55 @@
// Applies pending Drizzle migrations from ./drizzle. Runs as the schema owner
// (DB_MIGRATE_URL, role `personal`); falls back to DB_URL for local dev.
//
// bun scripts/db-migrate.ts apply pending migrations
// bun scripts/db-migrate.ts --init record the baseline as applied without running it
// (once, on a database already reorganised by the
// hand-written reorg scripts in db/reorg/)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
const url = process.env.DB_MIGRATE_URL || process.env.DB_URL;
if (!url) throw new Error('Set DB_MIGRATE_URL (schema owner) or DB_URL');
const init = process.argv.includes('--init');
const client = postgres(url, { max: 1, connect_timeout: 10, onnotice: () => {} });
try {
const db = drizzle({ client });
// Safety: never run the baseline on a database that already has data. Existing
// databases are brought to the baseline by the reviewed reorg scripts and then
// recorded with --init; only a truly empty database gets the baseline executed.
const [state] = await client`
select
to_regclass('drizzle.__drizzle_migrations') is not null as tracked,
(select count(*)::int from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname not in ('pg_catalog', 'information_schema', 'drizzle')
and n.nspname not like 'pg_toast%') as tables`;
const recorded = state.tracked
? (await client`select count(*)::int as n from drizzle.__drizzle_migrations`)[0].n
: 0;
if (!init && recorded === 0 && state.tables > 0)
throw new Error(
`Refusing to run the Drizzle baseline on a non-empty database (${state.tables} tables). ` +
'Apply the reorg scripts (db/reorg/) and record the baseline with --init first.'
);
if (init && state.tables === 0)
throw new Error('--init on an empty database: run without --init to create the schema');
const result = await migrate(db, {
migrationsFolder: new URL('../drizzle', import.meta.url).pathname,
migrationsSchema: 'drizzle',
migrationsTable: '__drizzle_migrations',
...(init ? { init: true } : {})
});
if (result && 'exitCode' in result) {
throw new Error(
result.exitCode === 'databaseMigrations'
? '--init refused: migrations are already recorded in this database'
: '--init refused: more than one local migration exists'
);
}
const [{ n }] = await client`select count(*)::int as n from drizzle.__drizzle_migrations`;
console.log(`${init ? 'Baseline recorded' : 'Migrations up to date'}; ${n} recorded`);
} finally {
await client.end();
}

112
scripts/db-refresh-dev.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Refresh the development database (personal_dev) from a fresh production dump.
#
# Prod is the source of truth for data; dev is a disposable copy of it.
#
# Required env (put them in .env.dev-refresh or export them; never commit real values):
# PROD_DB_URL read access to prod, e.g. postgresql://personal:...@127.0.0.1:5440/personal
# DEV_ADMIN_URL superuser on the dev cluster, maintenance DB, e.g. postgresql://postgres:...@127.0.0.1:5440/postgres
# (not needed with PG_CONTAINER, which uses the container's local superuser)
# Optional:
# DEV_DB_NAME target database (default personal_dev)
# DEV_OWNER role that owns every object in the dev DB (default personal_dev). It is the
# role the dev site logs in as, so it can also run migrations there. It must not
# be a member of any prod role; prod's CONNECT is not granted to it.
# PG_DOCKER=1 use the postgres:17-alpine image instead of local pg_dump/pg_restore
# PG_CONTAINER name of the Postgres container (e.g. postgres-db-1): run every tool inside it
# via `docker exec` as the local superuser. URLs are then as seen from inside
# the container, e.g. PROD_DB_URL=postgresql://personal:...@127.0.0.1:5432/personal
# DOCKER docker command (default "docker"; e.g. "sudo -n docker")
#
# What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME owned by DEV_OWNER ->
# restore without prod ownership/grants, every object owned by DEV_OWNER -> clear sessions and
# verification tokens so prod logins can't be reused in dev. It never writes to prod.
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ -f .env.dev-refresh ]]; then set -a; . ./.env.dev-refresh; set +a; fi
: "${PROD_DB_URL:?set PROD_DB_URL (source, read-only use)}"
DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}"
DEV_OWNER="${DEV_OWNER:-personal_dev}"
DOCKER="${DOCKER:-docker}"
if [[ -n "${PG_CONTAINER:-}" ]]; then
DEV_ADMIN_URL="${DEV_ADMIN_URL:-postgresql://postgres@localhost/postgres}"
fi
: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}"
for ident in "$DEV_DB_NAME" "$DEV_OWNER"; do
if [[ ! "$ident" =~ ^[a-z_][a-z0-9_]*$ ]]; then
echo "DEV_DB_NAME / DEV_OWNER must be plain lowercase identifiers" >&2
exit 1
fi
done
case "$DEV_DB_NAME" in
personal | personal_prod | postgres | template0 | template1)
echo "Refusing to overwrite '$DEV_DB_NAME'" >&2
exit 1
;;
esac
case "$DEV_OWNER" in
personal | personal_app | personal_ddl | personal_ro | postgres)
echo "Refusing: DEV_OWNER '$DEV_OWNER' is a prod/superuser role" >&2
exit 1
;;
esac
# host:port/db of a URL (no credentials), for the "same database" guard.
endpoint() { bun -e 'const u=new URL(process.argv[1]); console.log(`${u.hostname}:${u.port||5432}${u.pathname}`)' "$1"; }
with_db() { bun -e 'const u=new URL(process.argv[1]); u.pathname="/"+process.argv[2]; console.log(u.toString())' "$1" "$2"; }
prod_endpoint="$(endpoint "$PROD_DB_URL")"
dev_url="$(with_db "$DEV_ADMIN_URL" "$DEV_DB_NAME")"
if [[ "$(endpoint "$dev_url")" == "$prod_endpoint" ]]; then
echo "Refusing: target $DEV_DB_NAME is the prod source database ($prod_endpoint)" >&2
exit 1
fi
# Dump goes to stdout and restore reads stdin, so the same commands work locally,
# in a throwaway postgres image, or inside the Postgres container.
if [[ -n "${PG_CONTAINER:-}" ]]; then
pg() { $DOCKER exec -i -u postgres "$PG_CONTAINER" "$@"; }
elif [[ "${PG_DOCKER:-0}" == 1 ]]; then
pg() { $DOCKER run --rm -i --network host postgres:17-alpine "$@"; }
else
pg() { "$@"; }
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
dump="$work/prod.dump"
echo "Dumping prod ($prod_endpoint)..."
pg pg_dump --format=custom --no-password "$PROD_DB_URL" >"$dump"
[[ -s "$dump" ]] || { echo "Empty dump; aborting before touching $DEV_DB_NAME" >&2; exit 1; }
pg pg_restore --list <"$dump" >/dev/null
echo "Recreating $DEV_DB_NAME (owner $DEV_OWNER)..."
pg psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" <<SQL
SELECT count(pg_terminate_backend(pid)) AS terminated_sessions FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "$DEV_DB_NAME";
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DEV_OWNER') THEN
EXECUTE format('CREATE ROLE %I NOLOGIN', '$DEV_OWNER');
RAISE NOTICE 'created role $DEV_OWNER (NOLOGIN; give it a password + LOGIN for dev use)';
END IF;
END \$\$;
CREATE DATABASE "$DEV_DB_NAME" OWNER "$DEV_OWNER";
REVOKE CONNECT, TEMPORARY ON DATABASE "$DEV_DB_NAME" FROM PUBLIC;
SQL
echo "Restoring into $DEV_DB_NAME..."
# --no-owner/--no-privileges + --role: everything ends up owned by DEV_OWNER and none of
# prod's grants (personal_app, personal_ro, ...) are copied into dev.
pg pg_restore --no-password --exit-on-error --no-owner --no-privileges --role="$DEV_OWNER" \
--dbname="$dev_url" <"$dump"
echo "Clearing sessions and verification tokens..."
pg psql -v ON_ERROR_STOP=1 -q "$dev_url" -c 'TRUNCATE auth.session, auth.verification'
pg psql -qAt "$dev_url" -c "SELECT 'drizzle migrations recorded: ' || count(*) FROM drizzle.__drizzle_migrations"
echo "Done. Apply pending migrations from your branch with: bun run db:migrate"

View File

@@ -17,49 +17,176 @@ if (process.env.ORIGIN !== expectedOrigin || process.env.BETTER_AUTH_URL !== exp
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 }); const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
try { try {
await client`select 1`; await client`select 1`;
// Schema layout after the reorg (auth/money/house/ref/games/cs/animaldex/site/meta/lists).
const required = [
'auth.user',
'auth.session',
'auth.account',
'auth.verification',
'money.accounts',
'money.transactions',
'money.recurring',
'money.forecasts',
'money.utility_settings',
'money.utility_readings',
'money.wishlist',
'money.insurances',
'money.projection_settings',
'house.listings',
'house.price_history',
'house.alert_rules',
'house.worker_runs',
'ref.pokemon',
'ref.pokemon_games',
'ref.pokemon_game_entries',
'ref.pokemon_locations',
'ref.pokemon_encounters',
'ref.pokemon_evolutions',
'ref.pokemon_research',
'ref.pokemon_dex_numbers',
'ref.pokemon_moves',
'ref.pokemon_learnsets',
'ref.pokemon_game_research',
'ref.tracker_games',
'ref.tracker_items',
'ref.tracker_challenges',
'games.catch_em_all_entries',
'games.catch_em_all_games',
'games.catch_em_all_settings',
'games.pokemon_journeys',
'games.pokemon_catches',
'games.pokemon_milestones',
'games.pokemon_seen',
'games.tracker_progress',
'games.tracker_ranks',
'games.tracker_weapon_ranks',
'games.tracker_challenge_progress',
'games.osu_entries',
'cs.items',
'cs.price_observations',
'cs.item_dimensions',
'cs.price_streams',
'animaldex.animals',
'animaldex.sightings',
'animaldex.sighting_photos',
'site.blog_articles',
'site.tasks',
'site.topics',
'site.task_topics',
'meta.tables',
'meta.columns'
];
const missing = await client<{ name: string }[]>`
select name from unnest(${required}::text[]) as name where to_regclass(name) is null`;
if (missing.length)
throw new Error(`Schema reorg/migrations missing: ${missing.map((m) => m.name).join(', ')}`);
const [schema] = await client` const [schema] = await client`
select select
(select count(*) from information_schema.columns (select count(*) from information_schema.columns
where table_name = 'user' and column_name = 'role') as user_role, where table_schema = 'auth' and table_name = 'user' and column_name = 'role') as user_role,
(select count(*) from information_schema.columns (select count(*) from information_schema.columns
where table_name = 'account' and column_name = 'issuer') as account_issuer, where table_schema = 'money' and table_name = 'projection_settings'
and column_name = 'include_recurring' and data_type = 'boolean') as include_recurring_bool,
(select count(*) from information_schema.columns (select count(*) from information_schema.columns
where table_name = 'blog_articles' and column_name = 'tags') as blog_tags, where table_schema = 'games' and table_name = 'osu_entries'
to_regclass('public.catch_em_all_settings') is not null as settings_ready, and column_name in ('accuracy', 'pp', 'rate') and data_type = 'numeric') as osu_numeric,
(select count(*) from information_schema.columns where table_name = 'lists' and column_name = 'slug') as list_slug, (select count(*) from meta.tables where kind = 'list') as lists,
to_regclass('public.list_item_completions') is not null as list_completions, has_function_privilege('meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer)', 'execute') as can_create_list,
(select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id, has_schema_privilege('money', 'create') as app_has_ddl
(select count(*) from information_schema.columns
where table_name in ('animals', 'sightings', 'photos')
and column_name = 'id' and data_type = 'integer') as animal_serial_ids
`; `;
if ( if (
!Number(schema.user_role) || !Number(schema.user_role) ||
!Number(schema.account_issuer) || !Number(schema.include_recurring_bool) ||
!Number(schema.blog_tags) || Number(schema.osu_numeric) !== 3 ||
!schema.settings_ready || !Number(schema.lists) ||
!Number(schema.list_slug) || !schema.can_create_list
!schema.list_completions ||
!Number(schema.topic_link_id) ||
Number(schema.animal_serial_ids) !== 3
) { ) {
throw new Error( throw new Error(
'Required migrations or Animaldex ID conversion have not been applied to this database' 'The schema reorg has not been applied to this database (or grants are missing)'
); );
} }
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` const [catalog] = await client`
select select
(select count(*) from pokemon) as pokemon, (select count(*) from ref.pokemon) as pokemon,
(select count(*) from games where source_id is not null) as games, (select count(*) from ref.pokemon_games where source_id is not null) as games,
(select count(*) from pokemon_game_entries) as entries (select count(*) from ref.pokemon_game_entries) as entries,
(select count(*) from ref.pokemon_encounters) as encounters,
(select count(*) from ref.tracker_games where franchise = 'call-of-duty') as cod_games,
(select count(*) from ref.pokemon_research) as pokemon_research,
(select count(*) from ref.pokemon_moves) as pokemon_moves,
(select count(*) from ref.pokemon_learnsets) as pokemon_learnsets,
(select count(*) from ref.pokemon_dex_numbers) as pokemon_dex_numbers,
(select count(*) from ref.tracker_challenges) as tracker_challenges
`; `;
if ( if (
Number(catalog.pokemon) < 1025 || Number(catalog.pokemon) < 1025 ||
Number(catalog.games) < 53 || Number(catalog.games) < 53 ||
Number(catalog.entries) < 27267 Number(catalog.entries) < 27267 ||
Number(catalog.encounters) < 60000
) { ) {
throw new Error('Pokémon catalog has not been seeded in this database'); throw new Error('Pokémon catalog has not been seeded in this database');
} }
if (
Number(catalog.pokemon_research) < 1025 ||
Number(catalog.pokemon_moves) < 937 ||
Number(catalog.pokemon_learnsets) < 11000 ||
Number(catalog.pokemon_dex_numbers) < 16000 ||
Number(catalog.tracker_challenges) < 3600
)
throw new Error(
'Run db:seed:research and db:seed:challenges before deploying the tracker expansion'
);
if (Number(catalog.cod_games) < 7) {
throw new Error('Call of Duty catalog has not been seeded in this database');
}
const [bo2] = await client`select rules,
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons,
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures
from ref.tracker_games g where slug='black-ops-2'`;
if (
!bo2 ||
bo2.rules.rankCaps?.weaponPrestige !== 2 ||
bo2.rules.rankCaps?.weaponLevelsByClass?.['Assault Rifles'] !== 17 ||
bo2.weapons < 40 ||
bo2.pictures < 39
) {
throw new Error('Seed the updated BO2 catalog and artwork before deploying');
}
} finally { } finally {
await client.end(); await client.end();
} }

View File

@@ -1,41 +1,123 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Production is the only deployed environment (local dev uses scripts/db-refresh-dev.sh).
# deploy-remote.sh <sha> deploy back up, migrate, seed, preflight, switch, health-check
# deploy-remote.sh <sha> rollback back up, preflight, switch to an earlier image (no migrations;
# the database is not rolled back, restore a backup for that)
sha=${1:?commit SHA required} sha=${1:?commit SHA required}
directory=${2:?deployment directory required} mode=${2:-deploy}
origin=${3:?public origin required}
case "$sha" in case "$sha" in
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;; *[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
esac esac
case "$directory:$origin" in case "$mode" in deploy|rollback) ;; *) echo 'Mode must be deploy or rollback' >&2; exit 2 ;; esac
/opt/personal-website:https://stanrunge.dev) directory=/opt/personal-website
environment=/etc/personal-website/prod.env ;; origin=https://stanrunge.dev
/opt/personal-website-staging:https://staging.stanrunge.dev) environment=/etc/personal-website/prod.env
environment=/etc/personal-website-staging/staging.env ;; environment_name=production
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
esac
image="git.stanrunge.dev/stan/personal-website:$sha" image="git.stanrunge.dev/stan/website:$sha"
compose="$directory/compose.prod.yml" 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"
if ! sudo docker image inspect "$image" >/dev/null 2>&1 &&
sudo docker image inspect "$legacy_image" >/dev/null 2>&1; then
sudo docker tag "$legacy_image" "$image"
fi
if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
echo "Image $image is not on this host" >&2; exit 1
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0 switched=0
rollback() { rollback() {
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
echo "Health check failed; restoring prior image tag $previous_tag" >&2 echo "Health check failed; restoring prior image tag $previous_tag" >&2
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null 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 fi
} }
trap rollback ERR trap rollback ERR
# The deploy account cannot read the env file directly; check inside a container.
if ! sudo docker run --rm --env-file "$environment" --entrypoint sh "$image" \
-c 'test -n "${DB_MIGRATE_URL:-}"'; then
echo "DB_MIGRATE_URL (schema owner) is missing from $environment" >&2; exit 1
fi
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/print-db-server-version.ts)
case "$db_version" in
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
esac
db_major=$((db_version / 10000))
if [ "$db_major" -gt 17 ]; then
echo "Server is PostgreSQL $db_major; update the pinned postgres:17-alpine backup image" >&2; exit 1
fi
backup_image=postgres:17-alpine
backup_dir=/var/backups/personal-website
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
sudo docker pull "$backup_image"
# The deploy account can run Docker, but has no general host-file sudo access.
# A bind volume creates the root-owned directory; restrict it before dumping.
sudo docker run --rm --network backend --env-file "$environment" \
--volume "$backup_dir:/backups" \
--env "BACKUP_FILE=$backup_name" "$backup_image" \
sh -eu -c 'chmod 0700 /backups; umask 077; pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_MIGRATE_URL"'
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
# Migrations and seeds (ref.* is read-only for the app) need the owner role.
# DB_MIGRATE_URL (role personal) must be set in the env file; DB_URL is the app
# role (personal_app), which has no DDL rights.
run_database_script() {
echo "Running $1"
sudo docker run --rm --network backend --env-file "$environment" \
--entrypoint sh "$image" -eu -c 'DB_URL="$DB_MIGRATE_URL" exec bun "$0"' "$1"
}
if [ "$mode" = deploy ]; then
# Schema changes: Drizzle migrations from ./drizzle, as the schema owner.
run_database_script scripts/db-migrate.ts
run_database_script scripts/seed-catalog.ts
run_database_script scripts/seed-cod-catalog.ts
run_database_script scripts/seed-pokemon-research.ts
run_database_script scripts/seed-cod-challenges.ts
run_database_script scripts/seed-cod-art.ts
fi
sudo docker run --rm --network backend --env-file "$environment" "$image" \ sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/deploy-preflight.ts "$origin" bun scripts/deploy-preflight.ts "$origin"
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
switched=1 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") expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container") actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
if [ "$actual_image" != "$expected_image" ]; then if [ "$actual_image" != "$expected_image" ]; then
@@ -45,6 +127,8 @@ fi
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/login" >/dev/null "$origin/login" >/dev/null
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/projects/catch-em-all" >/dev/null "$origin/projects/game-tracker/pokemon" >/dev/null
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null
trap - ERR trap - ERR
echo "Deployed $sha to $origin" echo "Deployed $sha to $origin ($mode)"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Download the reviewed artwork manifest, preserving aspect ratio, as local WebP.
Requires Pillow. Existing valid files are reused; failed downloads never replace files.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import io
import json
from pathlib import Path
import time
import urllib.request
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parents[1]
def download(row):
target = ROOT / 'static' / row['path'].lstrip('/')
if target.exists():
with Image.open(target) as img:
if img.format == 'WEBP':
return row['id'], None
for attempt in range(3):
try:
request = urllib.request.Request(row['url'], headers={'User-Agent': 'PersonalGameJournal/1.0 (image catalog)'})
with urllib.request.urlopen(request, timeout=45) as response:
content = response.read()
with Image.open(io.BytesIO(content)) as image:
image = ImageOps.exif_transpose(image).convert('RGBA')
image.thumbnail((row.get('maxSize', 800),) * 2, Image.Resampling.LANCZOS)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix('.tmp')
image.save(temporary, format='WEBP', lossless=row.get('lossless', False), quality=88, method=6)
temporary.replace(target)
return row['id'], None
except Exception as error:
if attempt == 2:
return row['id'], str(error)
time.sleep(attempt + 1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--filter', default='')
parser.add_argument('--workers', type=int, default=6)
args = parser.parse_args()
rows = json.loads((ROOT / 'data/game-images.json').read_text())
rows = [r for r in rows if args.filter in r['id']]
failed = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
for index, (name, error) in enumerate(pool.map(download, rows), 1):
if error:
failed.append((name, error))
print('FAILED', name, error, flush=True)
elif index % 100 == 0 or len(rows) < 100:
print(f'{index}/{len(rows)} {name}', flush=True)
print(f'{len(rows) - len(failed)}/{len(rows)} images ready', flush=True)
if failed:
raise SystemExit(1)
if __name__ == '__main__':
main()

View File

@@ -1,93 +0,0 @@
-- Apply after auth-catalog-migration.sql and before deploying the foundation release.
-- Additive domain-schema changes; existing records and numeric IDs are retained.
ALTER TABLE blog_articles ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}'::text[];
ALTER TABLE lists ADD COLUMN IF NOT EXISTS slug text;
UPDATE lists SET slug = COALESCE(NULLIF(trim(both '-' from regexp_replace(lower(name), '[^a-z0-9]+', '-', 'g')), ''), 'list') || '-' || id
WHERE slug IS NULL;
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug);
ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;
CREATE TABLE IF NOT EXISTS list_item_completions (
id serial PRIMARY KEY,
item_id integer NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT list_item_completions_item_id_user_id_unique UNIQUE (item_id, user_id)
);
CREATE INDEX IF NOT EXISTS list_item_completions_user_id_idx ON list_item_completions(user_id);
-- Join/setting records gain the same numeric identity as other site-owned rows.
ALTER TABLE tasks_to_topics ADD COLUMN IF NOT EXISTS id serial;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND conname = 'tasks_to_topics_pkey'
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
ALTER TABLE tasks_to_topics DROP CONSTRAINT tasks_to_topics_pkey;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND contype = 'p') THEN
ALTER TABLE tasks_to_topics ADD CONSTRAINT tasks_to_topics_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS tasks_to_topics_task_id_topic_id_unique ON tasks_to_topics(task_id, topic_id);
ALTER TABLE catch_em_all_settings ADD COLUMN IF NOT EXISTS id serial;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND conname = 'catch_em_all_settings_pkey'
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
ALTER TABLE catch_em_all_settings DROP CONSTRAINT catch_em_all_settings_pkey;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND contype = 'p') THEN
ALTER TABLE catch_em_all_settings ADD CONSTRAINT catch_em_all_settings_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS catch_em_all_settings_user_id_unique ON catch_em_all_settings(user_id);
-- Every site-owned table records creation and latest update. Domain-specific
-- event times (submitted_at, seen_at, uploaded_at) remain untouched.
DO $$
DECLARE table_name text;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'blog_articles', 'lists', 'list_items', 'list_item_completions',
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
'catch_em_all_settings'
] LOOP
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', table_name);
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', table_name);
END LOOP;
END $$;
-- Preserve the best available creation time for older Animaldex rows.
UPDATE animals SET created_at = submitted_at WHERE created_at > submitted_at;
UPDATE sightings SET created_at = submitted_at WHERE created_at > submitted_at;
UPDATE photos SET created_at = uploaded_at WHERE created_at > uploaded_at;
CREATE OR REPLACE FUNCTION site_touch_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$;
DO $$
DECLARE table_name text;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'blog_articles', 'lists', 'list_items', 'list_item_completions',
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
'catch_em_all_settings'
] LOOP
EXECUTE format('DROP TRIGGER IF EXISTS site_touch_updated_at ON %I', table_name);
EXECUTE format('CREATE TRIGGER site_touch_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION site_touch_updated_at()', table_name);
END LOOP;
END $$;

72
scripts/health-import.ts Normal file
View File

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

382
scripts/house-worker.ts Normal file
View File

@@ -0,0 +1,382 @@
/**
* House tracker home worker. Runs on a machine at home (residential IP), polls
* Pararius politely and POSTs what it finds to the website, which stores it and
* sends the alerts. See docs/house-tracker.md.
*
* HOUSE_API_URL=https://stanrunge.dev HOUSE_WORKER_TOKEN=... bun scripts/house-worker.ts
* bun scripts/house-worker.ts --once # one fast-lane poll, then exit
* bun scripts/house-worker.ts --dry-run # fetch + parse, print, send nothing
* bun scripts/house-worker.ts --check-api # check website auth only, no Pararius requests
*
* Politeness rules, in order of importance:
* - one request at a time, at least HOUSE_MIN_GAP_SECONDS apart (with jitter)
* - a daily request budget
* - any challenge / 403 / 429 stops everything and backs off exponentially
* - the fast lane only polls often on weekdays 07:00–18:00 Amsterdam time
*/
import {
isChallenge,
parseDetailPage,
parseSearchPage,
searchPageUrl
} from '../src/lib/house-tracker/pararius';
import { backoffMs, nextDelayMs } from '../src/lib/house-tracker/schedule';
import { createWorkerApi } from '../src/lib/house-tracker/worker-api';
import type {
Lane,
ListingDetail,
ReportRequest,
RunStatus,
SyncRequest,
SyncResponse
} from '../src/lib/house-tracker/types';
const args = new Set(process.argv.slice(2));
const once = args.has('--once');
const checkApi = args.has('--check-api');
const dryRun = args.has('--dry-run') || process.env.HOUSE_DRY_RUN === '1';
const list = (value: string | undefined, fallback: string[]) =>
value
? value
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean)
: fallback;
const number = (value: string | undefined, fallback: number) =>
value && Number.isFinite(Number(value)) ? Number(value) : fallback;
const config = {
api: (process.env.HOUSE_API_URL ?? '').replace(/\/$/, ''),
token: process.env.HOUSE_WORKER_TOKEN ?? '',
fastSearches: list(process.env.HOUSE_FAST_SEARCHES, [
'https://www.pararius.nl/huurwoningen/gouda/1300-1700'
]),
slowSearches: list(
process.env.HOUSE_SLOW_SEARCHES,
['gouda', 'rotterdam', 'den-haag', 'utrecht', 'amsterdam', 'eindhoven'].flatMap((city) => [
`https://www.pararius.nl/huurwoningen/${city}`,
`https://www.pararius.nl/koopwoningen/${city}`
])
),
slowEveryHours: number(process.env.HOUSE_SLOW_EVERY_HOURS, 12),
slowMaxPages: number(process.env.HOUSE_SLOW_MAX_PAGES, 80),
minGapMs: Math.max(8, number(process.env.HOUSE_MIN_GAP_SECONDS, 8)) * 1000,
dailyBudget: number(process.env.HOUSE_DAILY_BUDGET, 1500),
fetchMode: (process.env.HOUSE_FETCH_MODE ?? 'fetch') as 'fetch' | 'browser',
userAgent:
process.env.HOUSE_USER_AGENT ??
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
};
if (checkApi && dryRun) throw new Error('--check-api cannot be combined with dry-run mode');
const requestApi = dryRun
? null
: createWorkerApi(
{
url: config.api,
token: config.token,
accessClientId: process.env.HOUSE_CF_ACCESS_CLIENT_ID,
accessClientSecret: process.env.HOUSE_CF_ACCESS_CLIENT_SECRET
},
checkApi ? 1 : 4
);
const SOURCE = 'pararius';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const log = (...parts: unknown[]) => console.log(new Date().toISOString(), ...parts);
// ---------------------------------------------------------------------------
// Polite fetching
class Challenged extends Error {
constructor(
readonly url: string,
readonly status: number
) {
super(`Challenged (${status}) on ${url}`);
}
}
const cookies = new Map<string, string>();
let lastRequestAt = 0;
let budgetDay = '';
let budgetUsed = 0;
function remember(response: Response) {
for (const header of response.headers.getSetCookie?.() ?? []) {
const [pair] = header.split(';');
const index = pair.indexOf('=');
if (index > 0) cookies.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
}
}
function resetBudget() {
const day = new Date().toISOString().slice(0, 10);
if (day !== budgetDay) {
budgetDay = day;
budgetUsed = 0;
}
}
/** The slow lane may only use part of the budget so the fast lane never runs dry. */
function slowBudgetLeft() {
resetBudget();
return budgetUsed < config.dailyBudget * 0.6;
}
async function waitForTurn() {
resetBudget();
if (budgetUsed >= config.dailyBudget) throw new Error('Daily request budget used up');
const gap = config.minGapMs * (1 + Math.random() * 0.5);
const wait = lastRequestAt + gap - Date.now();
if (wait > 0) await sleep(wait);
lastRequestAt = Date.now();
budgetUsed++;
}
type BrowserPage = {
goto(url: string, options: object): Promise<{ status(): number } | null>;
content(): Promise<string>;
};
let browserPage: Promise<BrowserPage> | null = null;
async function openBrowser(): Promise<BrowserPage> {
// Real Chromium for when plain requests get challenged. Needs
// `npx playwright install chromium` on the worker machine.
const { chromium } = await import('@playwright/test');
const context = await chromium.launchPersistentContext(
process.env.HOUSE_BROWSER_PROFILE ?? '.house-worker-profile',
{ headless: true, locale: 'nl-NL', timezoneId: 'Europe/Amsterdam' }
);
return context.pages()[0] ?? (await context.newPage());
}
async function getHtml(url: string, referer?: string): Promise<{ html: string; status: number }> {
await waitForTurn();
if (config.fetchMode === 'browser') {
browserPage ??= openBrowser();
const page = await browserPage;
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
const html = await page.content();
const status = response?.status() ?? 0;
if (isChallenge(html, status)) throw new Challenged(url, status);
return { html, status };
}
const response = await fetch(url, {
redirect: 'follow',
signal: AbortSignal.timeout(30_000),
headers: {
'user-agent': config.userAgent,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7',
'cache-control': 'no-cache',
...(referer ? { referer } : {}),
...(cookies.size
? { cookie: [...cookies].map(([key, value]) => `${key}=${value}`).join('; ') }
: {})
}
});
remember(response);
const html = await response.text();
if (isChallenge(html, response.status)) throw new Challenged(url, response.status);
return { html, status: response.status };
}
// ---------------------------------------------------------------------------
// Website API
async function api<T>(path: 'sync' | 'details' | 'report', body: unknown): Promise<T | null> {
return requestApi ? requestApi<T>(path, body) : null;
}
async function report(
lane: Lane,
url: string,
status: RunStatus,
extra: Partial<ReportRequest> = {}
) {
try {
await api('report', { source: SOURCE, lane, url, status, ...extra });
} catch (error) {
log('Could not report:', (error as Error).message);
}
}
// ---------------------------------------------------------------------------
// Lanes
async function fetchDetails(
lane: Lane,
wanted: { sourceId: string; url: string }[],
referer: string
) {
const details: ListingDetail[] = [];
for (const item of wanted) {
const { html } = await getHtml(item.url, referer);
const detail = parseDetailPage(html, item.url);
if (detail) details.push(detail);
else await report(lane, item.url, 'parse-error', { message: 'listing page did not parse' });
}
if (dryRun) {
for (const detail of details) {
log('detail', { ...detail, description: undefined, features: undefined });
}
}
if (details.length) await api('details', { source: SOURCE, details });
}
async function pollSearch(
lane: Lane,
url: string,
completedSweep?: SyncRequest['completedSweep']
): Promise<{ nextPageUrl: string | null; found: number }> {
const startedAt = new Date().toISOString();
const { html } = await getHtml(url);
const page = parseSearchPage(html, url);
if (!page.cards.length && page.nextPageUrl)
await report(lane, url, 'parse-error', {
message: 'no listing cards on a page with a next page'
});
if (dryRun) {
log(`${lane} ${url}: ${page.cards.length} cards, next=${page.nextPageUrl}`);
for (const card of page.cards.slice(0, 5)) log('card', card);
}
const sync = await api<SyncResponse>('sync', {
source: SOURCE,
lane,
searchUrl: url,
startedAt,
cards: page.cards,
completedSweep: page.nextPageUrl ? null : (completedSweep ?? null)
} satisfies SyncRequest);
if (sync) {
if (sync.newIds.length || sync.alerted)
log(`${lane} ${url}: ${sync.newIds.length} new, ${sync.alerted} alerted`);
if (sync.needDetails.length) await fetchDetails(lane, sync.needDetails, url);
} else if (dryRun && page.cards[0]) {
await fetchDetails(lane, [page.cards[0]], url);
}
return { nextPageUrl: page.nextPageUrl, found: page.cards.length };
}
async function fastTick() {
for (const url of config.fastSearches) await pollSearch('fast', url);
}
/** Only an unfiltered `/huurwoningen/<city>` sweep can tell us what disappeared. */
function sweepScope(url: string) {
const [kind, city, ...rest] = new URL(url).pathname.split('/').filter(Boolean);
if (rest.length || !city) return null;
if (kind === 'huurwoningen') return { offer: 'rent' as const, city };
if (kind === 'koopwoningen') return { offer: 'buy' as const, city };
return null;
}
type Sweep = { index: number; page: number; startedAt: string };
let sweep: Sweep | null = null;
/** One page of the slow lane, so fast polls can interleave. Returns false when idle. */
async function slowStep(): Promise<boolean> {
if (!sweep || !slowBudgetLeft()) return false;
const base = config.slowSearches[sweep.index];
if (!base) {
log('Slow sweep finished');
sweep = null;
return false;
}
const url = searchPageUrl(base, sweep.page);
const scope = sweepScope(base);
const result = await pollSearch(
'slow',
url,
scope ? { ...scope, sweepStartedAt: sweep.startedAt } : null
);
if (result.nextPageUrl && sweep.page < config.slowMaxPages) {
sweep.page++;
} else {
sweep = { index: sweep.index + 1, page: 1, startedAt: new Date().toISOString() };
}
return true;
}
// ---------------------------------------------------------------------------
// Main loop
let stopping = false;
process.on('SIGTERM', () => (stopping = true));
process.on('SIGINT', () => (stopping = true));
async function guarded(
lane: Lane,
step: () => Promise<boolean | void>
): Promise<'ok' | 'idle' | 'failed'> {
const started = Date.now();
try {
return (await step()) === false ? 'idle' : 'ok';
} catch (error) {
if (error instanceof Challenged) {
log(`CHALLENGED on ${error.url} (${error.status})`);
await report(lane, error.url, 'challenged', {
httpStatus: error.status,
durationMs: Date.now() - started
});
} else {
log(`${lane} failed:`, (error as Error).message);
await report(lane, config.fastSearches[0], 'network-error', {
message: (error as Error).message.slice(0, 500),
durationMs: Date.now() - started
});
}
return 'failed';
}
}
async function main() {
if (checkApi) {
const result = await requestApi!<{ ok: boolean }>('health');
if (result?.ok !== true) throw new Error('API health: unexpected response');
log('API connection OK. Worker authentication passed; no Pararius requests were made.');
return;
}
log(
`house worker: ${config.fastSearches.length} fast, ${config.slowSearches.length} slow searches, mode=${config.fetchMode}${dryRun ? ', dry run' : ''}`
);
if (once) {
const result = await guarded('fast', fastTick);
process.exit(result === 'ok' ? 0 : 1);
}
let failures = 0;
let nextFast = Date.now();
let nextSlow = Date.now() + 5 * 60_000; // let the fast lane go first
while (!stopping) {
const now = Date.now();
let result: 'ok' | 'idle' | 'failed' = 'idle';
if (now >= nextFast) {
result = await guarded('fast', fastTick);
nextFast = Date.now() + nextDelayMs(new Date());
} else if (sweep || now >= nextSlow) {
if (!sweep) {
log('Slow sweep starting');
sweep = { index: 0, page: 1, startedAt: new Date().toISOString() };
nextSlow = now + config.slowEveryHours * 60 * 60_000;
}
result = await guarded('slow', slowStep);
}
if (result === 'failed') {
failures++;
const pause = backoffMs(failures);
log(`Backing off ${Math.round(pause / 60_000)} min (failure ${failures})`);
nextFast = Math.max(nextFast, Date.now() + pause);
await sleep(pause);
continue;
}
if (result === 'ok') failures = 0;
else await sleep(Math.min(30_000, Math.max(5_000, nextFast - Date.now())));
}
log('Stopped');
process.exit(0);
}
await main();

287
scripts/ingest-cs-prices.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* Steam Community Market ingestion. Run after db:migrate:cs-prices.
*
* bun scripts/ingest-cs-prices.ts catalog [--limit=500] [--start=0]
* STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... bun scripts/ingest-cs-prices.ts history [--limit=100] [--start=0]
* bun scripts/ingest-cs-prices.ts import ./observations.jsonl
*
* The first two modes use Steam's public market web endpoints, which are not a
* contracted API and may rate limit or change. `import` remains provider-neutral.
*/
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
import { dimensionsFromName, type Dimensions } from '../src/lib/cs-prices/taxonomy';
import { steamDate } from '../src/lib/cs-prices/steam';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
const mode = process.argv[2];
const flags = new Map(
process.argv
.slice(3)
.filter((arg) => arg.startsWith('--'))
.map((arg) => {
const [key, value] = arg.slice(2).split('=');
return [key, value ?? ''];
})
);
const start = Math.max(0, Number(flags.get('start') ?? 0));
const limit = Math.max(1, Number(flags.get('limit') ?? 1_000_000_000));
const delayMs = Math.max(1000, Number(flags.get('delay-ms') ?? 2000));
const sweepTime = new Date();
const knownStreams = new Set<string>();
type Observation = {
marketHashName: string;
name?: string;
source: string;
currency: string;
observedAt: string;
priceCents: number;
volume?: number | null;
kind: string;
dimensions?: Dimensions;
};
function validObservation(value: unknown): Observation {
if (!value || typeof value !== 'object') throw new Error('Observation must be an object');
const row = value as Record<string, unknown>;
for (const key of ['marketHashName', 'source', 'currency', 'observedAt', 'kind']) {
if (typeof row[key] !== 'string' || !row[key]) throw new Error(`Invalid ${key}`);
}
if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(row.source as string)) throw new Error('Invalid source');
if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(row.kind as string)) throw new Error('Invalid kind');
if (!/^[A-Z]{3}$/.test(row.currency as string)) throw new Error('Invalid currency');
if (row.name != null && typeof row.name !== 'string') throw new Error('Invalid name');
if (
!Number.isInteger(row.priceCents) ||
(row.priceCents as number) < 0 ||
(row.priceCents as number) > 2_147_483_647
)
throw new Error('Invalid priceCents');
if (
row.volume != null &&
(!Number.isInteger(row.volume) ||
(row.volume as number) < 0 ||
(row.volume as number) > 2_147_483_647)
)
throw new Error('Invalid volume');
if (Number.isNaN(Date.parse(row.observedAt as string))) throw new Error('Invalid observedAt');
if (
row.dimensions != null &&
(typeof row.dimensions !== 'object' ||
Array.isArray(row.dimensions) ||
Object.values(row.dimensions).some((value) => typeof value !== 'string'))
)
throw new Error('Invalid dimensions');
return row as Observation;
}
async function itemId(
name: string,
displayName = name,
iconUrl?: string,
itemType?: string,
extraDimensions: Dimensions = {}
) {
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
ON CONFLICT (market_hash_name) DO UPDATE SET
name = EXCLUDED.name,
icon_url = COALESCE(EXCLUDED.icon_url, cs_market_items.icon_url),
item_type = COALESCE(EXCLUDED.item_type, cs_market_items.item_type),
last_seen_at = now()
RETURNING id`;
const inferred = dimensionsFromName(name);
if (itemType) inferred.steam_type = itemType;
for (const [key, value] of Object.entries(inferred)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO NOTHING`;
}
for (const [key, value] of Object.entries(extraDimensions)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
}
return row.id;
}
async function saveObservation(row: Observation, knownItemId?: number) {
const item =
knownItemId ??
(await itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
await ensureStream(row.source, row.currency, row.kind);
await sql`
INSERT INTO cs.price_observations
(item_id, source, currency, observed_at, price_cents, volume, kind)
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
price_cents = EXCLUDED.price_cents,
volume = EXCLUDED.volume`;
}
async function ensureStream(source: string, currency: string, kind: string) {
const key = `${source}\0${currency}\0${kind}`;
if (knownStreams.has(key)) return;
await sql`INSERT INTO cs.price_streams (source, currency, kind)
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
knownStreams.add(key);
}
async function saveHistoryRows(
item: { id: number; market_hash_name: string },
prices: [string, number, string][]
) {
await ensureStream('steam-market', 'USD', 'sale-median');
const rows = prices
.filter(([, price]) => Number.isFinite(price) && price >= 0)
.map(([date, price, volume]) => ({
item_id: item.id,
source: 'steam-market',
currency: 'USD',
observed_at: new Date(steamDate(date)),
price_cents: Math.round(price * 100),
volume: Number(volume.replaceAll(',', '')) || 0,
kind: 'sale-median'
}));
for (let offset = 0; offset < rows.length; offset += 500) {
await sql`INSERT INTO cs.price_observations ${sql(rows.slice(offset, offset + 500))}
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
}
}
async function steamJson<T>(url: URL, authenticated = false): Promise<T> {
for (let attempt = 0; attempt < 5; attempt++) {
const cookie = authenticated
? `sessionid=${process.env.STEAM_SESSIONID ?? ''}; steamLoginSecure=${process.env.STEAM_LOGIN_SECURE ?? ''}`
: undefined;
const response = await fetch(url, {
headers: {
'User-Agent': 'cs-prices/1.0 personal historical archive',
...(cookie ? { Cookie: cookie } : {})
}
});
if (response.ok) {
const json = await response.json();
if (!json || json.success === false) throw new Error(`Steam rejected ${url.pathname}`);
return json as T;
}
if (response.status !== 429 && response.status < 500)
throw new Error(`Steam HTTP ${response.status}`);
await new Promise((resolve) => setTimeout(resolve, delayMs * 2 ** attempt));
}
throw new Error(`Steam request failed after retries: ${url.pathname}`);
}
const pause = () => new Promise((resolve) => setTimeout(resolve, delayMs));
async function catalog() {
let offset = start;
let processed = 0;
while (processed < limit) {
const url = new URL('https://steamcommunity.com/market/search/render/');
url.search = new URLSearchParams({
appid: '730',
currency: '1',
country: 'US',
norender: '1',
count: String(Math.min(100, limit - processed)),
start: String(offset),
sort_column: 'name',
sort_dir: 'asc'
}).toString();
const page = await steamJson<{
total_count: number;
results: {
hash_name: string;
name: string;
sell_price?: number;
sell_listings?: number;
asset_description?: { icon_url?: string; type?: string };
}[];
}>(url);
if (!page.results?.length) break;
for (const entry of page.results) {
if (!entry.hash_name) continue;
const id = await itemId(
entry.hash_name,
entry.name || entry.hash_name,
entry.asset_description?.icon_url,
entry.asset_description?.type
);
if (Number.isSafeInteger(entry.sell_price) && (entry.sell_price ?? -1) >= 0) {
await saveObservation(
{
marketHashName: entry.hash_name,
source: 'steam-market',
currency: 'USD',
observedAt: sweepTime.toISOString(),
priceCents: entry.sell_price!,
volume: entry.sell_listings,
kind: 'lowest-listing'
},
id
);
}
}
processed += page.results.length;
offset += page.results.length;
console.log(`Catalog: ${offset}/${page.total_count} listings`);
if (offset >= page.total_count) break;
await pause();
}
}
async function history() {
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
const items = await sql<{ id: number; market_hash_name: string }[]>`
SELECT id, market_hash_name FROM cs.items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
let done = 0;
for (const item of items) {
const url = new URL('https://steamcommunity.com/market/pricehistory/');
url.search = new URLSearchParams({
appid: '730',
market_hash_name: item.market_hash_name,
currency: '1',
country: 'US'
}).toString();
const data = await steamJson<{ prices: [string, number, string][] }>(url, true);
await saveHistoryRows(item, data.prices ?? []);
console.log(
`History: ${++done}/${items.length} ${item.market_hash_name} (${data.prices?.length ?? 0} points)`
);
await pause();
}
}
async function importFile(path: string) {
const lines = (await readFile(path, 'utf8')).split(/\r?\n/);
let imported = 0;
for (const [index, line] of lines.entries()) {
if (!line.trim()) continue;
try {
await saveObservation(validObservation(JSON.parse(line)));
imported++;
} catch (cause) {
throw new Error(`Invalid observation on line ${index + 1}: ${String(cause)}`, { cause });
}
}
console.log(`Imported ${imported} observations`);
}
try {
if (mode === 'catalog') await catalog();
else if (mode === 'history') await history();
else if (mode === 'import' && process.argv[3]) await importFile(process.argv[3]);
else throw new Error('Usage: ingest-cs-prices.ts catalog|history|import <file>');
await sql`ANALYZE cs_market_items, cs_item_dimensions, cs_price_observations`;
} finally {
await sql.end();
}

View File

@@ -1,14 +0,0 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
await sql.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./auth-catalog-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Authentication and catalog migration applied');
} finally {
await sql.end();
}

View File

@@ -1,13 +0,0 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(await readFile(new URL('./foundation-migration.sql', import.meta.url), 'utf8'));
});
console.log('Foundation migration applied');
} finally {
await client.end();
}

58
scripts/preview.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Run a branch against a throwaway copy of the dev database (itself a prod copy).
#
# scripts/preview.sh <branch> [port]
#
# Creates a git worktree in /tmp, clones DEV_DB_NAME into a temporary database
# (CREATE DATABASE ... TEMPLATE), runs that branch's Drizzle migrations on the copy,
# builds and serves it on <port> (default 5180). Ctrl-C removes the server,
# database and worktree. Prod and the dev DB itself are never modified.
#
# Uses .env from the main checkout: DB_URL (app role) and DB_MIGRATE_URL (owner role)
# pointing at the dev DB, plus DEV_ADMIN_URL (superuser, maintenance DB) for create/drop.
set -euo pipefail
branch="${1:?usage: scripts/preview.sh <branch> [port]}"
port="${2:-5180}"
repo="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo"
set -a
. ./.env
[[ -f .env.dev-refresh ]] && . ./.env.dev-refresh
set +a
: "${DB_URL:?}" "${DB_MIGRATE_URL:?}" "${DEV_ADMIN_URL:?set DEV_ADMIN_URL}"
DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}"
safe="$(printf '%s' "$branch" | tr -c 'a-zA-Z0-9' '_' | tr 'A-Z' 'a-z' | cut -c1-40)"
db="preview_${safe}"
dir="/tmp/personal-website-preview-${safe}"
with_db() { bun -e 'const u=new URL(process.argv[1]); u.pathname="/"+process.argv[2]; console.log(u.toString())' "$1" "$2"; }
server_pid=""
cleanup() {
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null || true
psql -q "$DEV_ADMIN_URL" -c "DROP DATABASE IF EXISTS \"$db\" WITH (FORCE)" || true
git -C "$repo" worktree remove --force "$dir" 2>/dev/null || true
echo "Preview cleaned up."
}
trap cleanup EXIT INT TERM
git fetch origin "$branch"
git worktree add --detach "$dir" "origin/$branch"
# TEMPLATE needs no other connections to the source; stop `bun run dev` first if it fails.
psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" -c "CREATE DATABASE \"$db\" TEMPLATE \"$DEV_DB_NAME\" OWNER personal"
export DB_URL="$(with_db "$DB_URL" "$db")"
export DB_MIGRATE_URL="$(with_db "$DB_MIGRATE_URL" "$db")"
export ORIGIN="http://localhost:$port" BETTER_AUTH_URL="http://localhost:$port" PORT="$port"
cd "$dir"
cp "$repo/.env" .env
bun install --frozen-lockfile
bun scripts/db-migrate.ts
bun run build
echo "Preview of $branch on http://localhost:$port (Ctrl-C to stop and clean up)"
bun build/index.js &
server_pid=$!
wait "$server_pid"

View File

@@ -0,0 +1,10 @@
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
const [row] = await client`select current_setting('server_version_num')::integer as version`;
console.log(row.version);
} finally {
await client.end();
}

View File

@@ -1,25 +1,67 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, isNull, sql } from 'drizzle-orm'; import { and, eq, isNull, ne, sql } from 'drizzle-orm';
import postgres from 'postgres'; import postgres from 'postgres';
import { pokemon, games, pokemonGameEntries } from '../src/lib/server/db/schema/catch-em-all'; import {
pokemon,
games,
pokemonGameEntries,
pokemonLocations,
pokemonEncounters,
pokemonEvolutions
} from '../src/lib/server/db/schema/catch-em-all';
type Availability = 'catchable' | 'unknown' | 'evolve' | 'breed' | 'event_only';
type Catalog = { type Catalog = {
source: { revision: string }; source: { revision: string };
pokemon: (typeof pokemon.$inferInsert)[]; pokemon: (typeof pokemon.$inferInsert)[];
games: { sourceId: number; name: string; generation: number }[]; games: {
entries: [number, number, 'catchable' | 'unknown'][]; sourceId: number;
name: string;
slug: string;
generation: number;
sortOrder: number;
breeding: boolean;
}[];
entries: [number, number, Availability, string?][];
evolutions: [from: number, to: number, versions: number[], method: string][];
}; };
type Locations = {
locations: { sourceId: number; slug: string; name: string; region: string | null }[];
methods: string[];
conditions: string[];
encounters: [
number: number,
version: number,
location: number,
area: string | null,
method: number,
minLevel: number,
maxLevel: number,
rarity: number | null,
form: string | null,
conditions: number[]
][];
};
if (!process.env.DB_URL) throw new Error('DB_URL is required'); if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse( const read = async <T>(name: string): Promise<T> =>
await readFile(new URL('../data/pokemon-catalog.json', import.meta.url), 'utf8') JSON.parse(await readFile(new URL(`../data/${name}`, import.meta.url), 'utf8'));
); const catalog = await read<Catalog>('pokemon-catalog.json');
const places = await read<Locations>('pokemon-locations.json');
const client = postgres(process.env.DB_URL, { max: 1 }); const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client }); const db = drizzle({ client });
async function inBatches<T>(rows: T[], insert: (batch: T[]) => Promise<unknown>) {
for (let offset = 0; offset < rows.length; offset += 1000)
await insert(rows.slice(offset, offset + 1000));
}
try { try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const pokemonIds = new Map<number, number>(); const pokemonIds = new Map<number, number>();
const gameIds = new Map<number, number>(); const gameIds = new Map<number, number>();
const locationIds = new Map<number, number>();
for (const row of catalog.pokemon) { for (const row of catalog.pokemon) {
const [saved] = await tx const [saved] = await tx
.insert(pokemon) .insert(pokemon)
@@ -47,38 +89,110 @@ try {
.returning(); .returning();
gameIds.set(row.sourceId, saved.id); gameIds.set(row.sourceId, saved.id);
} }
for (let offset = 0; offset < catalog.entries.length; offset += 1000) { const lookup = (map: Map<number, number>, key: number, kind: string) => {
const rows = catalog.entries const id = map.get(key);
.slice(offset, offset + 1000) if (!id) throw new Error(`Unknown catalog ${kind} ${key}`);
.map(([number, version, availability]) => { return id;
const pokemonId = pokemonIds.get(number), };
gameId = gameIds.get(version);
if (!pokemonId || !gameId) await inBatches(catalog.entries, (batch) =>
throw new Error(`Unknown catalog reference ${number}/${version}`); tx
return {
pokemonId,
gameId,
availability,
note:
availability === 'catchable'
? 'PokéAPI: documented encounter; conditions, location, or a specific form may be required.'
: 'PokéAPI: Pokédex or game-index record only; catch, evolution, gift, trade, transfer, and event availability is not confirmed.'
};
});
await tx
.insert(pokemonGameEntries) .insert(pokemonGameEntries)
.values(rows) .values(
batch.map(([number, version, availability, note]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
availability,
note: note ?? null,
source:
availability === 'catchable' || availability === 'unknown'
? ('pokeapi' as const)
: ('derived' as const)
}))
)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId], target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId],
set: { availability: sql`excluded.availability`, note: sql`excluded.note` }, set: {
// Preserve hand-maintained availability and notes. availability: sql`excluded.availability`,
setWhere: sql`${pokemonGameEntries.note} like 'PokéAPI:%'` note: sql`excluded.note`,
}); source: sql`excluded.source`
},
// Admin corrections are never overwritten.
setWhere: ne(pokemonGameEntries.source, 'manual')
})
);
for (const row of places.locations) {
const [saved] = await tx
.insert(pokemonLocations)
.values(row)
.onConflictDoUpdate({
target: pokemonLocations.sourceId,
// descriptions and pictures are site-owned and kept
set: { slug: row.slug, name: row.name, region: row.region },
setWhere: ne(pokemonLocations.source, 'manual')
})
.returning();
locationIds.set(row.sourceId, saved?.id ?? 0);
} }
for (const [sourceId, id] of locationIds) {
if (id) continue;
const [manual] = await tx
.select({ id: pokemonLocations.id })
.from(pokemonLocations)
.where(eq(pokemonLocations.sourceId, sourceId));
locationIds.set(sourceId, manual.id);
}
// Encounters and evolutions have no natural key; replace imported rows wholesale.
await tx.delete(pokemonEncounters).where(ne(pokemonEncounters.source, 'manual'));
await inBatches(places.encounters, (batch) =>
tx.insert(pokemonEncounters).values(
batch.map(
([
number,
version,
location,
area,
method,
minLevel,
maxLevel,
rarity,
form,
conditions
]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
locationId: lookup(locationIds, location, 'location'),
area,
method: places.methods[method],
minLevel,
maxLevel,
rarity,
form,
conditions: conditions.map((index) => places.conditions[index])
})
)
)
);
await tx.delete(pokemonEvolutions).where(ne(pokemonEvolutions.source, 'manual'));
const evolutions = catalog.evolutions.flatMap(([from, to, versions, method]) =>
versions.map((version) => ({
fromPokemonId: lookup(pokemonIds, from, 'Pokémon'),
toPokemonId: lookup(pokemonIds, to, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
method
}))
);
await inBatches(evolutions, (batch) =>
tx.insert(pokemonEvolutions).values(batch).onConflictDoNothing()
);
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries, ` +
`${places.locations.length} locations, ${places.encounters.length} encounters and ` +
`${evolutions.length} evolution methods from ${catalog.source.revision}`
);
}); });
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
);
} finally { } finally {
await client.end(); await client.end();
} }

23
scripts/seed-cod-art.ts Normal file
View File

@@ -0,0 +1,23 @@
import postgres from 'postgres';
import artwork from '../data/cod-art.json';
import achievementArt from '../data/cod-achievement-art.json';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
await sql.begin(async (tx) => {
for (const image of artwork) {
const rows =
await tx`update ref.tracker_items i set icon=${image.path} from ref.tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
}
for (const [source, path] of Object.entries(achievementArt)) {
await tx`update ref.tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
}
});
console.log(
`Seeded ${artwork.length} weapon pictures and ${Object.keys(achievementArt).length} achievement icons; progress preserved.`
);
} finally {
await sql.end();
}

164
scripts/seed-cod-catalog.ts Normal file
View File

@@ -0,0 +1,164 @@
import achievementArt from '../data/cod-achievement-art.json';
const achievementImages: Record<string, string> = achievementArt;
import artwork from '../data/cod-art.json';
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
import postgres from 'postgres';
import {
trackerGames,
trackerItems,
trackerProgress
} from '../src/lib/server/db/schema/game-tracker';
import { trackerWeaponRanks } from '../src/lib/server/db/schema/tracker-journal';
import type { MasteryRules } from '../src/lib/game-tracker/mastery';
type Catalog = {
games: (Omit<MasteryRules, 'classes'> & {
slug: string;
name: string;
shortName: string;
releaseYear: number;
classes: { name: string; weapons: string[]; postLaunch?: string[] }[];
achievements: {
key: string;
category: string;
name: string;
description: string | null;
icon: string;
}[];
})[];
};
// Keys are derived from weapon names, so renaming a weapon starts its progress over.
const weaponKey = (name: string) =>
`weapon:${name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`;
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse(
await readFile(new URL('../data/call-of-duty.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
try {
await db.transaction(async (tx) => {
for (const [order, game] of catalog.games.entries()) {
const rules: MasteryRules = {
milestones: game.milestones,
tiers: game.tiers,
classes: game.classes.map((c) => c.name),
steamApps: game.steamApps,
rankCaps: game.rankCaps
};
const values = {
slug: game.slug,
franchise: 'call-of-duty',
name: game.name,
shortName: game.shortName,
releaseYear: game.releaseYear,
sortOrder: order,
rules
};
const [saved] = await tx
.insert(trackerGames)
.values(values)
.onConflictDoUpdate({ target: trackerGames.slug, set: values })
.returning();
const weapons = game.classes.flatMap((c) =>
[
...c.weapons.map((name) => ({ name, postLaunch: false })),
...(c.postLaunch ?? []).map((name) => ({ name, postLaunch: true }))
].map((weapon) => ({ ...weapon, category: c.name }))
);
const items = [
...weapons.map((weapon, index) => ({
gameId: saved.id,
key: weaponKey(weapon.name),
kind: 'weapon' as const,
category: weapon.category,
name: weapon.name,
description: null,
icon:
artwork.find((a) => a.game === game.slug && a.key === weaponKey(weapon.name))?.path ??
null,
postLaunch: weapon.postLaunch,
sortOrder: index
})),
...game.achievements.map((achievement, index) => ({
gameId: saved.id,
key: achievement.key,
kind: 'achievement' as const,
category: achievement.category,
name: achievement.name,
description: achievement.description,
icon: achievementImages[achievement.icon] ?? achievement.icon,
postLaunch: false,
sortOrder: index
}))
];
await tx
.insert(trackerItems)
.values(items)
.onConflictDoUpdate({
target: [trackerItems.gameId, trackerItems.key],
set: {
kind: sql`excluded.kind`,
category: sql`excluded.category`,
name: sql`excluded.name`,
description: sql`excluded.description`,
icon: sql`excluded.icon`,
postLaunch: sql`excluded.post_launch`,
sortOrder: sql`excluded.sort_order`
}
});
// Items dropped from the catalog disappear unless someone has progress on them.
const stale = await tx
.select({ id: trackerItems.id, name: trackerItems.name })
.from(trackerItems)
.where(
and(
eq(trackerItems.gameId, saved.id),
notInArray(
trackerItems.key,
items.map((item) => item.key)
)
)
);
if (stale.length) {
const kept = await tx
.selectDistinct({ id: trackerProgress.itemId })
.from(trackerProgress)
.where(
inArray(
trackerProgress.itemId,
stale.map((item) => item.id)
)
);
const ranked = await tx
.select({ id: trackerWeaponRanks.itemId })
.from(trackerWeaponRanks)
.where(
inArray(
trackerWeaponRanks.itemId,
stale.map((item) => item.id)
)
);
const keep = new Set([...kept, ...ranked].map((row) => row.id));
const remove = stale.filter((item) => !keep.has(item.id)).map((item) => item.id);
if (remove.length) await tx.delete(trackerItems).where(inArray(trackerItems.id, remove));
for (const item of stale.filter((item) => keep.has(item.id)))
console.warn(
`${game.shortName}: kept "${item.name}", which has progress but left the catalog`
);
}
console.log(
`${game.shortName}: ${weapons.length} weapons, ${game.achievements.length} achievements`
);
}
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog = JSON.parse(
await readFile(new URL('../data/cod-challenges.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
for (const [slug, rows] of Object.entries(catalog.games) as [
string,
{
key: string;
name: string;
category: string;
requirement: string;
reward: string | null;
icon: string | null;
target: number | null;
sourceUrl: string;
}[]
][]) {
const [game] =
await tx`select id from ref.tracker_games where slug=${slug} and franchise='call-of-duty'`;
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
for (let offset = 0; offset < rows.length; offset += 500) {
const batch = rows
.slice(offset, offset + 500)
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
await tx`insert into ref.tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
}
// Retire removed source definitions only when nobody has saved progress.
if (rows.length)
await tx`delete from ref.tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from games.tracker_challenge_progress p where p.challenge_id=c.id)`;
console.log(`${slug}: ${rows.length} challenge rows`);
}
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog = JSON.parse(
await readFile(new URL('../data/pokemon-research.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
const species = new Map(
(await tx`select id, number from ref.pokemon`).map((r) => [r.number, r.id])
);
const games = new Map(
(await tx`select id, source_id from ref.pokemon_games`).map((r) => [r.source_id, r.id])
);
const speciesId = (n: number) => {
const id = species.get(n);
if (!id) throw new Error(`Seed species ${n} first`);
return id;
};
const gameId = (n: number) => {
const id = games.get(n);
if (!id) throw new Error(`Seed game ${n} first`);
return id;
};
for (const row of catalog.facts)
await tx`insert into ref.pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
for (const row of catalog.games) {
if (!games.has(row.sourceId)) continue;
await tx`insert into ref.pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
}
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
const batch = catalog.dexes
.slice(offset, offset + 1000)
.filter((r: number[]) => games.has(r[1]))
.map(([n, v, d, name, number]: [number, number, string, string, number]) => ({
pokemon_id: speciesId(n),
game_id: gameId(v),
dex: d,
dex_name: name,
number
}));
if (batch.length)
await tx`insert into ref.pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
}
for (const row of catalog.moves)
await tx`insert into ref.pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
const batch = catalog.learnsets
.slice(offset, offset + 500)
.map(([n, version_group, moves]: [number, number, never]) => ({
pokemon_id: speciesId(n),
version_group,
moves: tx.json(moves)
}));
await tx`insert into ref.pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
}
console.log(
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
);
});
} finally {
await client.end();
}

View File

@@ -6,7 +6,7 @@ if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 }); const sql = postgres(process.env.DB_URL, { max: 1 });
try { try {
const users = const users =
await sql`update "user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`; await sql`update auth."user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
if (!users.length) if (!users.length)
throw new Error('User ID not found. Register first and copy the ID from your account page.'); throw new Error('User ID not found. Register first and copy the ID from your account page.');
console.log(users[0]); console.log(users[0]);

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
sha=${1:?staging commit required}
case "$sha" in
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
esac
directory=/opt/personal-website-staging
tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
if [ "$tag" != "$sha" ]; then
echo "Staging runs $tag, not $sha" >&2
exit 1
fi
image="git.stanrunge.dev/stan/personal-website:$sha"
container=$(sudo docker compose --env-file "$directory/.env" -f "$directory/compose.prod.yml" ps -q app)
expected=$(sudo docker image inspect -f '{{.Id}}' "$image")
actual=$(sudo docker inspect -f '{{.Image}}' "$container")
if [ "$actual" != "$expected" ]; then
echo 'Staging container differs from the tested image' >&2
exit 1
fi
curl --fail --silent --show-error https://staging.stanrunge.dev/login >/dev/null
echo "Verified staging image $sha"

View File

@@ -1,4 +1,9 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn-svelte/tailwind.css';
@import '@fontsource-variable/montserrat';
@custom-variant dark (&:is(.dark *));
@plugin '@tailwindcss/typography'; @plugin '@tailwindcss/typography';
@theme { @theme {
@@ -11,13 +16,50 @@
--color-text-muted: #a1a1a1; --color-text-muted: #a1a1a1;
--color-text-dim: #888; --color-text-dim: #888;
--color-text-ghost: #666; --color-text-ghost: #666;
--color-accent: #ededed; --color-accent: var(--accent);
--color-accent-hover: #fff; --color-accent-hover: #fff;
--color-danger: #b44; --color-danger: #b44;
--font-heading: Montserrat, Arial, Helvetica, sans-serif; --font-heading: 'Montserrat Variable', Arial, Helvetica, sans-serif;
--font-mono: 'IBM Plex Mono', 'Courier New', monospace; --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
--font-body: Montserrat, Arial, Helvetica, sans-serif; --font-body: 'Montserrat Variable', Arial, Helvetica, sans-serif;
--font-sans: 'Montserrat Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
} }
:root { :root {
@@ -28,6 +70,38 @@
--site-text: #ededed; --site-text: #ededed;
--site-muted: #a1a1a1; --site-muted: #a1a1a1;
--site-border: #292929; --site-border: #292929;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
} }
:root[data-theme='light'] { :root[data-theme='light'] {
@@ -116,10 +190,12 @@
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
@apply border-border outline-ring/50;
} }
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
@apply font-sans;
} }
section[id] { section[id] {
@@ -134,6 +210,7 @@
line-height: 1.6; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@apply bg-background text-foreground;
} }
button, button,
@@ -143,6 +220,14 @@
font: inherit; font: inherit;
} }
input:not([data-slot]):not([type='checkbox']):not([type='radio']):not([type='file']):not(
[type='hidden']
),
textarea:not([data-slot]),
select:not([data-slot]) {
@apply rounded-md border border-input bg-background px-2.5 py-1 text-foreground shadow-xs;
}
:focus-visible { :focus-visible {
outline: 2px solid var(--site-text); outline: 2px solid var(--site-text);
outline-offset: 3px; outline-offset: 3px;
@@ -157,3 +242,28 @@
text-decoration: none; text-decoration: none;
} }
} }
/* Keep the site's black and white palette across every shadcn component. */
.dark {
--background: #000;
--foreground: #ededed;
--card: #0a0a0a;
--card-foreground: #ededed;
--popover: #0a0a0a;
--popover-foreground: #ededed;
--primary: #ededed;
--primary-foreground: #000;
--secondary: #171717;
--secondary-foreground: #ededed;
--muted: #171717;
--muted-foreground: #a1a1a1;
--accent: #171717;
--accent-foreground: #ededed;
--border: #292929;
--input: #292929;
--ring: #ededed;
--sidebar: #0a0a0a;
--sidebar-foreground: #ededed;
--sidebar-accent: #171717;
--sidebar-border: #292929;
}

8
src/app.d.ts vendored
View File

@@ -6,11 +6,13 @@ declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
user: typeof import('$lib/server/auth').auth.$Infer.Session.user | null; user: typeof import('#lib/server/auth.js').auth.$Infer.Session.user | null;
session: typeof import('$lib/server/auth').auth.$Infer.Session.session | null; session: typeof import('#lib/server/auth.js').auth.$Infer.Session.session | null;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} interface PageState {
animal?: import('#lib/animaldex/AnimalDetails.svelte').AnimalDetail;
}
// interface Platform {} // interface Platform {}
} }
} }

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="dark" data-theme="dark">
<head> <head>
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<script> <script>
@@ -17,6 +17,10 @@
? 'light' ? 'light'
: 'dark' : 'dark'
: themePreference; : themePreference;
document.documentElement.classList.toggle(
'dark',
document.documentElement.dataset.theme === 'dark'
);
document.querySelector('meta[name="theme-color"]').content = document.querySelector('meta[name="theme-color"]').content =
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000'; document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
</script> </script>
@@ -37,12 +41,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" /> <link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>

27
src/env.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineEnvVars } from '@sveltejs/kit/env';
// Every variable is optional and falls back to '' (falsy, like an unset $env/dynamic
// value used to be); the code that uses each one checks whether it is configured.
export const variables = defineEnvVars({
PUBLIC_MAPTILER_API_KEY: { public: true, schema: (input) => input ?? '' },
R2_PUBLIC_URL: { schema: (input) => input ?? '' },
ORIGIN: { schema: (input) => input ?? '' },
BETTER_AUTH_SECRET: { schema: (input) => input ?? '' },
BETTER_AUTH_URL: { schema: (input) => input ?? '' },
GOOGLE_CLIENT_ID: { schema: (input) => input ?? '' },
GOOGLE_CLIENT_SECRET: { schema: (input) => input ?? '' },
OSU_CLIENT_ID: { schema: (input) => input ?? '' },
OSU_CLIENT_SECRET: { schema: (input) => input ?? '' },
ANTHROPIC_API_KEY: { schema: (input) => input ?? '' },
HOUSE_WORKER_TOKEN: { schema: (input) => input ?? '' },
HOUSE_DISCORD_WEBHOOK_URL: { schema: (input) => input ?? '' },
HOUSE_NTFY_URL: { schema: (input) => input ?? '' },
HOUSE_NTFY_TOKEN: { schema: (input) => input ?? '' },
R2_ACCESS_KEY_ID: { schema: (input) => input ?? '' },
R2_SECRET_ACCESS_KEY: { schema: (input) => input ?? '' },
R2_ACCOUNT_ID: { schema: (input) => input ?? '' },
R2_BUCKET: { schema: (input) => input ?? '' },
SPOTIFY_CLIENT_ID: { schema: (input) => input ?? '' },
SPOTIFY_CLIENT_SECRET: { schema: (input) => input ?? '' },
DB_URL: { schema: (input) => input ?? '' }
});

View File

@@ -1,6 +1,8 @@
import { building } from '$app/environment'; import type { Handle } from '@sveltejs/kit/hooks';
import { auth } from '$lib/server/auth'; import { building } from '$app/env';
import type { Handle } from '@sveltejs/kit'; import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
import { error } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite. // Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
@@ -9,5 +11,20 @@ export const handle: Handle = async ({ event, resolve }) => {
const session = building ? null : await auth.api.getSession({ headers: event.request.headers }); const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
event.locals.user = session?.user ?? null; event.locals.user = session?.user ?? null;
event.locals.session = session?.session ?? null; event.locals.session = session?.session ?? null;
return resolve(event); // 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);
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) {
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
response.headers.set('cache-control', 'private, no-store');
}
return response;
}; };

View File

@@ -0,0 +1,139 @@
<script lang="ts" module>
export interface AnimalDetail {
id: number;
species: string;
breed: string | null;
animalName: string | null;
description: string | null;
sightings: {
id: number;
seenAt: string;
reporterName: string | null;
lat: number;
lng: number;
photos: { id: number; url: string }[];
}[];
}
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import PawPrint from 'lucide-svelte/icons/paw-print';
let { animal }: { animal: AnimalDetail } = $props();
const title = $derived(animal.animalName || animal.species);
const allPhotos = $derived(animal.sightings.flatMap((s) => s.photos));
let activePhotoId = $state<number | null>(null);
const activePhoto = $derived(allPhotos.find((p) => p.id === activePhotoId) ?? allPhotos[0]);
const firstSeen = $derived(animal.sightings.at(-1)?.seenAt);
const lastSeen = $derived(animal.sightings[0]?.seenAt);
function formatDate(value: string) {
return new Date(value).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
</script>
<div class="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div>
<div class="aspect-[4/3] overflow-hidden rounded bg-[var(--site-surface)]">
{#if activePhoto}
<img src={activePhoto.url} alt={title} class="h-full w-full object-cover" />
{:else}
<div class="flex h-full items-center justify-center text-[var(--site-muted)]">
<PawPrint size={64} aria-hidden="true" />
</div>
{/if}
</div>
{#if allPhotos.length > 1}
<ul class="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="Photos">
{#each allPhotos as photo (photo.id)}
<li class="shrink-0">
<button
type="button"
onclick={() => (activePhotoId = photo.id)}
aria-label="Show photo"
aria-pressed={photo.id === activePhoto?.id}
class="block h-16 w-16 overflow-hidden rounded border-2 {photo.id === activePhoto?.id
? 'border-[var(--site-text)]'
: 'border-transparent opacity-70 hover:opacity-100'}"
>
<img src={photo.url} alt="" class="h-full w-full object-cover" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div>
<h2 class="text-3xl font-semibold tracking-tight sm:text-4xl">{title}</h2>
<p class="mt-2 text-lg text-[var(--site-muted)]">
{animal.species}{animal.breed ? ` · ${animal.breed}` : ''}
</p>
{#if animal.description}
<p class="mt-5 text-base whitespace-pre-line">{animal.description}</p>
{/if}
<dl class="mt-6 grid grid-cols-3 gap-4 border-y border-[var(--site-border)] py-4">
<div>
<dt class="text-sm text-[var(--site-muted)]">sightings</dt>
<dd class="text-xl font-semibold">{animal.sightings.length}</dd>
</div>
{#if firstSeen}
<div>
<dt class="text-sm text-[var(--site-muted)]">first seen</dt>
<dd class="text-base font-medium">{formatDate(firstSeen)}</dd>
</div>
{/if}
{#if lastSeen}
<div>
<dt class="text-sm text-[var(--site-muted)]">last seen</dt>
<dd class="text-base font-medium">{formatDate(lastSeen)}</dd>
</div>
{/if}
</dl>
<a
href={resolve(`projects/animaldex/register?animalId=${animal.id}`)}
class="mt-6 inline-flex items-center gap-2 rounded bg-[var(--site-text)] px-4 py-2.5 text-base font-medium text-[var(--site-bg)]"
>
i saw this animal too <ArrowRight size={16} aria-hidden="true" />
</a>
<h3 class="mt-8 mb-2 text-lg font-semibold">sighting history</h3>
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each animal.sightings as s (s.id)}
<li class="flex items-center gap-3 py-3 text-base">
{#if s.photos[0]}
<button
type="button"
onclick={() => (activePhotoId = s.photos[0].id)}
aria-label="Show photo from {formatDate(s.seenAt)}"
class="h-10 w-10 shrink-0 overflow-hidden rounded"
>
<img src={s.photos[0].url} alt="" class="h-full w-full object-cover" />
</button>
{:else}
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-[var(--site-surface)] text-[var(--site-muted)]"
><PawPrint size={18} aria-hidden="true" /></span
>
{/if}
<span class="min-w-0">
<span class="block">{formatDate(s.seenAt)}</span>
{#if s.reporterName}<span class="block text-[var(--site-muted)]"
>by {s.reporterName}</span
>{/if}
</span>
</li>
{/each}
</ul>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More