import BaseClient from 'remotestoragejs/release/types/baseclient'; import Geohash from 'latlon-geohash'; import { ulid } from 'ulid'; import { FromSchema } from 'json-schema-to-ts'; 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'], } as const; const listSchema = { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, color: { type: 'string' }, placeRefs: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, geohash: { type: 'string' }, }, required: ['id', 'geohash'], }, default: [], }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, }, required: ['id', 'title', 'placeRefs', 'createdAt'], } as const; export type List = FromSchema & { [key: string]: any }; /** * Represents a Place object. * * Core properties enforced by schema: * - `id`: Unique identifier (ULID) * - `title`: Name of the place * - `lat`: Latitude * - `lon`: Longitude * - `geohash`: Geohash for indexing * - `createdAt`: ISO date string * * Optional properties: * - `description`: Text description * - `zoom`: Map zoom level * - `url`: Related URL * - `osmId`, `osmType`, `osmTags`: OpenStreetMap data * - `tags`: Array of string tags * - `updatedAt`: ISO date string */ export type Place = FromSchema & { [key: string]: any }; export interface PlacesClient { /** * Store a place. * Generates ID and Geohash if missing. * Path structure: `//` * * @param placeData - The data of the place to store. * @returns The stored place object. */ store(placeData: Partial): Promise; /** * Remove a place. * Requires geohash to locate the folder. * * @param id - The ID of the place to remove. * @param geohash - The geohash of the place. */ remove(id: string, geohash: string): Promise; /** * Get a single place. * Requires geohash to locate the folder. * * @param id - The ID of the place to retrieve. * @param geohash - The geohash of the place. * @returns The place object. */ get(id: string, geohash: string): Promise; /** * 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. * * @param prefix - The geohash prefix to filter by. * @returns A map of objects found at the prefix. */ listByPrefix(prefix: string): Promise; /** * 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). * @returns An array of places. */ getPlaces(prefixes?: string[]): Promise; lists: { /** * Get all lists. * @returns Array of List objects. */ getAll(): Promise; /** * Get a single list by ID (slug). * @param id - The slug ID of the list. */ get(id: string): Promise; /** * Create or update a list. * @param id - The slug ID (e.g., "to-go"). * @param title - Human readable title. * @param color - Optional hex color code. */ create(id: string, title: string, color?: string): Promise; /** * Delete a list. * @param id - The slug ID of the list. */ delete(id: string): Promise; /** * Add or remove a place from a list. * @param listId - The slug ID of the list. * @param placeId - The ID of the place. * @param geohash - The geohash of the place (needed for reference). */ togglePlace( listId: string, placeId: string, geohash: string ): Promise; }; } const Places = function ( privateClient: BaseClient /*, publicClient: BaseClient */ ): { exports: PlacesClient } { // Define Schema privateClient.declareType('place', placeSchema as any); privateClient.declareType('list', listSchema as any); // Helper to normalize place object function preparePlace(data: Partial): Place { 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: 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: string, id: string): string { const p1 = geohash.substring(0, 2); const p2 = geohash.substring(2, 4); return `${p1}/${p2}/${id}`; } const lists = { async getAll(): Promise { const result = await privateClient.getAll('_lists/'); if (!result) return []; // Normalize result: remoteStorage.getAll returns { 'slug': object } return Object.values(result); }, async get(id: string): Promise { const path = `_lists/${id}`; return privateClient.getObject(path) as Promise; }, async create(id: string, title: string, color?: string): Promise { const path = `_lists/${id}`; let list = (await privateClient.getObject(path)) as List; const now = new Date().toISOString(); if (list) { // Update existing list.title = title; if (color) list.color = color; list.updatedAt = now; } else { // Create new list = { id, title, color, placeRefs: [], createdAt: now, updatedAt: now, } as List; } await privateClient.storeObject('list', path, list); return list; }, async delete(id: string): Promise { await privateClient.remove(`_lists/${id}`); }, async togglePlace( listId: string, placeId: string, geohash: string ): Promise { const path = `_lists/${listId}`; const list = (await privateClient.getObject(path)) as List; if (!list) { throw new Error(`List not found: ${listId}`); } const index = list.placeRefs.findIndex((ref: any) => ref.id === placeId); if (index !== -1) { // Remove list.placeRefs.splice(index, 1); } else { // Add list.placeRefs.push({ id: placeId, geohash }); } list.updatedAt = new Date().toISOString(); await privateClient.storeObject('list', path, list); return list; }, async initDefaults(): Promise { const defaults = [ { id: 'to-go', title: 'Want to go', color: '#ff00ff' }, // Magenta { id: 'to-do', title: 'To do', color: '#008000' }, // Green ]; for (const def of defaults) { const existing = await this.get(def.id); if (!existing) { await this.create(def.id, def.title, def.color); } } }, }; const places = { lists, /** * Store a place. * Generates ID and Geohash if missing. * Path structure: `//` */ store: async function (placeData: Partial) { 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: string, geohash: string) { 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: string, geohash: string) { 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: string) { 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?: string[]) { const places: Place[] = []; // Helper to process a specific 4-char path (e.g. "ab/cd/") const fetchPath = async (path: string) => { // 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 as Place); } }); } }; 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: any): string[] => { 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 };