trilium-api 1.0.1 → 1.0.2

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/src/mapper.ts DELETED
@@ -1,534 +0,0 @@
1
- /**
2
- * Trilium Note Mapper and Search Query Builder
3
- *
4
- * Provides utilities for mapping Trilium notes to strongly-typed objects
5
- * and building type-safe search queries.
6
- */
7
-
8
- import type { TriliumNote } from './client.js';
9
-
10
- // ============================================================================
11
- // Search Query Builder Types
12
- // ============================================================================
13
-
14
- /**
15
- * Comparison operators for search conditions
16
- */
17
- export type ComparisonOperator = '=' | '!=' | '<' | '<=' | '>' | '>=' | '*=' | '=*' | '*=*';
18
-
19
- /**
20
- * A value with an optional comparison operator
21
- */
22
- export interface ConditionValue<T = string | number | boolean> {
23
- value: T;
24
- operator?: ComparisonOperator;
25
- }
26
-
27
- /**
28
- * Simple value or condition with operator
29
- */
30
- export type SearchValue = string | number | boolean | ConditionValue;
31
-
32
- /**
33
- * Base search conditions for labels, relations, and note properties.
34
- * Use template literal keys for labels (#) and relations (~).
35
- * Regular string keys are treated as note properties.
36
- */
37
- export type TriliumSearchConditions = {
38
- /** Labels: use #labelName as key */
39
- [key: `#${string}`]: SearchValue | undefined;
40
- } & {
41
- /** Relations: use ~relationName as key */
42
- [key: `~${string}`]: SearchValue | undefined;
43
- } & {
44
- /** Note properties: use note.property as key or just property name */
45
- [key: string]: SearchValue | undefined;
46
- };
47
-
48
- /**
49
- * Logical operators for combining search conditions
50
- */
51
- export interface TriliumSearchLogical {
52
- /** Combine multiple conditions with AND */
53
- AND?: TriliumSearchHelpers[];
54
- /** Combine multiple conditions with OR */
55
- OR?: TriliumSearchHelpers[];
56
- /** Negate a condition */
57
- NOT?: TriliumSearchHelpers;
58
- }
59
-
60
- /**
61
- * Complete search helpers combining conditions and logical operators.
62
- * Can contain field conditions AND/OR logical operators.
63
- */
64
- export type TriliumSearchHelpers =
65
- | TriliumSearchLogical
66
- | (TriliumSearchConditions & Partial<TriliumSearchLogical>);
67
-
68
- /**
69
- * Builds a Trilium search query string from a structured helper object
70
- *
71
- * @param helpers - The search conditions and logical operators
72
- * @returns A properly formatted Trilium search query string
73
- *
74
- * @example
75
- * // Simple label search
76
- * buildSearchQuery({ '#blog': true })
77
- * // => '#blog'
78
- *
79
- * @example
80
- * // Label with value
81
- * buildSearchQuery({ '#status': 'published' })
82
- * // => "#status = 'published'"
83
- *
84
- * @example
85
- * // Complex AND/OR conditions
86
- * buildSearchQuery({
87
- * AND: [
88
- * { '#blog': true },
89
- * { OR: [
90
- * { '#status': 'published' },
91
- * { '#status': 'featured' }
92
- * ]}
93
- * ]
94
- * })
95
- * // => "#blog AND (#status = 'published' OR #status = 'featured')"
96
- *
97
- * @example
98
- * // Note properties with operators
99
- * buildSearchQuery({
100
- * 'note.type': 'text',
101
- * '#wordCount': { value: 1000, operator: '>=' }
102
- * })
103
- * // => "note.type = 'text' AND #wordCount >= 1000"
104
- */
105
- export function buildSearchQuery(helpers: TriliumSearchHelpers): string {
106
- // Handle logical operators
107
- if ('AND' in helpers && Array.isArray(helpers.AND)) {
108
- return helpers.AND.map((h) => {
109
- const query = buildSearchQuery(h);
110
- // Wrap in parentheses if it contains OR
111
- return query.includes(' OR ') ? `(${query})` : query;
112
- }).join(' AND ');
113
- }
114
-
115
- if ('OR' in helpers && Array.isArray(helpers.OR)) {
116
- return helpers.OR.map((h) => {
117
- const query = buildSearchQuery(h);
118
- // Wrap in parentheses if it contains AND or OR
119
- return query.includes(' AND ') || query.includes(' OR ') ? `(${query})` : query;
120
- }).join(' OR ');
121
- }
122
-
123
- if ('NOT' in helpers && helpers.NOT !== undefined) {
124
- const notValue = helpers.NOT;
125
- // Type guard: check if it's a valid TriliumSearchHelpers object
126
- if (typeof notValue === 'object' && notValue !== null && !('value' in notValue)) {
127
- const query = buildSearchQuery(notValue);
128
- return `not(${query})`;
129
- }
130
- // If it's a simple value, this shouldn't happen in valid usage
131
- throw new Error('NOT operator requires a query object, not a simple value');
132
- }
133
-
134
- // Build individual conditions from TriliumSearchConditions
135
- const parts: string[] = [];
136
-
137
- for (const [key, value] of Object.entries(helpers)) {
138
- if (value === undefined || value === null) continue;
139
-
140
- // Handle labels (#)
141
- if (key.startsWith('#')) {
142
- const labelName = key.slice(1);
143
-
144
- // Check if it's a nested property like #template.title
145
- if (labelName.includes('.')) {
146
- // For nested properties, use the full key as-is
147
- if (typeof value === 'object' && 'value' in value) {
148
- const operator = value.operator || '=';
149
- const val = typeof value.value === 'string' ? `'${value.value}'` : value.value;
150
- parts.push(`${key} ${operator} ${val}`);
151
- } else if (typeof value === 'string') {
152
- parts.push(`${key} = '${value}'`);
153
- } else {
154
- parts.push(`${key} = ${value}`);
155
- }
156
- } else {
157
- // Simple label
158
- if (value === true) {
159
- parts.push(`#${labelName}`);
160
- } else if (value === false) {
161
- parts.push(`#!${labelName}`);
162
- } else if (typeof value === 'object' && 'value' in value) {
163
- const operator = value.operator || '=';
164
- const val = typeof value.value === 'string' ? `'${value.value}'` : value.value;
165
- parts.push(`#${labelName} ${operator} ${val}`);
166
- } else if (typeof value === 'string') {
167
- parts.push(`#${labelName} = '${value}'`);
168
- } else {
169
- parts.push(`#${labelName} = ${value}`);
170
- }
171
- }
172
- }
173
- // Handle relations (~)
174
- else if (key.startsWith('~')) {
175
- const relationName = key.slice(1);
176
-
177
- // Check if it's a nested property like ~author.title
178
- if (relationName.includes('.')) {
179
- // For nested properties, use the full key as-is
180
- if (typeof value === 'object' && 'value' in value) {
181
- const operator = value.operator || '=';
182
- const val = typeof value.value === 'string' ? `'${value.value}'` : value.value;
183
- parts.push(`${key} ${operator} ${val}`);
184
- } else if (typeof value === 'string') {
185
- parts.push(`${key} = '${value}'`);
186
- } else {
187
- parts.push(`${key} = ${value}`);
188
- }
189
- } else {
190
- // Simple relation - default to title search with contains
191
- if (typeof value === 'object' && 'value' in value) {
192
- const operator = value.operator || '*=*';
193
- const val = typeof value.value === 'string' ? `'${value.value}'` : value.value;
194
- parts.push(`${key} ${operator} ${val}`);
195
- } else if (typeof value === 'string') {
196
- parts.push(`${key} *=* '${value}'`);
197
- }
198
- }
199
- }
200
- // Handle note properties
201
- else {
202
- const path = key.startsWith('note.') ? key : `note.${key}`;
203
-
204
- if (typeof value === 'object' && 'value' in value) {
205
- const operator = value.operator || '=';
206
- const val = typeof value.value === 'string' ? `'${value.value}'` : value.value;
207
- parts.push(`${path} ${operator} ${val}`);
208
- } else if (typeof value === 'string') {
209
- parts.push(`${path} = '${value}'`);
210
- } else if (typeof value === 'boolean') {
211
- parts.push(`${path} = ${value}`);
212
- } else {
213
- parts.push(`${path} = ${value}`);
214
- }
215
- }
216
- }
217
-
218
- return parts.join(' AND ');
219
- }
220
-
221
- // ============================================================================
222
- // Mapper Types
223
- // ============================================================================
224
-
225
- /**
226
- * Transform function that converts a raw value into the target type
227
- * @template T - The target object type
228
- * @template K - The specific key in the target type
229
- */
230
- export type TransformFunction<T, K extends keyof T> = (value: unknown, note: TriliumNote) => T[K] | undefined;
231
-
232
- /**
233
- * Computed function that calculates a value from the partially mapped object
234
- * @template T - The target object type
235
- * @template K - The specific key in the target type
236
- */
237
- export type ComputedFunction<T, K extends keyof T> = (partial: Partial<T>, note: TriliumNote) => T[K] | undefined;
238
-
239
- /**
240
- * Field mapping configuration for a single property
241
- * Can be a simple string path, a detailed configuration object, or a computed value
242
- *
243
- * @template T - The target object type
244
- * @template K - The specific key in the target type
245
- *
246
- * @example
247
- * // Shorthand string path
248
- * title: 'note.title'
249
- *
250
- * @example
251
- * // Full configuration
252
- * tags: {
253
- * from: '#tags',
254
- * transform: transforms.commaSeparated,
255
- * default: [],
256
- * required: false
257
- * }
258
- *
259
- * @example
260
- * // Computed value
261
- * readTimeMinutes: {
262
- * computed: (partial) => Math.ceil((partial.wordCount || 0) / 200)
263
- * }
264
- */
265
- export type FieldMapping<T, K extends keyof T = keyof T> =
266
- | string // Shorthand: direct path like 'note.title' or '#label'
267
- | {
268
- /** Source path (string) or extractor function */
269
- from: string | ((note: TriliumNote) => unknown);
270
- /** Optional transform function to convert the raw value */
271
- transform?: TransformFunction<T, K>;
272
- /** Default value if extraction returns undefined */
273
- default?: T[K];
274
- /** Whether this field is required (throws if missing) */
275
- required?: boolean;
276
- }
277
- | {
278
- /** Computed function that calculates value from other mapped fields */
279
- computed: ComputedFunction<T, K>;
280
- /** Default value if computed returns undefined */
281
- default?: T[K];
282
- };
283
-
284
- /**
285
- * Complete mapping configuration for a type
286
- * Maps each property key to its field mapping configuration
287
- *
288
- * @template T - The target object type to map to
289
- */
290
- export type MappingConfig<T> = {
291
- [K in keyof T]?: FieldMapping<T, K>;
292
- };
293
-
294
- /**
295
- * Maps Trilium notes to strongly-typed objects using declarative field mappings
296
- *
297
- * Supports:
298
- * - Direct property paths (note.title, note.noteId)
299
- * - Label attributes (#labelName)
300
- * - Relation attributes (~relationName)
301
- * - Custom extractor functions
302
- * - Transform functions
303
- * - Computed values from other fields
304
- * - Default values
305
- * - Required field validation
306
- *
307
- * @template T - The target type to map notes to
308
- *
309
- * @example
310
- * const mapper = new TriliumMapper<BlogPost>({
311
- * title: 'note.title',
312
- * slug: { from: '#slug', required: true },
313
- * wordCount: { from: '#wordCount', transform: transforms.number, default: 0 },
314
- * readTimeMinutes: {
315
- * computed: (partial) => Math.ceil((partial.wordCount || 0) / 200)
316
- * }
317
- * });
318
- *
319
- * const posts = mapper.map(notes);
320
- */
321
- export class TriliumMapper<T> {
322
- /** The mapping configuration for this mapper */
323
- private readonly config: MappingConfig<T>;
324
-
325
- /**
326
- * Creates a new TriliumMapper instance
327
- * @param config - The mapping configuration defining how to map note fields to the target type
328
- */
329
- constructor(config: MappingConfig<T>) {
330
- this.config = config;
331
- }
332
-
333
- /**
334
- * Merges multiple mapping configurations into a single configuration
335
- * Later configs override earlier ones for the same keys
336
- * Supports merging configs from base types into derived types
337
- *
338
- * @template T - The target type for the merged configuration
339
- * @param configs - One or more mapping configurations to merge
340
- * @returns A new merged mapping configuration
341
- *
342
- * @example
343
- * const merged = TriliumMapper.merge<BlogPost>(
344
- * StandardNoteMapping,
345
- * BlogSpecificMapping,
346
- * OverrideMapping
347
- * );
348
- */
349
- static merge<T>(...configs: (Partial<MappingConfig<T>> | MappingConfig<unknown>)[]): MappingConfig<T> {
350
- return Object.assign({}, ...configs) as MappingConfig<T>;
351
- }
352
-
353
- /**
354
- * Maps a single note to the target type
355
- * @param note - The Trilium note to map
356
- * @returns The mapped object of type T
357
- */
358
- map(note: TriliumNote): T;
359
-
360
- /**
361
- * Maps an array of notes to the target type
362
- * @param notes - The Trilium notes to map
363
- * @returns An array of mapped objects of type T
364
- */
365
- map(notes: TriliumNote[]): T[];
366
-
367
- /**
368
- * Maps one or more Trilium notes to the target type
369
- * @param noteOrNotes - A single note or array of notes to map
370
- * @returns A single mapped object or array of mapped objects
371
- */
372
- map(noteOrNotes: TriliumNote | TriliumNote[]): T | T[] {
373
- return Array.isArray(noteOrNotes) ? noteOrNotes.map((note) => this.mapSingle(note)) : this.mapSingle(noteOrNotes);
374
- }
375
-
376
- /**
377
- * Maps a single note to the target type using the configured field mappings
378
- * Processes in two passes: first regular fields, then computed fields
379
- * @param note - The Trilium note to map
380
- * @returns The mapped object
381
- * @throws Error if a required field is missing
382
- * @private
383
- */
384
- private mapSingle(note: TriliumNote): T {
385
- const result = {} as Record<keyof T, unknown>;
386
- const computedFields: [keyof T, { computed: ComputedFunction<T, keyof T>; default?: T[keyof T] }][] = [];
387
-
388
- // First pass: process regular fields
389
- for (const [key, fieldMapping] of Object.entries(this.config) as [keyof T, FieldMapping<T>][]) {
390
- if (!fieldMapping) continue;
391
-
392
- // Check if it's a computed field
393
- if (typeof fieldMapping === 'object' && 'computed' in fieldMapping) {
394
- computedFields.push([key, fieldMapping]);
395
- continue;
396
- }
397
-
398
- // Normalize shorthand to full mapping
399
- const mapping = typeof fieldMapping === 'string' ? { from: fieldMapping } : fieldMapping;
400
-
401
- // Extract value
402
- let value: unknown = typeof mapping.from === 'function' ? mapping.from(note) : this.extractValue(note, mapping.from);
403
-
404
- // Transform
405
- if (mapping.transform) {
406
- value = mapping.transform(value, note);
407
- }
408
-
409
- // Default
410
- if (value === undefined && mapping.default !== undefined) {
411
- value = mapping.default;
412
- }
413
-
414
- // Validate required
415
- if (mapping.required && value === undefined) {
416
- throw new Error(`Required field '${String(key)}' missing from note ${note.noteId} (${note.title})`);
417
- }
418
-
419
- result[key] = value;
420
- }
421
-
422
- // Second pass: process computed fields
423
- for (const [key, mapping] of computedFields) {
424
- let value = mapping.computed(result as Partial<T>, note);
425
-
426
- // Default
427
- if (value === undefined && mapping.default !== undefined) {
428
- value = mapping.default;
429
- }
430
-
431
- result[key] = value;
432
- }
433
-
434
- return result as T;
435
- }
436
-
437
- /**
438
- * Extracts a value from a note using a string path
439
- *
440
- * Supports:
441
- * - Label attributes: #labelName
442
- * - Relation attributes: ~relationName
443
- * - Note properties: note.property.path
444
- *
445
- * @param note - The Trilium note to extract from
446
- * @param path - The path string indicating where to extract the value
447
- * @returns The extracted value or undefined if not found
448
- * @private
449
- *
450
- * @example
451
- * extractValue(note, 'note.title') // => note.title
452
- * extractValue(note, '#slug') // => label attribute 'slug'
453
- * extractValue(note, '~template') // => relation attribute 'template'
454
- */
455
- private extractValue(note: TriliumNote, path: string): unknown {
456
- if (!path) return undefined;
457
-
458
- // Label attribute: #labelName
459
- if (path.startsWith('#')) {
460
- return note.attributes?.find((attr) => attr.type === 'label' && attr.name === path.slice(1))?.value;
461
- }
462
-
463
- // Relation attribute: ~relationName
464
- if (path.startsWith('~')) {
465
- return note.attributes?.find((attr) => attr.type === 'relation' && attr.name === path.slice(1))?.value;
466
- }
467
-
468
- // Note property: note.property.path
469
- if (path.startsWith('note.')) {
470
- return path.slice(5).split('.').reduce((obj, key) => (obj as Record<string, unknown>)?.[key], note as unknown);
471
- }
472
-
473
- return undefined;
474
- }
475
- }
476
-
477
- // ============================================================================
478
- // Common Transform Functions
479
- // ============================================================================
480
-
481
- /**
482
- * Common transform functions for use with TriliumMapper
483
- */
484
- export const transforms = {
485
- /** Convert to number */
486
- number: (value: unknown): number | undefined => {
487
- if (value === undefined || value === null || value === '') return undefined;
488
- const num = Number(value);
489
- return isNaN(num) ? undefined : num;
490
- },
491
-
492
- /** Convert to boolean */
493
- boolean: (value: unknown): boolean | undefined => {
494
- if (value === undefined || value === null) return undefined;
495
- if (typeof value === 'boolean') return value;
496
- if (typeof value === 'string') {
497
- const lower = value.toLowerCase();
498
- if (lower === 'true' || lower === '1' || lower === 'yes') return true;
499
- if (lower === 'false' || lower === '0' || lower === 'no') return false;
500
- }
501
- return undefined;
502
- },
503
-
504
- /** Split comma-separated string into array */
505
- commaSeparated: (value: unknown): string[] | undefined => {
506
- if (value === undefined || value === null || value === '') return undefined;
507
- if (typeof value !== 'string') return undefined;
508
- return value.split(',').map((s) => s.trim()).filter(Boolean);
509
- },
510
-
511
- /** Parse JSON string */
512
- json: <T>(value: unknown): T | undefined => {
513
- if (value === undefined || value === null || value === '') return undefined;
514
- if (typeof value !== 'string') return undefined;
515
- try {
516
- return JSON.parse(value) as T;
517
- } catch {
518
- return undefined;
519
- }
520
- },
521
-
522
- /** Parse date string */
523
- date: (value: unknown): Date | undefined => {
524
- if (value === undefined || value === null || value === '') return undefined;
525
- const date = new Date(String(value));
526
- return isNaN(date.getTime()) ? undefined : date;
527
- },
528
-
529
- /** Trim whitespace from string */
530
- trim: (value: unknown): string | undefined => {
531
- if (value === undefined || value === null) return undefined;
532
- return String(value).trim() || undefined;
533
- },
534
- };
package/tsconfig.json DELETED
@@ -1,42 +0,0 @@
1
- {
2
- // Visit https://aka.ms/tsconfig to read more about this file
3
- "compilerOptions": {
4
- // File Layout
5
- // "rootDir": "./src",
6
- // "outDir": "./dist",
7
-
8
- // Environment Settings
9
- // See also https://aka.ms/tsconfig/module
10
- "module": "nodenext",
11
- "target": "esnext",
12
- "types": ["node"],
13
- // For nodejs:
14
- // "lib": ["esnext"],
15
-
16
- // Other Outputs
17
- "sourceMap": true,
18
- "declaration": true,
19
- "declarationMap": true,
20
-
21
- // Stricter Typechecking Options
22
- "noUncheckedIndexedAccess": true,
23
- "exactOptionalPropertyTypes": true,
24
-
25
- // Style Options
26
- // "noImplicitReturns": true,
27
- // "noImplicitOverride": true,
28
- // "noUnusedLocals": true,
29
- // "noUnusedParameters": true,
30
- // "noFallthroughCasesInSwitch": true,
31
- // "noPropertyAccessFromIndexSignature": true,
32
-
33
- // Recommended Options
34
- "strict": true,
35
- "jsx": "react-jsx",
36
- "verbatimModuleSyntax": true,
37
- "isolatedModules": true,
38
- "noUncheckedSideEffectImports": true,
39
- "moduleDetection": "force",
40
- "skipLibCheck": true,
41
- }
42
- }