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/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/Notes) 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. Optionally check **Force publish** to overwrite an existing version on npm
819
- 4. Click **Run workflow**
820
-
821
- The workflow will automatically:
822
- - Run type checking and tests
823
- - Bump the version in `package.json`
824
- - Commit the version change and create a git tag
825
- - Build the package (CJS, ESM, and TypeScript declarations)
826
- - Publish to npm
827
- - Create a GitHub Release with auto-generated release notes
828
-
829
- ### Verifying the Release
830
-
831
- - Check the [Actions tab](../../actions) for the workflow status
832
- - Verify the package on [npm](https://www.npmjs.com/package/trilium-api)
833
-
834
- ## License
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).