Hello world
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
55
dist/places.d.ts
vendored
Normal file
55
dist/places.d.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import BaseClient from 'remotestoragejs/release/types/baseclient';
|
||||||
|
interface Place {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
geohash: string;
|
||||||
|
zoom?: number;
|
||||||
|
url?: string;
|
||||||
|
osmId?: string;
|
||||||
|
osmType?: string;
|
||||||
|
osmTags?: Record<string, string>;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
declare const _default: {
|
||||||
|
name: string;
|
||||||
|
builder: (privateClient: BaseClient) => {
|
||||||
|
exports: {
|
||||||
|
/**
|
||||||
|
* Store a place.
|
||||||
|
* Generates ID and Geohash if missing.
|
||||||
|
* Path structure: <geohash-prefix-2>/<geohash-prefix-2>/<id>
|
||||||
|
*/
|
||||||
|
store: (placeData: Partial<Place>) => Promise<Place>;
|
||||||
|
/**
|
||||||
|
* Remove a place.
|
||||||
|
* Requires geohash to locate the folder.
|
||||||
|
*/
|
||||||
|
remove: (id: string, geohash: string) => Promise<import("remotestoragejs/release/types/interfaces/queued_request_response").QueuedRequestResponse>;
|
||||||
|
/**
|
||||||
|
* Get a single place.
|
||||||
|
* Requires geohash to locate the folder.
|
||||||
|
*/
|
||||||
|
get: (id: string, geohash: string) => Promise<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.
|
||||||
|
*/
|
||||||
|
listByPrefix: (prefix: string) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* 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: (prefixes?: string[]) => Promise<Place[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default _default;
|
||||||
176
dist/places.js
vendored
Normal file
176
dist/places.js
vendored
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import Geohash from 'latlon-geohash';
|
||||||
|
import { ulid } from 'ulid';
|
||||||
|
const Places = function (privateClient /*, publicClient: BaseClient */) {
|
||||||
|
// Define Schema
|
||||||
|
privateClient.declareType('place', {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', required: true },
|
||||||
|
title: { type: 'string', required: true },
|
||||||
|
lat: { type: 'number', required: true },
|
||||||
|
lon: { type: 'number', required: true },
|
||||||
|
geohash: { type: 'string', required: true },
|
||||||
|
zoom: { type: 'number' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
osmId: { type: 'string' },
|
||||||
|
osmType: { type: 'string' },
|
||||||
|
osmTags: { type: 'object' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
createdAt: { type: 'string', format: 'date-time', required: true },
|
||||||
|
updatedAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
|
||||||
|
});
|
||||||
|
// 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,
|
||||||
|
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: <geohash-prefix-2>/<geohash-prefix-2>/<id>
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
275
package-lock.json
generated
Normal file
275
package-lock.json
generated
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"name": "@remotestorage/module-places",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@remotestorage/module-places",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"latlon-geohash": "^2.0.0",
|
||||||
|
"ulid": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"remotestoragejs": "^2.0.0-beta.8",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/balanced-match": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/brace-expansion": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/balanced-match": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.0.tgz",
|
||||||
|
"integrity": "sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/tv4": {
|
||||||
|
"version": "1.2.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/tv4/-/tv4-1.2.33.tgz",
|
||||||
|
"integrity": "sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esm": {
|
||||||
|
"version": "3.2.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
||||||
|
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^10.1.1",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"path-scurry": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/latlon-geohash": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/latlon-geohash/-/latlon-geohash-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-OKBswTwrvTdtenV+9C9euBmvgGuqyjJNAzpQCarRz1m8/pYD2nz9fKkXmLs2S3jeXaLi3Ry76twQplKKUlgS/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||||
|
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/brace-expansion": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/remotestoragejs": {
|
||||||
|
"version": "2.0.0-beta.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/remotestoragejs/-/remotestoragejs-2.0.0-beta.8.tgz",
|
||||||
|
"integrity": "sha512-rtyHTG2VbtiKTRmbwjponRf5VTPJMcHv/ijNid1zX48C0Z0F8ZCBBfkKD2QCxTQyQvCupkWNy3wuIu4HE+AEng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "20.14.0",
|
||||||
|
"@types/tv4": "^1.2.29",
|
||||||
|
"esm": "^3.2.25",
|
||||||
|
"tv4": "^1.3.0",
|
||||||
|
"webfinger.js": "^2.7.1",
|
||||||
|
"xhr2": "^0.2.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "^2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rimraf": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"package-json-from-dist": "^1.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rimraf": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tv4": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": [
|
||||||
|
{
|
||||||
|
"type": "Public Domain",
|
||||||
|
"url": "http://geraintluff.github.io/tv4/LICENSE.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MIT",
|
||||||
|
"url": "http://jsonary.com/LICENSE.txt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ulid": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ulid": "dist/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/webfinger.js": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/webfinger.js/-/webfinger.js-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-Zqn9KXkGrD1tVEm029bVUIfmzef2KXs3G7OZrdqehDHtgv9YSxX1oy4RoPoMk2PHWIifwWCA0xwKZOAZqXMpfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "AGPL"
|
||||||
|
},
|
||||||
|
"node_modules/xhr2": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@remotestorage/module-places",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Manage favorite/saved places",
|
||||||
|
"main": "dist/places.js",
|
||||||
|
"types": "dist/places.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "rimraf dist && tsc",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "Râu Cao <raucao@kosmos.org>",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"remotestoragejs": "^2.0.0-beta.8",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"latlon-geohash": "^2.0.0",
|
||||||
|
"ulid": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/places.ts
Normal file
224
src/places.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import BaseClient from 'remotestoragejs/release/types/baseclient';
|
||||||
|
import Geohash from 'latlon-geohash';
|
||||||
|
import { ulid } from 'ulid';
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
geohash: string;
|
||||||
|
zoom?: number;
|
||||||
|
url?: string;
|
||||||
|
osmId?: string;
|
||||||
|
osmType?: string;
|
||||||
|
osmTags?: Record<string, string>;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Places = function (
|
||||||
|
privateClient: BaseClient /*, publicClient: BaseClient */
|
||||||
|
) {
|
||||||
|
// Define Schema
|
||||||
|
privateClient.declareType('place', {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', required: true },
|
||||||
|
title: { type: 'string', required: true },
|
||||||
|
lat: { type: 'number', required: true },
|
||||||
|
lon: { type: 'number', required: true },
|
||||||
|
geohash: { type: 'string', required: true },
|
||||||
|
zoom: { type: 'number' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
osmId: { type: 'string' },
|
||||||
|
osmType: { type: 'string' },
|
||||||
|
osmTags: { type: 'object' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
createdAt: { type: 'string', format: 'date-time', required: true },
|
||||||
|
updatedAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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 places = {
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
1
src/types.d.ts
vendored
Normal file
1
src/types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module 'latlon-geohash';
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user