diff --git a/src/app.d.ts b/src/app.d.ts index 057ee2319..37e9c7b6f 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -9,6 +9,11 @@ declare global { interface PageData { geo?: GeoLocation; } + // Shallow-routing state. The add-location wizard stores its current step + // here so browser back/forward navigates between steps. + interface PageState { + wizardStep?: "intro" | "online" | "map" | "update" | "new" | "success"; + } // interface Platform {} } diff --git a/src/components/form/AddressSearch.svelte b/src/components/form/AddressSearch.svelte index 1c5739c4f..3da272d4b 100644 --- a/src/components/form/AddressSearch.svelte +++ b/src/components/form/AddressSearch.svelte @@ -13,7 +13,19 @@ export let disabled = false; export let locale = "en"; const dispatch = createEventDispatcher<{ - select: { lat: number; lng: number; displayName: string }; + select: { + lat: number; + lng: number; + displayName: string; + // Enriched OSM metadata, present when the match is a tagged POI. + name?: string; + category?: string; + website?: string; + phone?: string; + openingHours?: string; + osmType?: string; + osmId?: string; + }; }>(); // Per-instance ID prefix so multiple AddressSearch components on the @@ -43,7 +55,18 @@ $: if ( } function emitSelect(r: GeocodeResult) { - dispatch("select", { lat: r.lat, lng: r.lon, displayName: r.displayName }); + dispatch("select", { + lat: r.lat, + lng: r.lon, + displayName: r.displayName, + name: r.name, + category: r.category, + website: r.website, + phone: r.phone, + openingHours: r.openingHours, + osmType: r.osmType, + osmId: r.osmId, + }); } function closeResults() { diff --git a/src/components/form/OpeningHoursEditor.svelte b/src/components/form/OpeningHoursEditor.svelte new file mode 100644 index 000000000..acbe793ec --- /dev/null +++ b/src/components/form/OpeningHoursEditor.svelte @@ -0,0 +1,277 @@ + + +
+ {#if rawMode} +
+

+ {$_('openingHours.rawHint')} +

+ + +
+ {:else} + + + {#if !always24} +
+ {#each days as day, i (DAYS[i])} +
+ + {#if day.open} +
+ + {#if !day.is24} + {#each day.ranges as range, r (r)} +
+ + + + {#if day.ranges.length > 1} + + {/if} +
+ {/each} + + {/if} +
+ {:else} + {$_('openingHours.closed')} + {/if} +
+ {/each} +
+ + + {/if} + +
+ {$_('openingHours.preview')}: + {value || $_('openingHours.none')} +
+ {/if} + + {#if validationError} +

{validationError}

+ {/if} +
diff --git a/src/lib/btcmapApi.ts b/src/lib/btcmapApi.ts new file mode 100644 index 000000000..dbcc4fea4 --- /dev/null +++ b/src/lib/btcmapApi.ts @@ -0,0 +1,105 @@ +// Server-only client for the btcmap-api JSON-RPC endpoint (/rpc). +// +// Currently exposes submit_place, the sanctioned import pipeline: it puts a +// place on BTC Map instantly (in btcmap-api's own database) and lets editors +// merge it into OSM later. We call it on maintainer approval, never on raw +// public submission, so it doesn't bypass spam review. +// +// Requires a trusted bearer token with the places_source role, provisioned by +// the btcmap-api maintainers. Everything is gated behind btcmapApiConfigured() +// so the site runs without the token — the submit simply stays disabled. + +import { API_BASE } from "$lib/api-base"; +import type { OsmPayload } from "$lib/osmPayload"; + +import { env } from "$env/dynamic/private"; + +const DEFAULT_ORIGIN = "btcmap-org"; + +export function btcmapApiConfigured(): boolean { + return Boolean(env.BTCMAP_API_RPC_TOKEN); +} + +function importOrigin(): string { + return env.BTCMAP_PLACE_IMPORT_ORIGIN || DEFAULT_ORIGIN; +} + +type SubmitPlaceResult = { + id: number; + origin: string; + external_id: string; +}; + +async function rpc( + method: string, + params: Record, +): Promise { + const res = await fetch(`${API_BASE}/rpc`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.BTCMAP_API_RPC_TOKEN}`, + }, + body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }), + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`btcmap-api ${method} failed (${res.status}): ${text}`); + } + const parsed = JSON.parse(text); + if (parsed.error) { + throw new Error( + `btcmap-api ${method} error: ${JSON.stringify(parsed.error)}`, + ); + } + return parsed.result as T; +} + +// Map an OSM create-payload to submit_place params. external_id is derived from +// the Gitea issue so a re-approval patches the same record instead of +// duplicating it (submit_place is idempotent on (origin, external_id)). +export async function submitPlaceFromPayload( + payload: OsmPayload, + externalId: string, +): Promise { + if (!btcmapApiConfigured()) { + throw new Error( + "submit_place not configured (missing BTCMAP_API_RPC_TOKEN)", + ); + } + const name = payload.tags.name; + const category = payload.category; + if (!name) throw new Error("submit_place payload missing name"); + if (!category) throw new Error("submit_place payload missing category"); + if (!Number.isFinite(payload.lat) || !Number.isFinite(payload.lon)) { + throw new Error("submit_place payload missing coordinates"); + } + + // Carry the human-relevant tags across as extra_fields for editor review. + const extraFields: Record = {}; + const carry: Array<[string, string]> = [ + ["website", "contact:website"], + ["phone", "contact:phone"], + ["opening_hours", "opening_hours"], + ["twitter", "contact:twitter"], + ["facebook", "contact:facebook"], + ["instagram", "contact:instagram"], + ]; + for (const [field, tag] of carry) { + if (payload.tags[tag]) extraFields[field] = payload.tags[tag]; + } + if (payload.tags["payment:onchain"]) extraFields.payment_onchain = "yes"; + if (payload.tags["payment:lightning"]) extraFields.payment_lightning = "yes"; + if (payload.tags["payment:lightning_contactless"]) + extraFields.payment_lightning_contactless = "yes"; + + return rpc("submit_place", { + origin: importOrigin(), + external_id: externalId, + lat: payload.lat, + lon: payload.lon, + category, + name, + extra_fields: extraFields, + }); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6cc4098b1..112599968 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -12,6 +12,21 @@ export const GITEA_LABELS = { }, } as const; +// Name-based labels resolved/created at issue-creation time via createLabel(). +// Using names (instead of hardcoded numeric ids) means these labels do not need +// to be pre-created in Gitea with a known id — the first submission creates them. +export const GITEA_LABEL_NAMES = { + // Wizard: business with no physical location visitors can go to — online + // shops/webshops and services delivered at the customer's place (e.g. a mobile + // window cleaner). Stored for a future listing, not shown on the map yet. + ONLINE_OR_MOBILE: "online-or-mobile", + // Wizard: user reports updated data for a merchant already present on OSM. + UPDATE_LOCATION: "update-location", + // Applied by a maintainer to green-light an automated OSM push (see + // src/routes/api/gitea/webhook). The webhook only acts on issues carrying this label. + APPROVED: "osm-approved", +} as const; + export const POLLING_INTERVAL = 2500; export const QR_CODE_SIZE = { mobile: 200, desktop: 275 }; diff --git a/src/lib/geocoding.test.ts b/src/lib/geocoding.test.ts index aefd81e1d..4f9a903f7 100644 --- a/src/lib/geocoding.test.ts +++ b/src/lib/geocoding.test.ts @@ -24,6 +24,8 @@ describe("searchAddress", () => { format: "jsonv2", limit: 5, addressdetails: 0, + extratags: 1, + namedetails: 1, "accept-language": "de", }); }); @@ -54,6 +56,51 @@ describe("searchAddress", () => { ]); }); + it("enriches results with OSM POI metadata when present", async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: [ + { + lat: "53.0318", + lon: "5.6580", + display_name: "Kreta, Sneek, Netherlands", + name: "Kreta", + type: "restaurant", + osm_type: "node", + osm_id: 42, + namedetails: { name: "Kreta" }, + extratags: { + website: "https://kreta.example", + phone: "+31 515 123456", + opening_hours: "Mo-Su 12:00-22:00", + }, + }, + ], + }); + + const results = await searchAddress("kreta sneek", "en"); + + expect(results[0]).toEqual({ + lat: 53.0318, + lon: 5.658, + displayName: "Kreta, Sneek, Netherlands", + name: "Kreta", + category: "restaurant", + website: "https://kreta.example", + phone: "+31 515 123456", + openingHours: "Mo-Su 12:00-22:00", + osmType: "node", + osmId: "42", + }); + }); + + it("ignores an uninformative type ('yes') for category", async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: [{ lat: "1", lon: "2", display_name: "x", type: "yes" }], + }); + const results = await searchAddress("x", "en"); + expect(results[0].category).toBeUndefined(); + }); + it("returns an empty array on empty response", async () => { mockedAxios.get.mockResolvedValueOnce({ data: [] }); diff --git a/src/lib/geocoding.ts b/src/lib/geocoding.ts index abf34999a..2b6ea37bc 100644 --- a/src/lib/geocoding.ts +++ b/src/lib/geocoding.ts @@ -4,17 +4,42 @@ export type GeocodeResult = { lat: number; lon: number; displayName: string; + // Enriched OSM metadata (when the matched result is a tagged POI). Used by + // the add-location wizard to prefill a new merchant from an existing OSM place. + name?: string; + category?: string; + website?: string; + phone?: string; + openingHours?: string; + osmType?: string; + osmId?: string; }; type NominatimResult = { lat: string; lon: string; display_name: string; + name?: string; + category?: string; + type?: string; + osm_type?: string; + osm_id?: number; + extratags?: Record | null; + namedetails?: Record | null; }; const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; const REQUEST_TIMEOUT_MS = 8000; +const firstNonEmpty = ( + ...vals: Array +): string | undefined => { + for (const v of vals) { + if (v && v.trim()) return v.trim(); + } + return undefined; +}; + export const searchAddress = async ( query: string, locale: string, @@ -25,6 +50,9 @@ export const searchAddress = async ( format: "jsonv2", limit: 5, addressdetails: 0, + // Request POI metadata so the wizard can prefill name/category/contact. + extratags: 1, + namedetails: 1, "accept-language": locale, }, timeout: REQUEST_TIMEOUT_MS, @@ -34,9 +62,23 @@ export const searchAddress = async ( for (const entry of response.data) { const lat = Number(entry.lat); const lon = Number(entry.lon); - if (Number.isFinite(lat) && Number.isFinite(lon)) { - results.push({ lat, lon, displayName: entry.display_name }); - } + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + const extra = entry.extratags ?? {}; + results.push({ + lat, + lon, + displayName: entry.display_name, + name: firstNonEmpty(entry.namedetails?.name, entry.name), + // `type` is the specific OSM tag value (restaurant, cafe, ...); it maps + // well to a single-word merchant category. Skip uninformative values. + category: entry.type && entry.type !== "yes" ? entry.type : undefined, + website: firstNonEmpty(extra.website, extra["contact:website"]), + phone: firstNonEmpty(extra.phone, extra["contact:phone"]), + openingHours: firstNonEmpty(extra.opening_hours), + osmType: entry.osm_type, + osmId: entry.osm_id != null ? String(entry.osm_id) : undefined, + }); } return results; }; diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index ac3d495df..6a79c015c 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -503,6 +503,65 @@ "address": "Address", "captchaPlaceholder": "Please enter the captcha text." }, + "addLocationWizard": { + "title": "Add a Location", + "physicalQuestion": "Does the business have a physical location visitors can go to?", + "physicalHint": "A physical location is a place customers can visit in person (a shop, café, office, market stall, etc.).", + "yes": "Yes", + "no": "No", + "back": "Back", + "onlineHeading": "Online or mobile business", + "onlineIntro": "Thanks! This is for businesses without a location customers visit — online shops and webshops, and services delivered at the customer's place (for example a mobile window cleaner). We can't show these on the map yet, but we're building that. Share your details and we'll store them for the upcoming listing.", + "socialLabel": "Social media", + "socialPlaceholder": "Links to X, Instagram, Nostr, etc.", + "onlineSuccessType": "online or mobile business", + "onlineSuccessText": "Thanks for your submission! The map currently shows only businesses with a visitable location, but we'll list online and mobile bitcoin-accepting businesses in the future and your data will be used for that.", + "mapHeading": "Is your business already on the map?", + "mapIntro": "Search for your business or browse the map. If you find it, select it to view and update its details.", + "mapClickHint": "Tap an orange dot to select an existing location.", + "searchedResultLabel": "Search result", + "useSearchedLocation": "This is my business — add it", + "notOnMap": "It's not on the map yet", + "loadingPlace": "Loading location…", + "updateHeading": "Update this location", + "updateIntro": "Review the details below and correct anything that's outdated or missing.", + "updatePaymentLegend": "Accepted payment methods (update if changed)", + "newHeading": "Add a new physical location", + "newIntro": "Tell us where the business is and how it accepts bitcoin. Place a pin on the map, then give an address or a description of the location — whichever fits best.", + "locationDescriptionLabel": "Location description", + "locationDescriptionHint": "Describe how to find the place if it has no street address — landmarks, directions, floor, etc.", + "locationDescriptionPlaceholder": "e.g. 200m north of the temple gate, second stall on the left", + "addressOrDescriptionHint": "Provide a street address or a location description below — at least one is required.", + "locationTextError": "Please provide either an address or a location description.", + "newSuccessType": "location", + "newSuccessText": "Thanks! Your location has been submitted for review. Once a BTC Map editor approves it, it will appear on the map and be merged into OpenStreetMap.", + "updateSuccessType": "update", + "updateSuccessText": "Thanks! Your update has been submitted for review by a BTC Map editor." + }, + "openingHours": { + "open247": "Open 24/7", + "allDay": "All day", + "from": "Opening time", + "to": "Closing time", + "closed": "Closed", + "addRange": "Add hours", + "removeRange": "Remove this time range", + "copyToAll": "Copy first open day to all days", + "preview": "Result", + "none": "Not set", + "invalid": "This doesn't look like valid opening hours.", + "rawHint": "Editing opening hours in raw OSM format:", + "useSimpleEditor": "Switch back to the simple editor (clears current value)", + "days": { + "Mo": "Monday", + "Tu": "Tuesday", + "We": "Wednesday", + "Th": "Thursday", + "Fr": "Friday", + "Sa": "Saturday", + "Su": "Sunday" + } + }, "addLocation": { "title": "Add Location", "subheading": "Accept bitcoin? Get found", diff --git a/src/lib/osm.ts b/src/lib/osm.ts new file mode 100644 index 000000000..4a0a235ce --- /dev/null +++ b/src/lib/osm.ts @@ -0,0 +1,211 @@ +// Minimal OSM API 0.6 client for the approve-and-push flow. +// +// Server-only. Authenticates with an OAuth 2.0 bearer token belonging to a +// dedicated BTC Map bot account. Everything is gated behind osmConfigured() so +// the site runs fine without OSM credentials — the push simply stays disabled. + +import type { OsmPayload } from "$lib/osmPayload"; + +import { env } from "$env/dynamic/private"; + +const DEFAULT_API_BASE = "https://api.openstreetmap.org"; + +export function osmConfigured(): boolean { + return Boolean(env.OSM_OAUTH_TOKEN); +} + +function apiBase(): string { + return (env.OSM_API_BASE || DEFAULT_API_BASE).replace(/\/$/, ""); +} + +function authHeaders( + extra: Record = {}, +): Record { + return { + Authorization: `Bearer ${env.OSM_OAUTH_TOKEN}`, + ...extra, + }; +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function tagsToXml(tags: Record): string { + return Object.entries(tags) + .filter(([, v]) => v !== undefined && v !== null && String(v) !== "") + .map( + ([k, v]) => ` `, + ) + .join("\n"); +} + +async function osmRequest( + method: string, + path: string, + body?: string, +): Promise { + const response = await fetch(`${apiBase()}${path}`, { + method, + headers: authHeaders(body ? { "Content-Type": "text/xml" } : {}), + body, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `OSM ${method} ${path} failed (${response.status}): ${text}`, + ); + } + return text; +} + +async function createChangeset(comment: string): Promise { + const xml = ` + + + + + +`; + const id = await osmRequest("PUT", "/api/0.6/changeset/create", xml); + return id.trim(); +} + +async function closeChangeset(id: string): Promise { + await osmRequest("PUT", `/api/0.6/changeset/${id}/close`); +} + +// Parse the bits we need out of a node's XML representation. Deliberately small: +// we only read version, coordinates and existing tags so an update can merge +// without dropping data (a full-element upload replaces all tags). +type ParsedNode = { + version: string; + lat: string; + lon: string; + tags: Record; +}; + +function parseNodeXml(xml: string): ParsedNode { + const nodeMatch = xml.match(/]*>/); + const attrs = nodeMatch ? nodeMatch[0] : ""; + const attr = (name: string): string => { + const m = attrs.match(new RegExp(`${name}="([^"]*)"`)); + return m ? m[1] : ""; + }; + const tags: Record = {}; + const tagRe = //g; + let m: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop + while ((m = tagRe.exec(xml)) !== null) { + tags[decodeXml(m[1])] = decodeXml(m[2]); + } + return { + version: attr("version"), + lat: attr("lat"), + lon: attr("lon"), + tags, + }; +} + +function decodeXml(value: string): string { + return value + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +async function fetchNode(osmId: string): Promise { + const xml = await osmRequest("GET", `/api/0.6/node/${osmId}`); + return parseNodeXml(xml); +} + +export type OsmPushResult = { + changesetId: string; + osmType: "node"; + osmId: string; + url: string; +}; + +// Create a new node or merge tags into an existing one, then close the changeset. +export async function pushToOsm(payload: OsmPayload): Promise { + if (!osmConfigured()) { + throw new Error("OSM push not configured (missing OSM_OAUTH_TOKEN)"); + } + + if (payload.action === "update") { + if (payload.osmType && payload.osmType !== "node") { + // Ways/relations need geometry handling we intentionally don't do here. + throw new Error( + `Unsupported OSM element type for push: ${payload.osmType}`, + ); + } + if (!payload.osmId) throw new Error("Update payload missing osmId"); + } + if (payload.action === "create") { + if (!Number.isFinite(payload.lat) || !Number.isFinite(payload.lon)) { + throw new Error("Create payload missing coordinates"); + } + } + + const name = payload.tags.name || "merchant"; + const changesetId = await createChangeset( + payload.action === "create" + ? `Add bitcoin-accepting merchant "${name}" (via BTC Map)` + : `Update bitcoin-accepting merchant "${name}" (via BTC Map)`, + ); + + try { + if (payload.action === "create") { + const xml = ` + +${tagsToXml(payload.tags)} + +`; + const newId = ( + await osmRequest("PUT", "/api/0.6/node/create", xml) + ).trim(); + await closeChangeset(changesetId); + return { + changesetId, + osmType: "node", + osmId: newId, + url: `${apiBase()}/node/${newId}`, + }; + } + + // update: fetch current node, merge tags, upload with same version. + const osmId = payload.osmId as string; + const existing = await fetchNode(osmId); + const mergedTags = { ...existing.tags, ...payload.tags }; + const lat = Number.isFinite(payload.lat) ? payload.lat : existing.lat; + const lon = Number.isFinite(payload.lon) ? payload.lon : existing.lon; + const xml = ` + +${tagsToXml(mergedTags)} + +`; + await osmRequest("PUT", `/api/0.6/node/${osmId}`, xml); + await closeChangeset(changesetId); + return { + changesetId, + osmType: "node", + osmId, + url: `${apiBase()}/node/${osmId}`, + }; + } catch (err) { + // Best-effort cleanup so we don't leave an open changeset dangling. + try { + await closeChangeset(changesetId); + } catch { + // ignore + } + throw err; + } +} diff --git a/src/lib/osmPayload.test.ts b/src/lib/osmPayload.test.ts new file mode 100644 index 000000000..01e48cb35 --- /dev/null +++ b/src/lib/osmPayload.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { + buildOsmPayload, + buildOsmPayloadBlock, + parseOsmPayloadBlock, +} from "./osmPayload"; + +describe("buildOsmPayload", () => { + it("maps wizard fields to OSM tags for a create", () => { + const payload = buildOsmPayload("create", { + name: "Satoshi Cafe", + nameEn: "Satoshi Cafe", + address: "1 Main St", + locationDescription: "next to the gate", + website: "https://example.com", + phone: "+123", + hours: "Mo-Fr 09:00-17:00", + methods: "onchain,lightning,nfc", + category: "cafe", + lat: "18.2649", + long: "98.5013", + }); + + expect(payload.action).toBe("create"); + expect(payload.category).toBe("cafe"); + expect(payload.lat).toBe(18.2649); + expect(payload.lon).toBe(98.5013); + expect(payload.tags).toMatchObject({ + name: "Satoshi Cafe", + "name:en": "Satoshi Cafe", + "addr:full": "1 Main St", + description: "next to the gate", + "contact:website": "https://example.com", + "contact:phone": "+123", + opening_hours: "Mo-Fr 09:00-17:00", + "currency:XBT": "yes", + "payment:onchain": "yes", + "payment:lightning": "yes", + "payment:lightning_contactless": "yes", + }); + // check_date is set to today (YYYY-MM-DD). + expect(payload.tags.check_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("omits empty fields instead of writing blank tags", () => { + const payload = buildOsmPayload("create", { + name: "Bar", + category: "bar", + website: "", + phone: " ", + lat: "1", + long: "2", + }); + expect(payload.tags).not.toHaveProperty("contact:website"); + expect(payload.tags).not.toHaveProperty("contact:phone"); + expect(payload.tags).not.toHaveProperty("name:en"); + }); + + it("does not set payment tags when no methods are given", () => { + const payload = buildOsmPayload("create", { + name: "Bar", + category: "bar", + methods: "", + lat: "1", + long: "2", + }); + expect(payload.tags).not.toHaveProperty("currency:XBT"); + expect(payload.tags).not.toHaveProperty("payment:onchain"); + }); + + it("captures osm element identity for an update", () => { + const payload = buildOsmPayload("update", { + name: "Shop", + osmType: "node", + osmId: "12345", + website: "https://shop.example", + lat: "10", + long: "20", + }); + expect(payload.action).toBe("update"); + expect(payload.osmType).toBe("node"); + expect(payload.osmId).toBe("12345"); + }); + + it("defaults a missing update osmType to node and drops invalid types", () => { + // No osmType given -> defaults to node. + expect(buildOsmPayload("update", { osmId: "1" }).osmType).toBe("node"); + // A valid non-node type is preserved. + expect( + buildOsmPayload("update", { osmType: "way", osmId: "1" }).osmType, + ).toBe("way"); + // An unrecognized type is dropped (undefined); the OSM push treats an + // absent osmType as a node. + expect( + buildOsmPayload("update", { osmType: "bogus", osmId: "1" }).osmType, + ).toBeUndefined(); + }); + + it("omits coordinates when not numeric", () => { + const payload = buildOsmPayload("create", { + name: "X", + category: "c", + lat: "", + long: "abc", + }); + expect(payload.lat).toBeUndefined(); + expect(payload.lon).toBeUndefined(); + }); +}); + +describe("payload block round-trip", () => { + it("wraps a payload in delimited fences and parses it back", () => { + const block = buildOsmPayloadBlock("create", { + name: "Satoshi Cafe", + category: "cafe", + lat: "18.2649", + long: "98.5013", + }); + expect(block).toContain(""); + expect(block).toContain(""); + expect(block).toContain("```json"); + + const parsed = parseOsmPayloadBlock(`Some issue text\n\n${block}\n\nmore`); + expect(parsed).not.toBeNull(); + expect(parsed?.action).toBe("create"); + expect(parsed?.tags.name).toBe("Satoshi Cafe"); + expect(parsed?.category).toBe("cafe"); + }); + + it("returns null when no block is present", () => { + expect(parseOsmPayloadBlock("just a plain issue body")).toBeNull(); + }); + + it("returns null for a malformed block", () => { + const broken = + "\n```json\n{ not json }\n```\n"; + expect(parseOsmPayloadBlock(broken)).toBeNull(); + }); + + it("returns null when the action is invalid", () => { + const bad = + '\n```json\n{"action":"delete","tags":{}}\n```\n'; + expect(parseOsmPayloadBlock(bad)).toBeNull(); + }); +}); diff --git a/src/lib/osmPayload.ts b/src/lib/osmPayload.ts new file mode 100644 index 000000000..3330e86ab --- /dev/null +++ b/src/lib/osmPayload.ts @@ -0,0 +1,147 @@ +// Machine-readable OSM payload embedded in Gitea issue bodies. +// +// The add-location wizard emits this block so a maintainer can approve an issue +// and have api/gitea/webhook push it to OSM automatically — no manual copy/paste. +// The block is delimited by HTML comments so it stays invisible in Gitea's +// rendered markdown while remaining trivially parseable. + +export const OSM_PAYLOAD_START = ""; +export const OSM_PAYLOAD_END = ""; + +export type OsmPayloadAction = "create" | "update"; + +export type OsmPayload = { + action: OsmPayloadAction; + // Present for updates: the existing OSM element to edit. + osmType?: "node" | "way" | "relation"; + osmId?: string; + lat?: number; + lon?: number; + // Free-text merchant category (used by btcmap-api submit_place, which + // requires it; not written to OSM as-is). + category?: string; + // OSM tags to set (create) or merge (update). + tags: Record; +}; + +const str = (v: unknown): string => + (v === undefined || v === null ? "" : String(v)).trim(); + +// Only assign a tag when the source value is non-empty, so we never write blank +// tags to OSM. +function setIf( + tags: Record, + key: string, + value: unknown, +): void { + const v = str(value); + if (v) tags[key] = v; +} + +// Translate wizard form fields (loosely typed request body) into OSM tags. +function tagsFromData(data: Record): Record { + const tags: Record = {}; + + setIf(tags, "name", data.name); + setIf(tags, "name:en", data.nameEn); + + // Address: prefer a structured full address; fall back to a free-text + // location description as a note so the maintainer keeps the context. + setIf(tags, "addr:full", data.address); + setIf(tags, "description", data.locationDescription); + + setIf(tags, "contact:website", data.website); + setIf(tags, "contact:phone", data.phone); + setIf(tags, "opening_hours", data.hours); + setIf(tags, "contact:twitter", data.twitter); + setIf(tags, "contact:facebook", data.facebook); + setIf(tags, "contact:instagram", data.instagram); + + // Payment methods arrive as a comma-separated string ("onchain,lightning,nfc"). + const methods = str(data.methods) + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + if (methods.length) { + tags["currency:XBT"] = "yes"; + if (methods.includes("onchain")) tags["payment:onchain"] = "yes"; + if (methods.includes("lightning")) tags["payment:lightning"] = "yes"; + if (methods.includes("nfc")) tags["payment:lightning_contactless"] = "yes"; + } + + // Record the survey date so downstream tooling sees a fresh check. + tags["check_date"] = new Date().toISOString().slice(0, 10); + + return tags; +} + +export function buildOsmPayload( + action: OsmPayloadAction, + data: Record, +): OsmPayload { + const payload: OsmPayload = { + action, + tags: tagsFromData(data), + }; + + const category = str(data.category); + if (category) payload.category = category; + + // Guard against Number("") === 0, which would silently place a merchant at + // 0,0 (the Gulf of Guinea) instead of treating coordinates as missing. + const latText = str(data.lat); + const lonText = str(data.long); + const lat = latText === "" ? Number.NaN : Number(latText); + const lon = lonText === "" ? Number.NaN : Number(lonText); + if (Number.isFinite(lat)) payload.lat = lat; + if (Number.isFinite(lon)) payload.lon = lon; + + if (action === "update") { + const osmType = str(data.osmType) || "node"; + if (osmType === "node" || osmType === "way" || osmType === "relation") { + payload.osmType = osmType; + } + const osmId = str(data.osmId); + if (osmId) payload.osmId = osmId; + } + + return payload; +} + +export function buildOsmPayloadBlock( + action: OsmPayloadAction, + data: Record, +): string { + const payload = buildOsmPayload(action, data); + const json = JSON.stringify(payload, null, 2); + return `${OSM_PAYLOAD_START}\n\`\`\`json\n${json}\n\`\`\`\n${OSM_PAYLOAD_END}`; +} + +// Extract and parse the payload block from a Gitea issue body. Returns null when +// no valid block is present. +export function parseOsmPayloadBlock(body: string): OsmPayload | null { + const start = body.indexOf(OSM_PAYLOAD_START); + const end = body.indexOf(OSM_PAYLOAD_END); + if (start === -1 || end === -1 || end < start) return null; + + const between = body.slice(start + OSM_PAYLOAD_START.length, end); + // Strip the ```json fences if present. + const jsonText = between + .replace(/```json/gi, "") + .replace(/```/g, "") + .trim(); + + try { + const parsed = JSON.parse(jsonText) as OsmPayload; + if ( + !parsed || + (parsed.action !== "create" && parsed.action !== "update") || + typeof parsed.tags !== "object" + ) { + return null; + } + return parsed; + } catch { + return null; + } +} diff --git a/src/routes/add-location/wizard/+page.svelte b/src/routes/add-location/wizard/+page.svelte new file mode 100644 index 000000000..4962fed9d --- /dev/null +++ b/src/routes/add-location/wizard/+page.svelte @@ -0,0 +1,996 @@ + + + + BTC Map - {$_('addLocationWizard.title')} + + + + +{#if typeof window !== 'undefined'} +

+ {$_('addLocationWizard.title')} +

+{:else} + +{/if} + +
+ {#if step === 'intro'} + +

+ {$_('addLocationWizard.physicalQuestion')} +

+

{$_('addLocationWizard.physicalHint')}

+
+ answerPhysical(true)}> + {$_('addLocationWizard.yes')} + + answerPhysical(false)}> + {$_('addLocationWizard.no')} + +
+ + {:else if step === 'online'} + +

+ {$_('addLocationWizard.onlineHeading')} +

+

{$_('addLocationWizard.onlineIntro')}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {$_('addLocation.paymentMethodsLegend')} +
+ + + +
+
+
+ +