439 lines
13 KiB
TypeScript
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 };
|