trilium-api 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +37 -37
- package/.github/workflows/publish.yml +84 -86
- package/LICENSE +660 -660
- package/README.md +835 -836
- package/package.json +15 -13
- package/src/client.test.ts +477 -477
- package/src/client.ts +91 -91
- package/src/demo-mapper.ts +166 -166
- package/src/demo-search.ts +108 -108
- package/src/demo.ts +126 -126
- package/src/index.ts +34 -34
- package/src/mapper.test.ts +638 -638
- package/src/mapper.ts +534 -534
- package/tsconfig.json +42 -42
package/README.md
CHANGED
|
@@ -1,836 +1,835 @@
|
|
|
1
|
-
# trilium-api
|
|
2
|
-
|
|
3
|
-
A type-safe TypeScript client for the [Trilium Notes](https://github.com/TriliumNext/
|
|
4
|
-
|
|
5
|
-
## Table of Contents
|
|
6
|
-
|
|
7
|
-
- [Features](#features)
|
|
8
|
-
- [Installation](#installation)
|
|
9
|
-
- [Quick Start](#quick-start)
|
|
10
|
-
- [API Reference](#api-reference)
|
|
11
|
-
- [Search Query Builder](#search-query-builder)
|
|
12
|
-
- [Note Mapper](#note-mapper)
|
|
13
|
-
- [Types](#types)
|
|
14
|
-
- [Error Handling](#error-handling)
|
|
15
|
-
- [Demo](#demo)
|
|
16
|
-
- [Development](#development)
|
|
17
|
-
- [Releasing](#releasing)
|
|
18
|
-
- [License](#license)
|
|
19
|
-
|
|
20
|
-
## Features
|
|
21
|
-
|
|
22
|
-
- **Fully typed** - Auto-generated types from OpenAPI specification
|
|
23
|
-
- **Lightweight** - Built on [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) (~6kb)
|
|
24
|
-
- **Query Builder** - Type-safe search query construction
|
|
25
|
-
- **Mapper** - Declarative note-to-object mapping with transforms
|
|
26
|
-
|
|
27
|
-
## Installation
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
npm install trilium-api
|
|
31
|
-
# or
|
|
32
|
-
pnpm add trilium-api
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Quick Start
|
|
36
|
-
|
|
37
|
-
```typescript
|
|
38
|
-
import { createTriliumClient } from 'trilium-api';
|
|
39
|
-
|
|
40
|
-
const client = createTriliumClient({
|
|
41
|
-
baseUrl: 'http://localhost:37840',
|
|
42
|
-
apiKey: 'your-etapi-token',
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
// Get app info
|
|
46
|
-
const { data: appInfo } = await client.GET('/app-info');
|
|
47
|
-
console.log(`Trilium version: ${appInfo?.appVersion}`);
|
|
48
|
-
|
|
49
|
-
// Get a note by ID
|
|
50
|
-
const { data: note } = await client.GET('/notes/{noteId}', {
|
|
51
|
-
params: { path: { noteId: 'root' } },
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Search notes
|
|
55
|
-
const { data: results } = await client.GET('/notes', {
|
|
56
|
-
params: { query: { search: '#blog' } },
|
|
57
|
-
});
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## API Reference
|
|
61
|
-
|
|
62
|
-
### Creating a Client
|
|
63
|
-
|
|
64
|
-
```typescript
|
|
65
|
-
import { createTriliumClient } from 'trilium-api';
|
|
66
|
-
|
|
67
|
-
const client = createTriliumClient({
|
|
68
|
-
baseUrl: 'http://localhost:37840', // Your Trilium server URL
|
|
69
|
-
apiKey: 'your-etapi-token', // ETAPI token from Trilium settings
|
|
70
|
-
});
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
### Common Operations
|
|
74
|
-
|
|
75
|
-
#### Get a Note
|
|
76
|
-
|
|
77
|
-
```typescript
|
|
78
|
-
const { data: note, error } = await client.GET('/notes/{noteId}', {
|
|
79
|
-
params: { path: { noteId: 'abc123' } },
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
if (error) {
|
|
83
|
-
console.error('Failed to fetch note:', error);
|
|
84
|
-
} else {
|
|
85
|
-
console.log(note.title);
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
#### Create a Note
|
|
90
|
-
|
|
91
|
-
```typescript
|
|
92
|
-
const { data } = await client.POST('/create-note', {
|
|
93
|
-
body: {
|
|
94
|
-
parentNoteId: 'root',
|
|
95
|
-
title: 'My New Note',
|
|
96
|
-
type: 'text',
|
|
97
|
-
content: '<p>Hello World!</p>',
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
console.log(`Created note: ${data?.note?.noteId}`);
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
#### Update a Note
|
|
105
|
-
|
|
106
|
-
```typescript
|
|
107
|
-
await client.PATCH('/notes/{noteId}', {
|
|
108
|
-
params: { path: { noteId: 'abc123' } },
|
|
109
|
-
body: { title: 'Updated Title' },
|
|
110
|
-
});
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
#### Delete a Note
|
|
114
|
-
|
|
115
|
-
```typescript
|
|
116
|
-
await client.DELETE('/notes/{noteId}', {
|
|
117
|
-
params: { path: { noteId: 'abc123' } },
|
|
118
|
-
});
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
#### Get/Update Note Content
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
// Get content
|
|
125
|
-
const { data: content } = await client.GET('/notes/{noteId}/content', {
|
|
126
|
-
params: { path: { noteId: 'abc123' } },
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Update content
|
|
130
|
-
await client.PUT('/notes/{noteId}/content', {
|
|
131
|
-
params: { path: { noteId: 'abc123' } },
|
|
132
|
-
body: '<p>New content</p>',
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
#### Branches
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
// Create a branch (clone a note to another location)
|
|
140
|
-
const { data: branch } = await client.POST('/branches', {
|
|
141
|
-
body: {
|
|
142
|
-
noteId: 'sourceNote123',
|
|
143
|
-
parentNoteId: 'targetParent456',
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
// Delete a branch
|
|
148
|
-
await client.DELETE('/branches/{branchId}', {
|
|
149
|
-
params: { path: { branchId: 'branch123' } },
|
|
150
|
-
});
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
#### Attributes
|
|
154
|
-
|
|
155
|
-
```typescript
|
|
156
|
-
// Create a label
|
|
157
|
-
await client.POST('/attributes', {
|
|
158
|
-
body: {
|
|
159
|
-
noteId: 'abc123',
|
|
160
|
-
type: 'label',
|
|
161
|
-
name: 'status',
|
|
162
|
-
value: 'published',
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// Create a relation
|
|
167
|
-
await client.POST('/attributes', {
|
|
168
|
-
body: {
|
|
169
|
-
noteId: 'abc123',
|
|
170
|
-
type: 'relation',
|
|
171
|
-
name: 'author',
|
|
172
|
-
value: 'authorNoteId',
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
## Search Query Builder
|
|
178
|
-
|
|
179
|
-
Build type-safe Trilium search queries with the `buildSearchQuery` helper:
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
import { buildSearchQuery } from 'trilium-api';
|
|
183
|
-
|
|
184
|
-
// Simple label search
|
|
185
|
-
buildSearchQuery({ '#blog': true });
|
|
186
|
-
// => '#blog'
|
|
187
|
-
|
|
188
|
-
// Label with value
|
|
189
|
-
buildSearchQuery({ '#status': 'published' });
|
|
190
|
-
// => "#status = 'published'"
|
|
191
|
-
|
|
192
|
-
// Label absence check
|
|
193
|
-
buildSearchQuery({ '#draft': false });
|
|
194
|
-
// => '#!draft'
|
|
195
|
-
|
|
196
|
-
// Comparison operators
|
|
197
|
-
buildSearchQuery({ '#wordCount': { value: 1000, operator: '>=' } });
|
|
198
|
-
// => '#wordCount >= 1000'
|
|
199
|
-
|
|
200
|
-
// Note properties
|
|
201
|
-
buildSearchQuery({ 'note.type': 'text', title: { value: 'Blog', operator: '*=' } });
|
|
202
|
-
// => "note.type = 'text' AND note.title *= 'Blog'"
|
|
203
|
-
|
|
204
|
-
// Relations
|
|
205
|
-
buildSearchQuery({ '~author': 'John' });
|
|
206
|
-
// => "~author *=* 'John'"
|
|
207
|
-
|
|
208
|
-
// AND conditions
|
|
209
|
-
buildSearchQuery({
|
|
210
|
-
AND: [
|
|
211
|
-
{ '#blog': true },
|
|
212
|
-
{ '#published': true },
|
|
213
|
-
],
|
|
214
|
-
});
|
|
215
|
-
// => '#blog AND #published'
|
|
216
|
-
|
|
217
|
-
// OR conditions
|
|
218
|
-
buildSearchQuery({
|
|
219
|
-
OR: [
|
|
220
|
-
{ '#status': 'draft' },
|
|
221
|
-
{ '#status': 'review' },
|
|
222
|
-
],
|
|
223
|
-
});
|
|
224
|
-
// => "#status = 'draft' OR #status = 'review'"
|
|
225
|
-
|
|
226
|
-
// NOT conditions
|
|
227
|
-
buildSearchQuery({
|
|
228
|
-
NOT: { '#archived': true },
|
|
229
|
-
});
|
|
230
|
-
// => 'not(#archived)'
|
|
231
|
-
|
|
232
|
-
// Complex nested conditions
|
|
233
|
-
buildSearchQuery({
|
|
234
|
-
AND: [
|
|
235
|
-
{ '#blog': true },
|
|
236
|
-
{ 'note.type': 'text' },
|
|
237
|
-
{ OR: [
|
|
238
|
-
{ '#category': 'tech' },
|
|
239
|
-
{ '#category': 'programming' },
|
|
240
|
-
]},
|
|
241
|
-
{ NOT: { '#draft': true } },
|
|
242
|
-
],
|
|
243
|
-
});
|
|
244
|
-
// => "#blog AND note.type = 'text' AND (#category = 'tech' OR #category = 'programming') AND not(#draft)"
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
### Using with the Client
|
|
248
|
-
|
|
249
|
-
```typescript
|
|
250
|
-
const query = buildSearchQuery({
|
|
251
|
-
AND: [
|
|
252
|
-
{ '#blog': true },
|
|
253
|
-
{ '#published': true },
|
|
254
|
-
],
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const { data } = await client.GET('/notes', {
|
|
258
|
-
params: { query: { search: query, limit: 10 } },
|
|
259
|
-
});
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
## Note Mapper
|
|
263
|
-
|
|
264
|
-
Map Trilium notes to strongly-typed objects using declarative field mappings:
|
|
265
|
-
|
|
266
|
-
```typescript
|
|
267
|
-
import { TriliumMapper, transforms } from 'trilium-api';
|
|
268
|
-
|
|
269
|
-
// Define your target type
|
|
270
|
-
interface BlogPost {
|
|
271
|
-
id: string;
|
|
272
|
-
title: string;
|
|
273
|
-
slug: string;
|
|
274
|
-
publishDate: Date;
|
|
275
|
-
wordCount: number;
|
|
276
|
-
readTimeMinutes: number;
|
|
277
|
-
tags: string[];
|
|
278
|
-
isPublished: boolean;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Create a mapper
|
|
282
|
-
const blogMapper = new TriliumMapper<BlogPost>({
|
|
283
|
-
// Simple property mapping (shorthand)
|
|
284
|
-
id: 'note.noteId',
|
|
285
|
-
title: 'note.title',
|
|
286
|
-
|
|
287
|
-
// Label attribute with required validation
|
|
288
|
-
slug: { from: '#slug', required: true },
|
|
289
|
-
|
|
290
|
-
// Transform string to Date
|
|
291
|
-
publishDate: { from: '#publishDate', transform: transforms.date },
|
|
292
|
-
|
|
293
|
-
// Transform string to number with default
|
|
294
|
-
wordCount: { from: '#wordCount', transform: transforms.number, default: 0 },
|
|
295
|
-
|
|
296
|
-
// Computed field based on other mapped values
|
|
297
|
-
readTimeMinutes: {
|
|
298
|
-
computed: (partial) => Math.ceil((partial.wordCount || 0) / 200),
|
|
299
|
-
},
|
|
300
|
-
|
|
301
|
-
// Transform comma-separated string to array
|
|
302
|
-
tags: { from: '#tags', transform: transforms.commaSeparated, default: [] },
|
|
303
|
-
|
|
304
|
-
// Transform to boolean
|
|
305
|
-
isPublished: { from: '#published', transform: transforms.boolean, default: false },
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// Map a single note
|
|
309
|
-
const post = blogMapper.map(note);
|
|
310
|
-
|
|
311
|
-
// Map an array of notes
|
|
312
|
-
const posts = blogMapper.map(notes);
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
### Field Mapping Options
|
|
316
|
-
|
|
317
|
-
#### Shorthand String Path
|
|
318
|
-
|
|
319
|
-
```typescript
|
|
320
|
-
{
|
|
321
|
-
title: 'note.title', // Note property
|
|
322
|
-
slug: '#slug', // Label attribute
|
|
323
|
-
authorId: '~author', // Relation attribute
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
#### Full Configuration Object
|
|
328
|
-
|
|
329
|
-
```typescript
|
|
330
|
-
{
|
|
331
|
-
fieldName: {
|
|
332
|
-
from: '#labelName', // Source path or extractor function
|
|
333
|
-
transform: transforms.number, // Optional transform function
|
|
334
|
-
default: 0, // Default value if undefined
|
|
335
|
-
required: false, // Throw if missing (default: false)
|
|
336
|
-
},
|
|
337
|
-
}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
#### Custom Extractor Function
|
|
341
|
-
|
|
342
|
-
```typescript
|
|
343
|
-
{
|
|
344
|
-
labelCount: {
|
|
345
|
-
from: (note) => note.attributes?.filter(a => a.type === 'label').length || 0,
|
|
346
|
-
},
|
|
347
|
-
}
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
#### Computed Fields
|
|
351
|
-
|
|
352
|
-
```typescript
|
|
353
|
-
{
|
|
354
|
-
readTimeMinutes: {
|
|
355
|
-
computed: (partial, note) => Math.ceil((partial.wordCount || 0) / 200),
|
|
356
|
-
default: 1,
|
|
357
|
-
},
|
|
358
|
-
}
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
### Built-in Transforms
|
|
362
|
-
|
|
363
|
-
| Transform | Description | Example |
|
|
364
|
-
|-----------|-------------|---------|
|
|
365
|
-
| `transforms.number` | Convert to number | `"123"` → `123` |
|
|
366
|
-
| `transforms.boolean` | Convert to boolean | `"true"` → `true` |
|
|
367
|
-
| `transforms.commaSeparated` | Split string to array | `"a, b, c"` → `["a", "b", "c"]` |
|
|
368
|
-
| `transforms.json` | Parse JSON string | `'{"a":1}'` → `{ a: 1 }` |
|
|
369
|
-
| `transforms.date` | Parse date string | `"2024-01-15"` → `Date` |
|
|
370
|
-
| `transforms.trim` | Trim whitespace | `" hello "` → `"hello"` |
|
|
371
|
-
|
|
372
|
-
### Merging Configurations
|
|
373
|
-
|
|
374
|
-
Reuse and extend mapping configurations:
|
|
375
|
-
|
|
376
|
-
```typescript
|
|
377
|
-
// Base configuration for common fields
|
|
378
|
-
const baseMapping = {
|
|
379
|
-
id: 'note.noteId',
|
|
380
|
-
title: 'note.title',
|
|
381
|
-
createdAt: { from: 'note.utcDateCreated', transform: transforms.date },
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
// Extended configuration for blog posts
|
|
385
|
-
const blogMapping = {
|
|
386
|
-
slug: '#slug',
|
|
387
|
-
tags: { from: '#tags', transform: transforms.commaSeparated, default: [] },
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
// Merge configurations
|
|
391
|
-
const merged = TriliumMapper.merge<BlogPost>(baseMapping, blogMapping);
|
|
392
|
-
const mapper = new TriliumMapper<BlogPost>(merged);
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
## Types
|
|
396
|
-
|
|
397
|
-
The package exports all types from the OpenAPI specification:
|
|
398
|
-
|
|
399
|
-
```typescript
|
|
400
|
-
import type {
|
|
401
|
-
TriliumNote,
|
|
402
|
-
TriliumBranch,
|
|
403
|
-
TriliumAttribute,
|
|
404
|
-
TriliumAttachment,
|
|
405
|
-
TriliumAppInfo,
|
|
406
|
-
paths,
|
|
407
|
-
components,
|
|
408
|
-
operations,
|
|
409
|
-
} from 'trilium-api';
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
## Error Handling
|
|
413
|
-
|
|
414
|
-
The client returns `{ data, error }` for all operations:
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
const { data, error } = await client.GET('/notes/{noteId}', {
|
|
418
|
-
params: { path: { noteId: 'nonexistent' } },
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
if (error) {
|
|
422
|
-
// error contains the response body on failure
|
|
423
|
-
console.error('Error:', error);
|
|
424
|
-
} else {
|
|
425
|
-
// data is typed based on the endpoint
|
|
426
|
-
console.log('Note:', data.title);
|
|
427
|
-
}
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
## Demo
|
|
431
|
-
|
|
432
|
-
Several demo scripts are included to help you understand the library's features.
|
|
433
|
-
|
|
434
|
-
### Note Tree Demo
|
|
435
|
-
|
|
436
|
-
Connects to a local Trilium instance and displays a tree view of your notes.
|
|
437
|
-
|
|
438
|
-
```bash
|
|
439
|
-
# Set your ETAPI token and run
|
|
440
|
-
TRILIUM_API_KEY=your-token pnpm demo
|
|
441
|
-
|
|
442
|
-
# On Windows PowerShell
|
|
443
|
-
$env:TRILIUM_API_KEY="your-token"; pnpm demo
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
**Configuration:**
|
|
447
|
-
|
|
448
|
-
| Variable | Default | Description |
|
|
449
|
-
|----------|---------|-------------|
|
|
450
|
-
| `TRILIUM_URL` | `http://localhost:8080` | Trilium server URL |
|
|
451
|
-
| `TRILIUM_API_KEY` | - | Your ETAPI token (required) |
|
|
452
|
-
| `MAX_DEPTH` | `3` | Maximum depth of the note tree |
|
|
453
|
-
|
|
454
|
-
**Getting Your ETAPI Token:**
|
|
455
|
-
|
|
456
|
-
1. Open Trilium Notes
|
|
457
|
-
2. Go to **Menu** > **Options** > **ETAPI**
|
|
458
|
-
3. Click **Create new ETAPI token**
|
|
459
|
-
4. Copy the generated token
|
|
460
|
-
|
|
461
|
-
### Search Query Builder Demo
|
|
462
|
-
|
|
463
|
-
Demonstrates how to build type-safe search queries (no Trilium connection required).
|
|
464
|
-
|
|
465
|
-
```bash
|
|
466
|
-
pnpm demo:search
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
Example output:
|
|
470
|
-
```
|
|
471
|
-
1. Simple label search:
|
|
472
|
-
Code: buildSearchQuery({ "#blog": true })
|
|
473
|
-
Result: #blog
|
|
474
|
-
|
|
475
|
-
2. Label with value:
|
|
476
|
-
Code: buildSearchQuery({ "#status": "published" })
|
|
477
|
-
Result: #status = 'published'
|
|
478
|
-
|
|
479
|
-
3. Complex nested conditions:
|
|
480
|
-
Result: #blog AND (#status = 'published' OR #status = 'featured') AND #wordCount >= 500
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
### Note Mapper Demo
|
|
484
|
-
|
|
485
|
-
Demonstrates how to map Trilium notes to strongly-typed objects (no Trilium connection required).
|
|
486
|
-
|
|
487
|
-
```bash
|
|
488
|
-
pnpm demo:mapper
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
Example output:
|
|
492
|
-
```
|
|
493
|
-
Title: Getting Started with TypeScript
|
|
494
|
-
ID: note1
|
|
495
|
-
Slug: getting-started-typescript
|
|
496
|
-
Status: published
|
|
497
|
-
Word Count: 1500
|
|
498
|
-
Tags: [typescript, programming, tutorial]
|
|
499
|
-
Published: 2024-01-20T00:00:00.000Z
|
|
500
|
-
Read Time: 8 min
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
## Development
|
|
504
|
-
|
|
505
|
-
### Prerequisites
|
|
506
|
-
|
|
507
|
-
- Node.js 18+
|
|
508
|
-
- pnpm (recommended) or npm
|
|
509
|
-
|
|
510
|
-
### Setup
|
|
511
|
-
|
|
512
|
-
```bash
|
|
513
|
-
# Clone the repository
|
|
514
|
-
git clone https://github.com/your-username/trilium-api.git
|
|
515
|
-
cd trilium-api
|
|
516
|
-
|
|
517
|
-
# Install dependencies
|
|
518
|
-
pnpm install
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
### Scripts
|
|
522
|
-
|
|
523
|
-
| Script | Description |
|
|
524
|
-
|--------|-------------|
|
|
525
|
-
| `pnpm test` | Run tests in watch mode |
|
|
526
|
-
| `pnpm run test:run` | Run tests once |
|
|
527
|
-
| `pnpm run test:ts` | Type check without emitting |
|
|
528
|
-
| `pnpm run generate-api` | Regenerate types from OpenAPI spec |
|
|
529
|
-
|
|
530
|
-
### Regenerating API Types
|
|
531
|
-
|
|
532
|
-
The TypeScript types are auto-generated from the [TriliumNext OpenAPI specification](https://github.com/TriliumNext/Trilium/blob/develop/apps/server/etapi.openapi.yaml). To regenerate types after an API update:
|
|
533
|
-
|
|
534
|
-
```bash
|
|
535
|
-
pnpm run generate-api
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
This runs `openapi-typescript` which:
|
|
539
|
-
1. Fetches the latest OpenAPI spec from the TriliumNext repository
|
|
540
|
-
2. Generates TypeScript types to `src/generated/trilium.d.ts`
|
|
541
|
-
3. Creates fully typed `paths`, `components`, and `operations` interfaces
|
|
542
|
-
|
|
543
|
-
#### Using a Different OpenAPI Source
|
|
544
|
-
|
|
545
|
-
To generate from a local file or different URL, modify the command in `package.json`:
|
|
546
|
-
|
|
547
|
-
```json
|
|
548
|
-
{
|
|
549
|
-
"scripts": {
|
|
550
|
-
"generate-api": "openapi-typescript ./path/to/local/etapi.openapi.yaml -o ./src/generated/trilium.d.ts"
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
Or from a different URL:
|
|
556
|
-
|
|
557
|
-
```json
|
|
558
|
-
{
|
|
559
|
-
"scripts": {
|
|
560
|
-
"generate-api": "openapi-typescript https://your-server.com/etapi.openapi.yaml -o ./src/generated/trilium.d.ts"
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
#### Verifying Generation
|
|
566
|
-
|
|
567
|
-
After regenerating, always verify:
|
|
568
|
-
|
|
569
|
-
```bash
|
|
570
|
-
# 1. Check TypeScript compilation
|
|
571
|
-
pnpm run test:ts
|
|
572
|
-
|
|
573
|
-
# 2. Run all tests
|
|
574
|
-
pnpm run test:run
|
|
575
|
-
|
|
576
|
-
# 3. Check for any breaking changes in the generated types
|
|
577
|
-
git diff src/generated/trilium.d.ts
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
### Writing Tests
|
|
581
|
-
|
|
582
|
-
Tests are written using [Vitest](https://vitest.dev/) and located alongside source files with `.test.ts` extension.
|
|
583
|
-
|
|
584
|
-
#### Test File Structure
|
|
585
|
-
|
|
586
|
-
```
|
|
587
|
-
src/
|
|
588
|
-
├── client.ts # API client
|
|
589
|
-
├── client.test.ts # Client tests
|
|
590
|
-
├── mapper.ts # Mapper utilities
|
|
591
|
-
├── mapper.test.ts # Mapper tests
|
|
592
|
-
└── generated/
|
|
593
|
-
└── trilium.d.ts # Generated types (don't test directly)
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
#### Adding Tests for the Client
|
|
597
|
-
|
|
598
|
-
The client tests mock `fetch` globally. Here's how to add a new test:
|
|
599
|
-
|
|
600
|
-
```typescript
|
|
601
|
-
// src/client.test.ts
|
|
602
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
603
|
-
import { createTriliumClient } from './client.js';
|
|
604
|
-
|
|
605
|
-
const mockFetch = vi.fn();
|
|
606
|
-
globalThis.fetch = mockFetch;
|
|
607
|
-
|
|
608
|
-
// Helper to create mock responses
|
|
609
|
-
function createMockResponse(body: any, status = 200, contentType = 'application/json') {
|
|
610
|
-
return {
|
|
611
|
-
ok: status >= 200 && status < 300,
|
|
612
|
-
status,
|
|
613
|
-
headers: new Headers({ 'content-type': contentType }),
|
|
614
|
-
json: async () => body,
|
|
615
|
-
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
616
|
-
blob: async () => new Blob([JSON.stringify(body)]),
|
|
617
|
-
clone: function() { return this; },
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
describe('my new feature', () => {
|
|
622
|
-
const config = {
|
|
623
|
-
baseUrl: 'http://localhost:37840',
|
|
624
|
-
apiKey: 'test-api-key',
|
|
625
|
-
};
|
|
626
|
-
|
|
627
|
-
beforeEach(() => {
|
|
628
|
-
mockFetch.mockReset();
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it('should do something', async () => {
|
|
632
|
-
// 1. Setup mock response
|
|
633
|
-
mockFetch.mockResolvedValueOnce(createMockResponse({
|
|
634
|
-
noteId: 'test123',
|
|
635
|
-
title: 'Test Note'
|
|
636
|
-
}));
|
|
637
|
-
|
|
638
|
-
// 2. Create client and make request
|
|
639
|
-
const client = createTriliumClient(config);
|
|
640
|
-
const { data, error } = await client.GET('/notes/{noteId}', {
|
|
641
|
-
params: { path: { noteId: 'test123' } },
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
// 3. Assert results
|
|
645
|
-
expect(error).toBeUndefined();
|
|
646
|
-
expect(data?.title).toBe('Test Note');
|
|
647
|
-
|
|
648
|
-
// 4. Verify the request (openapi-fetch uses Request objects)
|
|
649
|
-
const request = mockFetch.mock.calls[0]![0] as Request;
|
|
650
|
-
expect(request.url).toBe('http://localhost:37840/etapi/notes/test123');
|
|
651
|
-
expect(request.method).toBe('GET');
|
|
652
|
-
expect(request.headers.get('Authorization')).toBe('test-api-key');
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
#### Adding Tests for the Mapper
|
|
658
|
-
|
|
659
|
-
Mapper tests don't require fetch mocking—just create mock note objects:
|
|
660
|
-
|
|
661
|
-
```typescript
|
|
662
|
-
// src/mapper.test.ts
|
|
663
|
-
import { describe, it, expect } from 'vitest';
|
|
664
|
-
import { TriliumMapper, transforms, buildSearchQuery } from './mapper.js';
|
|
665
|
-
import type { TriliumNote } from './client.js';
|
|
666
|
-
|
|
667
|
-
// Helper to create mock notes
|
|
668
|
-
function createMockNote(overrides: Partial<TriliumNote> = {}): TriliumNote {
|
|
669
|
-
return {
|
|
670
|
-
noteId: 'test123',
|
|
671
|
-
title: 'Test Note',
|
|
672
|
-
type: 'text',
|
|
673
|
-
mime: 'text/html',
|
|
674
|
-
isProtected: false,
|
|
675
|
-
blobId: 'blob123',
|
|
676
|
-
attributes: [],
|
|
677
|
-
parentNoteIds: ['root'],
|
|
678
|
-
childNoteIds: [],
|
|
679
|
-
parentBranchIds: ['branch123'],
|
|
680
|
-
childBranchIds: [],
|
|
681
|
-
dateCreated: '2024-01-01 12:00:00.000+0000',
|
|
682
|
-
dateModified: '2024-01-01 12:00:00.000+0000',
|
|
683
|
-
utcDateCreated: '2024-01-01 12:00:00.000Z',
|
|
684
|
-
utcDateModified: '2024-01-01 12:00:00.000Z',
|
|
685
|
-
...overrides,
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
describe('my mapper feature', () => {
|
|
690
|
-
it('should map custom fields', () => {
|
|
691
|
-
interface MyType {
|
|
692
|
-
customField: string;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
const mapper = new TriliumMapper<MyType>({
|
|
696
|
-
customField: {
|
|
697
|
-
from: '#myLabel',
|
|
698
|
-
transform: (value) => String(value).toUpperCase(),
|
|
699
|
-
},
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
const note = createMockNote({
|
|
703
|
-
attributes: [{
|
|
704
|
-
attributeId: 'a1',
|
|
705
|
-
noteId: 'test123',
|
|
706
|
-
type: 'label',
|
|
707
|
-
name: 'myLabel',
|
|
708
|
-
value: 'hello',
|
|
709
|
-
position: 0,
|
|
710
|
-
isInheritable: false,
|
|
711
|
-
}],
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
const result = mapper.map(note);
|
|
715
|
-
expect(result.customField).toBe('HELLO');
|
|
716
|
-
});
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
describe('buildSearchQuery', () => {
|
|
720
|
-
it('should handle my custom query', () => {
|
|
721
|
-
const query = buildSearchQuery({
|
|
722
|
-
'#myLabel': { value: 100, operator: '>=' },
|
|
723
|
-
});
|
|
724
|
-
expect(query).toBe('#myLabel >= 100');
|
|
725
|
-
});
|
|
726
|
-
});
|
|
727
|
-
```
|
|
728
|
-
|
|
729
|
-
#### Adding a New Transform
|
|
730
|
-
|
|
731
|
-
To add a custom transform function:
|
|
732
|
-
|
|
733
|
-
```typescript
|
|
734
|
-
// src/mapper.ts - add to the transforms object
|
|
735
|
-
export const transforms = {
|
|
736
|
-
// ... existing transforms ...
|
|
737
|
-
|
|
738
|
-
/** Convert to lowercase */
|
|
739
|
-
lowercase: (value: unknown): string | undefined => {
|
|
740
|
-
if (value === undefined || value === null) return undefined;
|
|
741
|
-
return String(value).toLowerCase();
|
|
742
|
-
},
|
|
743
|
-
|
|
744
|
-
/** Parse as URL */
|
|
745
|
-
url: (value: unknown): URL | undefined => {
|
|
746
|
-
if (value === undefined || value === null || value === '') return undefined;
|
|
747
|
-
try {
|
|
748
|
-
return new URL(String(value));
|
|
749
|
-
} catch {
|
|
750
|
-
return undefined;
|
|
751
|
-
}
|
|
752
|
-
},
|
|
753
|
-
};
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
Then add tests:
|
|
757
|
-
|
|
758
|
-
```typescript
|
|
759
|
-
// src/mapper.test.ts
|
|
760
|
-
describe('transforms', () => {
|
|
761
|
-
describe('lowercase', () => {
|
|
762
|
-
it('should convert to lowercase', () => {
|
|
763
|
-
expect(transforms.lowercase('HELLO')).toBe('hello');
|
|
764
|
-
expect(transforms.lowercase('HeLLo WoRLD')).toBe('hello world');
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
it('should return undefined for null/undefined', () => {
|
|
768
|
-
expect(transforms.lowercase(undefined)).toBeUndefined();
|
|
769
|
-
expect(transforms.lowercase(null)).toBeUndefined();
|
|
770
|
-
});
|
|
771
|
-
});
|
|
772
|
-
});
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
### Running Specific Tests
|
|
776
|
-
|
|
777
|
-
```bash
|
|
778
|
-
# Run tests matching a pattern
|
|
779
|
-
pnpm test -- -t "buildSearchQuery"
|
|
780
|
-
|
|
781
|
-
# Run tests in a specific file
|
|
782
|
-
pnpm test -- src/mapper.test.ts
|
|
783
|
-
|
|
784
|
-
# Run with coverage
|
|
785
|
-
pnpm test -- --coverage
|
|
786
|
-
```
|
|
787
|
-
|
|
788
|
-
### Debugging Tests
|
|
789
|
-
|
|
790
|
-
Add `.only` to focus on specific tests:
|
|
791
|
-
|
|
792
|
-
```typescript
|
|
793
|
-
describe.only('focused suite', () => {
|
|
794
|
-
it.only('focused test', () => {
|
|
795
|
-
// Only this test will run
|
|
796
|
-
});
|
|
797
|
-
});
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
Use `console.log` or the Vitest UI:
|
|
801
|
-
|
|
802
|
-
```bash
|
|
803
|
-
pnpm test -- --ui
|
|
804
|
-
```
|
|
805
|
-
|
|
806
|
-
## Releasing
|
|
807
|
-
|
|
808
|
-
This project uses GitHub Actions to automatically version, release, and publish to npm.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
### Creating a Release
|
|
812
|
-
|
|
813
|
-
1. Go to **Actions** → **Publish to npm** → **Run workflow**
|
|
814
|
-
2. Select the version bump type:
|
|
815
|
-
- `patch` - Bug fixes (1.0.0 → 1.0.1) - **default**
|
|
816
|
-
- `minor` - New features (1.0.0 → 1.1.0)
|
|
817
|
-
- `major` - Breaking changes (1.0.0 → 2.0.0)
|
|
818
|
-
3.
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
-
|
|
823
|
-
-
|
|
824
|
-
-
|
|
825
|
-
-
|
|
826
|
-
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
This project is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
|
1
|
+
# trilium-api
|
|
2
|
+
|
|
3
|
+
A type-safe TypeScript client for the [Trilium Notes](https://github.com/TriliumNext/Trilium) ETAPI.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Features](#features)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [API Reference](#api-reference)
|
|
11
|
+
- [Search Query Builder](#search-query-builder)
|
|
12
|
+
- [Note Mapper](#note-mapper)
|
|
13
|
+
- [Types](#types)
|
|
14
|
+
- [Error Handling](#error-handling)
|
|
15
|
+
- [Demo](#demo)
|
|
16
|
+
- [Development](#development)
|
|
17
|
+
- [Releasing](#releasing)
|
|
18
|
+
- [License](#license)
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Fully typed** - Auto-generated types from OpenAPI specification
|
|
23
|
+
- **Lightweight** - Built on [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) (~6kb)
|
|
24
|
+
- **Query Builder** - Type-safe search query construction
|
|
25
|
+
- **Mapper** - Declarative note-to-object mapping with transforms
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install trilium-api
|
|
31
|
+
# or
|
|
32
|
+
pnpm add trilium-api
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { createTriliumClient } from 'trilium-api';
|
|
39
|
+
|
|
40
|
+
const client = createTriliumClient({
|
|
41
|
+
baseUrl: 'http://localhost:37840',
|
|
42
|
+
apiKey: 'your-etapi-token',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Get app info
|
|
46
|
+
const { data: appInfo } = await client.GET('/app-info');
|
|
47
|
+
console.log(`Trilium version: ${appInfo?.appVersion}`);
|
|
48
|
+
|
|
49
|
+
// Get a note by ID
|
|
50
|
+
const { data: note } = await client.GET('/notes/{noteId}', {
|
|
51
|
+
params: { path: { noteId: 'root' } },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Search notes
|
|
55
|
+
const { data: results } = await client.GET('/notes', {
|
|
56
|
+
params: { query: { search: '#blog' } },
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API Reference
|
|
61
|
+
|
|
62
|
+
### Creating a Client
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { createTriliumClient } from 'trilium-api';
|
|
66
|
+
|
|
67
|
+
const client = createTriliumClient({
|
|
68
|
+
baseUrl: 'http://localhost:37840', // Your Trilium server URL
|
|
69
|
+
apiKey: 'your-etapi-token', // ETAPI token from Trilium settings
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Common Operations
|
|
74
|
+
|
|
75
|
+
#### Get a Note
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const { data: note, error } = await client.GET('/notes/{noteId}', {
|
|
79
|
+
params: { path: { noteId: 'abc123' } },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (error) {
|
|
83
|
+
console.error('Failed to fetch note:', error);
|
|
84
|
+
} else {
|
|
85
|
+
console.log(note.title);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Create a Note
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const { data } = await client.POST('/create-note', {
|
|
93
|
+
body: {
|
|
94
|
+
parentNoteId: 'root',
|
|
95
|
+
title: 'My New Note',
|
|
96
|
+
type: 'text',
|
|
97
|
+
content: '<p>Hello World!</p>',
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.log(`Created note: ${data?.note?.noteId}`);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### Update a Note
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
await client.PATCH('/notes/{noteId}', {
|
|
108
|
+
params: { path: { noteId: 'abc123' } },
|
|
109
|
+
body: { title: 'Updated Title' },
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### Delete a Note
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
await client.DELETE('/notes/{noteId}', {
|
|
117
|
+
params: { path: { noteId: 'abc123' } },
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### Get/Update Note Content
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// Get content
|
|
125
|
+
const { data: content } = await client.GET('/notes/{noteId}/content', {
|
|
126
|
+
params: { path: { noteId: 'abc123' } },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Update content
|
|
130
|
+
await client.PUT('/notes/{noteId}/content', {
|
|
131
|
+
params: { path: { noteId: 'abc123' } },
|
|
132
|
+
body: '<p>New content</p>',
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### Branches
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// Create a branch (clone a note to another location)
|
|
140
|
+
const { data: branch } = await client.POST('/branches', {
|
|
141
|
+
body: {
|
|
142
|
+
noteId: 'sourceNote123',
|
|
143
|
+
parentNoteId: 'targetParent456',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Delete a branch
|
|
148
|
+
await client.DELETE('/branches/{branchId}', {
|
|
149
|
+
params: { path: { branchId: 'branch123' } },
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
#### Attributes
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// Create a label
|
|
157
|
+
await client.POST('/attributes', {
|
|
158
|
+
body: {
|
|
159
|
+
noteId: 'abc123',
|
|
160
|
+
type: 'label',
|
|
161
|
+
name: 'status',
|
|
162
|
+
value: 'published',
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Create a relation
|
|
167
|
+
await client.POST('/attributes', {
|
|
168
|
+
body: {
|
|
169
|
+
noteId: 'abc123',
|
|
170
|
+
type: 'relation',
|
|
171
|
+
name: 'author',
|
|
172
|
+
value: 'authorNoteId',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Search Query Builder
|
|
178
|
+
|
|
179
|
+
Build type-safe Trilium search queries with the `buildSearchQuery` helper:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { buildSearchQuery } from 'trilium-api';
|
|
183
|
+
|
|
184
|
+
// Simple label search
|
|
185
|
+
buildSearchQuery({ '#blog': true });
|
|
186
|
+
// => '#blog'
|
|
187
|
+
|
|
188
|
+
// Label with value
|
|
189
|
+
buildSearchQuery({ '#status': 'published' });
|
|
190
|
+
// => "#status = 'published'"
|
|
191
|
+
|
|
192
|
+
// Label absence check
|
|
193
|
+
buildSearchQuery({ '#draft': false });
|
|
194
|
+
// => '#!draft'
|
|
195
|
+
|
|
196
|
+
// Comparison operators
|
|
197
|
+
buildSearchQuery({ '#wordCount': { value: 1000, operator: '>=' } });
|
|
198
|
+
// => '#wordCount >= 1000'
|
|
199
|
+
|
|
200
|
+
// Note properties
|
|
201
|
+
buildSearchQuery({ 'note.type': 'text', title: { value: 'Blog', operator: '*=' } });
|
|
202
|
+
// => "note.type = 'text' AND note.title *= 'Blog'"
|
|
203
|
+
|
|
204
|
+
// Relations
|
|
205
|
+
buildSearchQuery({ '~author': 'John' });
|
|
206
|
+
// => "~author *=* 'John'"
|
|
207
|
+
|
|
208
|
+
// AND conditions
|
|
209
|
+
buildSearchQuery({
|
|
210
|
+
AND: [
|
|
211
|
+
{ '#blog': true },
|
|
212
|
+
{ '#published': true },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
// => '#blog AND #published'
|
|
216
|
+
|
|
217
|
+
// OR conditions
|
|
218
|
+
buildSearchQuery({
|
|
219
|
+
OR: [
|
|
220
|
+
{ '#status': 'draft' },
|
|
221
|
+
{ '#status': 'review' },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
// => "#status = 'draft' OR #status = 'review'"
|
|
225
|
+
|
|
226
|
+
// NOT conditions
|
|
227
|
+
buildSearchQuery({
|
|
228
|
+
NOT: { '#archived': true },
|
|
229
|
+
});
|
|
230
|
+
// => 'not(#archived)'
|
|
231
|
+
|
|
232
|
+
// Complex nested conditions
|
|
233
|
+
buildSearchQuery({
|
|
234
|
+
AND: [
|
|
235
|
+
{ '#blog': true },
|
|
236
|
+
{ 'note.type': 'text' },
|
|
237
|
+
{ OR: [
|
|
238
|
+
{ '#category': 'tech' },
|
|
239
|
+
{ '#category': 'programming' },
|
|
240
|
+
]},
|
|
241
|
+
{ NOT: { '#draft': true } },
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
// => "#blog AND note.type = 'text' AND (#category = 'tech' OR #category = 'programming') AND not(#draft)"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Using with the Client
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const query = buildSearchQuery({
|
|
251
|
+
AND: [
|
|
252
|
+
{ '#blog': true },
|
|
253
|
+
{ '#published': true },
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const { data } = await client.GET('/notes', {
|
|
258
|
+
params: { query: { search: query, limit: 10 } },
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Note Mapper
|
|
263
|
+
|
|
264
|
+
Map Trilium notes to strongly-typed objects using declarative field mappings:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { TriliumMapper, transforms } from 'trilium-api';
|
|
268
|
+
|
|
269
|
+
// Define your target type
|
|
270
|
+
interface BlogPost {
|
|
271
|
+
id: string;
|
|
272
|
+
title: string;
|
|
273
|
+
slug: string;
|
|
274
|
+
publishDate: Date;
|
|
275
|
+
wordCount: number;
|
|
276
|
+
readTimeMinutes: number;
|
|
277
|
+
tags: string[];
|
|
278
|
+
isPublished: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Create a mapper
|
|
282
|
+
const blogMapper = new TriliumMapper<BlogPost>({
|
|
283
|
+
// Simple property mapping (shorthand)
|
|
284
|
+
id: 'note.noteId',
|
|
285
|
+
title: 'note.title',
|
|
286
|
+
|
|
287
|
+
// Label attribute with required validation
|
|
288
|
+
slug: { from: '#slug', required: true },
|
|
289
|
+
|
|
290
|
+
// Transform string to Date
|
|
291
|
+
publishDate: { from: '#publishDate', transform: transforms.date },
|
|
292
|
+
|
|
293
|
+
// Transform string to number with default
|
|
294
|
+
wordCount: { from: '#wordCount', transform: transforms.number, default: 0 },
|
|
295
|
+
|
|
296
|
+
// Computed field based on other mapped values
|
|
297
|
+
readTimeMinutes: {
|
|
298
|
+
computed: (partial) => Math.ceil((partial.wordCount || 0) / 200),
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
// Transform comma-separated string to array
|
|
302
|
+
tags: { from: '#tags', transform: transforms.commaSeparated, default: [] },
|
|
303
|
+
|
|
304
|
+
// Transform to boolean
|
|
305
|
+
isPublished: { from: '#published', transform: transforms.boolean, default: false },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Map a single note
|
|
309
|
+
const post = blogMapper.map(note);
|
|
310
|
+
|
|
311
|
+
// Map an array of notes
|
|
312
|
+
const posts = blogMapper.map(notes);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Field Mapping Options
|
|
316
|
+
|
|
317
|
+
#### Shorthand String Path
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
{
|
|
321
|
+
title: 'note.title', // Note property
|
|
322
|
+
slug: '#slug', // Label attribute
|
|
323
|
+
authorId: '~author', // Relation attribute
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
#### Full Configuration Object
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
{
|
|
331
|
+
fieldName: {
|
|
332
|
+
from: '#labelName', // Source path or extractor function
|
|
333
|
+
transform: transforms.number, // Optional transform function
|
|
334
|
+
default: 0, // Default value if undefined
|
|
335
|
+
required: false, // Throw if missing (default: false)
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### Custom Extractor Function
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
{
|
|
344
|
+
labelCount: {
|
|
345
|
+
from: (note) => note.attributes?.filter(a => a.type === 'label').length || 0,
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
#### Computed Fields
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
{
|
|
354
|
+
readTimeMinutes: {
|
|
355
|
+
computed: (partial, note) => Math.ceil((partial.wordCount || 0) / 200),
|
|
356
|
+
default: 1,
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Built-in Transforms
|
|
362
|
+
|
|
363
|
+
| Transform | Description | Example |
|
|
364
|
+
|-----------|-------------|---------|
|
|
365
|
+
| `transforms.number` | Convert to number | `"123"` → `123` |
|
|
366
|
+
| `transforms.boolean` | Convert to boolean | `"true"` → `true` |
|
|
367
|
+
| `transforms.commaSeparated` | Split string to array | `"a, b, c"` → `["a", "b", "c"]` |
|
|
368
|
+
| `transforms.json` | Parse JSON string | `'{"a":1}'` → `{ a: 1 }` |
|
|
369
|
+
| `transforms.date` | Parse date string | `"2024-01-15"` → `Date` |
|
|
370
|
+
| `transforms.trim` | Trim whitespace | `" hello "` → `"hello"` |
|
|
371
|
+
|
|
372
|
+
### Merging Configurations
|
|
373
|
+
|
|
374
|
+
Reuse and extend mapping configurations:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
// Base configuration for common fields
|
|
378
|
+
const baseMapping = {
|
|
379
|
+
id: 'note.noteId',
|
|
380
|
+
title: 'note.title',
|
|
381
|
+
createdAt: { from: 'note.utcDateCreated', transform: transforms.date },
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Extended configuration for blog posts
|
|
385
|
+
const blogMapping = {
|
|
386
|
+
slug: '#slug',
|
|
387
|
+
tags: { from: '#tags', transform: transforms.commaSeparated, default: [] },
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Merge configurations
|
|
391
|
+
const merged = TriliumMapper.merge<BlogPost>(baseMapping, blogMapping);
|
|
392
|
+
const mapper = new TriliumMapper<BlogPost>(merged);
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## Types
|
|
396
|
+
|
|
397
|
+
The package exports all types from the OpenAPI specification:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
import type {
|
|
401
|
+
TriliumNote,
|
|
402
|
+
TriliumBranch,
|
|
403
|
+
TriliumAttribute,
|
|
404
|
+
TriliumAttachment,
|
|
405
|
+
TriliumAppInfo,
|
|
406
|
+
paths,
|
|
407
|
+
components,
|
|
408
|
+
operations,
|
|
409
|
+
} from 'trilium-api';
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Error Handling
|
|
413
|
+
|
|
414
|
+
The client returns `{ data, error }` for all operations:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
const { data, error } = await client.GET('/notes/{noteId}', {
|
|
418
|
+
params: { path: { noteId: 'nonexistent' } },
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (error) {
|
|
422
|
+
// error contains the response body on failure
|
|
423
|
+
console.error('Error:', error);
|
|
424
|
+
} else {
|
|
425
|
+
// data is typed based on the endpoint
|
|
426
|
+
console.log('Note:', data.title);
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Demo
|
|
431
|
+
|
|
432
|
+
Several demo scripts are included to help you understand the library's features.
|
|
433
|
+
|
|
434
|
+
### Note Tree Demo
|
|
435
|
+
|
|
436
|
+
Connects to a local Trilium instance and displays a tree view of your notes.
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
# Set your ETAPI token and run
|
|
440
|
+
TRILIUM_API_KEY=your-token pnpm demo
|
|
441
|
+
|
|
442
|
+
# On Windows PowerShell
|
|
443
|
+
$env:TRILIUM_API_KEY="your-token"; pnpm demo
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Configuration:**
|
|
447
|
+
|
|
448
|
+
| Variable | Default | Description |
|
|
449
|
+
|----------|---------|-------------|
|
|
450
|
+
| `TRILIUM_URL` | `http://localhost:8080` | Trilium server URL |
|
|
451
|
+
| `TRILIUM_API_KEY` | - | Your ETAPI token (required) |
|
|
452
|
+
| `MAX_DEPTH` | `3` | Maximum depth of the note tree |
|
|
453
|
+
|
|
454
|
+
**Getting Your ETAPI Token:**
|
|
455
|
+
|
|
456
|
+
1. Open Trilium Notes
|
|
457
|
+
2. Go to **Menu** > **Options** > **ETAPI**
|
|
458
|
+
3. Click **Create new ETAPI token**
|
|
459
|
+
4. Copy the generated token
|
|
460
|
+
|
|
461
|
+
### Search Query Builder Demo
|
|
462
|
+
|
|
463
|
+
Demonstrates how to build type-safe search queries (no Trilium connection required).
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
pnpm demo:search
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Example output:
|
|
470
|
+
```
|
|
471
|
+
1. Simple label search:
|
|
472
|
+
Code: buildSearchQuery({ "#blog": true })
|
|
473
|
+
Result: #blog
|
|
474
|
+
|
|
475
|
+
2. Label with value:
|
|
476
|
+
Code: buildSearchQuery({ "#status": "published" })
|
|
477
|
+
Result: #status = 'published'
|
|
478
|
+
|
|
479
|
+
3. Complex nested conditions:
|
|
480
|
+
Result: #blog AND (#status = 'published' OR #status = 'featured') AND #wordCount >= 500
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Note Mapper Demo
|
|
484
|
+
|
|
485
|
+
Demonstrates how to map Trilium notes to strongly-typed objects (no Trilium connection required).
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
pnpm demo:mapper
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Example output:
|
|
492
|
+
```
|
|
493
|
+
Title: Getting Started with TypeScript
|
|
494
|
+
ID: note1
|
|
495
|
+
Slug: getting-started-typescript
|
|
496
|
+
Status: published
|
|
497
|
+
Word Count: 1500
|
|
498
|
+
Tags: [typescript, programming, tutorial]
|
|
499
|
+
Published: 2024-01-20T00:00:00.000Z
|
|
500
|
+
Read Time: 8 min
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
## Development
|
|
504
|
+
|
|
505
|
+
### Prerequisites
|
|
506
|
+
|
|
507
|
+
- Node.js 18+
|
|
508
|
+
- pnpm (recommended) or npm
|
|
509
|
+
|
|
510
|
+
### Setup
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
# Clone the repository
|
|
514
|
+
git clone https://github.com/your-username/trilium-api.git
|
|
515
|
+
cd trilium-api
|
|
516
|
+
|
|
517
|
+
# Install dependencies
|
|
518
|
+
pnpm install
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### Scripts
|
|
522
|
+
|
|
523
|
+
| Script | Description |
|
|
524
|
+
|--------|-------------|
|
|
525
|
+
| `pnpm test` | Run tests in watch mode |
|
|
526
|
+
| `pnpm run test:run` | Run tests once |
|
|
527
|
+
| `pnpm run test:ts` | Type check without emitting |
|
|
528
|
+
| `pnpm run generate-api` | Regenerate types from OpenAPI spec |
|
|
529
|
+
|
|
530
|
+
### Regenerating API Types
|
|
531
|
+
|
|
532
|
+
The TypeScript types are auto-generated from the [TriliumNext OpenAPI specification](https://github.com/TriliumNext/Trilium/blob/develop/apps/server/etapi.openapi.yaml). To regenerate types after an API update:
|
|
533
|
+
|
|
534
|
+
```bash
|
|
535
|
+
pnpm run generate-api
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
This runs `openapi-typescript` which:
|
|
539
|
+
1. Fetches the latest OpenAPI spec from the TriliumNext repository
|
|
540
|
+
2. Generates TypeScript types to `src/generated/trilium.d.ts`
|
|
541
|
+
3. Creates fully typed `paths`, `components`, and `operations` interfaces
|
|
542
|
+
|
|
543
|
+
#### Using a Different OpenAPI Source
|
|
544
|
+
|
|
545
|
+
To generate from a local file or different URL, modify the command in `package.json`:
|
|
546
|
+
|
|
547
|
+
```json
|
|
548
|
+
{
|
|
549
|
+
"scripts": {
|
|
550
|
+
"generate-api": "openapi-typescript ./path/to/local/etapi.openapi.yaml -o ./src/generated/trilium.d.ts"
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Or from a different URL:
|
|
556
|
+
|
|
557
|
+
```json
|
|
558
|
+
{
|
|
559
|
+
"scripts": {
|
|
560
|
+
"generate-api": "openapi-typescript https://your-server.com/etapi.openapi.yaml -o ./src/generated/trilium.d.ts"
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
#### Verifying Generation
|
|
566
|
+
|
|
567
|
+
After regenerating, always verify:
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
# 1. Check TypeScript compilation
|
|
571
|
+
pnpm run test:ts
|
|
572
|
+
|
|
573
|
+
# 2. Run all tests
|
|
574
|
+
pnpm run test:run
|
|
575
|
+
|
|
576
|
+
# 3. Check for any breaking changes in the generated types
|
|
577
|
+
git diff src/generated/trilium.d.ts
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Writing Tests
|
|
581
|
+
|
|
582
|
+
Tests are written using [Vitest](https://vitest.dev/) and located alongside source files with `.test.ts` extension.
|
|
583
|
+
|
|
584
|
+
#### Test File Structure
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
src/
|
|
588
|
+
├── client.ts # API client
|
|
589
|
+
├── client.test.ts # Client tests
|
|
590
|
+
├── mapper.ts # Mapper utilities
|
|
591
|
+
├── mapper.test.ts # Mapper tests
|
|
592
|
+
└── generated/
|
|
593
|
+
└── trilium.d.ts # Generated types (don't test directly)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
#### Adding Tests for the Client
|
|
597
|
+
|
|
598
|
+
The client tests mock `fetch` globally. Here's how to add a new test:
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
// src/client.test.ts
|
|
602
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
603
|
+
import { createTriliumClient } from './client.js';
|
|
604
|
+
|
|
605
|
+
const mockFetch = vi.fn();
|
|
606
|
+
globalThis.fetch = mockFetch;
|
|
607
|
+
|
|
608
|
+
// Helper to create mock responses
|
|
609
|
+
function createMockResponse(body: any, status = 200, contentType = 'application/json') {
|
|
610
|
+
return {
|
|
611
|
+
ok: status >= 200 && status < 300,
|
|
612
|
+
status,
|
|
613
|
+
headers: new Headers({ 'content-type': contentType }),
|
|
614
|
+
json: async () => body,
|
|
615
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
616
|
+
blob: async () => new Blob([JSON.stringify(body)]),
|
|
617
|
+
clone: function() { return this; },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
describe('my new feature', () => {
|
|
622
|
+
const config = {
|
|
623
|
+
baseUrl: 'http://localhost:37840',
|
|
624
|
+
apiKey: 'test-api-key',
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
mockFetch.mockReset();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should do something', async () => {
|
|
632
|
+
// 1. Setup mock response
|
|
633
|
+
mockFetch.mockResolvedValueOnce(createMockResponse({
|
|
634
|
+
noteId: 'test123',
|
|
635
|
+
title: 'Test Note'
|
|
636
|
+
}));
|
|
637
|
+
|
|
638
|
+
// 2. Create client and make request
|
|
639
|
+
const client = createTriliumClient(config);
|
|
640
|
+
const { data, error } = await client.GET('/notes/{noteId}', {
|
|
641
|
+
params: { path: { noteId: 'test123' } },
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// 3. Assert results
|
|
645
|
+
expect(error).toBeUndefined();
|
|
646
|
+
expect(data?.title).toBe('Test Note');
|
|
647
|
+
|
|
648
|
+
// 4. Verify the request (openapi-fetch uses Request objects)
|
|
649
|
+
const request = mockFetch.mock.calls[0]![0] as Request;
|
|
650
|
+
expect(request.url).toBe('http://localhost:37840/etapi/notes/test123');
|
|
651
|
+
expect(request.method).toBe('GET');
|
|
652
|
+
expect(request.headers.get('Authorization')).toBe('test-api-key');
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
#### Adding Tests for the Mapper
|
|
658
|
+
|
|
659
|
+
Mapper tests don't require fetch mocking—just create mock note objects:
|
|
660
|
+
|
|
661
|
+
```typescript
|
|
662
|
+
// src/mapper.test.ts
|
|
663
|
+
import { describe, it, expect } from 'vitest';
|
|
664
|
+
import { TriliumMapper, transforms, buildSearchQuery } from './mapper.js';
|
|
665
|
+
import type { TriliumNote } from './client.js';
|
|
666
|
+
|
|
667
|
+
// Helper to create mock notes
|
|
668
|
+
function createMockNote(overrides: Partial<TriliumNote> = {}): TriliumNote {
|
|
669
|
+
return {
|
|
670
|
+
noteId: 'test123',
|
|
671
|
+
title: 'Test Note',
|
|
672
|
+
type: 'text',
|
|
673
|
+
mime: 'text/html',
|
|
674
|
+
isProtected: false,
|
|
675
|
+
blobId: 'blob123',
|
|
676
|
+
attributes: [],
|
|
677
|
+
parentNoteIds: ['root'],
|
|
678
|
+
childNoteIds: [],
|
|
679
|
+
parentBranchIds: ['branch123'],
|
|
680
|
+
childBranchIds: [],
|
|
681
|
+
dateCreated: '2024-01-01 12:00:00.000+0000',
|
|
682
|
+
dateModified: '2024-01-01 12:00:00.000+0000',
|
|
683
|
+
utcDateCreated: '2024-01-01 12:00:00.000Z',
|
|
684
|
+
utcDateModified: '2024-01-01 12:00:00.000Z',
|
|
685
|
+
...overrides,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
describe('my mapper feature', () => {
|
|
690
|
+
it('should map custom fields', () => {
|
|
691
|
+
interface MyType {
|
|
692
|
+
customField: string;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const mapper = new TriliumMapper<MyType>({
|
|
696
|
+
customField: {
|
|
697
|
+
from: '#myLabel',
|
|
698
|
+
transform: (value) => String(value).toUpperCase(),
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const note = createMockNote({
|
|
703
|
+
attributes: [{
|
|
704
|
+
attributeId: 'a1',
|
|
705
|
+
noteId: 'test123',
|
|
706
|
+
type: 'label',
|
|
707
|
+
name: 'myLabel',
|
|
708
|
+
value: 'hello',
|
|
709
|
+
position: 0,
|
|
710
|
+
isInheritable: false,
|
|
711
|
+
}],
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const result = mapper.map(note);
|
|
715
|
+
expect(result.customField).toBe('HELLO');
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe('buildSearchQuery', () => {
|
|
720
|
+
it('should handle my custom query', () => {
|
|
721
|
+
const query = buildSearchQuery({
|
|
722
|
+
'#myLabel': { value: 100, operator: '>=' },
|
|
723
|
+
});
|
|
724
|
+
expect(query).toBe('#myLabel >= 100');
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
#### Adding a New Transform
|
|
730
|
+
|
|
731
|
+
To add a custom transform function:
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
// src/mapper.ts - add to the transforms object
|
|
735
|
+
export const transforms = {
|
|
736
|
+
// ... existing transforms ...
|
|
737
|
+
|
|
738
|
+
/** Convert to lowercase */
|
|
739
|
+
lowercase: (value: unknown): string | undefined => {
|
|
740
|
+
if (value === undefined || value === null) return undefined;
|
|
741
|
+
return String(value).toLowerCase();
|
|
742
|
+
},
|
|
743
|
+
|
|
744
|
+
/** Parse as URL */
|
|
745
|
+
url: (value: unknown): URL | undefined => {
|
|
746
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
747
|
+
try {
|
|
748
|
+
return new URL(String(value));
|
|
749
|
+
} catch {
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Then add tests:
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
// src/mapper.test.ts
|
|
760
|
+
describe('transforms', () => {
|
|
761
|
+
describe('lowercase', () => {
|
|
762
|
+
it('should convert to lowercase', () => {
|
|
763
|
+
expect(transforms.lowercase('HELLO')).toBe('hello');
|
|
764
|
+
expect(transforms.lowercase('HeLLo WoRLD')).toBe('hello world');
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('should return undefined for null/undefined', () => {
|
|
768
|
+
expect(transforms.lowercase(undefined)).toBeUndefined();
|
|
769
|
+
expect(transforms.lowercase(null)).toBeUndefined();
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Running Specific Tests
|
|
776
|
+
|
|
777
|
+
```bash
|
|
778
|
+
# Run tests matching a pattern
|
|
779
|
+
pnpm test -- -t "buildSearchQuery"
|
|
780
|
+
|
|
781
|
+
# Run tests in a specific file
|
|
782
|
+
pnpm test -- src/mapper.test.ts
|
|
783
|
+
|
|
784
|
+
# Run with coverage
|
|
785
|
+
pnpm test -- --coverage
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
### Debugging Tests
|
|
789
|
+
|
|
790
|
+
Add `.only` to focus on specific tests:
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
describe.only('focused suite', () => {
|
|
794
|
+
it.only('focused test', () => {
|
|
795
|
+
// Only this test will run
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
Use `console.log` or the Vitest UI:
|
|
801
|
+
|
|
802
|
+
```bash
|
|
803
|
+
pnpm test -- --ui
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
## Releasing
|
|
807
|
+
|
|
808
|
+
This project uses GitHub Actions to automatically version, release, and publish to npm.
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
### Creating a Release
|
|
812
|
+
|
|
813
|
+
1. Go to **Actions** → **Publish to npm** → **Run workflow**
|
|
814
|
+
2. Select the version bump type:
|
|
815
|
+
- `patch` - Bug fixes (1.0.0 → 1.0.1) - **default**
|
|
816
|
+
- `minor` - New features (1.0.0 → 1.1.0)
|
|
817
|
+
- `major` - Breaking changes (1.0.0 → 2.0.0)
|
|
818
|
+
3. Click **Run workflow**
|
|
819
|
+
|
|
820
|
+
The workflow will automatically:
|
|
821
|
+
- Run type checking and tests
|
|
822
|
+
- Bump the version in `package.json`
|
|
823
|
+
- Commit the version change and create a git tag
|
|
824
|
+
- Build the package (CJS, ESM, and TypeScript declarations)
|
|
825
|
+
- Publish to npm
|
|
826
|
+
- Create a GitHub Release with auto-generated release notes
|
|
827
|
+
|
|
828
|
+
### Verifying the Release
|
|
829
|
+
|
|
830
|
+
- Check the [Actions tab](../../actions) for the workflow status
|
|
831
|
+
- Verify the package on [npm](https://www.npmjs.com/package/trilium-api)
|
|
832
|
+
|
|
833
|
+
## License
|
|
834
|
+
|
|
835
|
+
This project is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|