import Geohash from 'latlon-geohash'; import { ulid } from 'ulid'; const placeSchema = { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' }, geohash: { type: 'string' }, zoom: { type: 'number' }, url: { type: 'string' }, osmId: { type: 'string' }, osmType: { type: 'string' }, osmTags: { type: 'object', additionalProperties: { type: 'string' }, }, description: { type: 'string' }, tags: { type: 'array', items: { type: 'string' }, default: [], }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, }, required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'], }; const Places = function (privateClient /*, publicClient: BaseClient */) { // Define Schema privateClient.declareType('place', placeSchema); // Helper to normalize place object function preparePlace(data) { const now = new Date().toISOString(); const id = data.id || ulid(); // Ensure essential data is present before processing (runtime check for safety, though caller should validate) // We default to 0/0 if missing to satisfy TS, relying on schema validation later if really empty const lat = typeof data.lat === 'number' ? data.lat : 0; const lon = typeof data.lon === 'number' ? data.lon : 0; const title = data.title || 'Untitled Place'; // Compute full precision geohash (default from library is usually 9-10 chars) const geohash = data.geohash || Geohash.encode(lat, lon, 10); const place = { ...data, id, lat, lon, geohash, title, tags: data.tags || [], createdAt: data.createdAt || now, updatedAt: data.id ? now : undefined, }; // Explicitly remove undefined fields (like URL) so storeObject validation doesn't fail on them // RemoteStorage schemas are strict about types; undefined is not a string. Object.keys(place).forEach((key) => { if (place[key] === undefined) { delete place[key]; } }); return place; } // Get path for a place based on its geohash (group by first 4 chars: 2+2) function getPath(geohash, id) { const p1 = geohash.substring(0, 2); const p2 = geohash.substring(2, 4); return `${p1}/${p2}/${id}`; } const places = { /** * Store a place. * Generates ID and Geohash if missing. * Path structure: `//` */ store: async function (placeData) { const place = preparePlace(placeData); const path = getPath(place.geohash, place.id); await privateClient.storeObject('place', path, place); return place; }, /** * Remove a place. * Requires geohash to locate the folder. */ remove: async function (id, geohash) { if (!id || !geohash) { throw new Error('Both id and geohash are required to remove a place'); } const path = getPath(geohash, id); return privateClient.remove(path); }, /** * Get a single place. * Requires geohash to locate the folder. */ get: async function (id, geohash) { if (!id || !geohash) { throw new Error('Both id and geohash are required to get a place'); } const path = getPath(geohash, id); return privateClient.getObject(path); }, /** * List places matching a geohash prefix. * Supports 2-char ("ab") or 4-char ("abcd") prefixes. * If 2-char, it returns the sub-folders (prefixes), not places. * If 4-char, it returns the places in that sector. */ listByPrefix: async function (prefix) { let path = ''; if (prefix.length >= 2) { path += prefix.substring(0, 2) + '/'; } if (prefix.length >= 4) { path += prefix.substring(2, 4) + '/'; } return privateClient.getAll(path); }, /** * Get places from specific prefixes. * @param prefixes Optional array of 4-character geohash prefixes to load (e.g. ['w1q7', 'w1q8']). * If not provided, it will attempt to scan ALL prefixes (recursive). */ getPlaces: async function (prefixes) { const places = []; // Helper to process a specific 4-char path (e.g. "ab/cd/") const fetchPath = async (path) => { // Use getAll to load objects (places) // Set maxAge to false to skip cache check if configured const items = await privateClient.getAll(path, false); if (items) { Object.values(items).forEach((p) => { if (p && typeof p === 'object' && !p.toString().endsWith('/')) { places.push(p); } }); } }; if (prefixes && prefixes.length > 0) { // Load specific sectors const promises = prefixes.map(async (prefix) => { if (prefix.length < 4) return; const p1 = prefix.substring(0, 2); const p2 = prefix.substring(2, 4); await fetchPath(`${p1}/${p2}/`); }); await Promise.all(promises); } else { // Load EVERYTHING (Recursive scan) // Helper to extract keys from a listing result, handling both simple and detailed formats const getKeys = (listing) => { if (!listing || typeof listing !== 'object') return []; let target = listing; if (listing.items && typeof listing.items === 'object') { target = listing.items; } return Object.keys(target).filter((k) => k.endsWith('/') && !k.startsWith('@')); }; // Level 1: xx/ const level1 = await privateClient.getListing('', false); const l1Keys = getKeys(level1); await Promise.all(l1Keys.map(async (l1Key) => { // Level 2: xx/yy/ const level2 = await privateClient.getListing(l1Key, false); const l2Keys = getKeys(level2); await Promise.all(l2Keys.map(async (l2Key) => { // Level 3: Items in xx/yy/ await fetchPath(l1Key + l2Key); })); })); } return places; }, }; return { exports: places }; }; export default { name: 'places', builder: Places };