17 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
15 changed files with 1986 additions and 8 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.

View File

@@ -1,15 +1,27 @@
# @remotestorage/module-places # @remotestorage/module-places
[![npm version](https://img.shields.io/npm/v/@remotestorage/module-places.svg)](https://www.npmjs.com/package/@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. 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. 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 ## Installation
```bash ```bash
# npm
npm install @remotestorage/module-places
# pnpm
pnpm add @remotestorage/module-places pnpm add @remotestorage/module-places
# yarn
yarn add @remotestorage/module-places
# bun
bun add @remotestorage/module-places
``` ```
## Usage ## Usage
@@ -41,6 +53,56 @@ const areaPlaces = await places.getPlaces(['u33d', 'u33e']);
console.log(areaPlaces); 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 ## API Reference
### Interfaces ### Interfaces
@@ -50,3 +112,4 @@ console.log(areaPlaces);
### Type Aliases ### Type Aliases
- [Place](docs/type-aliases/Place.md) - [Place](docs/type-aliases/Place.md)
- [List](docs/type-aliases/List.md)

92
dist/places.d.ts vendored
View File

@@ -57,6 +57,61 @@ declare const placeSchema: {
}; };
readonly required: readonly ["id", "title", "lat", "lon", "geohash", "createdAt"]; readonly required: readonly ["id", "title", "lat", "lon", "geohash", "createdAt"];
}; };
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;
};
/** /**
* Represents a Place object. * Represents a Place object.
* *
@@ -126,6 +181,43 @@ export interface PlacesClient {
* @returns An array of places. * @returns An array of places.
*/ */
getPlaces(prefixes?: string[]): Promise<Place[]>; 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;

118
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,7 +91,91 @@ 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.
@@ -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);
}, },

View File

@@ -10,6 +10,7 @@
## Type Aliases ## Type Aliases
- [List](type-aliases/List.md)
- [Place](type-aliases/Place.md) - [Place](type-aliases/Place.md)
## Variables ## Variables

View File

@@ -6,6 +6,144 @@
# Interface: 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 ## Methods
### get() ### get()

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

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@remotestorage/module-places", "name": "@remotestorage/module-places",
"version": "1.1.3", "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", "homepage": "https://gitea.kosmos.org/raucao/remotestorage-module-places#remotestoragemodule-places",
"repository": { "repository": {
@@ -20,7 +20,9 @@
"scripts": { "scripts": {
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"doc": "typedoc", "doc": "typedoc",
"test": "echo \"Error: no test specified\" && exit 1", "test": "vitest run",
"test:watch": "vitest",
"preversion": "pnpm test",
"version": "pnpm run build && pnpm run doc && git add dist docs README.md" "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>",
@@ -31,7 +33,8 @@
"rimraf": "^6.1.2", "rimraf": "^6.1.2",
"typedoc": "^0.28.16", "typedoc": "^0.28.16",
"typedoc-plugin-markdown": "^4.9.0", "typedoc-plugin-markdown": "^4.9.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.0.18"
}, },
"dependencies": { "dependencies": {
"latlon-geohash": "^2.0.0", "latlon-geohash": "^2.0.0",

882
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,45 @@ const placeSchema = {
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'], required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
} as const; } as const;
const listSchema = {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
color: { type: 'string' },
placeRefs: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
geohash: { type: 'string' },
},
required: ['id', 'geohash'],
},
default: [],
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
required: ['id', 'title', 'placeRefs', 'createdAt'],
} as const;
/**
* 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. * Represents a Place object.
* *
@@ -101,6 +140,49 @@ export interface PlacesClient {
* @returns An array of places. * @returns An array of places.
*/ */
getPlaces(prefixes?: string[]): Promise<Place[]>; 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 (
@@ -108,6 +190,7 @@ const Places = function (
): { exports: PlacesClient } { ): { 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 {
@@ -153,7 +236,110 @@ 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.
@@ -174,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();
}

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,
},
},
});