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/src/mapper.ts
CHANGED
|
@@ -1,534 +1,534 @@
|
|
|
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
|
-
};
|
|
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
|
+
};
|