Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/android-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
cache: gradle
- name: Install dependencies
run: pnpm install --frozen-lockfile
- 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
run: pnpm --filter postcards build
- name: Sync the Android project
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
push:
branches:
- main
# Weekly, after the shared data's Monday build, so stations and landmarks stay current.
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
Expand All @@ -35,6 +38,9 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- 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)
run: pnpm --filter postcards build
env:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions apps/postcards/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion apps/postcards/public/reference/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ mirrors it for people browsing the repository.
| `subdivisions.json` | First-level regions: each city's GeoNames admin-1 code (<https://download.geonames.org/export/dump/cities500.zip>) and each code's GeoNames name (<https://download.geonames.org/export/dump/admin1CodesASCII.txt>), 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 — <https://github.com/dr5hn/countries-states-cities-database> | 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) — <https://openflights.org/data.html> | 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) — <https://whc.unesco.org/en/list/> | 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) — <https://www.wikidata.org> | CC0 (facts) | seed |
| `landmarks.json` | Landmarks (museums, monuments, castles, palaces, parks; UNESCO sites excluded), the 10,000 best known, from Wikidata via Offware's shared data — <https://github.com/offware-apps/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-<Wikidata QID>`. | 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 — <https://github.com/offware-apps/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 — <https://github.com/trainline-eu/stations> | ODbL 1.0 | `scripts/build-railways.mjs` |
| `languages.json` | Per-country spoken languages (via world-countries) — <https://github.com/mledoze/countries> | ODbL 1.0 | 5.1.0 |
| `article-names.json` | Common country names (Wikivoyage/Wikipedia article titles, via world-countries) — <https://github.com/mledoze/countries> | ODbL 1.0 | 5.1.0 |
| `experiences.json` | Postcards "Moments" — app-authored name/hint/emoji only; anchor coordinates aggregated from Wikidata (<https://www.wikidata.org>) / GeoNames (<https://www.geonames.org>); experience concepts attributed to UNESCO World Heritage (<https://whc.unesco.org/en/list/>), UNESCO Intangible Cultural Heritage (<https://ich.unesco.org/en/lists>) & Wikivoyage travel topics (<https://en.wikivoyage.org>). 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 |
Expand Down
74 changes: 74 additions & 0 deletions apps/postcards/scripts/fetch-places.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 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)
// 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/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/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)";

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/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/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("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("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
.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(`places/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);
}
5 changes: 4 additions & 1 deletion apps/postcards/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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). 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",
Expand Down
5 changes: 4 additions & 1 deletion apps/postcards/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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). 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",
Expand Down
5 changes: 4 additions & 1 deletion apps/postcards/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 공유 데이터를 통해 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": "최적화",
Expand Down
18 changes: 12 additions & 6 deletions apps/postcards/src/lib/reference/data/provenance.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 shared data (default; choosable in Settings)",
"license": "CC0-1.0",
"version": "fetched at build time",
"url": "https://github.com/offware-apps/data"
},
{
"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"
},
{
Expand All @@ -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 Offware's shared data)",
"license": "CC0-1.0",
"version": "fetched at build time",
"url": "https://github.com/offware-apps/data"
},
{
"dataset": "UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025)",
Expand Down
2 changes: 1 addition & 1 deletion apps/postcards/src/lib/reference/referenceData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ async function loadExtras(impl: ReferenceDataImpl): Promise<void> {
.then((r) => (r.ok ? (r.json() as Promise<T>) : 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
Expand Down
37 changes: 26 additions & 11 deletions apps/postcards/src/lib/reference/stationSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 Offware's shared data (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;
Expand All @@ -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);
Expand All @@ -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;
}
Expand Down
12 changes: 6 additions & 6 deletions apps/postcards/tests/unit/referenceStartup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () =>
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading