Files
remotestorage-module-places/src/places.ts
2026-03-12 17:06:53 +04:00

439 lines
13 KiB
TypeScript

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<typeof listSchema> & { [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<typeof placeSchema> & { [key: string]: any };
export interface PlacesClient {
/**
* Store a place.
* Generates ID and Geohash if missing.
* Path structure: `<geohash-prefix-2>/<geohash-prefix-2>/<id>`
*
* @param placeData - The data of the place to store.
* @returns The stored place object.
*/
store(placeData: Partial<Place>): Promise<Place>;
/**
* 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<unknown>;
/**
* 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<Place | unknown>;
/**
* 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<unknown | { [key: string]: any }>;
/**
* 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<Place[]>;
lists: {
/**
* Get all lists.
* @returns Array of List objects.
*/
getAll(): Promise<List[]>;
/**
* Get a single list by ID (slug).
* @param id - The slug ID of the list.
*/
get(id: string): Promise<List | null>;
/**
* 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<List>;
/**
* Delete a list.
* @param id - The slug ID of the list.
*/
delete(id: string): Promise<void>;
/**
* 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<List>;
};
}
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>): 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<List[]> {
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<List | null> {
const path = `_lists/${id}`;
return privateClient.getObject(path) as Promise<List | null>;
},
async create(id: string, title: string, color?: string): Promise<List> {
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<void> {
await privateClient.remove(`_lists/${id}`);
},
async togglePlace(
listId: string,
placeId: string,
geohash: string
): Promise<List> {
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<void> {
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: `<geohash-prefix-2>/<geohash-prefix-2>/<id>`
*/
store: async function (placeData: Partial<Place>) {
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 };