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/.claude/skills/synap-assistant/SKILL.md +476 -0
- package/README.md +200 -0
- package/package.json +50 -0
- package/scripts/postinstall.js +58 -0
- package/src/cli.js +1734 -0
- package/src/deletion-log.js +105 -0
- package/src/preferences.js +175 -0
- package/src/skill-installer.js +175 -0
- package/src/storage.js +803 -0
- package/src/templates/user-preferences-template.md +16 -0
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
|
+
};
|