synap 0.1.0

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/storage.js ADDED
@@ -0,0 +1,803 @@
1
+ /**
2
+ * storage.js - Entry CRUD operations and JSON file handling
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const { v4: uuidv4 } = require('uuid');
9
+
10
+ // Storage directory
11
+ const CONFIG_DIR = process.env.SYNAP_DIR || path.join(os.homedir(), '.config', 'synap');
12
+ const ENTRIES_FILE = path.join(CONFIG_DIR, 'entries.json');
13
+ const ARCHIVE_FILE = path.join(CONFIG_DIR, 'archive.json');
14
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
15
+
16
+ // Valid types, statuses, and date formats
17
+ const VALID_TYPES = ['idea', 'project', 'feature', 'todo', 'question', 'reference', 'note'];
18
+ const VALID_STATUSES = ['raw', 'active', 'someday', 'done', 'archived'];
19
+ const VALID_DATE_FORMATS = ['relative', 'absolute', 'locale'];
20
+
21
+ /**
22
+ * Ensure config directory exists
23
+ */
24
+ function ensureConfigDir() {
25
+ if (!fs.existsSync(CONFIG_DIR)) {
26
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Atomic file write - write to temp file then rename
32
+ */
33
+ function atomicWriteSync(filePath, data) {
34
+ const tmpPath = filePath + '.tmp';
35
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
36
+ fs.renameSync(tmpPath, filePath);
37
+ }
38
+
39
+ /**
40
+ * Load entries from file
41
+ */
42
+ function loadEntries() {
43
+ ensureConfigDir();
44
+ if (!fs.existsSync(ENTRIES_FILE)) {
45
+ return { version: 1, entries: [] };
46
+ }
47
+ try {
48
+ return JSON.parse(fs.readFileSync(ENTRIES_FILE, 'utf8'));
49
+ } catch {
50
+ return { version: 1, entries: [] };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Save entries to file
56
+ */
57
+ function saveEntries(data) {
58
+ ensureConfigDir();
59
+ atomicWriteSync(ENTRIES_FILE, data);
60
+ }
61
+
62
+ /**
63
+ * Load archived entries
64
+ */
65
+ function loadArchive() {
66
+ ensureConfigDir();
67
+ if (!fs.existsSync(ARCHIVE_FILE)) {
68
+ return { version: 1, entries: [] };
69
+ }
70
+ try {
71
+ return JSON.parse(fs.readFileSync(ARCHIVE_FILE, 'utf8'));
72
+ } catch {
73
+ return { version: 1, entries: [] };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Save archived entries
79
+ */
80
+ function saveArchive(data) {
81
+ ensureConfigDir();
82
+ atomicWriteSync(ARCHIVE_FILE, data);
83
+ }
84
+
85
+ /**
86
+ * Load configuration
87
+ */
88
+ function loadConfig() {
89
+ ensureConfigDir();
90
+ const defaultConfig = {
91
+ defaultType: 'idea',
92
+ defaultTags: [],
93
+ editor: null, // Falls back to EDITOR env var in CLI
94
+ dateFormat: 'relative'
95
+ };
96
+
97
+ if (!fs.existsSync(CONFIG_FILE)) {
98
+ return defaultConfig;
99
+ }
100
+
101
+ try {
102
+ const userConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
103
+ const config = { ...defaultConfig, ...userConfig };
104
+
105
+ // Validate defaultType
106
+ if (!VALID_TYPES.includes(config.defaultType)) {
107
+ console.warn(`Warning: Invalid defaultType "${config.defaultType}" in config. Using "idea".`);
108
+ config.defaultType = 'idea';
109
+ }
110
+
111
+ // Validate dateFormat
112
+ if (!VALID_DATE_FORMATS.includes(config.dateFormat)) {
113
+ console.warn(`Warning: Invalid dateFormat "${config.dateFormat}" in config. Using "relative".`);
114
+ config.dateFormat = 'relative';
115
+ }
116
+
117
+ // Validate defaultTags is array of strings
118
+ if (!Array.isArray(config.defaultTags)) {
119
+ config.defaultTags = [];
120
+ } else {
121
+ config.defaultTags = config.defaultTags
122
+ .filter(t => typeof t === 'string')
123
+ .map(t => t.trim());
124
+ }
125
+
126
+ return config;
127
+ } catch (err) {
128
+ console.warn(`Warning: Could not parse config.json: ${err.message}`);
129
+ return defaultConfig;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Get default configuration values
135
+ */
136
+ function getDefaultConfig() {
137
+ return {
138
+ defaultType: 'idea',
139
+ defaultTags: [],
140
+ editor: null,
141
+ dateFormat: 'relative'
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Validate a config key-value pair
147
+ * @returns {object} { valid: boolean, error?: string }
148
+ */
149
+ function validateConfigValue(key, value) {
150
+ const defaults = getDefaultConfig();
151
+
152
+ if (!(key in defaults)) {
153
+ return { valid: false, error: `Unknown config key: ${key}. Valid keys: ${Object.keys(defaults).join(', ')}` };
154
+ }
155
+
156
+ switch (key) {
157
+ case 'defaultType':
158
+ if (!VALID_TYPES.includes(value)) {
159
+ return { valid: false, error: `Invalid type "${value}". Valid types: ${VALID_TYPES.join(', ')}` };
160
+ }
161
+ break;
162
+ case 'dateFormat':
163
+ if (!VALID_DATE_FORMATS.includes(value)) {
164
+ return { valid: false, error: `Invalid format "${value}". Valid formats: ${VALID_DATE_FORMATS.join(', ')}` };
165
+ }
166
+ break;
167
+ case 'defaultTags':
168
+ // Will be parsed as comma-separated string
169
+ break;
170
+ case 'editor':
171
+ // Any string or null is valid
172
+ break;
173
+ }
174
+ return { valid: true };
175
+ }
176
+
177
+ /**
178
+ * Save configuration to file
179
+ */
180
+ function saveConfig(config) {
181
+ ensureConfigDir();
182
+ atomicWriteSync(CONFIG_FILE, config);
183
+ }
184
+
185
+ /**
186
+ * Parse duration string to milliseconds
187
+ * e.g., "7d" -> 7 days, "24h" -> 24 hours
188
+ */
189
+ function parseDuration(duration) {
190
+ if (!duration) return null;
191
+ const match = duration.match(/^(\d+)([dhwm])$/);
192
+ if (!match) return null;
193
+ const value = parseInt(match[1], 10);
194
+ const unit = match[2];
195
+ const multipliers = {
196
+ h: 60 * 60 * 1000, // hours
197
+ d: 24 * 60 * 60 * 1000, // days
198
+ w: 7 * 24 * 60 * 60 * 1000, // weeks
199
+ m: 30 * 24 * 60 * 60 * 1000 // months (approx)
200
+ };
201
+ return value * multipliers[unit];
202
+ }
203
+
204
+ /**
205
+ * Add a new entry
206
+ */
207
+ async function addEntry(options) {
208
+ const data = loadEntries();
209
+
210
+ // Resolve partial parent ID to full ID
211
+ let parentId = options.parent;
212
+ if (parentId) {
213
+ const parentEntry = data.entries.find(e => e.id.startsWith(parentId));
214
+ if (parentEntry) {
215
+ parentId = parentEntry.id;
216
+ }
217
+ }
218
+
219
+ const now = new Date().toISOString();
220
+ const entry = {
221
+ id: uuidv4(),
222
+ content: options.content,
223
+ title: options.title || extractTitle(options.content),
224
+ type: VALID_TYPES.includes(options.type) ? options.type : 'idea',
225
+ status: 'raw',
226
+ priority: options.priority && [1, 2, 3].includes(options.priority) ? options.priority : undefined,
227
+ tags: options.tags || [],
228
+ parent: parentId || undefined,
229
+ related: [],
230
+ createdAt: now,
231
+ updatedAt: now,
232
+ source: options.source || 'cli'
233
+ };
234
+
235
+ // Clean up undefined fields
236
+ Object.keys(entry).forEach(key => {
237
+ if (entry[key] === undefined) delete entry[key];
238
+ });
239
+
240
+ data.entries.push(entry);
241
+ saveEntries(data);
242
+
243
+ return entry;
244
+ }
245
+
246
+ /**
247
+ * Extract title from content (first line, max 60 chars)
248
+ */
249
+ function extractTitle(content) {
250
+ const firstLine = content.split('\n')[0].trim();
251
+ if (firstLine.length <= 60) return firstLine;
252
+ return firstLine.slice(0, 57) + '...';
253
+ }
254
+
255
+ /**
256
+ * Get a single entry by ID (supports partial matching)
257
+ */
258
+ async function getEntry(id) {
259
+ const data = loadEntries();
260
+
261
+ // Try exact match first
262
+ let entry = data.entries.find(e => e.id === id);
263
+ if (entry) return entry;
264
+
265
+ // Try partial match (first 8 chars)
266
+ const matches = data.entries.filter(e => e.id.startsWith(id));
267
+ if (matches.length === 1) return matches[0];
268
+
269
+ // Check archive
270
+ const archive = loadArchive();
271
+ entry = archive.entries.find(e => e.id === id);
272
+ if (entry) return entry;
273
+
274
+ const archiveMatches = archive.entries.filter(e => e.id.startsWith(id));
275
+ if (archiveMatches.length === 1) return archiveMatches[0];
276
+
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * Get multiple entries by IDs
282
+ */
283
+ async function getEntriesByIds(ids) {
284
+ const entries = [];
285
+ for (const id of ids) {
286
+ const entry = await getEntry(id);
287
+ if (entry) entries.push(entry);
288
+ }
289
+ return entries;
290
+ }
291
+
292
+ /**
293
+ * Get children of an entry
294
+ */
295
+ async function getChildren(parentId) {
296
+ const data = loadEntries();
297
+ // Match if parent starts with the given ID OR if the given ID starts with parent
298
+ return data.entries.filter(e => e.parent && (e.parent.startsWith(parentId) || parentId.startsWith(e.parent)));
299
+ }
300
+
301
+ /**
302
+ * List entries with filtering
303
+ */
304
+ async function listEntries(query = {}) {
305
+ const data = loadEntries();
306
+ let entries = [...data.entries];
307
+
308
+ // Include archive if requested
309
+ if (query.status === 'archived') {
310
+ const archive = loadArchive();
311
+ entries = archive.entries;
312
+ }
313
+
314
+ // Filter by type
315
+ if (query.type) {
316
+ entries = entries.filter(e => e.type === query.type);
317
+ }
318
+
319
+ // Filter by status
320
+ if (query.status && query.status !== 'archived') {
321
+ const statuses = query.status.split(',').map(s => s.trim());
322
+ entries = entries.filter(e => statuses.includes(e.status));
323
+ }
324
+
325
+ // Filter by tags (AND logic)
326
+ if (query.tags && query.tags.length > 0) {
327
+ entries = entries.filter(e =>
328
+ query.tags.every(tag => e.tags && e.tags.includes(tag))
329
+ );
330
+ }
331
+
332
+ // Filter by any tags (OR logic)
333
+ if (query.anyTags && query.anyTags.length > 0) {
334
+ entries = entries.filter(e =>
335
+ query.anyTags.some(tag => e.tags && e.tags.includes(tag))
336
+ );
337
+ }
338
+
339
+ // Exclude type
340
+ if (query.notType) {
341
+ entries = entries.filter(e => e.type !== query.notType);
342
+ }
343
+
344
+ // Exclude tags
345
+ if (query.notTags && query.notTags.length > 0) {
346
+ entries = entries.filter(e =>
347
+ !query.notTags.some(tag => e.tags && e.tags.includes(tag))
348
+ );
349
+ }
350
+
351
+ // Filter by priority
352
+ if (query.priority) {
353
+ entries = entries.filter(e => e.priority === query.priority);
354
+ }
355
+
356
+ // Filter by parent
357
+ if (query.parent) {
358
+ entries = entries.filter(e => e.parent && e.parent.startsWith(query.parent));
359
+ }
360
+
361
+ // Filter orphans only
362
+ if (query.orphans) {
363
+ entries = entries.filter(e => !e.parent);
364
+ }
365
+
366
+ // Filter by since (created after)
367
+ if (query.since) {
368
+ const ms = parseDuration(query.since);
369
+ if (ms) {
370
+ const cutoff = new Date(Date.now() - ms);
371
+ entries = entries.filter(e => new Date(e.createdAt) >= cutoff);
372
+ }
373
+ }
374
+
375
+ // Filter by before (created before)
376
+ if (query.before) {
377
+ const ms = parseDuration(query.before);
378
+ if (ms) {
379
+ const cutoff = new Date(Date.now() - ms);
380
+ entries = entries.filter(e => new Date(e.createdAt) < cutoff);
381
+ }
382
+ }
383
+
384
+ // Filter by date range (between)
385
+ if (query.between) {
386
+ const { start, end } = query.between;
387
+ const startDate = new Date(start);
388
+ const endDate = new Date(end);
389
+ entries = entries.filter(e => {
390
+ const created = new Date(e.createdAt);
391
+ return created >= startDate && created <= endDate;
392
+ });
393
+ }
394
+
395
+ // Include done if requested
396
+ if (!query.includeDone && query.status !== 'done') {
397
+ entries = entries.filter(e => e.status !== 'done');
398
+ }
399
+
400
+ // Sort
401
+ const sortField = query.sort || 'created';
402
+ entries.sort((a, b) => {
403
+ if (sortField === 'priority') {
404
+ const pA = a.priority || 99;
405
+ const pB = b.priority || 99;
406
+ return pA - pB;
407
+ }
408
+ if (sortField === 'updated') {
409
+ return new Date(b.updatedAt) - new Date(a.updatedAt);
410
+ }
411
+ // Default: created
412
+ return new Date(b.createdAt) - new Date(a.createdAt);
413
+ });
414
+
415
+ if (query.reverse) {
416
+ entries.reverse();
417
+ }
418
+
419
+ const total = entries.length;
420
+
421
+ // Limit
422
+ if (query.limit) {
423
+ entries = entries.slice(0, query.limit);
424
+ }
425
+
426
+ return { entries, total };
427
+ }
428
+
429
+ /**
430
+ * Search entries by text
431
+ */
432
+ async function searchEntries(query, options = {}) {
433
+ const data = loadEntries();
434
+ let entries = [...data.entries];
435
+
436
+ const lowerQuery = query.toLowerCase();
437
+
438
+ // Text search (content, title, and tags)
439
+ entries = entries.filter(e => {
440
+ const content = (e.content || '').toLowerCase();
441
+ const title = (e.title || '').toLowerCase();
442
+ const tagsStr = (e.tags || []).join(' ').toLowerCase();
443
+ return content.includes(lowerQuery) || title.includes(lowerQuery) || tagsStr.includes(lowerQuery);
444
+ });
445
+
446
+ // Apply filters
447
+ if (options.type) {
448
+ entries = entries.filter(e => e.type === options.type);
449
+ }
450
+ if (options.notType) {
451
+ entries = entries.filter(e => e.type !== options.notType);
452
+ }
453
+ if (options.status) {
454
+ const statuses = options.status.split(',').map(s => s.trim());
455
+ entries = entries.filter(e => statuses.includes(e.status));
456
+ }
457
+ if (options.since) {
458
+ const ms = parseDuration(options.since);
459
+ if (ms) {
460
+ const cutoff = new Date(Date.now() - ms);
461
+ entries = entries.filter(e => new Date(e.createdAt) >= cutoff);
462
+ }
463
+ }
464
+
465
+ // Sort by relevance (simple: exact matches first)
466
+ entries.sort((a, b) => {
467
+ const aExact = a.title?.toLowerCase() === lowerQuery || a.content?.toLowerCase() === lowerQuery;
468
+ const bExact = b.title?.toLowerCase() === lowerQuery || b.content?.toLowerCase() === lowerQuery;
469
+ if (aExact && !bExact) return -1;
470
+ if (!aExact && bExact) return 1;
471
+ return new Date(b.createdAt) - new Date(a.createdAt);
472
+ });
473
+
474
+ const total = entries.length;
475
+
476
+ if (options.limit) {
477
+ entries = entries.slice(0, options.limit);
478
+ }
479
+
480
+ return { entries, total };
481
+ }
482
+
483
+ /**
484
+ * Update an entry
485
+ */
486
+ async function updateEntry(id, updates) {
487
+ const data = loadEntries();
488
+ const index = data.entries.findIndex(e => e.id === id || e.id.startsWith(id));
489
+
490
+ if (index === -1) {
491
+ // Check archive
492
+ const archive = loadArchive();
493
+ const archiveIndex = archive.entries.findIndex(e => e.id === id || e.id.startsWith(id));
494
+ if (archiveIndex !== -1) {
495
+ const entry = archive.entries[archiveIndex];
496
+ Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
497
+ saveArchive(archive);
498
+ return entry;
499
+ }
500
+ return null;
501
+ }
502
+
503
+ const entry = data.entries[index];
504
+
505
+ // Validate type if provided
506
+ if (updates.type && !VALID_TYPES.includes(updates.type)) {
507
+ throw new Error(`Invalid type: ${updates.type}`);
508
+ }
509
+
510
+ // Validate status if provided
511
+ if (updates.status && !VALID_STATUSES.includes(updates.status)) {
512
+ throw new Error(`Invalid status: ${updates.status}`);
513
+ }
514
+
515
+ // Apply updates
516
+ Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
517
+
518
+ // Handle null values (clear fields)
519
+ if (updates.priority === null) delete entry.priority;
520
+ if (updates.parent === null) delete entry.parent;
521
+
522
+ saveEntries(data);
523
+
524
+ return entry;
525
+ }
526
+
527
+ /**
528
+ * Archive entries
529
+ */
530
+ async function archiveEntries(ids) {
531
+ const data = loadEntries();
532
+ const archive = loadArchive();
533
+
534
+ const toArchive = [];
535
+ const remaining = [];
536
+
537
+ for (const entry of data.entries) {
538
+ const shouldArchive = ids.some(id => entry.id === id || entry.id.startsWith(id));
539
+ if (shouldArchive) {
540
+ entry.status = 'archived';
541
+ entry.updatedAt = new Date().toISOString();
542
+ toArchive.push(entry);
543
+ } else {
544
+ remaining.push(entry);
545
+ }
546
+ }
547
+
548
+ archive.entries.push(...toArchive);
549
+ data.entries = remaining;
550
+
551
+ saveEntries(data);
552
+ saveArchive(archive);
553
+
554
+ return toArchive;
555
+ }
556
+
557
+ /**
558
+ * Delete entries
559
+ */
560
+ async function deleteEntries(ids) {
561
+ const data = loadEntries();
562
+ const archive = loadArchive();
563
+
564
+ // Remove from entries
565
+ data.entries = data.entries.filter(e =>
566
+ !ids.some(id => e.id === id || e.id.startsWith(id))
567
+ );
568
+
569
+ // Remove from archive
570
+ archive.entries = archive.entries.filter(e =>
571
+ !ids.some(id => e.id === id || e.id.startsWith(id))
572
+ );
573
+
574
+ saveEntries(data);
575
+ saveArchive(archive);
576
+ }
577
+
578
+ /**
579
+ * Restore entries from deletion log
580
+ */
581
+ async function restoreEntries(entries) {
582
+ const data = loadEntries();
583
+
584
+ for (const entry of entries) {
585
+ // Remove deletedAt if present
586
+ delete entry.deletedAt;
587
+ // Set status back to raw if it was archived
588
+ if (entry.status === 'archived') {
589
+ entry.status = 'raw';
590
+ }
591
+ data.entries.push(entry);
592
+ }
593
+
594
+ saveEntries(data);
595
+ }
596
+
597
+ /**
598
+ * Get statistics
599
+ */
600
+ async function getStats() {
601
+ const data = loadEntries();
602
+ const archive = loadArchive();
603
+ const allEntries = [...data.entries, ...archive.entries];
604
+
605
+ const stats = {
606
+ total: allEntries.length,
607
+ byStatus: {},
608
+ byType: {},
609
+ highPriority: 0,
610
+ createdThisWeek: 0,
611
+ updatedToday: 0
612
+ };
613
+
614
+ const now = new Date();
615
+ const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
616
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
617
+
618
+ for (const entry of allEntries) {
619
+ // By status
620
+ stats.byStatus[entry.status] = (stats.byStatus[entry.status] || 0) + 1;
621
+
622
+ // By type
623
+ stats.byType[entry.type] = (stats.byType[entry.type] || 0) + 1;
624
+
625
+ // High priority
626
+ if (entry.priority === 1) stats.highPriority++;
627
+
628
+ // Created this week
629
+ if (new Date(entry.createdAt) >= oneWeekAgo) stats.createdThisWeek++;
630
+
631
+ // Updated today
632
+ if (new Date(entry.updatedAt) >= today) stats.updatedToday++;
633
+ }
634
+
635
+ return stats;
636
+ }
637
+
638
+ /**
639
+ * Get all tags with counts
640
+ */
641
+ async function getAllTags() {
642
+ const data = loadEntries();
643
+ const archive = loadArchive();
644
+ const allEntries = [...data.entries, ...archive.entries];
645
+
646
+ const tagCounts = {};
647
+ for (const entry of allEntries) {
648
+ for (const tag of entry.tags || []) {
649
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
650
+ }
651
+ }
652
+
653
+ return Object.entries(tagCounts)
654
+ .map(([tag, count]) => ({ tag, count }))
655
+ .sort((a, b) => b.count - a.count);
656
+ }
657
+
658
+ /**
659
+ * Rename a tag across all entries
660
+ */
661
+ async function renameTag(oldTag, newTag) {
662
+ const data = loadEntries();
663
+ const archive = loadArchive();
664
+ let count = 0;
665
+
666
+ for (const entry of data.entries) {
667
+ const idx = entry.tags.indexOf(oldTag);
668
+ if (idx !== -1) {
669
+ entry.tags[idx] = newTag;
670
+ entry.updatedAt = new Date().toISOString();
671
+ count++;
672
+ }
673
+ }
674
+
675
+ for (const entry of archive.entries) {
676
+ const idx = entry.tags.indexOf(oldTag);
677
+ if (idx !== -1) {
678
+ entry.tags[idx] = newTag;
679
+ entry.updatedAt = new Date().toISOString();
680
+ count++;
681
+ }
682
+ }
683
+
684
+ saveEntries(data);
685
+ saveArchive(archive);
686
+
687
+ return { oldTag, newTag, entriesUpdated: count };
688
+ }
689
+
690
+ /**
691
+ * Build tree structure from entries
692
+ */
693
+ async function buildEntryTree(rootIds = null, maxDepth = 10) {
694
+ const data = loadEntries();
695
+ const entriesById = {};
696
+ const childrenByParent = {};
697
+
698
+ // Index entries
699
+ for (const entry of data.entries) {
700
+ entriesById[entry.id] = entry;
701
+ const parentKey = entry.parent || 'root';
702
+ if (!childrenByParent[parentKey]) childrenByParent[parentKey] = [];
703
+ childrenByParent[parentKey].push(entry);
704
+ }
705
+
706
+ // Build tree node recursively
707
+ function buildNode(entry, depth) {
708
+ if (depth >= maxDepth) return { ...entry, children: [] };
709
+ const children = (childrenByParent[entry.id] || [])
710
+ .map(c => buildNode(c, depth + 1));
711
+ return { ...entry, children };
712
+ }
713
+
714
+ // Get roots
715
+ let roots;
716
+ if (rootIds && rootIds.length > 0) {
717
+ roots = rootIds.map(id => {
718
+ // Support partial ID match
719
+ const entry = data.entries.find(e => e.id.startsWith(id));
720
+ return entry;
721
+ }).filter(Boolean);
722
+ } else {
723
+ // Get entries without parents
724
+ roots = childrenByParent['root'] || [];
725
+ }
726
+
727
+ return roots.map(e => buildNode(e, 0));
728
+ }
729
+
730
+ /**
731
+ * Export entries
732
+ */
733
+ async function exportEntries(options = {}) {
734
+ const data = loadEntries();
735
+ const archive = loadArchive();
736
+ let entries = [...data.entries, ...archive.entries];
737
+
738
+ if (options.type) {
739
+ entries = entries.filter(e => e.type === options.type);
740
+ }
741
+ if (options.status) {
742
+ entries = entries.filter(e => e.status === options.status);
743
+ }
744
+
745
+ return { version: 1, entries, exportedAt: new Date().toISOString() };
746
+ }
747
+
748
+ /**
749
+ * Import entries
750
+ */
751
+ async function importEntries(entries, options = {}) {
752
+ const data = loadEntries();
753
+ let added = 0;
754
+ let updated = 0;
755
+
756
+ for (const entry of entries) {
757
+ const existing = data.entries.find(e => e.id === entry.id);
758
+
759
+ if (existing) {
760
+ if (options.merge) {
761
+ Object.assign(existing, entry);
762
+ updated++;
763
+ }
764
+ // If skipExisting, do nothing
765
+ } else {
766
+ data.entries.push(entry);
767
+ added++;
768
+ }
769
+ }
770
+
771
+ saveEntries(data);
772
+
773
+ return { added, updated };
774
+ }
775
+
776
+ module.exports = {
777
+ addEntry,
778
+ getEntry,
779
+ getEntriesByIds,
780
+ getChildren,
781
+ listEntries,
782
+ searchEntries,
783
+ updateEntry,
784
+ archiveEntries,
785
+ deleteEntries,
786
+ restoreEntries,
787
+ getStats,
788
+ getAllTags,
789
+ renameTag,
790
+ buildEntryTree,
791
+ exportEntries,
792
+ importEntries,
793
+ loadConfig,
794
+ getDefaultConfig,
795
+ validateConfigValue,
796
+ saveConfig,
797
+ CONFIG_DIR,
798
+ ENTRIES_FILE,
799
+ ARCHIVE_FILE,
800
+ VALID_TYPES,
801
+ VALID_STATUSES,
802
+ VALID_DATE_FORMATS
803
+ };