From c9862b024cc709f5bc5e6e31abd1ed4c831e1601 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:34:08 +0000 Subject: [PATCH 1/2] Use the shared places dataset for worldwide stations and landmarks Stations now default to Wikidata's railway stations worldwide, fetched at build time from the Offware places dataset (CC0), instead of Trainline's European list. People who used the app before keep Trainline unless they switch in Settings, so their saved station ids stay valid. Landmarks come from the same dataset: the 10,000 best-known sights, UNESCO sites excluded. --- .github/workflows/android-apk.yml | 3 + .github/workflows/pages.yml | 6 ++ .gitignore | 3 + apps/postcards/package.json | 1 + apps/postcards/public/reference/PROVENANCE.md | 4 +- apps/postcards/scripts/fetch-places.mjs | 74 +++++++++++++++++++ apps/postcards/src/lib/i18n/en.ts | 5 +- apps/postcards/src/lib/i18n/fr.ts | 5 +- apps/postcards/src/lib/i18n/ko.ts | 5 +- .../src/lib/reference/data/provenance.json | 18 +++-- .../src/lib/reference/referenceData.ts | 2 +- .../src/lib/reference/stationSources.ts | 37 +++++++--- .../tests/unit/referenceStartup.spec.ts | 12 +-- apps/postcards/tests/unit/stations.spec.ts | 29 +++++++- apps/postcards/vite.config.ts | 15 +++- specs/022-shared-places/plan.md | 24 ++++++ specs/022-shared-places/spec.md | 63 ++++++++++++++++ specs/022-shared-places/tasks.md | 11 +++ 18 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 apps/postcards/scripts/fetch-places.mjs create mode 100644 specs/022-shared-places/plan.md create mode 100644 specs/022-shared-places/spec.md create mode 100644 specs/022-shared-places/tasks.md diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 599cb96..6a90858 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -45,6 +45,9 @@ jobs: cache: gradle - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Fetch the shared places dataset (stations worldwide, landmarks) + # Never fails the build: an unreachable dataset leaves the committed files as they are. + run: pnpm --filter postcards places - name: Build the web app run: pnpm --filter postcards build - name: Sync the Android project diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 44024a8..8917c76 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -9,6 +9,9 @@ on: push: branches: - main + # Weekly, after the places dataset's Monday build, so stations and landmarks stay current. + schedule: + - cron: "0 6 * * 1" workflow_dispatch: permissions: @@ -35,6 +38,9 @@ jobs: cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Fetch the shared places dataset (stations worldwide, landmarks) + # Never fails the build: an unreachable dataset leaves the committed files as they are. + run: pnpm --filter postcards places - name: Build (GitHub Pages subpath) run: pnpm --filter postcards build env: diff --git a/.gitignore b/.gitignore index c9e32cd..e7125fc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ Thumbs.db .env .env.* !.env.example + +# Fetched at build time from the Offware places dataset (scripts/fetch-places.mjs) +apps/postcards/public/reference/railways-wikidata.json diff --git a/apps/postcards/package.json b/apps/postcards/package.json index 6683642..2a091d4 100644 --- a/apps/postcards/package.json +++ b/apps/postcards/package.json @@ -16,6 +16,7 @@ "format": "prettier --write .", "reference": "node scripts/build-reference.mjs", "railways": "node scripts/build-railways.mjs", + "places": "node scripts/fetch-places.mjs", "cap:sync": "cap sync", "cap:copy": "cap copy", "cap:add:android": "cap add android", diff --git a/apps/postcards/public/reference/PROVENANCE.md b/apps/postcards/public/reference/PROVENANCE.md index 9185ccd..215c914 100644 --- a/apps/postcards/public/reference/PROVENANCE.md +++ b/apps/postcards/public/reference/PROVENANCE.md @@ -13,7 +13,9 @@ mirrors it for people browsing the repository. | `subdivisions.json` | First-level regions: each city's GeoNames admin-1 code () and each code's GeoNames name (), both retrieved 2026-09-30; France's 13 regions by their French names; a code GeoNames does not name keeps one from dr5hn countries-states-cities — | CC BY 4.0 (GeoNames) / ODbL (dr5hn) | all-the-cities 3.1.0 + GeoNames 2026-09-30 + country-state-city | | `airports.json` | OpenFlights (IATA-coded, aggregated from OurAirports) — | ODbL 1.0 / OurAirports public domain | airport-data 1.0.1 | | `heritage.json` | UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025) — | CC BY-SA 3.0 IGO — © UNESCO World Heritage Centre | 2025 list, retrieved 2026-07-11 | -| `landmarks.json` | Famous landmarks seed (full set via Wikidata build script) — | CC0 (facts) | seed | +| `landmarks.json` | Landmarks (museums, monuments, castles, palaces, parks; UNESCO sites excluded), the 10,000 best known, from Wikidata via the Offware places dataset — . Written at build time by `scripts/fetch-places.mjs`; the committed file is a small seed used when the dataset can't be reached. Each id is `wd-`. | CC0 1.0 | fetched at build time | +| `railways-wikidata.json` | Railway stations worldwide from Wikidata (open mainline stations, instance of Q55488) via the Offware places dataset — . Written at build time by `scripts/fetch-places.mjs`, not committed. Each id is the station's Wikidata QID. | CC0 1.0 | fetched at build time | +| `railways.json` | Railway stations in Europe from the Trainline EU open stations database — | ODbL 1.0 | `scripts/build-railways.mjs` | | `languages.json` | Per-country spoken languages (via world-countries) — | ODbL 1.0 | 5.1.0 | | `article-names.json` | Common country names (Wikivoyage/Wikipedia article titles, via world-countries) — | ODbL 1.0 | 5.1.0 | | `experiences.json` | Postcards "Moments" — app-authored name/hint/emoji only; anchor coordinates aggregated from Wikidata () / GeoNames (); experience concepts attributed to UNESCO World Heritage (), UNESCO Intangible Cultural Heritage () & Wikivoyage travel topics (). Per-item `sources` cite the specific record. | Mixed — name/hint/emoji CC0; Wikidata CC0; GeoNames CC BY 4.0; UNESCO CC BY-SA 3.0 IGO; Wikivoyage CC BY-SA 3.0 | V1 | diff --git a/apps/postcards/scripts/fetch-places.mjs b/apps/postcards/scripts/fetch-places.mjs new file mode 100644 index 0000000..7c7fc2e --- /dev/null +++ b/apps/postcards/scripts/fetch-places.mjs @@ -0,0 +1,74 @@ +// Fetch the shared Offware places dataset (https://github.com/offware-apps/places) into +// the bundled reference files, at build time: +// +// public/reference/railways-wikidata.json railway stations worldwide (Wikidata, CC0) +// public/reference/landmarks.json sights beyond UNESCO's list (Wikidata, CC0) +// +// Aggregator-only (Constitution I): the dataset reshapes Wikidata facts, each record +// keeping its Wikidata id as provenance; this script only downloads and checks it. +// A file that can't be fetched or doesn't validate is left as it is (the committed +// landmarks seed, or the stations of an earlier run). With no stations file at all the +// script fails: they're the default station source, and a build without them would +// leave every new user with no stations. +// +// node scripts/fetch-places.mjs +// PLACES_BASE=https://example.org/places/v1 node scripts/fetch-places.mjs + +import { existsSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const BASE = process.env.PLACES_BASE ?? "https://offware-apps.github.io/places/v1"; +const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "reference"); +const LANDMARK_MAX = 10_000; +const UA = "Postcards-reference-build/1.0 (https://github.com/offware-apps/Postcards)"; + +const finite = (n, max) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= max; +const validPoint = (p) => + p && typeof p.id === "string" && typeof p.name === "string" && p.name.trim() && + /^[A-Z]{2}$/.test(p.countryIso2) && finite(p.lat, 90) && finite(p.lon, 180); + +async function get(path) { + const res = await fetch(`${BASE}/${path}`, { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`); + return res.json(); +} + +async function stations() { + const json = await get("stations.json"); + if (json?._source?.license !== "CC0-1.0" || !Array.isArray(json.stations)) throw new Error("stations.json: unexpected shape or licence"); + const list = json.stations.filter(validPoint); + if (list.length < 1000) throw new Error(`stations.json: only ${list.length} valid stations`); + writeFileSync(join(OUT, "railways-wikidata.json"), JSON.stringify({ _source: json._source, stations: list })); + return `${list.length} stations`; +} + +async function landmarks() { + const [list, sources] = await Promise.all([get("landmarks.json"), get("sources.json")]); + const wikidata = sources?.sources?.find((s) => s.id === "wikidata"); + if (wikidata?.license !== "CC0-1.0" || !Array.isArray(list)) throw new Error("landmarks.json: unexpected shape or licence"); + // The best known LANDMARK_MAX, so the file stays small enough to load at start-up. + // Postcards' landmarks are one category; the dataset's finer kinds stay out of the app. + const out = list + .filter(validPoint) + .sort((a, b) => (b.sitelinks ?? 0) - (a.sitelinks ?? 0) || a.id.localeCompare(b.id)) + .slice(0, LANDMARK_MAX) + .map(({ id, name, countryIso2, lat, lon }) => ({ id, name, countryIso2, lat, lon, category: "landmark" })) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + if (out.length < 100) throw new Error(`landmarks.json: only ${out.length} valid landmarks`); + writeFileSync(join(OUT, "landmarks.json"), JSON.stringify(out)); + return `${out.length} landmarks`; +} + +for (const [name, run] of [["stations", stations], ["landmarks", landmarks]]) { + try { + process.stderr.write(`places: ${await run()} written\n`); + } catch (e) { + process.stderr.write(`places: ${name} kept as it was (${e?.message ?? e})\n`); + } +} + +if (!existsSync(join(OUT, "railways-wikidata.json"))) { + process.stderr.write("places: no railway stations file, the default station source would be empty\n"); + process.exit(1); +} diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index f0cfc68..80864c8 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -84,10 +84,13 @@ export const en = { "Used everywhere a country is counted: totals, the passport, per-continent progress.", "settings.stations.title": "Railway stations", "settings.stations.recommended": "recommended", + "settings.stations.source.wikidata": "World — Wikidata", "settings.stations.source.trainline": "Europe — Trainline", "settings.stations.source.none": "None", + "settings.stations.desc.wikidata": + "Open railway stations worldwide from Wikidata (CC0), via the shared Offware places dataset. Each station links back to its Wikidata item, where anyone can correct it.", "settings.stations.desc.trainline": - "~44,000 European stations from Trainline's open database (ODbL). The only worldwide-open set that loads on this build; a global set (Wikidata/OSM) can be added later.", + "~44,000 European stations from Trainline's open database (ODbL). Choose it to keep stations you marked from this list before the worldwide set arrived.", "settings.stations.desc.none": "No railway stations are loaded — they won't appear in search, on the map, or in stats.", "settings.map.advanced": "More map options & offline packs", "settings.optimize.title": "Optimisation", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 094153b..d733dd6 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -78,10 +78,13 @@ export const fr: Messages = { "Utilisé partout où un pays est compté : totaux, passeport, progression par continent.", "settings.stations.title": "Gares ferroviaires", "settings.stations.recommended": "recommandé", + "settings.stations.source.wikidata": "Monde — Wikidata", "settings.stations.source.trainline": "Europe — Trainline", "settings.stations.source.none": "Aucune", + "settings.stations.desc.wikidata": + "Les gares ferroviaires ouvertes du monde entier, issues de Wikidata (CC0) via le jeu de données partagé Offware places. Chaque gare renvoie à sa fiche Wikidata, que chacun peut corriger.", "settings.stations.desc.trainline": - "~44 000 gares européennes issues de la base ouverte de Trainline (ODbL). Le seul jeu ouvert disponible sur cette version ; un jeu mondial (Wikidata/OSM) pourra être ajouté plus tard.", + "~44 000 gares européennes issues de la base ouverte de Trainline (ODbL). Choisissez-la pour garder les gares marquées depuis cette liste avant l'arrivée du jeu mondial.", "settings.stations.desc.none": "Aucune gare n'est chargée — elles n'apparaîtront ni dans la recherche, ni sur la carte, ni dans les stats.", "settings.map.advanced": "Plus d'options de carte et cartes hors ligne", "settings.optimize.title": "Optimisation", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 0956acc..a648786 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -78,10 +78,13 @@ export const ko: Messages = { "국가를 세는 모든 곳에 사용됩니다: 합계, 여권, 대륙별 진행률.", "settings.stations.title": "기차역", "settings.stations.recommended": "권장", + "settings.stations.source.wikidata": "전 세계 — Wikidata", "settings.stations.source.trainline": "유럽 — Trainline", "settings.stations.source.none": "없음", + "settings.stations.desc.wikidata": + "공유 Offware places 데이터셋을 통해 Wikidata(CC0)에서 가져온 전 세계 운행 중인 기차역. 각 역은 누구나 수정할 수 있는 Wikidata 항목으로 연결됩니다.", "settings.stations.desc.trainline": - "Trainline 공개 데이터베이스(ODbL)의 유럽 기차역 약 44,000곳. 이 빌드에서 불러올 수 있는 유일한 공개 데이터이며, 전 세계 데이터(Wikidata/OSM)는 나중에 추가할 수 있습니다.", + "Trainline 공개 데이터베이스(ODbL)의 유럽 기차역 약 44,000곳. 전 세계 데이터가 추가되기 전에 이 목록에서 표시한 역을 유지하려면 선택하세요.", "settings.stations.desc.none": "기차역을 불러오지 않습니다 — 검색, 지도, 통계에 나타나지 않습니다.", "settings.map.advanced": "추가 지도 옵션 및 오프라인 팩", "settings.optimize.title": "최적화", diff --git a/apps/postcards/src/lib/reference/data/provenance.json b/apps/postcards/src/lib/reference/data/provenance.json index bdd007a..b976b49 100644 --- a/apps/postcards/src/lib/reference/data/provenance.json +++ b/apps/postcards/src/lib/reference/data/provenance.json @@ -30,9 +30,15 @@ "url": "https://openflights.org/data.html" }, { - "dataset": "Railway stations — Trainline EU open stations database (choosable in Settings)", + "dataset": "Railway stations worldwide — Wikidata via Offware places (default; choosable in Settings)", + "license": "CC0-1.0", + "version": "fetched at build time", + "url": "https://github.com/offware-apps/places" + }, + { + "dataset": "Railway stations — Trainline EU open stations database (Europe; choosable in Settings)", "license": "ODbL-1.0", - "version": "trainline · worldwide: --source=wikidata", + "version": "trainline", "url": "https://github.com/trainline-eu/stations" }, { @@ -54,10 +60,10 @@ "url": "https://en.wikivoyage.org" }, { - "dataset": "Famous landmarks seed (full set via Wikidata build script)", - "license": "CC0 (facts)", - "version": "seed", - "url": "https://www.wikidata.org" + "dataset": "Landmarks — the 10,000 best-known sights (Wikidata, via the Offware places dataset)", + "license": "CC0-1.0", + "version": "fetched at build time", + "url": "https://github.com/offware-apps/places" }, { "dataset": "UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025)", diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 928a041..9b8a413 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -482,7 +482,7 @@ async function loadExtras(impl: ReferenceDataImpl): Promise { .then((r) => (r.ok ? (r.json() as Promise) : empty)) .catch(() => empty); // Railway stations: whichever dataset the user chose in Settings (default - // Trainline; "None" loads nothing). A { _source, stations:[…] } wrapper; + // Wikidata, worldwide; "None" loads nothing). A { _source, stations:[…] } wrapper; // absent file → []. Switching source later re-fetches via setStationData. // The biggest file here (5 MB, a long parse on a phone), so it waits for a // screen to ask (requestStations): on the map, once the map has loaded, so it diff --git a/apps/postcards/src/lib/reference/stationSources.ts b/apps/postcards/src/lib/reference/stationSources.ts index f1a210d..efab176 100644 --- a/apps/postcards/src/lib/reference/stationSources.ts +++ b/apps/postcards/src/lib/reference/stationSources.ts @@ -3,15 +3,14 @@ // aggregator, never author) bundled as its own file under public/reference/; the // user picks which one they load — or "None" to carry none at all. // -// This catalogue is DATA-DRIVEN: the Settings picker lists exactly what's here, so -// adding a worldwide build output (e.g. railways-wikidata.json from -// `node scripts/build-railways.mjs --source=wikidata`) is a one-line addition that -// automatically shows up as a selectable source — no UI change needed. +// This catalogue is DATA-DRIVEN: the Settings picker lists exactly what's here. +// The worldwide set comes from the shared Offware places dataset (Wikidata, CC0), +// fetched at build time by `scripts/fetch-places.mjs`. const BASE = import.meta.env.BASE_URL; /** Stable ids, persisted in localStorage — never renumber. "none" loads nothing. */ -export type StationSourceId = "trainline" | "none"; +export type StationSourceId = "wikidata" | "trainline" | "none"; export interface StationSourceDef { id: StationSourceId; @@ -24,23 +23,34 @@ export interface StationSourceDef { recommended: boolean; } -// Trainline (ODbL, Europe) is the recommended default — the only full open station -// set reachable from the build sandbox. A future worldwide file (Wikidata CC0 / -// OSM ODbL), produced by the build script on an open network, slots in here. +// Wikidata (CC0, worldwide) is the recommended default. Trainline (ODbL, Europe) +// stays selectable: its station ids differ, so people who marked stations from it +// keep it until they choose otherwise (see loadStationSource). export const STATION_SOURCES: readonly StationSourceDef[] = [ + { + id: "wikidata", + url: `${BASE}reference/railways-wikidata.json`, + license: "CC0-1.0", + coverage: "worldwide", + recommended: true, + }, { id: "trainline", url: `${BASE}reference/railways.json`, license: "ODbL-1.0", coverage: "europe", - recommended: true, + recommended: false, }, { id: "none", url: null, license: "", coverage: "none", recommended: false }, ]; -export const DEFAULT_STATION_SOURCE: StationSourceId = "trainline"; +export const DEFAULT_STATION_SOURCE: StationSourceId = "wikidata"; const STATION_SOURCE_KEY = "postcards-station-source"; +// Set once the app has been opened before (App.tsx). Before the worldwide set, +// Trainline was everyone's default, so an earlier user who never chose keeps it: +// their marked stations carry Trainline ids, which the worldwide set doesn't have. +const RETURNING_USER_KEY = "postcards-intro-seen"; export function isStationSource(v: unknown): v is StationSourceId { return STATION_SOURCES.some((s) => s.id === v); @@ -55,7 +65,12 @@ export function stationSourceById(id: StationSourceId): StationSourceDef { export function loadStationSource(): StationSourceId { try { const v = localStorage.getItem(STATION_SOURCE_KEY); - return isStationSource(v) ? v : DEFAULT_STATION_SOURCE; + if (isStationSource(v)) return v; + // Either way the choice is saved now: a new user's intro flag, set on this + // first run, must not turn them into a "returning" Trainline user next time. + const choice: StationSourceId = localStorage.getItem(RETURNING_USER_KEY) != null ? "trainline" : DEFAULT_STATION_SOURCE; + localStorage.setItem(STATION_SOURCE_KEY, choice); + return choice; } catch { return DEFAULT_STATION_SOURCE; } diff --git a/apps/postcards/tests/unit/referenceStartup.spec.ts b/apps/postcards/tests/unit/referenceStartup.spec.ts index 83be1ee..cd3e8b3 100644 --- a/apps/postcards/tests/unit/referenceStartup.spec.ts +++ b/apps/postcards/tests/unit/referenceStartup.spec.ts @@ -39,7 +39,7 @@ describe("reference data at startup", () => { // The stations, the biggest file, are not asked for until a screen asks // (the map once it has loaded), so they never compete with it. - expect(late["railways.json"]).toBeUndefined(); + expect(late["railways-wikidata.json"]).toBeUndefined(); const gen = gazetteerGeneration(); const next = () => @@ -58,11 +58,11 @@ describe("reference data at startup", () => { // Not on idle either: only on request. await new Promise((r) => setTimeout(r, 1600)); - expect(late["railways.json"]).toBeUndefined(); + expect(late["railways-wikidata.json"]).toBeUndefined(); requestStations(); - await vi.waitFor(() => expect(late["railways.json"]).toBeDefined(), { timeout: 3000 }); + await vi.waitFor(() => expect(late["railways-wikidata.json"]).toBeDefined(), { timeout: 3000 }); landed = next(); - late["railways.json"]!({ + late["railways-wikidata.json"]!({ stations: [{ id: "Q1", name: "Lisboa Oriente", countryIso2: "PT", lat: 38.77, lon: -9.1 }], }); await landed; @@ -94,9 +94,9 @@ describe("reference data at startup", () => { late["languages.json"]!({}); late["article-names.json"]!({}); requestStations(); - await vi.waitFor(() => expect(late["railways.json"]).toBeDefined(), { timeout: 3000 }); + await vi.waitFor(() => expect(late["railways-wikidata.json"]).toBeDefined(), { timeout: 3000 }); const landed = new Promise((r) => window.addEventListener(GAZETTEER_UPGRADED_EVENT, r, { once: true })); - late["railways.json"]!({ + late["railways-wikidata.json"]!({ stations: [{ id: "Q1", name: "Lisboa Oriente", countryIso2: "PT", lat: 38.77, lon: -9.1 }], }); await landed; diff --git a/apps/postcards/tests/unit/stations.spec.ts b/apps/postcards/tests/unit/stations.spec.ts index b79e2c7..0fcf893 100644 --- a/apps/postcards/tests/unit/stations.spec.ts +++ b/apps/postcards/tests/unit/stations.spec.ts @@ -1,10 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, it, expect } from "vitest"; import { initReferenceDataSync, setStationData } from "../../src/lib/reference/referenceData"; import type { Station } from "../../src/lib/reference/types"; import { STATION_SOURCES, DEFAULT_STATION_SOURCE, isStationSource, + loadStationSource, stationSourceById, } from "../../src/lib/reference/stationSources"; import { PlaceRefSchema } from "../../src/lib/schema/models"; @@ -71,6 +72,11 @@ describe("station data sources (Settings choice)", () => { expect(STATION_SOURCES.filter((s) => s.recommended)).toHaveLength(1); }); + it("defaults to the worldwide Wikidata set", () => { + expect(DEFAULT_STATION_SOURCE).toBe("wikidata"); + expect(stationSourceById("wikidata")).toMatchObject({ coverage: "worldwide", license: "CC0-1.0" }); + }); + it("rejects unknown source ids", () => { expect(isStationSource("osm")).toBe(false); expect(isStationSource(null)).toBe(false); @@ -88,3 +94,24 @@ describe("station data sources (Settings choice)", () => { expect(ref.allStations()).toEqual([]); }); }); + +describe("the station source a person starts with", () => { + beforeEach(() => localStorage.clear()); + + it("gives a new user the worldwide set, and keeps it after the first run", () => { + expect(loadStationSource()).toBe("wikidata"); + localStorage.setItem("postcards-intro-seen", "1"); // set at the end of the first run + expect(loadStationSource()).toBe("wikidata"); + }); + + it("keeps an earlier user on Trainline, whose station ids their visits use", () => { + localStorage.setItem("postcards-intro-seen", "1"); + expect(loadStationSource()).toBe("trainline"); + }); + + it("never overrides a choice made in Settings", () => { + localStorage.setItem("postcards-intro-seen", "1"); + localStorage.setItem("postcards-station-source", "wikidata"); + expect(loadStationSource()).toBe("wikidata"); + }); +}); diff --git a/apps/postcards/vite.config.ts b/apps/postcards/vite.config.ts index 40403d6..aaff671 100644 --- a/apps/postcards/vite.config.ts +++ b/apps/postcards/vite.config.ts @@ -76,7 +76,9 @@ export default defineConfig({ // top-10k core and downloads the full set ON DEMAND (a one-tap Settings // action, like a tile pack). Runtime-cached below so it reopens offline // once fetched. Never bundled — that would defeat the small-install goal. - globIgnores: ["**/reference/cities-all.json"], + // The worldwide stations (fetched at build time from the places dataset) can + // outgrow the precache limit; they're runtime-cached below instead. + globIgnores: ["**/reference/cities-all.json", "**/reference/railways-wikidata.json"], maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, // Control the page on the very first visit, so a tile pack or the full // city list downloaded in that session reaches the runtime caches below @@ -100,6 +102,17 @@ export default defineConfig({ cacheableResponse: { statuses: [200] }, }, }, + { + // Worldwide railway stations: cached when the map first asks for them, + // so they reopen offline; refreshed in the background after a release. + urlPattern: ({ url }) => url.pathname.endsWith("/reference/railways-wikidata.json"), + handler: "StaleWhileRevalidate", + options: { + cacheName: "stations-v1", + expiration: { maxEntries: 2 }, + cacheableResponse: { statuses: [200] }, + }, + }, { urlPattern: ({ url }) => url.hostname.endsWith("tile.openstreetmap.org"), handler: "CacheFirst", diff --git a/specs/022-shared-places/plan.md b/specs/022-shared-places/plan.md new file mode 100644 index 0000000..24d84a4 --- /dev/null +++ b/specs/022-shared-places/plan.md @@ -0,0 +1,24 @@ +# Implementation Plan: Worldwide stations and landmarks from the shared places dataset + +**Spec**: `specs/022-shared-places/spec.md` + +## Constitution Check + +- **I. Aggregator, never an author**: pass. Data comes from Wikidata (CC0) through the places + dataset; ids are Wikidata items; provenance updated. +- **Local-first / offline**: pass. Files are bundled at build time; stations runtime-cached. +- **Privacy**: pass. The fetch happens in CI at build time, never from a user's device. +- **Data is inert**: pass. The build script validates shape, licence and coordinates. +- **Zero lock-in**: pass. Static JSON on GitHub Pages; `PLACES_BASE` points it elsewhere. + +## Design + +- `scripts/fetch-places.mjs` (`pnpm places`): downloads, validates, keeps the 10,000 best-known + landmarks, writes the two files; failure leaves files as they are. +- `src/lib/reference/stationSources.ts`: adds `wikidata` (default, recommended); the first + `loadStationSource()` without a saved choice pins Trainline for a returning user (intro already + seen) and Wikidata for a new one. +- `vite.config.ts`: `railways-wikidata.json` out of the precache, into a runtime cache. +- Workflows: `pages.yml` and `android-apk.yml` run `pnpm places` before building; `pages.yml` also + runs weekly after the dataset's Monday build. +- `railways-wikidata.json` is git-ignored: it is produced at build time. diff --git a/specs/022-shared-places/spec.md b/specs/022-shared-places/spec.md new file mode 100644 index 0000000..817be84 --- /dev/null +++ b/specs/022-shared-places/spec.md @@ -0,0 +1,63 @@ +# Feature Specification: Worldwide stations and landmarks from the shared places dataset + +**Feature Branch**: `022-shared-places` + +**Created**: 2026-10-12 + +**Status**: Implemented + +**Input**: Maintainer: "update Postcards to use that centralized dataset. It uses only European +trains; make sure it uses worldwide train stations." The dataset is +[offware-apps/places](https://github.com/offware-apps/places), shared by the Offware apps: towns, +sights and railway stations from Wikidata (CC0), every record keeping its Wikidata id. + +## Overview + +Postcards' railway stations come from Trainline's open database, which covers Europe only, and its +landmarks are a hand-picked seed of ~70. The shared Offware places dataset publishes, every week, +open railway stations worldwide and the best-known sights of every town, both from Wikidata. This +feature makes Postcards read both from it, keeping Constitution I: the app still authors nothing, +and every record's provenance (its Wikidata item) travels with it. + +## User Scenarios & Testing + +### User Story 1 - Mark a station anywhere in the world (Priority: P1) + +A traveller who took the train in Japan searches "Tokyo Station", finds it, and marks it visited, +offline once the stations have loaded. + +**Acceptance Scenarios**: + +1. **Given** a new install, **When** the user searches a station outside Europe, **Then** it is + found, from the worldwide set, which is the recommended default. +2. **Given** the user marked stations from the Europe (Trainline) set before this release and never + chose a source, **When** they update, **Then** they stay on Trainline, so their marked stations + keep resolving; Settings offers the worldwide set. +3. **Given** any user, **When** they pick a source in Settings, **Then** that choice is kept. + +### User Story 2 - See well-known sights beyond UNESCO's list (Priority: P2) + +Museums, monuments, castles, palaces and parks show among sites & landmarks: the 10,000 best known +worldwide (by how many Wikipedia editions cover them), replacing the seed. UNESCO sites keep coming +from UNESCO's own list, so none is listed twice. + +## Requirements + +- **FR-001**: The build fetches `stations.json`, `landmarks.json` and `sources.json` from the + places dataset (`scripts/fetch-places.mjs`) and writes `public/reference/railways-wikidata.json` + and `public/reference/landmarks.json`. +- **FR-002**: A file is written only when it validates: the expected shape, a CC0 licence, and + enough valid records. Otherwise the existing file stays. The build fails only when no + stations file exists at all, since the default source would then be empty. +- **FR-003**: A "World — Wikidata" station source is the recommended default; Trainline and None + stay selectable. +- **FR-004**: A person who used the app before and never chose keeps Trainline (FR scenario 1.2). +- **FR-005**: The worldwide station file is runtime-cached (stale-while-revalidate), as it can + outgrow the precache size limit. +- **FR-006**: Provenance (Settings and `public/reference/PROVENANCE.md`) names the places dataset, + Wikidata and CC0 for both files. +- **FR-007**: Strings in en, fr and ko. + +## Out of scope + +Converting Trainline-id visits to Wikidata ids; per-category landmark glyphs. diff --git a/specs/022-shared-places/tasks.md b/specs/022-shared-places/tasks.md new file mode 100644 index 0000000..3925a3b --- /dev/null +++ b/specs/022-shared-places/tasks.md @@ -0,0 +1,11 @@ +# Tasks: Worldwide stations and landmarks from the shared places dataset + +All paths under `apps/postcards/` unless noted. + +- [x] T001 `scripts/fetch-places.mjs` + `pnpm places`, with validation and a never-failing fallback +- [x] T002 `wikidata` station source as the recommended default; returning users pinned to Trainline +- [x] T003 Unit tests: default source, new vs returning user, saved choice kept (`tests/unit/stations.spec.ts`) +- [x] T004 Runtime cache for `railways-wikidata.json` (`vite.config.ts`) +- [x] T005 en/fr/ko strings for the new source and the updated Trainline description +- [x] T006 Provenance: `src/lib/reference/data/provenance.json`, `public/reference/PROVENANCE.md` +- [x] T007 Workflows: fetch before building (`.github/workflows/pages.yml`, `android-apk.yml`); weekly Pages build From 8c005dc8c42254632336675bd2ebdefc4139c0ac Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:48:12 +0000 Subject: [PATCH 2/2] Read the shared data from its new home, offware-apps/data The places repository becomes Offware's shared data repository, with one folder per dataset: stations/railways.json and places/landmarks.json. --- .github/workflows/android-apk.yml | 2 +- .github/workflows/pages.yml | 4 ++-- apps/postcards/public/reference/PROVENANCE.md | 4 ++-- apps/postcards/scripts/fetch-places.mjs | 18 +++++++++--------- apps/postcards/src/lib/i18n/en.ts | 2 +- apps/postcards/src/lib/i18n/fr.ts | 2 +- apps/postcards/src/lib/i18n/ko.ts | 2 +- .../src/lib/reference/data/provenance.json | 8 ++++---- .../src/lib/reference/stationSources.ts | 2 +- specs/022-shared-places/plan.md | 2 +- specs/022-shared-places/spec.md | 12 ++++++------ specs/022-shared-places/tasks.md | 2 +- 12 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 6a90858..999bc6b 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -45,7 +45,7 @@ jobs: cache: gradle - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Fetch the shared places dataset (stations worldwide, landmarks) + - name: Fetch Offware's shared data (stations worldwide, landmarks) # Never fails the build: an unreachable dataset leaves the committed files as they are. run: pnpm --filter postcards places - name: Build the web app diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8917c76..9ab8195 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -9,7 +9,7 @@ on: push: branches: - main - # Weekly, after the places dataset's Monday build, so stations and landmarks stay current. + # Weekly, after the shared data's Monday build, so stations and landmarks stay current. schedule: - cron: "0 6 * * 1" workflow_dispatch: @@ -38,7 +38,7 @@ jobs: cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Fetch the shared places dataset (stations worldwide, landmarks) + - name: Fetch Offware's shared data (stations worldwide, landmarks) # Never fails the build: an unreachable dataset leaves the committed files as they are. run: pnpm --filter postcards places - name: Build (GitHub Pages subpath) diff --git a/apps/postcards/public/reference/PROVENANCE.md b/apps/postcards/public/reference/PROVENANCE.md index 215c914..5f417d7 100644 --- a/apps/postcards/public/reference/PROVENANCE.md +++ b/apps/postcards/public/reference/PROVENANCE.md @@ -13,8 +13,8 @@ mirrors it for people browsing the repository. | `subdivisions.json` | First-level regions: each city's GeoNames admin-1 code () and each code's GeoNames name (), both retrieved 2026-09-30; France's 13 regions by their French names; a code GeoNames does not name keeps one from dr5hn countries-states-cities — | CC BY 4.0 (GeoNames) / ODbL (dr5hn) | all-the-cities 3.1.0 + GeoNames 2026-09-30 + country-state-city | | `airports.json` | OpenFlights (IATA-coded, aggregated from OurAirports) — | ODbL 1.0 / OurAirports public domain | airport-data 1.0.1 | | `heritage.json` | UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025) — | CC BY-SA 3.0 IGO — © UNESCO World Heritage Centre | 2025 list, retrieved 2026-07-11 | -| `landmarks.json` | Landmarks (museums, monuments, castles, palaces, parks; UNESCO sites excluded), the 10,000 best known, from Wikidata via the Offware places dataset — . Written at build time by `scripts/fetch-places.mjs`; the committed file is a small seed used when the dataset can't be reached. Each id is `wd-`. | CC0 1.0 | fetched at build time | -| `railways-wikidata.json` | Railway stations worldwide from Wikidata (open mainline stations, instance of Q55488) via the Offware places dataset — . Written at build time by `scripts/fetch-places.mjs`, not committed. Each id is the station's Wikidata QID. | CC0 1.0 | fetched at build time | +| `landmarks.json` | Landmarks (museums, monuments, castles, palaces, parks; UNESCO sites excluded), the 10,000 best known, from Wikidata via Offware's shared data — . Written at build time by `scripts/fetch-places.mjs`; the committed file is a small seed used when the dataset can't be reached. Each id is `wd-`. | CC0 1.0 | fetched at build time | +| `railways-wikidata.json` | Railway stations worldwide from Wikidata (open mainline stations, instance of Q55488) via Offware's shared data — . Written at build time by `scripts/fetch-places.mjs`, not committed. Each id is the station's Wikidata QID. | CC0 1.0 | fetched at build time | | `railways.json` | Railway stations in Europe from the Trainline EU open stations database — | ODbL 1.0 | `scripts/build-railways.mjs` | | `languages.json` | Per-country spoken languages (via world-countries) — | ODbL 1.0 | 5.1.0 | | `article-names.json` | Common country names (Wikivoyage/Wikipedia article titles, via world-countries) — | ODbL 1.0 | 5.1.0 | diff --git a/apps/postcards/scripts/fetch-places.mjs b/apps/postcards/scripts/fetch-places.mjs index 7c7fc2e..1d9fcbe 100644 --- a/apps/postcards/scripts/fetch-places.mjs +++ b/apps/postcards/scripts/fetch-places.mjs @@ -1,4 +1,4 @@ -// Fetch the shared Offware places dataset (https://github.com/offware-apps/places) into +// Fetch Offware's shared data (https://github.com/offware-apps/data) into // the bundled reference files, at build time: // // public/reference/railways-wikidata.json railway stations worldwide (Wikidata, CC0) @@ -12,13 +12,13 @@ // leave every new user with no stations. // // node scripts/fetch-places.mjs -// PLACES_BASE=https://example.org/places/v1 node scripts/fetch-places.mjs +// PLACES_BASE=https://example.org/data/v1 node scripts/fetch-places.mjs import { existsSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -const BASE = process.env.PLACES_BASE ?? "https://offware-apps.github.io/places/v1"; +const BASE = process.env.PLACES_BASE ?? "https://offware-apps.github.io/data/v1"; const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "reference"); const LANDMARK_MAX = 10_000; const UA = "Postcards-reference-build/1.0 (https://github.com/offware-apps/Postcards)"; @@ -35,18 +35,18 @@ async function get(path) { } async function stations() { - const json = await get("stations.json"); - if (json?._source?.license !== "CC0-1.0" || !Array.isArray(json.stations)) throw new Error("stations.json: unexpected shape or licence"); + const json = await get("stations/railways.json"); + if (json?._source?.license !== "CC0-1.0" || !Array.isArray(json.stations)) throw new Error("stations/railways.json: unexpected shape or licence"); const list = json.stations.filter(validPoint); - if (list.length < 1000) throw new Error(`stations.json: only ${list.length} valid stations`); + if (list.length < 1000) throw new Error(`stations/railways.json: only ${list.length} valid stations`); writeFileSync(join(OUT, "railways-wikidata.json"), JSON.stringify({ _source: json._source, stations: list })); return `${list.length} stations`; } async function landmarks() { - const [list, sources] = await Promise.all([get("landmarks.json"), get("sources.json")]); + const [list, sources] = await Promise.all([get("places/landmarks.json"), get("sources.json")]); const wikidata = sources?.sources?.find((s) => s.id === "wikidata"); - if (wikidata?.license !== "CC0-1.0" || !Array.isArray(list)) throw new Error("landmarks.json: unexpected shape or licence"); + if (wikidata?.license !== "CC0-1.0" || !Array.isArray(list)) throw new Error("places/landmarks.json: unexpected shape or licence"); // The best known LANDMARK_MAX, so the file stays small enough to load at start-up. // Postcards' landmarks are one category; the dataset's finer kinds stay out of the app. const out = list @@ -55,7 +55,7 @@ async function landmarks() { .slice(0, LANDMARK_MAX) .map(({ id, name, countryIso2, lat, lon }) => ({ id, name, countryIso2, lat, lon, category: "landmark" })) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); - if (out.length < 100) throw new Error(`landmarks.json: only ${out.length} valid landmarks`); + if (out.length < 100) throw new Error(`places/landmarks.json: only ${out.length} valid landmarks`); writeFileSync(join(OUT, "landmarks.json"), JSON.stringify(out)); return `${out.length} landmarks`; } diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index 80864c8..91fe2b8 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -88,7 +88,7 @@ export const en = { "settings.stations.source.trainline": "Europe — Trainline", "settings.stations.source.none": "None", "settings.stations.desc.wikidata": - "Open railway stations worldwide from Wikidata (CC0), via the shared Offware places dataset. Each station links back to its Wikidata item, where anyone can correct it.", + "Open railway stations worldwide from Wikidata (CC0), via Offware's shared data. Each station links back to its Wikidata item, where anyone can correct it.", "settings.stations.desc.trainline": "~44,000 European stations from Trainline's open database (ODbL). Choose it to keep stations you marked from this list before the worldwide set arrived.", "settings.stations.desc.none": "No railway stations are loaded — they won't appear in search, on the map, or in stats.", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index d733dd6..f15cbd9 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -82,7 +82,7 @@ export const fr: Messages = { "settings.stations.source.trainline": "Europe — Trainline", "settings.stations.source.none": "Aucune", "settings.stations.desc.wikidata": - "Les gares ferroviaires ouvertes du monde entier, issues de Wikidata (CC0) via le jeu de données partagé Offware places. Chaque gare renvoie à sa fiche Wikidata, que chacun peut corriger.", + "Les gares ferroviaires ouvertes du monde entier, issues de Wikidata (CC0) via les données partagées d'Offware. Chaque gare renvoie à sa fiche Wikidata, que chacun peut corriger.", "settings.stations.desc.trainline": "~44 000 gares européennes issues de la base ouverte de Trainline (ODbL). Choisissez-la pour garder les gares marquées depuis cette liste avant l'arrivée du jeu mondial.", "settings.stations.desc.none": "Aucune gare n'est chargée — elles n'apparaîtront ni dans la recherche, ni sur la carte, ni dans les stats.", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index a648786..4d2c75b 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -82,7 +82,7 @@ export const ko: Messages = { "settings.stations.source.trainline": "유럽 — Trainline", "settings.stations.source.none": "없음", "settings.stations.desc.wikidata": - "공유 Offware places 데이터셋을 통해 Wikidata(CC0)에서 가져온 전 세계 운행 중인 기차역. 각 역은 누구나 수정할 수 있는 Wikidata 항목으로 연결됩니다.", + "Offware 공유 데이터를 통해 Wikidata(CC0)에서 가져온 전 세계 운행 중인 기차역. 각 역은 누구나 수정할 수 있는 Wikidata 항목으로 연결됩니다.", "settings.stations.desc.trainline": "Trainline 공개 데이터베이스(ODbL)의 유럽 기차역 약 44,000곳. 전 세계 데이터가 추가되기 전에 이 목록에서 표시한 역을 유지하려면 선택하세요.", "settings.stations.desc.none": "기차역을 불러오지 않습니다 — 검색, 지도, 통계에 나타나지 않습니다.", diff --git a/apps/postcards/src/lib/reference/data/provenance.json b/apps/postcards/src/lib/reference/data/provenance.json index b976b49..d2548bb 100644 --- a/apps/postcards/src/lib/reference/data/provenance.json +++ b/apps/postcards/src/lib/reference/data/provenance.json @@ -30,10 +30,10 @@ "url": "https://openflights.org/data.html" }, { - "dataset": "Railway stations worldwide — Wikidata via Offware places (default; choosable in Settings)", + "dataset": "Railway stations worldwide — Wikidata via Offware shared data (default; choosable in Settings)", "license": "CC0-1.0", "version": "fetched at build time", - "url": "https://github.com/offware-apps/places" + "url": "https://github.com/offware-apps/data" }, { "dataset": "Railway stations — Trainline EU open stations database (Europe; choosable in Settings)", @@ -60,10 +60,10 @@ "url": "https://en.wikivoyage.org" }, { - "dataset": "Landmarks — the 10,000 best-known sights (Wikidata, via the Offware places dataset)", + "dataset": "Landmarks — the 10,000 best-known sights (Wikidata, via Offware's shared data)", "license": "CC0-1.0", "version": "fetched at build time", - "url": "https://github.com/offware-apps/places" + "url": "https://github.com/offware-apps/data" }, { "dataset": "UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025)", diff --git a/apps/postcards/src/lib/reference/stationSources.ts b/apps/postcards/src/lib/reference/stationSources.ts index efab176..7ed9683 100644 --- a/apps/postcards/src/lib/reference/stationSources.ts +++ b/apps/postcards/src/lib/reference/stationSources.ts @@ -4,7 +4,7 @@ // user picks which one they load — or "None" to carry none at all. // // This catalogue is DATA-DRIVEN: the Settings picker lists exactly what's here. -// The worldwide set comes from the shared Offware places dataset (Wikidata, CC0), +// The worldwide set comes from Offware's shared data (Wikidata, CC0), // fetched at build time by `scripts/fetch-places.mjs`. const BASE = import.meta.env.BASE_URL; diff --git a/specs/022-shared-places/plan.md b/specs/022-shared-places/plan.md index 24d84a4..5658081 100644 --- a/specs/022-shared-places/plan.md +++ b/specs/022-shared-places/plan.md @@ -1,4 +1,4 @@ -# Implementation Plan: Worldwide stations and landmarks from the shared places dataset +# Implementation Plan: Worldwide stations and landmarks from Offware's shared data **Spec**: `specs/022-shared-places/spec.md` diff --git a/specs/022-shared-places/spec.md b/specs/022-shared-places/spec.md index 817be84..2e14d87 100644 --- a/specs/022-shared-places/spec.md +++ b/specs/022-shared-places/spec.md @@ -1,4 +1,4 @@ -# Feature Specification: Worldwide stations and landmarks from the shared places dataset +# Feature Specification: Worldwide stations and landmarks from Offware's shared data **Feature Branch**: `022-shared-places` @@ -8,13 +8,13 @@ **Input**: Maintainer: "update Postcards to use that centralized dataset. It uses only European trains; make sure it uses worldwide train stations." The dataset is -[offware-apps/places](https://github.com/offware-apps/places), shared by the Offware apps: towns, +[offware-apps/data](https://github.com/offware-apps/data), shared by the Offware apps: towns, sights and railway stations from Wikidata (CC0), every record keeping its Wikidata id. ## Overview Postcards' railway stations come from Trainline's open database, which covers Europe only, and its -landmarks are a hand-picked seed of ~70. The shared Offware places dataset publishes, every week, +landmarks are a hand-picked seed of ~70. Offware's shared data publishes, every week, open railway stations worldwide and the best-known sights of every town, both from Wikidata. This feature makes Postcards read both from it, keeping Constitution I: the app still authors nothing, and every record's provenance (its Wikidata item) travels with it. @@ -43,8 +43,8 @@ from UNESCO's own list, so none is listed twice. ## Requirements -- **FR-001**: The build fetches `stations.json`, `landmarks.json` and `sources.json` from the - places dataset (`scripts/fetch-places.mjs`) and writes `public/reference/railways-wikidata.json` +- **FR-001**: The build fetches `stations/railways.json`, `places/landmarks.json` and `sources.json` from the + shared data (`scripts/fetch-places.mjs`) and writes `public/reference/railways-wikidata.json` and `public/reference/landmarks.json`. - **FR-002**: A file is written only when it validates: the expected shape, a CC0 licence, and enough valid records. Otherwise the existing file stays. The build fails only when no @@ -54,7 +54,7 @@ from UNESCO's own list, so none is listed twice. - **FR-004**: A person who used the app before and never chose keeps Trainline (FR scenario 1.2). - **FR-005**: The worldwide station file is runtime-cached (stale-while-revalidate), as it can outgrow the precache size limit. -- **FR-006**: Provenance (Settings and `public/reference/PROVENANCE.md`) names the places dataset, +- **FR-006**: Provenance (Settings and `public/reference/PROVENANCE.md`) names the shared data, Wikidata and CC0 for both files. - **FR-007**: Strings in en, fr and ko. diff --git a/specs/022-shared-places/tasks.md b/specs/022-shared-places/tasks.md index 3925a3b..168c0be 100644 --- a/specs/022-shared-places/tasks.md +++ b/specs/022-shared-places/tasks.md @@ -1,4 +1,4 @@ -# Tasks: Worldwide stations and landmarks from the shared places dataset +# Tasks: Worldwide stations and landmarks from Offware's shared data All paths under `apps/postcards/` unless noted.