synap 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/synap-assistant/SKILL.md +47 -11
- package/package.json +1 -1
- package/src/cli.js +500 -45
- package/src/deletion-log.js +5 -3
- package/src/storage.js +57 -55
|
@@ -409,36 +409,65 @@ View or update configuration.
|
|
|
409
409
|
```bash
|
|
410
410
|
synap config # Show all settings + paths
|
|
411
411
|
synap config dataDir # Show data directory
|
|
412
|
-
synap config dataDir ~/synap-data # Set custom data directory
|
|
413
412
|
synap config --reset # Reset to defaults
|
|
414
413
|
```
|
|
415
414
|
|
|
415
|
+
#### `synap save`, `synap pull`, `synap sync`
|
|
416
|
+
Git sync commands for multi-device workflow.
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
# Save (commit + push)
|
|
420
|
+
synap save # Commit + push with auto timestamp
|
|
421
|
+
synap save "message" # Commit + push with custom message
|
|
422
|
+
synap save -m "message" # Same as above
|
|
423
|
+
synap save --dry-run # Preview changes without committing
|
|
424
|
+
synap save --no-push # Commit locally, don't push
|
|
425
|
+
|
|
426
|
+
# Pull
|
|
427
|
+
synap pull # Pull latest from remote
|
|
428
|
+
synap pull --force # Pull even with uncommitted local changes
|
|
429
|
+
|
|
430
|
+
# Sync (pull + save)
|
|
431
|
+
synap sync # Pull then save (full round-trip)
|
|
432
|
+
synap sync "end of day" # Sync with custom commit message
|
|
433
|
+
synap sync --dry-run # Preview what would happen
|
|
434
|
+
synap sync --no-push # Pull and commit, but don't push
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Git sync error codes:**
|
|
438
|
+
| Code | Meaning |
|
|
439
|
+
|------|---------|
|
|
440
|
+
| `NOT_GIT_REPO` | Data directory is not a git repository |
|
|
441
|
+
| `DIRTY_WORKING_TREE` | Uncommitted changes block pull (use `--force`) |
|
|
442
|
+
| `MERGE_CONFLICT` | Pull resulted in merge conflicts |
|
|
443
|
+
| `NO_REMOTE` | No git remote configured |
|
|
444
|
+
| `PUSH_FAILED` | Remote exists but push failed |
|
|
445
|
+
|
|
416
446
|
## Workflow Patterns
|
|
417
447
|
|
|
418
448
|
### Multi-Device Sync Setup
|
|
419
449
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
1. **Set custom data directory:**
|
|
450
|
+
1. **Set data directory:**
|
|
423
451
|
```bash
|
|
424
452
|
synap config dataDir ~/synap-data
|
|
425
453
|
```
|
|
426
454
|
|
|
427
|
-
2. **Initialize git (
|
|
455
|
+
2. **Initialize git (one-time):**
|
|
428
456
|
```bash
|
|
429
|
-
cd
|
|
457
|
+
cd $(synap config dataDir)
|
|
430
458
|
git init
|
|
431
459
|
git remote add origin git@github.com:user/synap-data.git
|
|
460
|
+
synap save "Initial commit"
|
|
432
461
|
```
|
|
433
462
|
|
|
434
|
-
3. **Daily
|
|
463
|
+
3. **Daily workflow:**
|
|
435
464
|
```bash
|
|
436
|
-
|
|
437
|
-
#
|
|
438
|
-
|
|
465
|
+
synap pull # Start of day
|
|
466
|
+
synap save # End of day
|
|
467
|
+
synap sync # Or full round-trip
|
|
439
468
|
```
|
|
440
469
|
|
|
441
|
-
4. **
|
|
470
|
+
4. **New device:**
|
|
442
471
|
```bash
|
|
443
472
|
git clone git@github.com:user/synap-data.git ~/synap-data
|
|
444
473
|
synap config dataDir ~/synap-data
|
|
@@ -568,6 +597,13 @@ For projects requiring ongoing progress logging (standups, journals, learning lo
|
|
|
568
597
|
4. **Confirm bulk operations** - Operations affecting >10 entries require confirmation
|
|
569
598
|
5. **Don't over-organize** - Simple thoughts don't need tags, priorities, and parents
|
|
570
599
|
|
|
600
|
+
**Git sync safety**:
|
|
601
|
+
|
|
602
|
+
6. **Preview before sync** - Use `--dry-run` to preview changes before committing
|
|
603
|
+
7. **Handle conflicts carefully** - `MERGE_CONFLICT` errors require manual resolution
|
|
604
|
+
8. **Protect uncommitted work** - `pull` checks for dirty tree; use `--force` only when safe
|
|
605
|
+
9. **Commit messages are safe** - Never executed as shell commands (stdin-based commit)
|
|
606
|
+
|
|
571
607
|
## Proactive Recommendation Patterns
|
|
572
608
|
|
|
573
609
|
- If raw entries are piling up, suggest `synap triage`.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -19,6 +19,11 @@ async function main() {
|
|
|
19
19
|
const chalk = (await import('chalk')).default;
|
|
20
20
|
const boxen = (await import('boxen')).default;
|
|
21
21
|
|
|
22
|
+
// Common requires used across commands
|
|
23
|
+
const { execSync, spawnSync } = require('child_process');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
22
27
|
// Check for updates (non-blocking)
|
|
23
28
|
const updateNotifier = (await import('update-notifier')).default;
|
|
24
29
|
updateNotifier({ pkg }).notify();
|
|
@@ -93,6 +98,81 @@ async function main() {
|
|
|
93
98
|
return '(<1m)';
|
|
94
99
|
};
|
|
95
100
|
|
|
101
|
+
// Helper: Check if data directory is a git repo
|
|
102
|
+
const isGitRepo = () => {
|
|
103
|
+
const path = require('path');
|
|
104
|
+
const gitDir = path.join(storage.getDataDir(), '.git');
|
|
105
|
+
return fs.existsSync(gitDir);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Helper: Check if working tree has uncommitted changes
|
|
109
|
+
const isDirty = () => {
|
|
110
|
+
const dataDir = storage.getDataDir();
|
|
111
|
+
try {
|
|
112
|
+
execSync('git diff --quiet', { cwd: dataDir, stdio: 'pipe' });
|
|
113
|
+
execSync('git diff --cached --quiet', { cwd: dataDir, stdio: 'pipe' });
|
|
114
|
+
return false;
|
|
115
|
+
} catch {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Helper: Check if there are merge conflicts (UU, AA, DD, etc.)
|
|
121
|
+
const hasConflicts = () => {
|
|
122
|
+
const dataDir = storage.getDataDir();
|
|
123
|
+
try {
|
|
124
|
+
const status = execSync('git status --porcelain', { cwd: dataDir, encoding: 'utf-8' });
|
|
125
|
+
const conflictPatterns = /^(UU|AA|DD|AU|UA|DU|UD) /m;
|
|
126
|
+
return conflictPatterns.test(status);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Helper: Get list of conflicted files
|
|
133
|
+
const getConflictFiles = () => {
|
|
134
|
+
const dataDir = storage.getDataDir();
|
|
135
|
+
try {
|
|
136
|
+
const status = execSync('git status --porcelain', { cwd: dataDir, encoding: 'utf-8' });
|
|
137
|
+
return status.split('\n')
|
|
138
|
+
.filter(line => /^(UU|AA|DD|AU|UA|DU|UD) /.test(line))
|
|
139
|
+
.map(line => line.slice(3).trim());
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Helper: Check if git remote is configured
|
|
146
|
+
const hasRemote = () => {
|
|
147
|
+
const dataDir = storage.getDataDir();
|
|
148
|
+
try {
|
|
149
|
+
const remotes = execSync('git remote', { cwd: dataDir, encoding: 'utf-8' });
|
|
150
|
+
return remotes.trim().length > 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Helper: Get diff stats for preview without staging/unstaging
|
|
157
|
+
const getDiffStats = () => {
|
|
158
|
+
const dataDir = storage.getDataDir();
|
|
159
|
+
try {
|
|
160
|
+
const status = execSync('git status --porcelain', { cwd: dataDir, encoding: 'utf-8' });
|
|
161
|
+
if (!status.trim()) return '';
|
|
162
|
+
|
|
163
|
+
const unstaged = execSync('git diff --stat', { cwd: dataDir, encoding: 'utf-8' });
|
|
164
|
+
const untracked = status.split('\n').filter(l => l.startsWith('??')).length;
|
|
165
|
+
|
|
166
|
+
let result = unstaged.trim();
|
|
167
|
+
if (untracked > 0) {
|
|
168
|
+
result += `\n ${untracked} untracked file(s)`;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
} catch {
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
96
176
|
// ============================================
|
|
97
177
|
// CAPTURE COMMANDS
|
|
98
178
|
// ============================================
|
|
@@ -675,6 +755,7 @@ async function main() {
|
|
|
675
755
|
.option('-s, --status <status>', 'Filter by status')
|
|
676
756
|
.option('--not-type <type>', 'Exclude entries of this type')
|
|
677
757
|
.option('--since <duration>', 'Only search recent entries')
|
|
758
|
+
.option('--include-archived', 'Include archived entries in search')
|
|
678
759
|
.option('-n, --limit <n>', 'Max results', '20')
|
|
679
760
|
.option('--json', 'Output as JSON')
|
|
680
761
|
.action(async (queryParts, options) => {
|
|
@@ -684,6 +765,7 @@ async function main() {
|
|
|
684
765
|
status: options.status,
|
|
685
766
|
notType: options.notType,
|
|
686
767
|
since: options.since,
|
|
768
|
+
includeArchived: options.includeArchived,
|
|
687
769
|
limit: parseInt(options.limit, 10)
|
|
688
770
|
});
|
|
689
771
|
|
|
@@ -748,11 +830,6 @@ async function main() {
|
|
|
748
830
|
|
|
749
831
|
if (Object.keys(updates).length === 0) {
|
|
750
832
|
// Interactive edit - open $EDITOR
|
|
751
|
-
const { execSync } = require('child_process');
|
|
752
|
-
const fs = require('fs');
|
|
753
|
-
const os = require('os');
|
|
754
|
-
const path = require('path');
|
|
755
|
-
|
|
756
833
|
const tmpFile = path.join(os.tmpdir(), `synap-${entry.id.slice(0, 8)}.txt`);
|
|
757
834
|
fs.writeFileSync(tmpFile, entry.content);
|
|
758
835
|
|
|
@@ -1780,8 +1857,6 @@ async function main() {
|
|
|
1780
1857
|
}
|
|
1781
1858
|
|
|
1782
1859
|
preferences.loadPreferences();
|
|
1783
|
-
const { execSync } = require('child_process');
|
|
1784
|
-
const fs = require('fs');
|
|
1785
1860
|
|
|
1786
1861
|
const editor = config.editor || process.env.EDITOR || 'vi';
|
|
1787
1862
|
const preferencesPath = preferences.getPreferencesPath();
|
|
@@ -2035,16 +2110,6 @@ async function main() {
|
|
|
2035
2110
|
const os = require('os');
|
|
2036
2111
|
const path = require('path');
|
|
2037
2112
|
|
|
2038
|
-
let hasEntries = false;
|
|
2039
|
-
if (fs.existsSync(storage.ENTRIES_FILE)) {
|
|
2040
|
-
try {
|
|
2041
|
-
const data = JSON.parse(fs.readFileSync(storage.ENTRIES_FILE, 'utf8'));
|
|
2042
|
-
hasEntries = Array.isArray(data.entries) && data.entries.length > 0;
|
|
2043
|
-
} catch {
|
|
2044
|
-
hasEntries = false;
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
2113
|
let skillResult = { prompted: false };
|
|
2049
2114
|
let dataLocationResult = { configured: false };
|
|
2050
2115
|
|
|
@@ -2070,7 +2135,7 @@ async function main() {
|
|
|
2070
2135
|
message: 'Where should synap store your data?',
|
|
2071
2136
|
choices: [
|
|
2072
2137
|
{
|
|
2073
|
-
name: `Default (${
|
|
2138
|
+
name: `Default (${storage.DATA_DIR})`,
|
|
2074
2139
|
value: 'default',
|
|
2075
2140
|
description: 'Recommended for most users'
|
|
2076
2141
|
},
|
|
@@ -2083,10 +2148,8 @@ async function main() {
|
|
|
2083
2148
|
});
|
|
2084
2149
|
|
|
2085
2150
|
if (dataChoice === 'custom') {
|
|
2086
|
-
const defaultSuggestion = path.join(os.homedir(), 'synap-data');
|
|
2087
2151
|
const customPath = await input({
|
|
2088
2152
|
message: 'Enter path for synap data:',
|
|
2089
|
-
default: defaultSuggestion,
|
|
2090
2153
|
validate: (val) => {
|
|
2091
2154
|
if (!val.trim()) return 'Path is required';
|
|
2092
2155
|
const expanded = val.replace(/^~/, os.homedir());
|
|
@@ -2121,20 +2184,10 @@ async function main() {
|
|
|
2121
2184
|
}
|
|
2122
2185
|
}
|
|
2123
2186
|
|
|
2124
|
-
// Step 2: Quick capture
|
|
2125
|
-
console.log('\n[2/4] Quick capture
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
createdEntry = await storage.addEntry({
|
|
2129
|
-
content: 'My first thought',
|
|
2130
|
-
type: config.defaultType || 'idea',
|
|
2131
|
-
source: 'setup'
|
|
2132
|
-
});
|
|
2133
|
-
console.log(` synap add "My first thought"`);
|
|
2134
|
-
console.log(` ${chalk.green('✓')} Created entry ${createdEntry.id.slice(0, 8)}`);
|
|
2135
|
-
} else {
|
|
2136
|
-
console.log(` ${chalk.gray('Existing entries detected, skipping.')}`);
|
|
2137
|
-
}
|
|
2187
|
+
// Step 2: Quick capture example
|
|
2188
|
+
console.log('\n[2/4] Quick capture...');
|
|
2189
|
+
console.log(` Try: ${chalk.cyan('synap add "My first thought"')}`);
|
|
2190
|
+
console.log(` ${chalk.green('✓')} Ready to capture`)
|
|
2138
2191
|
|
|
2139
2192
|
// Step 3: Configuration
|
|
2140
2193
|
console.log('\n[3/4] Configuration...');
|
|
@@ -2185,19 +2238,8 @@ async function main() {
|
|
|
2185
2238
|
}
|
|
2186
2239
|
|
|
2187
2240
|
// JSON mode - minimal interaction
|
|
2188
|
-
let createdEntry = null;
|
|
2189
|
-
if (!hasEntries) {
|
|
2190
|
-
createdEntry = await storage.addEntry({
|
|
2191
|
-
content: 'My first thought',
|
|
2192
|
-
type: config.defaultType || 'idea',
|
|
2193
|
-
source: 'setup'
|
|
2194
|
-
});
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
2241
|
console.log(JSON.stringify({
|
|
2198
2242
|
success: true,
|
|
2199
|
-
mode: hasEntries ? 'existing' : 'first-run',
|
|
2200
|
-
entry: createdEntry,
|
|
2201
2243
|
config: { defaultType: config.defaultType || 'idea' },
|
|
2202
2244
|
skill: {
|
|
2203
2245
|
prompted: false,
|
|
@@ -2434,6 +2476,419 @@ async function main() {
|
|
|
2434
2476
|
}
|
|
2435
2477
|
});
|
|
2436
2478
|
|
|
2479
|
+
// ============================================
|
|
2480
|
+
// SYNC COMMANDS
|
|
2481
|
+
// ============================================
|
|
2482
|
+
|
|
2483
|
+
program
|
|
2484
|
+
.command('save [message...]')
|
|
2485
|
+
.description('Commit and push synap data to git remote')
|
|
2486
|
+
.option('-m, --message <message>', 'Commit message')
|
|
2487
|
+
.option('--dry-run', 'Show what would be committed without committing')
|
|
2488
|
+
.option('--no-push', 'Commit but do not push to remote')
|
|
2489
|
+
.option('--json', 'Output as JSON')
|
|
2490
|
+
.action(async (messageParts, options) => {
|
|
2491
|
+
const dataDir = storage.getDataDir();
|
|
2492
|
+
|
|
2493
|
+
if (!isGitRepo()) {
|
|
2494
|
+
if (options.json) {
|
|
2495
|
+
console.log(JSON.stringify({
|
|
2496
|
+
success: false,
|
|
2497
|
+
error: 'Data directory is not a git repository',
|
|
2498
|
+
code: 'NOT_GIT_REPO',
|
|
2499
|
+
setup: [
|
|
2500
|
+
`cd ${dataDir}`,
|
|
2501
|
+
'git init',
|
|
2502
|
+
'git remote add origin git@github.com:user/synap-data.git',
|
|
2503
|
+
'synap save "Initial commit"'
|
|
2504
|
+
]
|
|
2505
|
+
}, null, 2));
|
|
2506
|
+
} else {
|
|
2507
|
+
console.error(chalk.red('Data directory is not a git repository'));
|
|
2508
|
+
console.log(chalk.gray('\nSetup git sync:'));
|
|
2509
|
+
console.log(chalk.cyan(` cd ${dataDir}`));
|
|
2510
|
+
console.log(chalk.cyan(' git init'));
|
|
2511
|
+
console.log(chalk.cyan(' git remote add origin git@github.com:user/synap-data.git'));
|
|
2512
|
+
console.log(chalk.cyan(' synap save "Initial commit"'));
|
|
2513
|
+
}
|
|
2514
|
+
process.exit(1);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// Build commit message
|
|
2518
|
+
let commitMessage = options.message;
|
|
2519
|
+
if (!commitMessage && messageParts && messageParts.length > 0) {
|
|
2520
|
+
commitMessage = messageParts.join(' ');
|
|
2521
|
+
}
|
|
2522
|
+
if (!commitMessage) {
|
|
2523
|
+
const now = new Date();
|
|
2524
|
+
const timestamp = now.toISOString().replace('T', ' ').slice(0, 16);
|
|
2525
|
+
commitMessage = `synap sync ${timestamp}`;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// Dry-run mode: show preview only
|
|
2529
|
+
if (options.dryRun) {
|
|
2530
|
+
const stats = getDiffStats();
|
|
2531
|
+
if (options.json) {
|
|
2532
|
+
console.log(JSON.stringify({
|
|
2533
|
+
success: true,
|
|
2534
|
+
dryRun: true,
|
|
2535
|
+
message: commitMessage,
|
|
2536
|
+
changes: stats || 'No changes to commit'
|
|
2537
|
+
}, null, 2));
|
|
2538
|
+
} else {
|
|
2539
|
+
console.log(chalk.cyan('Dry run - would commit with message:'));
|
|
2540
|
+
console.log(chalk.yellow(` "${commitMessage}"`));
|
|
2541
|
+
if (stats) {
|
|
2542
|
+
console.log(chalk.cyan('\nChanges:'));
|
|
2543
|
+
console.log(stats);
|
|
2544
|
+
} else {
|
|
2545
|
+
console.log(chalk.yellow('No changes to commit'));
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
try {
|
|
2552
|
+
// Stage all changes
|
|
2553
|
+
execSync('git add .', { cwd: dataDir, stdio: 'pipe' });
|
|
2554
|
+
|
|
2555
|
+
// Check if there are changes to commit
|
|
2556
|
+
let hasChanges = true;
|
|
2557
|
+
try {
|
|
2558
|
+
execSync('git diff --cached --quiet', { cwd: dataDir, stdio: 'pipe' });
|
|
2559
|
+
hasChanges = false;
|
|
2560
|
+
} catch {
|
|
2561
|
+
// Non-zero exit means there are staged changes
|
|
2562
|
+
hasChanges = true;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
if (!hasChanges) {
|
|
2566
|
+
if (options.json) {
|
|
2567
|
+
console.log(JSON.stringify({ success: true, message: 'Nothing to commit', pushed: false }));
|
|
2568
|
+
} else {
|
|
2569
|
+
console.log(chalk.yellow('Nothing to commit'));
|
|
2570
|
+
}
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// Commit using stdin to prevent shell injection
|
|
2575
|
+
const commitResult = spawnSync('git', ['commit', '-F', '-'], {
|
|
2576
|
+
cwd: dataDir,
|
|
2577
|
+
input: commitMessage,
|
|
2578
|
+
encoding: 'utf-8',
|
|
2579
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
if (commitResult.status !== 0) {
|
|
2583
|
+
throw new Error(commitResult.stderr || 'Commit failed');
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Push (unless --no-push flag)
|
|
2587
|
+
let pushed = false;
|
|
2588
|
+
let pushError = null;
|
|
2589
|
+
let pushErrorCode = null;
|
|
2590
|
+
|
|
2591
|
+
if (options.push !== false) {
|
|
2592
|
+
// Check if remote exists
|
|
2593
|
+
if (!hasRemote()) {
|
|
2594
|
+
pushError = 'No git remote configured';
|
|
2595
|
+
pushErrorCode = 'NO_REMOTE';
|
|
2596
|
+
} else {
|
|
2597
|
+
try {
|
|
2598
|
+
execSync('git push', { cwd: dataDir, stdio: 'pipe' });
|
|
2599
|
+
pushed = true;
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
pushError = 'Push failed (check network or remote permissions)';
|
|
2602
|
+
pushErrorCode = 'PUSH_FAILED';
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
} else {
|
|
2606
|
+
pushError = 'Push skipped (--no-push flag)';
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
if (options.json) {
|
|
2610
|
+
const result = {
|
|
2611
|
+
success: true,
|
|
2612
|
+
message: commitMessage,
|
|
2613
|
+
pushed
|
|
2614
|
+
};
|
|
2615
|
+
if (pushError) result.pushError = pushError;
|
|
2616
|
+
if (pushErrorCode) result.pushErrorCode = pushErrorCode;
|
|
2617
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2618
|
+
} else {
|
|
2619
|
+
console.log(chalk.green(`Committed: "${commitMessage}"`));
|
|
2620
|
+
if (pushed) {
|
|
2621
|
+
console.log(chalk.green('Pushed to remote'));
|
|
2622
|
+
} else {
|
|
2623
|
+
console.log(chalk.yellow(pushError));
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
if (options.json) {
|
|
2628
|
+
console.log(JSON.stringify({ success: false, error: err.message, code: 'GIT_ERROR' }));
|
|
2629
|
+
} else {
|
|
2630
|
+
console.error(chalk.red(`Git error: ${err.message}`));
|
|
2631
|
+
}
|
|
2632
|
+
process.exit(1);
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
program
|
|
2637
|
+
.command('pull')
|
|
2638
|
+
.description('Pull latest synap data from git remote')
|
|
2639
|
+
.option('--force', 'Pull even if there are uncommitted local changes')
|
|
2640
|
+
.option('--json', 'Output as JSON')
|
|
2641
|
+
.action(async (options) => {
|
|
2642
|
+
const dataDir = storage.getDataDir();
|
|
2643
|
+
|
|
2644
|
+
if (!isGitRepo()) {
|
|
2645
|
+
if (options.json) {
|
|
2646
|
+
console.log(JSON.stringify({
|
|
2647
|
+
success: false,
|
|
2648
|
+
error: 'Data directory is not a git repository',
|
|
2649
|
+
code: 'NOT_GIT_REPO'
|
|
2650
|
+
}, null, 2));
|
|
2651
|
+
} else {
|
|
2652
|
+
console.error(chalk.red('Data directory is not a git repository'));
|
|
2653
|
+
console.log(chalk.gray('Run: synap save --help for setup instructions'));
|
|
2654
|
+
}
|
|
2655
|
+
process.exit(1);
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// Check for uncommitted changes
|
|
2659
|
+
if (isDirty() && !options.force) {
|
|
2660
|
+
if (options.json) {
|
|
2661
|
+
console.log(JSON.stringify({
|
|
2662
|
+
success: false,
|
|
2663
|
+
error: 'Working tree has uncommitted changes',
|
|
2664
|
+
code: 'DIRTY_WORKING_TREE',
|
|
2665
|
+
hint: 'Run `synap save` first or use `--force`'
|
|
2666
|
+
}, null, 2));
|
|
2667
|
+
} else {
|
|
2668
|
+
console.error(chalk.red('Working tree has uncommitted changes'));
|
|
2669
|
+
console.log(chalk.gray('Run `synap save` first or use `synap pull --force`'));
|
|
2670
|
+
}
|
|
2671
|
+
process.exit(1);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
try {
|
|
2675
|
+
const output = execSync('git pull', { cwd: dataDir, encoding: 'utf-8' });
|
|
2676
|
+
|
|
2677
|
+
if (options.json) {
|
|
2678
|
+
console.log(JSON.stringify({
|
|
2679
|
+
success: true,
|
|
2680
|
+
message: output.trim() || 'Already up to date',
|
|
2681
|
+
updated: !output.includes('Already up to date')
|
|
2682
|
+
}, null, 2));
|
|
2683
|
+
} else {
|
|
2684
|
+
console.log(chalk.green(output.trim() || 'Already up to date'));
|
|
2685
|
+
}
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
// Check for merge conflicts after failed pull
|
|
2688
|
+
if (hasConflicts()) {
|
|
2689
|
+
const conflictFiles = getConflictFiles();
|
|
2690
|
+
if (options.json) {
|
|
2691
|
+
console.log(JSON.stringify({
|
|
2692
|
+
success: false,
|
|
2693
|
+
error: 'Merge conflicts detected',
|
|
2694
|
+
code: 'MERGE_CONFLICT',
|
|
2695
|
+
conflictFiles,
|
|
2696
|
+
hint: `Resolve conflicts: cd ${dataDir} && git status`
|
|
2697
|
+
}, null, 2));
|
|
2698
|
+
} else {
|
|
2699
|
+
console.error(chalk.red('Merge conflicts detected'));
|
|
2700
|
+
console.log(chalk.yellow('Conflicted files:'));
|
|
2701
|
+
for (const file of conflictFiles) {
|
|
2702
|
+
console.log(chalk.yellow(` - ${file}`));
|
|
2703
|
+
}
|
|
2704
|
+
console.log(chalk.gray(`\nResolve conflicts: cd ${dataDir} && git status`));
|
|
2705
|
+
}
|
|
2706
|
+
process.exit(1);
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
if (options.json) {
|
|
2710
|
+
console.log(JSON.stringify({ success: false, error: err.message, code: 'GIT_ERROR' }));
|
|
2711
|
+
} else {
|
|
2712
|
+
console.error(chalk.red(`Git error: ${err.message}`));
|
|
2713
|
+
}
|
|
2714
|
+
process.exit(1);
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
program
|
|
2719
|
+
.command('sync [message...]')
|
|
2720
|
+
.description('Pull latest, then commit and push (full round-trip)')
|
|
2721
|
+
.option('-m, --message <message>', 'Commit message')
|
|
2722
|
+
.option('--dry-run', 'Show what would be committed without committing')
|
|
2723
|
+
.option('--no-push', 'Commit but do not push to remote')
|
|
2724
|
+
.option('--json', 'Output as JSON')
|
|
2725
|
+
.action(async (messageParts, options) => {
|
|
2726
|
+
const dataDir = storage.getDataDir();
|
|
2727
|
+
|
|
2728
|
+
if (!isGitRepo()) {
|
|
2729
|
+
if (options.json) {
|
|
2730
|
+
console.log(JSON.stringify({
|
|
2731
|
+
success: false,
|
|
2732
|
+
error: 'Data directory is not a git repository',
|
|
2733
|
+
code: 'NOT_GIT_REPO'
|
|
2734
|
+
}, null, 2));
|
|
2735
|
+
} else {
|
|
2736
|
+
console.error(chalk.red('Data directory is not a git repository'));
|
|
2737
|
+
console.log(chalk.gray('Run: synap save --help for setup instructions'));
|
|
2738
|
+
}
|
|
2739
|
+
process.exit(1);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// Build commit message
|
|
2743
|
+
let commitMessage = options.message;
|
|
2744
|
+
if (!commitMessage && messageParts && messageParts.length > 0) {
|
|
2745
|
+
commitMessage = messageParts.join(' ');
|
|
2746
|
+
}
|
|
2747
|
+
if (!commitMessage) {
|
|
2748
|
+
const now = new Date();
|
|
2749
|
+
const timestamp = now.toISOString().replace('T', ' ').slice(0, 16);
|
|
2750
|
+
commitMessage = `synap sync ${timestamp}`;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
// Dry-run mode: show preview only
|
|
2754
|
+
if (options.dryRun) {
|
|
2755
|
+
const stats = getDiffStats();
|
|
2756
|
+
if (options.json) {
|
|
2757
|
+
console.log(JSON.stringify({
|
|
2758
|
+
success: true,
|
|
2759
|
+
dryRun: true,
|
|
2760
|
+
message: commitMessage,
|
|
2761
|
+
changes: stats || 'No changes to commit'
|
|
2762
|
+
}, null, 2));
|
|
2763
|
+
} else {
|
|
2764
|
+
console.log(chalk.cyan('Dry run - would sync with message:'));
|
|
2765
|
+
console.log(chalk.yellow(` "${commitMessage}"`));
|
|
2766
|
+
if (stats) {
|
|
2767
|
+
console.log(chalk.cyan('\nLocal changes:'));
|
|
2768
|
+
console.log(stats);
|
|
2769
|
+
} else {
|
|
2770
|
+
console.log(chalk.yellow('No local changes to commit'));
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const results = { pulled: false, committed: false, pushed: false, errors: [] };
|
|
2777
|
+
|
|
2778
|
+
// Pull first
|
|
2779
|
+
try {
|
|
2780
|
+
const pullOutput = execSync('git pull', { cwd: dataDir, encoding: 'utf-8' });
|
|
2781
|
+
results.pulled = true;
|
|
2782
|
+
results.pullMessage = pullOutput.trim() || 'Already up to date';
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
// Check for merge conflicts
|
|
2785
|
+
if (hasConflicts()) {
|
|
2786
|
+
const conflictFiles = getConflictFiles();
|
|
2787
|
+
results.errors.push({
|
|
2788
|
+
step: 'pull',
|
|
2789
|
+
error: 'Merge conflicts detected',
|
|
2790
|
+
code: 'MERGE_CONFLICT',
|
|
2791
|
+
conflictFiles,
|
|
2792
|
+
hint: `Resolve conflicts: cd ${dataDir} && git status`
|
|
2793
|
+
});
|
|
2794
|
+
} else {
|
|
2795
|
+
results.errors.push({ step: 'pull', error: err.message, code: 'GIT_ERROR' });
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// Stage and commit (only if pull succeeded)
|
|
2800
|
+
if (results.pulled) {
|
|
2801
|
+
try {
|
|
2802
|
+
execSync('git add .', { cwd: dataDir, stdio: 'pipe' });
|
|
2803
|
+
|
|
2804
|
+
let hasChanges = true;
|
|
2805
|
+
try {
|
|
2806
|
+
execSync('git diff --cached --quiet', { cwd: dataDir, stdio: 'pipe' });
|
|
2807
|
+
hasChanges = false;
|
|
2808
|
+
} catch {
|
|
2809
|
+
hasChanges = true;
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
if (hasChanges) {
|
|
2813
|
+
// Commit using stdin to prevent shell injection
|
|
2814
|
+
const commitResult = spawnSync('git', ['commit', '-F', '-'], {
|
|
2815
|
+
cwd: dataDir,
|
|
2816
|
+
input: commitMessage,
|
|
2817
|
+
encoding: 'utf-8',
|
|
2818
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
2819
|
+
});
|
|
2820
|
+
|
|
2821
|
+
if (commitResult.status !== 0) {
|
|
2822
|
+
throw new Error(commitResult.stderr || 'Commit failed');
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
results.committed = true;
|
|
2826
|
+
results.commitMessage = commitMessage;
|
|
2827
|
+
} else {
|
|
2828
|
+
results.commitMessage = 'Nothing to commit';
|
|
2829
|
+
}
|
|
2830
|
+
} catch (err) {
|
|
2831
|
+
results.errors.push({ step: 'commit', error: err.message, code: 'GIT_ERROR' });
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// Push (unless --no-push flag or nothing committed)
|
|
2836
|
+
if (results.committed && options.push !== false) {
|
|
2837
|
+
if (!hasRemote()) {
|
|
2838
|
+
results.errors.push({ step: 'push', error: 'No git remote configured', code: 'NO_REMOTE' });
|
|
2839
|
+
} else {
|
|
2840
|
+
try {
|
|
2841
|
+
execSync('git push', { cwd: dataDir, stdio: 'pipe' });
|
|
2842
|
+
results.pushed = true;
|
|
2843
|
+
} catch (err) {
|
|
2844
|
+
results.errors.push({ step: 'push', error: 'Push failed (check network or remote permissions)', code: 'PUSH_FAILED' });
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
} else if (results.committed && options.push === false) {
|
|
2848
|
+
results.pushSkipped = true;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
if (options.json) {
|
|
2852
|
+
console.log(JSON.stringify({
|
|
2853
|
+
success: results.errors.length === 0,
|
|
2854
|
+
...results
|
|
2855
|
+
}, null, 2));
|
|
2856
|
+
} else {
|
|
2857
|
+
if (results.pulled) {
|
|
2858
|
+
console.log(chalk.green(`Pulled: ${results.pullMessage}`));
|
|
2859
|
+
}
|
|
2860
|
+
if (results.committed) {
|
|
2861
|
+
console.log(chalk.green(`Committed: "${results.commitMessage}"`));
|
|
2862
|
+
} else if (results.pullMessage !== 'Already up to date' || !results.pulled) {
|
|
2863
|
+
console.log(chalk.yellow(results.commitMessage || 'Nothing to commit'));
|
|
2864
|
+
}
|
|
2865
|
+
if (results.pushed) {
|
|
2866
|
+
console.log(chalk.green('Pushed to remote'));
|
|
2867
|
+
} else if (results.pushSkipped) {
|
|
2868
|
+
console.log(chalk.yellow('Push skipped (--no-push flag)'));
|
|
2869
|
+
} else if (results.committed) {
|
|
2870
|
+
const pushErr = results.errors.find(e => e.step === 'push');
|
|
2871
|
+
console.log(chalk.yellow(pushErr ? pushErr.error : 'Push failed'));
|
|
2872
|
+
}
|
|
2873
|
+
for (const err of results.errors) {
|
|
2874
|
+
if (err.step !== 'push' || !results.committed) {
|
|
2875
|
+
console.error(chalk.red(`${err.step}: ${err.error}`));
|
|
2876
|
+
}
|
|
2877
|
+
if (err.conflictFiles) {
|
|
2878
|
+
console.log(chalk.yellow('Conflicted files:'));
|
|
2879
|
+
for (const file of err.conflictFiles) {
|
|
2880
|
+
console.log(chalk.yellow(` - ${file}`));
|
|
2881
|
+
}
|
|
2882
|
+
console.log(chalk.gray(`\n${err.hint}`));
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
if (results.errors.length > 0) {
|
|
2888
|
+
process.exit(1);
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2437
2892
|
// Parse and execute
|
|
2438
2893
|
await program.parseAsync(process.argv);
|
|
2439
2894
|
}
|
package/src/deletion-log.js
CHANGED
|
@@ -8,6 +8,8 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const storage = require('./storage');
|
|
10
10
|
|
|
11
|
+
const MAX_DELETION_LOG_ENTRIES = 1000;
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Get deletion log file path (dynamic, based on DATA_DIR)
|
|
13
15
|
*/
|
|
@@ -73,9 +75,9 @@ async function logDeletions(entries) {
|
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
// Keep last
|
|
77
|
-
if (log.length >
|
|
78
|
-
log.length =
|
|
78
|
+
// Keep last N deletions
|
|
79
|
+
if (log.length > MAX_DELETION_LOG_ENTRIES) {
|
|
80
|
+
log.length = MAX_DELETION_LOG_ENTRIES;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
saveLog(log);
|
package/src/storage.js
CHANGED
|
@@ -127,9 +127,10 @@ const VALID_DATE_FORMATS = ['relative', 'absolute', 'locale'];
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* Atomic file write - write to temp file then rename
|
|
130
|
+
* Uses PID + timestamp to avoid race conditions between processes
|
|
130
131
|
*/
|
|
131
132
|
function atomicWriteSync(filePath, data) {
|
|
132
|
-
const tmpPath = filePath
|
|
133
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
133
134
|
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
134
135
|
fs.renameSync(tmpPath, filePath);
|
|
135
136
|
}
|
|
@@ -144,7 +145,9 @@ function loadEntries() {
|
|
|
144
145
|
}
|
|
145
146
|
try {
|
|
146
147
|
return JSON.parse(fs.readFileSync(ENTRIES_FILE, 'utf8'));
|
|
147
|
-
} catch {
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`Warning: entries.json is corrupted: ${err.message}`);
|
|
150
|
+
console.error(`File location: ${ENTRIES_FILE}`);
|
|
148
151
|
return { version: 1, entries: [] };
|
|
149
152
|
}
|
|
150
153
|
}
|
|
@@ -167,7 +170,9 @@ function loadArchive() {
|
|
|
167
170
|
}
|
|
168
171
|
try {
|
|
169
172
|
return JSON.parse(fs.readFileSync(ARCHIVE_FILE, 'utf8'));
|
|
170
|
-
} catch {
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error(`Warning: archive.json is corrupted: ${err.message}`);
|
|
175
|
+
console.error(`File location: ${ARCHIVE_FILE}`);
|
|
171
176
|
return { version: 1, entries: [] };
|
|
172
177
|
}
|
|
173
178
|
}
|
|
@@ -422,15 +427,13 @@ function parseDate(input) {
|
|
|
422
427
|
}
|
|
423
428
|
|
|
424
429
|
/**
|
|
425
|
-
*
|
|
430
|
+
* Create an entry object from options (shared by addEntry and addEntries)
|
|
426
431
|
*/
|
|
427
|
-
|
|
428
|
-
const data = loadEntries();
|
|
429
|
-
|
|
432
|
+
function createEntry(options, existingEntries, timestamp) {
|
|
430
433
|
// Resolve partial parent ID to full ID
|
|
431
434
|
let parentId = options.parent;
|
|
432
435
|
if (parentId) {
|
|
433
|
-
const parentEntry =
|
|
436
|
+
const parentEntry = existingEntries.find(e => e.id.startsWith(parentId));
|
|
434
437
|
if (parentEntry) {
|
|
435
438
|
parentId = parentEntry.id;
|
|
436
439
|
}
|
|
@@ -446,7 +449,6 @@ async function addEntry(options) {
|
|
|
446
449
|
}
|
|
447
450
|
}
|
|
448
451
|
|
|
449
|
-
const now = new Date().toISOString();
|
|
450
452
|
const entry = {
|
|
451
453
|
id: uuidv4(),
|
|
452
454
|
content: options.content,
|
|
@@ -458,8 +460,8 @@ async function addEntry(options) {
|
|
|
458
460
|
parent: parentId || undefined,
|
|
459
461
|
related: [],
|
|
460
462
|
due: dueDate,
|
|
461
|
-
createdAt:
|
|
462
|
-
updatedAt:
|
|
463
|
+
createdAt: timestamp,
|
|
464
|
+
updatedAt: timestamp,
|
|
463
465
|
source: options.source || 'cli'
|
|
464
466
|
};
|
|
465
467
|
|
|
@@ -468,6 +470,17 @@ async function addEntry(options) {
|
|
|
468
470
|
if (entry[key] === undefined) delete entry[key];
|
|
469
471
|
});
|
|
470
472
|
|
|
473
|
+
return entry;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Add a new entry
|
|
478
|
+
*/
|
|
479
|
+
async function addEntry(options) {
|
|
480
|
+
const data = loadEntries();
|
|
481
|
+
const now = new Date().toISOString();
|
|
482
|
+
const entry = createEntry(options, data.entries, now);
|
|
483
|
+
|
|
471
484
|
data.entries.push(entry);
|
|
472
485
|
saveEntries(data);
|
|
473
486
|
|
|
@@ -485,46 +498,7 @@ async function addEntries(entriesData) {
|
|
|
485
498
|
const now = new Date().toISOString();
|
|
486
499
|
|
|
487
500
|
for (const options of entriesData) {
|
|
488
|
-
|
|
489
|
-
let parentId = options.parent;
|
|
490
|
-
if (parentId) {
|
|
491
|
-
const parentEntry = data.entries.find(e => e.id.startsWith(parentId));
|
|
492
|
-
if (parentEntry) {
|
|
493
|
-
parentId = parentEntry.id;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Parse due date if provided
|
|
498
|
-
let dueDate = undefined;
|
|
499
|
-
const dueInput = typeof options.due === 'string' ? options.due.trim() : options.due;
|
|
500
|
-
if (dueInput) {
|
|
501
|
-
dueDate = parseDate(dueInput);
|
|
502
|
-
if (!dueDate) {
|
|
503
|
-
throw new Error(`Invalid due date: ${options.due}`);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const entry = {
|
|
508
|
-
id: uuidv4(),
|
|
509
|
-
content: options.content,
|
|
510
|
-
title: options.title || extractTitle(options.content),
|
|
511
|
-
type: VALID_TYPES.includes(options.type) ? options.type : 'idea',
|
|
512
|
-
status: VALID_STATUSES.includes(options.status) ? options.status : (options.priority ? 'active' : 'raw'),
|
|
513
|
-
priority: options.priority && [1, 2, 3].includes(options.priority) ? options.priority : undefined,
|
|
514
|
-
tags: options.tags || [],
|
|
515
|
-
parent: parentId || undefined,
|
|
516
|
-
related: [],
|
|
517
|
-
due: dueDate,
|
|
518
|
-
createdAt: now,
|
|
519
|
-
updatedAt: now,
|
|
520
|
-
source: options.source || 'cli'
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
// Clean up undefined fields
|
|
524
|
-
Object.keys(entry).forEach(key => {
|
|
525
|
-
if (entry[key] === undefined) delete entry[key];
|
|
526
|
-
});
|
|
527
|
-
|
|
501
|
+
const entry = createEntry(options, data.entries, now);
|
|
528
502
|
data.entries.push(entry);
|
|
529
503
|
created.push(entry);
|
|
530
504
|
}
|
|
@@ -552,9 +526,15 @@ async function getEntry(id) {
|
|
|
552
526
|
let entry = data.entries.find(e => e.id === id);
|
|
553
527
|
if (entry) return entry;
|
|
554
528
|
|
|
555
|
-
// Try partial match
|
|
529
|
+
// Try partial match
|
|
556
530
|
const matches = data.entries.filter(e => e.id.startsWith(id));
|
|
557
531
|
if (matches.length === 1) return matches[0];
|
|
532
|
+
if (matches.length > 1) {
|
|
533
|
+
const error = new Error(`Ambiguous ID: ${id} matches ${matches.length} entries`);
|
|
534
|
+
error.code = 'AMBIGUOUS_ID';
|
|
535
|
+
error.matches = matches.map(e => ({ id: e.id, title: e.title }));
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
558
538
|
|
|
559
539
|
// Check archive
|
|
560
540
|
const archive = loadArchive();
|
|
@@ -563,6 +543,12 @@ async function getEntry(id) {
|
|
|
563
543
|
|
|
564
544
|
const archiveMatches = archive.entries.filter(e => e.id.startsWith(id));
|
|
565
545
|
if (archiveMatches.length === 1) return archiveMatches[0];
|
|
546
|
+
if (archiveMatches.length > 1) {
|
|
547
|
+
const error = new Error(`Ambiguous ID: ${id} matches ${archiveMatches.length} archived entries`);
|
|
548
|
+
error.code = 'AMBIGUOUS_ID';
|
|
549
|
+
error.matches = archiveMatches.map(e => ({ id: e.id, title: e.title }));
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
566
552
|
|
|
567
553
|
return null;
|
|
568
554
|
}
|
|
@@ -584,8 +570,7 @@ async function getEntriesByIds(ids) {
|
|
|
584
570
|
*/
|
|
585
571
|
async function getChildren(parentId) {
|
|
586
572
|
const data = loadEntries();
|
|
587
|
-
|
|
588
|
-
return data.entries.filter(e => e.parent && (e.parent.startsWith(parentId) || parentId.startsWith(e.parent)));
|
|
573
|
+
return data.entries.filter(e => e.parent && e.parent.startsWith(parentId));
|
|
589
574
|
}
|
|
590
575
|
|
|
591
576
|
/**
|
|
@@ -764,6 +749,12 @@ async function searchEntries(query, options = {}) {
|
|
|
764
749
|
const data = loadEntries();
|
|
765
750
|
let entries = [...data.entries];
|
|
766
751
|
|
|
752
|
+
// Include archived entries if requested
|
|
753
|
+
if (options.includeArchived) {
|
|
754
|
+
const archive = loadArchive();
|
|
755
|
+
entries = [...entries, ...archive.entries];
|
|
756
|
+
}
|
|
757
|
+
|
|
767
758
|
const lowerQuery = query.toLowerCase();
|
|
768
759
|
|
|
769
760
|
// Text search (content, title, and tags)
|
|
@@ -1006,6 +997,7 @@ async function renameTag(oldTag, newTag) {
|
|
|
1006
997
|
const idx = entry.tags.indexOf(oldTag);
|
|
1007
998
|
if (idx !== -1) {
|
|
1008
999
|
entry.tags[idx] = newTag;
|
|
1000
|
+
entry.tags = [...new Set(entry.tags)];
|
|
1009
1001
|
entry.updatedAt = new Date().toISOString();
|
|
1010
1002
|
count++;
|
|
1011
1003
|
}
|
|
@@ -1015,6 +1007,7 @@ async function renameTag(oldTag, newTag) {
|
|
|
1015
1007
|
const idx = entry.tags.indexOf(oldTag);
|
|
1016
1008
|
if (idx !== -1) {
|
|
1017
1009
|
entry.tags[idx] = newTag;
|
|
1010
|
+
entry.tags = [...new Set(entry.tags)];
|
|
1018
1011
|
entry.updatedAt = new Date().toISOString();
|
|
1019
1012
|
count++;
|
|
1020
1013
|
}
|
|
@@ -1091,8 +1084,17 @@ async function importEntries(entries, options = {}) {
|
|
|
1091
1084
|
const data = loadEntries();
|
|
1092
1085
|
let added = 0;
|
|
1093
1086
|
let updated = 0;
|
|
1087
|
+
let skipped = 0;
|
|
1094
1088
|
|
|
1095
1089
|
for (const entry of entries) {
|
|
1090
|
+
// Validate required fields
|
|
1091
|
+
if (!entry.id || typeof entry.id !== 'string' ||
|
|
1092
|
+
!entry.content || typeof entry.content !== 'string' ||
|
|
1093
|
+
!entry.createdAt || typeof entry.createdAt !== 'string') {
|
|
1094
|
+
skipped++;
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1096
1098
|
const existing = data.entries.find(e => e.id === entry.id);
|
|
1097
1099
|
|
|
1098
1100
|
if (existing) {
|
|
@@ -1109,7 +1111,7 @@ async function importEntries(entries, options = {}) {
|
|
|
1109
1111
|
|
|
1110
1112
|
saveEntries(data);
|
|
1111
1113
|
|
|
1112
|
-
return { added, updated };
|
|
1114
|
+
return { added, updated, skipped };
|
|
1113
1115
|
}
|
|
1114
1116
|
|
|
1115
1117
|
module.exports = {
|