27 Commits

Author SHA1 Message Date
d36bef185c 1.2.2
All checks were successful
Test / test (push) Successful in 32s
2026-03-14 14:50:59 +04:00
b84010a332 Add proper lists documentation to README
Some checks failed
Test / test (push) Has been cancelled
2026-03-14 14:50:30 +04:00
05516e7642 Add more install commands
All checks were successful
Test / test (push) Successful in 33s
Not just pnpm
2026-03-14 14:35:31 +04:00
22c6b02e4b Add test/CI status to README
All checks were successful
Test / test (push) Successful in 32s
2026-03-14 14:29:57 +04:00
e859bc3ee7 Merge pull request 'Set up CI' (#3) from chore/setup_ci into master
All checks were successful
Test / test (push) Successful in 33s
Reviewed-on: #3
2026-03-14 10:25:39 +00:00
b3fd092acf Set up CI
All checks were successful
Test / test (pull_request) Successful in 32s
Test / test (push) Successful in 1m42s
2026-03-14 14:12:52 +04:00
cd349944cf Update AGENTS.md with testing details 2026-03-14 14:04:23 +04:00
6b434adde4 1.2.1 2026-03-13 16:59:06 +04:00
a966df95f0 Add doc for List type definition 2026-03-13 16:58:36 +04:00
90b7b1cf01 1.2.0 2026-03-13 16:41:55 +04:00
1f8c076051 Merge pull request 'Support place lists' (#1) from feature/lists into master
Reviewed-on: #1
2026-03-13 12:40:08 +00:00
ba01a579b6 Update docs 2026-03-13 16:39:37 +04:00
ccc865741c Change default list colors 2026-03-13 16:22:45 +04:00
d459a12115 Lists: use separate add/remove functions instead of toggle 2026-03-13 13:06:42 +04:00
c5c999ac79 Add test suite, cover existing functions 2026-03-12 17:28:43 +04:00
53b2c9b4f8 WIP Add lists 2026-03-12 17:06:53 +04:00
e019cb01ba Mention Marco for demo and usage example 2026-01-26 20:04:53 +07:00
eb2878ec6a 1.1.3 2026-01-26 20:02:29 +07:00
602cc44ae2 Improve README 2026-01-26 20:02:11 +07:00
b9615d7b97 Add npm badge to README 2026-01-26 19:52:55 +07:00
63e644c85a 1.1.2 2026-01-26 19:48:22 +07:00
d8e893a5f0 Add keywords 2026-01-26 19:48:09 +07:00
39ca1f18db 1.1.1 2026-01-26 19:43:24 +07:00
8ac75935ac Add repo, homepage to package.json 2026-01-26 19:43:08 +07:00
aa2d0ed58b Fix doc output 2026-01-26 19:40:38 +07:00
7274574f64 1.1.0 2026-01-26 19:21:42 +07:00
bc5eea7841 Add README and API docs 2026-01-26 19:20:54 +07:00
18 changed files with 2583 additions and 44 deletions

View File

@@ -0,0 +1,17 @@
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm test

View File

@@ -26,8 +26,10 @@ It is written in TypeScript and compiled to a JavaScript module suitable for use
- `dist/`: specific build artifacts. Do not edit files here directly. - `dist/`: specific build artifacts. Do not edit files here directly.
## Testing ## Testing
- Currently, no automated test suite is configured. - **Framework:** `vitest`
- `pnpm test` will exit with an error. - **Run tests:** `pnpm test`
- **Watch mode:** `pnpm run test:watch`
- **Location:** Tests are located in the `test/` directory.
## Contribution Guidelines ## Contribution Guidelines
- When adding new functionality, ensure proper types are exported in `src/types.d.ts` or within the module files. - When adding new functionality, ensure proper types are exported in `src/types.d.ts` or within the module files.

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# @remotestorage/module-places
[![npm version](https://img.shields.io/npm/v/@remotestorage/module-places.svg)](https://www.npmjs.com/package/@remotestorage/module-places) [![Build Status](https://gitea.kosmos.org/raucao/remotestorage-module-places/actions/workflows/test.yaml/badge.svg)](https://gitea.kosmos.org/raucao/remotestorage-module-places/actions)
This module allows you to manage saved places (Points of Interest) using the [remoteStorage](https://remotestorage.io/) protocol.
It leverages [Geohashes](https://www.geohash.es/) to organize data, enabling efficient retrieval of places within specific geographic areas. This structure is particularly optimized for map applications that need to load data only for the visible viewport.
For a demo application, as well as source code using this module, check out [Marco](https://marco.kosmos.org).
## Installation
```bash
# npm
npm install @remotestorage/module-places
# pnpm
pnpm add @remotestorage/module-places
# yarn
yarn add @remotestorage/module-places
# bun
bun add @remotestorage/module-places
```
## Usage
```javascript
import RemoteStorage from 'remotestoragejs';
import PlacesModule from '@remotestorage/module-places';
const remoteStorage = new RemoteStorage({
modules: [PlacesModule],
});
// Access the module
const places = remoteStorage.places;
// Store a place
await places.store({
title: 'My Favorite Coffee Shop',
lat: 52.520008,
lon: 13.404954,
});
// List all places
const allPlaces = await places.getPlaces();
console.log(allPlaces);
// List places for specific geohash prefixes (e.g. for a map view)
const areaPlaces = await places.getPlaces(['u33d', 'u33e']);
console.log(areaPlaces);
```
## Lists
### Default lists
There are currently two default lists, which you can initiate like this:
```javascript
await places.lists.initDefaults();
```
This will create the lists if they don't exist yet (meaning the user hasn't yet
used an app that integrates this module).
The default lists are:
| Path | Default Name | Default Color |
| --- | --- | --- |
| `_lists/to-go` | Want to go | #2e9e4f (green) |
| `_lists/to-do` | To do | #2a7fff (blue) |
### Custom lists
```javascript
// Create a list
await places.lists.create('hiking', 'Hiking', '#74d3ba');
// Delete a list
await places.lists.delete('hiking');
```
### List membership
```javascript
// Add a place to a list (requires list ID, place ID, and place geohash)
await places.lists.addPlace('to-go', 'place-id-123', 'u33dc0');
// Remove from list
await places.lists.removePlace('to-go', 'place-id-123');
```
### Reading lists
```javascript
// Get all lists
await places.lists.getAll();
// Get specific list
await places.lists.get('to-do');
```
## API Reference
### Interfaces
- [PlacesClient](docs/interfaces/PlacesClient.md)
### Type Aliases
- [Place](docs/type-aliases/Place.md)
- [List](docs/type-aliases/List.md)

193
dist/places.d.ts vendored
View File

@@ -57,43 +57,172 @@ declare const placeSchema: {
}; };
readonly required: readonly ["id", "title", "lat", "lon", "geohash", "createdAt"]; readonly required: readonly ["id", "title", "lat", "lon", "geohash", "createdAt"];
}; };
type Place = FromSchema<typeof placeSchema> & { declare const listSchema: {
readonly type: "object";
readonly properties: {
readonly id: {
readonly type: "string";
};
readonly title: {
readonly type: "string";
};
readonly color: {
readonly type: "string";
};
readonly placeRefs: {
readonly type: "array";
readonly items: {
readonly type: "object";
readonly properties: {
readonly id: {
readonly type: "string";
};
readonly geohash: {
readonly type: "string";
};
};
readonly required: readonly ["id", "geohash"];
};
readonly default: readonly [];
};
readonly createdAt: {
readonly type: "string";
readonly format: "date-time";
};
readonly updatedAt: {
readonly type: "string";
readonly format: "date-time";
};
};
readonly required: readonly ["id", "title", "placeRefs", "createdAt"];
};
/**
* Represents a List object.
*
* Core properties enforced by schema:
* - `id`: Unique identifier (slug)
* - `title`: Human readable title
* - `placeRefs`: Array of place references containing `id` and `geohash`
* - `createdAt`: ISO date string
*
* Optional properties:
* - `color`: Hex color code
* - `updatedAt`: ISO date string
*/
export type List = FromSchema<typeof listSchema> & {
[key: string]: any; [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 a place to a list.
* @param listId - The slug ID of the list.
* @param placeId - The ID of the place.
* @param geohash - The geohash of the place.
*/
addPlace(listId: string, placeId: string, geohash: string): Promise<List>;
/**
* Remove a place from a list.
* @param listId - The slug ID of the list.
* @param placeId - The ID of the place.
*/
removePlace(listId: string, placeId: string): Promise<List>;
};
}
declare const _default: { declare const _default: {
name: string; name: string;
builder: (privateClient: BaseClient) => { builder: (privateClient: BaseClient) => {
exports: { exports: PlacesClient;
/**
* 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; export default _default;

120
dist/places.js vendored
View File

@@ -27,9 +27,33 @@ const placeSchema = {
}, },
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'], required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
}; };
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'],
};
const Places = function (privateClient /*, publicClient: BaseClient */) { const Places = function (privateClient /*, publicClient: BaseClient */) {
// Define Schema // Define Schema
privateClient.declareType('place', placeSchema); privateClient.declareType('place', placeSchema);
privateClient.declareType('list', listSchema);
// Helper to normalize place object // Helper to normalize place object
function preparePlace(data) { function preparePlace(data) {
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -67,11 +91,95 @@ const Places = function (privateClient /*, publicClient: BaseClient */) {
const p2 = geohash.substring(2, 4); const p2 = geohash.substring(2, 4);
return `${p1}/${p2}/${id}`; return `${p1}/${p2}/${id}`;
} }
const lists = {
async getAll() {
const result = await privateClient.getAll('_lists/');
if (!result)
return [];
// Normalize result: remoteStorage.getAll returns { 'slug': object }
return Object.values(result);
},
async get(id) {
const path = `_lists/${id}`;
return privateClient.getObject(path);
},
async create(id, title, color) {
const path = `_lists/${id}`;
let list = (await privateClient.getObject(path));
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,
};
}
await privateClient.storeObject('list', path, list);
return list;
},
async delete(id) {
await privateClient.remove(`_lists/${id}`);
},
async addPlace(listId, placeId, geohash) {
const path = `_lists/${listId}`;
const list = (await privateClient.getObject(path));
if (!list) {
throw new Error(`List not found: ${listId}`);
}
const index = list.placeRefs.findIndex((ref) => ref.id === placeId);
if (index === -1) {
// Add only if not present
list.placeRefs.push({ id: placeId, geohash });
list.updatedAt = new Date().toISOString();
await privateClient.storeObject('list', path, list);
}
return list;
},
async removePlace(listId, placeId) {
const path = `_lists/${listId}`;
const list = (await privateClient.getObject(path));
if (!list) {
throw new Error(`List not found: ${listId}`);
}
const index = list.placeRefs.findIndex((ref) => ref.id === placeId);
if (index !== -1) {
// Remove only if present
list.placeRefs.splice(index, 1);
list.updatedAt = new Date().toISOString();
await privateClient.storeObject('list', path, list);
}
return list;
},
async initDefaults() {
const defaults = [
{ id: 'to-go', title: 'Want to go', color: '#2e9e4f' }, // Green
{ id: 'to-do', title: 'To do', color: '#2a7fff' }, // Blue
];
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 = { const places = {
lists,
/** /**
* Store a place. * Store a place.
* Generates ID and Geohash if missing. * Generates ID and Geohash if missing.
* Path structure: <geohash-prefix-2>/<geohash-prefix-2>/<id> * Path structure: `<geohash-prefix-2>/<geohash-prefix-2>/<id>`
*/ */
store: async function (placeData) { store: async function (placeData) {
const place = preparePlace(placeData); const place = preparePlace(placeData);
@@ -87,6 +195,16 @@ const Places = function (privateClient /*, publicClient: BaseClient */) {
if (!id || !geohash) { if (!id || !geohash) {
throw new Error('Both id and geohash are required to remove a place'); throw new Error('Both id and geohash are required to remove a place');
} }
// Cleanup: Remove this place from all lists
const allLists = await lists.getAll();
await Promise.all(allLists.map(async (list) => {
const index = list.placeRefs.findIndex((ref) => ref.id === id);
if (index !== -1) {
list.placeRefs.splice(index, 1);
list.updatedAt = new Date().toISOString();
await privateClient.storeObject('list', `_lists/${list.id}`, list);
}
}));
const path = getPath(geohash, id); const path = getPath(geohash, id);
return privateClient.remove(path); return privateClient.remove(path);
}, },

18
docs/README.md Normal file
View File

@@ -0,0 +1,18 @@
**@remotestorage/module-places**
***
# @remotestorage/module-places
## Interfaces
- [PlacesClient](interfaces/PlacesClient.md)
## Type Aliases
- [List](type-aliases/List.md)
- [Place](type-aliases/Place.md)
## Variables
- [default](variables/default.md)

View File

@@ -0,0 +1,273 @@
[**@remotestorage/module-places**](../README.md)
***
[@remotestorage/module-places](../README.md) / PlacesClient
# Interface: PlacesClient
## Properties
### lists
> **lists**: `object`
#### addPlace()
> **addPlace**(`listId`, `placeId`, `geohash`): `Promise`\<[`List`](../type-aliases/List.md)\>
Add a place to a list.
##### Parameters
###### listId
`string`
The slug ID of the list.
###### placeId
`string`
The ID of the place.
###### geohash
`string`
The geohash of the place.
##### Returns
`Promise`\<[`List`](../type-aliases/List.md)\>
#### create()
> **create**(`id`, `title`, `color?`): `Promise`\<[`List`](../type-aliases/List.md)\>
Create or update a list.
##### Parameters
###### id
`string`
The slug ID (e.g., "to-go").
###### title
`string`
Human readable title.
###### color?
`string`
Optional hex color code.
##### Returns
`Promise`\<[`List`](../type-aliases/List.md)\>
#### delete()
> **delete**(`id`): `Promise`\<`void`\>
Delete a list.
##### Parameters
###### id
`string`
The slug ID of the list.
##### Returns
`Promise`\<`void`\>
#### get()
> **get**(`id`): `Promise`\<[`List`](../type-aliases/List.md) \| `null`\>
Get a single list by ID (slug).
##### Parameters
###### id
`string`
The slug ID of the list.
##### Returns
`Promise`\<[`List`](../type-aliases/List.md) \| `null`\>
#### getAll()
> **getAll**(): `Promise`\<[`List`](../type-aliases/List.md)[]\>
Get all lists.
##### Returns
`Promise`\<[`List`](../type-aliases/List.md)[]\>
Array of List objects.
#### removePlace()
> **removePlace**(`listId`, `placeId`): `Promise`\<[`List`](../type-aliases/List.md)\>
Remove a place from a list.
##### Parameters
###### listId
`string`
The slug ID of the list.
###### placeId
`string`
The ID of the place.
##### Returns
`Promise`\<[`List`](../type-aliases/List.md)\>
## Methods
### get()
> **get**(`id`, `geohash`): `Promise`\<`unknown`\>
Get a single place.
Requires geohash to locate the folder.
#### Parameters
##### id
`string`
The ID of the place to retrieve.
##### geohash
`string`
The geohash of the place.
#### Returns
`Promise`\<`unknown`\>
The place object.
***
### getPlaces()
> **getPlaces**(`prefixes?`): `Promise`\<[`Place`](../type-aliases/Place.md)[]\>
Get places from specific prefixes.
#### Parameters
##### prefixes?
`string`[]
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
`Promise`\<[`Place`](../type-aliases/Place.md)[]\>
An array of places.
***
### listByPrefix()
> **listByPrefix**(`prefix`): `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.
#### Parameters
##### prefix
`string`
The geohash prefix to filter by.
#### Returns
`Promise`\<`unknown`\>
A map of objects found at the prefix.
***
### remove()
> **remove**(`id`, `geohash`): `Promise`\<`unknown`\>
Remove a place.
Requires geohash to locate the folder.
#### Parameters
##### id
`string`
The ID of the place to remove.
##### geohash
`string`
The geohash of the place.
#### Returns
`Promise`\<`unknown`\>
***
### store()
> **store**(`placeData`): `Promise`\<[`Place`](../type-aliases/Place.md)\>
Store a place.
Generates ID and Geohash if missing.
Path structure: `<geohash-prefix-2>/<geohash-prefix-2>/<id>`
#### Parameters
##### placeData
`Partial`\<[`Place`](../type-aliases/Place.md)\>
The data of the place to store.
#### Returns
`Promise`\<[`Place`](../type-aliases/Place.md)\>
The stored place object.

21
docs/type-aliases/List.md Normal file
View File

@@ -0,0 +1,21 @@
[**@remotestorage/module-places**](../README.md)
***
[@remotestorage/module-places](../README.md) / List
# Type Alias: List
> **List** = `FromSchema`\<*typeof* `listSchema`\> & `object`
Represents a List object.
Core properties enforced by schema:
- `id`: Unique identifier (slug)
- `title`: Human readable title
- `placeRefs`: Array of place references containing `id` and `geohash`
- `createdAt`: ISO date string
Optional properties:
- `color`: Hex color code
- `updatedAt`: ISO date string

View File

@@ -0,0 +1,27 @@
[**@remotestorage/module-places**](../README.md)
***
[@remotestorage/module-places](../README.md) / Place
# Type Alias: Place
> **Place** = `FromSchema`\<*typeof* `placeSchema`\> & `object`
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

33
docs/variables/default.md Normal file
View File

@@ -0,0 +1,33 @@
[**@remotestorage/module-places**](../README.md)
***
[@remotestorage/module-places](../README.md) / default
# Variable: default
> **default**: `object`
## Type Declaration
### builder()
> **builder**: (`privateClient`) => `object` = `Places`
#### Parameters
##### privateClient
`BaseClient`
#### Returns
`object`
##### exports
> **exports**: [`PlacesClient`](../interfaces/PlacesClient.md)
### name
> **name**: `string` = `'places'`

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@remotestorage/module-places", "name": "@remotestorage/module-places",
"version": "1.0.0", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@remotestorage/module-places", "name": "@remotestorage/module-places",
"version": "1.0.0", "version": "1.2.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"latlon-geohash": "^2.0.0", "latlon-geohash": "^2.0.0",

View File

@@ -1,7 +1,16 @@
{ {
"name": "@remotestorage/module-places", "name": "@remotestorage/module-places",
"version": "1.0.0", "version": "1.2.2",
"description": "Manage favorite/saved places", "description": "Manage favorite/saved places",
"homepage": "https://gitea.kosmos.org/raucao/remotestorage-module-places#remotestoragemodule-places",
"repository": {
"type": "git",
"url": "git+https://gitea.kosmos.org/raucao/remotestorage-module-places.git"
},
"keywords": [
"remotestorage",
"remotestorage-module"
],
"main": "dist/places.js", "main": "dist/places.js",
"types": "dist/places.d.ts", "types": "dist/places.d.ts",
"type": "module", "type": "module",
@@ -10,8 +19,11 @@
], ],
"scripts": { "scripts": {
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"test": "echo \"Error: no test specified\" && exit 1", "doc": "typedoc",
"version": "pnpm run build && git add dist" "test": "vitest run",
"test:watch": "vitest",
"preversion": "pnpm test",
"version": "pnpm run build && pnpm run doc && git add dist docs README.md"
}, },
"author": "Râu Cao <raucao@kosmos.org>", "author": "Râu Cao <raucao@kosmos.org>",
"license": "MIT", "license": "MIT",
@@ -19,7 +31,10 @@
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"remotestoragejs": "^2.0.0-beta.8", "remotestoragejs": "^2.0.0-beta.8",
"rimraf": "^6.1.2", "rimraf": "^6.1.2",
"typescript": "^5.9.3" "typedoc": "^0.28.16",
"typedoc-plugin-markdown": "^4.9.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}, },
"dependencies": { "dependencies": {
"latlon-geohash": "^2.0.0", "latlon-geohash": "^2.0.0",

1051
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,13 +31,166 @@ const placeSchema = {
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'], required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
} as const; } as const;
type Place = FromSchema<typeof placeSchema> & { [key: string]: any }; 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;
/**
* Represents a List object.
*
* Core properties enforced by schema:
* - `id`: Unique identifier (slug)
* - `title`: Human readable title
* - `placeRefs`: Array of place references containing `id` and `geohash`
* - `createdAt`: ISO date string
*
* Optional properties:
* - `color`: Hex color code
* - `updatedAt`: ISO date string
*/
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 a place to a list.
* @param listId - The slug ID of the list.
* @param placeId - The ID of the place.
* @param geohash - The geohash of the place.
*/
addPlace(listId: string, placeId: string, geohash: string): Promise<List>;
/**
* Remove a place from a list.
* @param listId - The slug ID of the list.
* @param placeId - The ID of the place.
*/
removePlace(listId: string, placeId: string): Promise<List>;
};
}
const Places = function ( const Places = function (
privateClient: BaseClient /*, publicClient: BaseClient */ privateClient: BaseClient /*, publicClient: BaseClient */
) { ): { exports: PlacesClient } {
// Define Schema // Define Schema
privateClient.declareType('place', placeSchema as any); privateClient.declareType('place', placeSchema as any);
privateClient.declareType('list', listSchema as any);
// Helper to normalize place object // Helper to normalize place object
function preparePlace(data: Partial<Place>): Place { function preparePlace(data: Partial<Place>): Place {
@@ -83,11 +236,114 @@ const Places = function (
return `${p1}/${p2}/${id}`; 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 addPlace(
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) {
// Add only if not present
list.placeRefs.push({ id: placeId, geohash });
list.updatedAt = new Date().toISOString();
await privateClient.storeObject('list', path, list);
}
return list;
},
async removePlace(listId: string, placeId: 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 only if present
list.placeRefs.splice(index, 1);
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: '#2e9e4f' }, // Green
{ id: 'to-do', title: 'To do', color: '#2a7fff' }, // Blue
];
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 = { const places = {
lists,
/** /**
* Store a place. * Store a place.
* Generates ID and Geohash if missing. * Generates ID and Geohash if missing.
* Path structure: <geohash-prefix-2>/<geohash-prefix-2>/<id> * Path structure: `<geohash-prefix-2>/<geohash-prefix-2>/<id>`
*/ */
store: async function (placeData: Partial<Place>) { store: async function (placeData: Partial<Place>) {
const place = preparePlace(placeData); const place = preparePlace(placeData);
@@ -104,6 +360,20 @@ const Places = function (
if (!id || !geohash) { if (!id || !geohash) {
throw new Error('Both id and geohash are required to remove a place'); throw new Error('Both id and geohash are required to remove a place');
} }
// Cleanup: Remove this place from all lists
const allLists = await lists.getAll();
await Promise.all(
allLists.map(async (list) => {
const index = list.placeRefs.findIndex((ref: any) => ref.id === id);
if (index !== -1) {
list.placeRefs.splice(index, 1);
list.updatedAt = new Date().toISOString();
await privateClient.storeObject('list', `_lists/${list.id}`, list);
}
})
);
const path = getPath(geohash, id); const path = getPath(geohash, id);
return privateClient.remove(path); return privateClient.remove(path);
}, },

414
test/places.test.ts Normal file
View File

@@ -0,0 +1,414 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import PlacesModule from '../src/places.js';
import { createMockClient, MockPrivateClient } from './utils.js';
// We need to cast our mock to any because we are not implementing the full BaseClient interface
const Places = PlacesModule.builder;
describe('Places Module', () => {
let mockClient: MockPrivateClient;
let moduleInstance: any;
beforeEach(() => {
mockClient = createMockClient();
moduleInstance = Places(mockClient as any);
});
describe('Places Functionality', () => {
let places: any;
beforeEach(() => {
places = moduleInstance.exports;
});
describe('store', () => {
it('saves a place with the correct path structure', async () => {
const placeData = {
title: 'Test Place',
lat: 52.5,
lon: 13.4,
};
await places.store(placeData);
expect(mockClient.storeObject).toHaveBeenCalledWith(
'place',
expect.stringMatching(/^u3\/3d\/.+/),
expect.objectContaining({
title: 'Test Place',
lat: 52.5,
lon: 13.4,
})
);
});
});
describe('remove', () => {
it('deletes a place at the correct path', async () => {
const id = 'some-id';
const geohash = 'u33dc0'; // u3/3d/
mockClient.getAll.mockResolvedValue([]);
await places.remove(id, geohash);
expect(mockClient.remove).toHaveBeenCalledWith('u3/3d/some-id');
});
it('cleans up references from lists', async () => {
const id = 'some-id';
const geohash = 'u33dc0';
const mockLists = {
'to-go': {
id: 'to-go',
placeRefs: [{ id: 'some-id', geohash: 'u33dc0' }],
},
'to-do': { id: 'to-do', placeRefs: [] },
};
mockClient.getAll.mockResolvedValue(Object.values(mockLists));
await places.remove(id, geohash);
expect(mockClient.getAll).toHaveBeenCalledWith('_lists/');
// Expect "to-go" to be updated without the reference
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/to-go',
expect.objectContaining({
placeRefs: [],
})
);
});
});
describe('get', () => {
it('retrieves a single place at the correct path', async () => {
const id = 'some-id';
const geohash = 'u33dc0';
const mockPlace = { id, geohash, title: 'Test Place' };
mockClient.getObject.mockResolvedValue(mockPlace);
const result = await places.get(id, geohash);
expect(mockClient.getObject).toHaveBeenCalledWith('u3/3d/some-id');
expect(result).toEqual(mockPlace);
});
});
describe('listByPrefix', () => {
it('lists subfolders for 2-char prefix', async () => {
const prefix = 'u3';
const mockListing = { '3d/': true };
mockClient.getAll.mockResolvedValue(mockListing);
const result = await places.listByPrefix(prefix);
expect(mockClient.getAll).toHaveBeenCalledWith('u3/');
expect(result).toEqual(mockListing);
});
it('lists places for 4-char prefix', async () => {
const prefix = 'u33d';
const mockPlaces = { place1: { title: 'Place 1' } };
mockClient.getAll.mockResolvedValue(mockPlaces);
const result = await places.listByPrefix(prefix);
expect(mockClient.getAll).toHaveBeenCalledWith('u3/3d/');
expect(result).toEqual(mockPlaces);
});
});
describe('getPlaces', () => {
it('fetches places from specified prefixes', async () => {
const prefixes = ['u33d', 'w1q7'];
const mockPlaces1 = { p1: { id: 'p1', geohash: 'u33d' } };
const mockPlaces2 = { p2: { id: 'p2', geohash: 'w1q7' } };
mockClient.getAll.mockImplementation(async (path: string) => {
if (path === 'u3/3d/') return mockPlaces1;
if (path === 'w1/q7/') return mockPlaces2;
return {};
});
const result = await places.getPlaces(prefixes);
expect(mockClient.getAll).toHaveBeenCalledWith('u3/3d/', false);
expect(mockClient.getAll).toHaveBeenCalledWith('w1/q7/', false);
expect(result).toHaveLength(2);
expect(result).toContainEqual(mockPlaces1['p1']);
expect(result).toContainEqual(mockPlaces2['p2']);
});
it('recursively fetches all places when no prefix is provided', async () => {
// Mock directory structure
// root -> 'u3/'
// 'u3/' -> '3d/'
// 'u3/3d/' -> place1
mockClient.getListing.mockImplementation(async (path: string) => {
if (path === '') return { 'u3/': true };
if (path === 'u3/') return { '3d/': true };
return {};
});
const mockPlaces = { place1: { id: 'p1', geohash: 'u33d' } };
mockClient.getAll.mockImplementation(async (path: string) => {
if (path === 'u3/3d/') return mockPlaces;
return {};
});
const result = await places.getPlaces();
expect(mockClient.getListing).toHaveBeenCalledWith('', false);
expect(mockClient.getListing).toHaveBeenCalledWith('u3/', false);
expect(mockClient.getAll).toHaveBeenCalledWith('u3/3d/', false);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(mockPlaces['place1']);
});
});
});
describe('Lists Functionality', () => {
let lists: any;
beforeEach(() => {
lists = moduleInstance.exports.lists;
});
describe('getAll', () => {
it('returns all lists from the _lists/ directory', async () => {
const mockLists = {
'to-go': { id: 'to-go', title: 'Want to go' },
hiking: { id: 'hiking', title: 'Hiking' },
};
mockClient.getAll.mockResolvedValue(mockLists);
const result = await lists.getAll();
expect(mockClient.getAll).toHaveBeenCalledWith('_lists/');
expect(result).toEqual(Object.values(mockLists));
});
});
describe('get', () => {
it('returns a single list by ID', async () => {
const mockList = { id: 'hiking', title: 'Hiking' };
mockClient.getObject.mockResolvedValue(mockList);
const result = await lists.get('hiking');
expect(mockClient.getObject).toHaveBeenCalledWith('_lists/hiking');
expect(result).toEqual(mockList);
});
});
describe('create', () => {
it('stores a new list when none exists', async () => {
const now = '2023-01-01T00:00:00.000Z';
vi.setSystemTime(new Date(now));
// Mock getObject to return null (not existing)
mockClient.getObject.mockResolvedValue(null);
const result = await lists.create('hiking', 'Hiking', '#00ff00');
expect(mockClient.getObject).toHaveBeenCalledWith('_lists/hiking');
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/hiking',
{
id: 'hiking',
title: 'Hiking',
color: '#00ff00',
placeRefs: [],
createdAt: now,
updatedAt: now,
}
);
expect(result).toMatchObject({ id: 'hiking', title: 'Hiking' });
vi.useRealTimers();
});
it('updates an existing list preserving creation time and references', async () => {
const now = '2023-01-02T00:00:00.000Z';
vi.setSystemTime(new Date(now));
const existing = {
id: 'hiking',
title: 'Old Title',
color: '#ffffff',
placeRefs: [{ id: '123', geohash: 'abc' }],
createdAt: '2022-01-01T00:00:00.000Z',
};
mockClient.getObject.mockResolvedValue(existing);
await lists.create('hiking', 'Hiking Updated', '#000000');
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/hiking',
expect.objectContaining({
id: 'hiking',
title: 'Hiking Updated',
color: '#000000',
placeRefs: existing.placeRefs, // Should preserve refs
createdAt: existing.createdAt, // Should preserve createdAt
updatedAt: now, // Should update updatedAt
})
);
vi.useRealTimers();
});
});
describe('delete', () => {
it('removes the list document', async () => {
await lists.delete('hiking');
expect(mockClient.remove).toHaveBeenCalledWith('_lists/hiking');
});
});
describe('addPlace', () => {
it('adds a place reference when not present', async () => {
const now = '2023-01-03T00:00:00.000Z';
vi.setSystemTime(new Date(now));
const list = {
id: 'hiking',
placeRefs: [],
updatedAt: 'old-date',
};
mockClient.getObject.mockResolvedValue(list);
await lists.addPlace('hiking', 'place-123', 'w1q7');
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/hiking',
expect.objectContaining({
placeRefs: [{ id: 'place-123', geohash: 'w1q7' }],
updatedAt: now,
})
);
vi.useRealTimers();
});
it('does nothing if place is already present', async () => {
const list = {
id: 'hiking',
placeRefs: [{ id: 'place-123', geohash: 'w1q7' }],
updatedAt: 'old-date',
};
mockClient.getObject.mockResolvedValue(list);
await lists.addPlace('hiking', 'place-123', 'w1q7');
expect(mockClient.storeObject).not.toHaveBeenCalled();
});
});
describe('removePlace', () => {
it('removes a place reference when present', async () => {
const now = '2023-01-04T00:00:00.000Z';
vi.setSystemTime(new Date(now));
const list = {
id: 'hiking',
placeRefs: [{ id: 'place-123', geohash: 'w1q7' }],
updatedAt: 'old-date',
};
mockClient.getObject.mockResolvedValue(list);
await lists.removePlace('hiking', 'place-123');
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/hiking',
expect.objectContaining({
placeRefs: [],
updatedAt: now,
})
);
vi.useRealTimers();
});
it('does nothing if place is not present', async () => {
const list = {
id: 'hiking',
placeRefs: [],
updatedAt: 'old-date',
};
mockClient.getObject.mockResolvedValue(list);
await lists.removePlace('hiking', 'place-123');
expect(mockClient.storeObject).not.toHaveBeenCalled();
});
});
describe('initDefaults', () => {
it('creates "Want to go" list if missing', async () => {
mockClient.getObject.mockResolvedValue(null);
await lists.initDefaults();
expect(mockClient.getObject).toHaveBeenCalledWith('_lists/to-go');
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/to-go',
expect.objectContaining({
title: 'Want to go',
id: 'to-go',
color: '#2e9e4f',
})
);
});
it('creates "To do" list if missing', async () => {
mockClient.getObject.mockResolvedValue(null);
await lists.initDefaults();
expect(mockClient.getObject).toHaveBeenCalledWith('_lists/to-do');
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/to-do',
expect.objectContaining({
title: 'To do',
id: 'to-do',
color: '#2a7fff',
})
);
});
it('does not overwrite existing lists', async () => {
// Mock that "to-go" exists but "to-do" does not
mockClient.getObject.mockImplementation(async (path: string) => {
if (path === '_lists/to-go')
return { id: 'to-go', title: 'Existing' };
return null;
});
await lists.initDefaults();
// Should NOT write to-go
expect(mockClient.storeObject).not.toHaveBeenCalledWith(
'list',
'_lists/to-go',
expect.anything()
);
// Should write to-do
expect(mockClient.storeObject).toHaveBeenCalledWith(
'list',
'_lists/to-do',
expect.anything()
);
});
});
});
});

16
test/utils.ts Normal file
View File

@@ -0,0 +1,16 @@
import { vi } from 'vitest';
export class MockPrivateClient {
storeObject = vi.fn();
remove = vi.fn();
getObject = vi.fn();
getAll = vi.fn();
getListing = vi.fn();
declareType = vi.fn();
// Helper to verify calls easily if needed directly
}
export function createMockClient() {
return new MockPrivateClient();
}

9
typedoc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["./src/places.ts"],
"out": "docs",
"plugin": ["typedoc-plugin-markdown"],
"disableSources": true,
"readme": "none",
"name": "@remotestorage/module-places"
}

11
vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node', // Using node environment as we are testing logic not browser specifics
include: ['test/**/*.test.ts'],
deps: {
interopDefault: true,
},
},
});