commit c14b5ff19b0eee8dfb5b8d499b7cd2c51bc96bd8 Author: Râu Cao Date: Thu Jan 15 20:57:03 2026 +0700 Hello world diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/dist/places.d.ts b/dist/places.d.ts new file mode 100644 index 0000000..ec96add --- /dev/null +++ b/dist/places.d.ts @@ -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; + 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: // + */ + store: (placeData: Partial) => Promise; + /** + * Remove a place. + * Requires geohash to locate the folder. + */ + remove: (id: string, geohash: string) => Promise; + /** + * Get a single place. + * Requires geohash to locate the folder. + */ + 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. + */ + 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). + */ + getPlaces: (prefixes?: string[]) => Promise; + }; + }; +}; +export default _default; diff --git a/dist/places.js b/dist/places.js new file mode 100644 index 0000000..30dabf1 --- /dev/null +++ b/dist/places.js @@ -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: // + */ + 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 }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bb40c14 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bacd5df --- /dev/null +++ b/package.json @@ -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 ", + "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" + } +} diff --git a/src/places.ts b/src/places.ts new file mode 100644 index 0000000..27ef74a --- /dev/null +++ b/src/places.ts @@ -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; + 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 { + 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: // + */ + 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 }; diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..8e2f1e4 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1 @@ +declare module 'latlon-geohash'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d45ec7f --- /dev/null +++ b/tsconfig.json @@ -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/**/*"] +}