synap 0.6.1 → 0.8.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 +72 -3
- package/package.json +1 -1
- package/src/cli.js +303 -28
- package/src/deletion-log.js +20 -13
- package/src/preferences.js +204 -14
- package/src/storage.js +137 -17
|
@@ -36,11 +36,40 @@ When assisting users with their synap entries:
|
|
|
36
36
|
|
|
37
37
|
## User Preferences (Memory)
|
|
38
38
|
|
|
39
|
-
synap stores
|
|
39
|
+
synap stores user data in a configurable data directory (default: `~/.config/synap/`).
|
|
40
|
+
|
|
41
|
+
**Data files (syncable):**
|
|
42
|
+
- `entries.json` - Active entries
|
|
43
|
+
- `archive.json` - Archived entries
|
|
44
|
+
- `user-preferences.md` - Agent memory / user preferences
|
|
45
|
+
|
|
46
|
+
**Config files (local only):**
|
|
47
|
+
- `config.json` - Settings (including `dataDir` for custom data location)
|
|
48
|
+
- `deletion-log.json` - Audit trail for restore
|
|
49
|
+
|
|
50
|
+
### Custom Data Directory (for sync)
|
|
51
|
+
|
|
52
|
+
Users can point synap to a custom folder (e.g., a git repo for multi-device sync):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
synap config dataDir ~/synap-data
|
|
56
|
+
# Or during setup:
|
|
57
|
+
synap setup
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
When custom `dataDir` is set:
|
|
61
|
+
- Data files go to the custom location
|
|
62
|
+
- Config stays in `~/.config/synap/`
|
|
63
|
+
- User can sync data folder via git, Dropbox, iCloud, etc.
|
|
64
|
+
|
|
65
|
+
### Preferences Operations
|
|
40
66
|
|
|
41
67
|
- Read preferences at the start of a session when present.
|
|
42
|
-
-
|
|
43
|
-
-
|
|
68
|
+
- Prefer idempotent updates with `synap preferences set --section "Tag Meanings" --entry "#urgent = must do today"`.
|
|
69
|
+
- Remove entries with `synap preferences remove --section tags --match "urgent"`.
|
|
70
|
+
- List entries with `synap preferences list --section tags --json`.
|
|
71
|
+
- `synap preferences --append "## Section" "..."` is still supported for raw appends.
|
|
72
|
+
- Avoid overwriting user-written content; prefer section-based updates.
|
|
44
73
|
|
|
45
74
|
## Operating Modes
|
|
46
75
|
|
|
@@ -79,6 +108,7 @@ Detect user intent and respond appropriately:
|
|
|
79
108
|
| Get stats | `synap stats` |
|
|
80
109
|
| Setup wizard | `synap setup` |
|
|
81
110
|
| Edit preferences | `synap preferences --edit` |
|
|
111
|
+
| Set preference | `synap preferences set --section tags --entry "#urgent = must do today"` |
|
|
82
112
|
|
|
83
113
|
## Pre-flight Check
|
|
84
114
|
|
|
@@ -373,8 +403,47 @@ synap import backup.json --merge # Update existing + add new
|
|
|
373
403
|
synap import backup.json --skip-existing # Only add new
|
|
374
404
|
```
|
|
375
405
|
|
|
406
|
+
#### `synap config`
|
|
407
|
+
View or update configuration.
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
synap config # Show all settings + paths
|
|
411
|
+
synap config dataDir # Show data directory
|
|
412
|
+
synap config dataDir ~/synap-data # Set custom data directory
|
|
413
|
+
synap config --reset # Reset to defaults
|
|
414
|
+
```
|
|
415
|
+
|
|
376
416
|
## Workflow Patterns
|
|
377
417
|
|
|
418
|
+
### Multi-Device Sync Setup
|
|
419
|
+
|
|
420
|
+
For users who want to sync their synap across devices:
|
|
421
|
+
|
|
422
|
+
1. **Set custom data directory:**
|
|
423
|
+
```bash
|
|
424
|
+
synap config dataDir ~/synap-data
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
2. **Initialize git (optional):**
|
|
428
|
+
```bash
|
|
429
|
+
cd ~/synap-data
|
|
430
|
+
git init
|
|
431
|
+
git remote add origin git@github.com:user/synap-data.git
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
3. **Daily sync workflow:**
|
|
435
|
+
```bash
|
|
436
|
+
cd ~/synap-data && git pull # Start of day
|
|
437
|
+
# ... use synap normally ...
|
|
438
|
+
cd ~/synap-data && git add . && git commit -m "sync" && git push # End of day
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
4. **On a new device:**
|
|
442
|
+
```bash
|
|
443
|
+
git clone git@github.com:user/synap-data.git ~/synap-data
|
|
444
|
+
synap config dataDir ~/synap-data
|
|
445
|
+
```
|
|
446
|
+
|
|
378
447
|
### Daily Review
|
|
379
448
|
|
|
380
449
|
Run this each morning to get oriented:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1749,7 +1749,16 @@ async function main() {
|
|
|
1749
1749
|
// PREFERENCES COMMANDS
|
|
1750
1750
|
// ============================================
|
|
1751
1751
|
|
|
1752
|
-
|
|
1752
|
+
const respondPreferencesError = (options, message, code = 'INVALID_ARGS') => {
|
|
1753
|
+
if (options.json) {
|
|
1754
|
+
console.log(JSON.stringify({ success: false, error: message, code }));
|
|
1755
|
+
} else {
|
|
1756
|
+
console.error(chalk.red(message));
|
|
1757
|
+
}
|
|
1758
|
+
process.exit(1);
|
|
1759
|
+
};
|
|
1760
|
+
|
|
1761
|
+
const preferencesCommand = program
|
|
1753
1762
|
.command('preferences')
|
|
1754
1763
|
.description('View or update user preferences')
|
|
1755
1764
|
.option('--edit', 'Open preferences in $EDITOR')
|
|
@@ -1761,22 +1770,13 @@ async function main() {
|
|
|
1761
1770
|
const hasAppend = Array.isArray(options.append);
|
|
1762
1771
|
const activeFlags = [options.edit, options.reset, hasAppend].filter(Boolean).length;
|
|
1763
1772
|
|
|
1764
|
-
const respondError = (message, code = 'INVALID_ARGS') => {
|
|
1765
|
-
if (options.json) {
|
|
1766
|
-
console.log(JSON.stringify({ success: false, error: message, code }));
|
|
1767
|
-
} else {
|
|
1768
|
-
console.error(chalk.red(message));
|
|
1769
|
-
}
|
|
1770
|
-
process.exit(1);
|
|
1771
|
-
};
|
|
1772
|
-
|
|
1773
1773
|
if (activeFlags > 1) {
|
|
1774
|
-
|
|
1774
|
+
respondPreferencesError(options, 'Use only one of --edit, --reset, or --append');
|
|
1775
1775
|
}
|
|
1776
1776
|
try {
|
|
1777
1777
|
if (options.edit) {
|
|
1778
1778
|
if (options.json) {
|
|
1779
|
-
|
|
1779
|
+
respondPreferencesError(options, 'Cannot use --json with --edit', 'INVALID_MODE');
|
|
1780
1780
|
}
|
|
1781
1781
|
|
|
1782
1782
|
preferences.loadPreferences();
|
|
@@ -1832,7 +1832,7 @@ async function main() {
|
|
|
1832
1832
|
if (hasAppend) {
|
|
1833
1833
|
const values = options.append || [];
|
|
1834
1834
|
if (values.length < 2) {
|
|
1835
|
-
|
|
1835
|
+
respondPreferencesError(options, 'Usage: synap preferences --append "## Section" "Text to append"');
|
|
1836
1836
|
}
|
|
1837
1837
|
|
|
1838
1838
|
const [section, ...textParts] = values;
|
|
@@ -1865,7 +1865,160 @@ async function main() {
|
|
|
1865
1865
|
console.log(content);
|
|
1866
1866
|
}
|
|
1867
1867
|
} catch (err) {
|
|
1868
|
-
|
|
1868
|
+
respondPreferencesError(options, err.message || 'Failed to update preferences', 'PREFERENCES_ERROR');
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
preferencesCommand
|
|
1873
|
+
.command('set')
|
|
1874
|
+
.description('Add a preference entry to a section')
|
|
1875
|
+
.option('--section <section>', 'Section name or alias')
|
|
1876
|
+
.option('--entry <entry>', 'Entry text')
|
|
1877
|
+
.option('--json', 'Output as JSON')
|
|
1878
|
+
.action((options) => {
|
|
1879
|
+
if (!options.section || !options.entry) {
|
|
1880
|
+
respondPreferencesError(
|
|
1881
|
+
options,
|
|
1882
|
+
'Usage: synap preferences set --section "tags" --entry "text to add"'
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
try {
|
|
1887
|
+
const result = preferences.setEntry(options.section, options.entry);
|
|
1888
|
+
const payload = { ...result, path: preferences.getPreferencesPath() };
|
|
1889
|
+
|
|
1890
|
+
if (options.json) {
|
|
1891
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1892
|
+
} else if (result.added) {
|
|
1893
|
+
console.log(chalk.green(`Added entry to ${result.section}`));
|
|
1894
|
+
} else {
|
|
1895
|
+
console.log(chalk.yellow(`Entry already exists in ${result.section}`));
|
|
1896
|
+
}
|
|
1897
|
+
} catch (err) {
|
|
1898
|
+
respondPreferencesError(options, err.message || 'Failed to set preferences', 'PREFERENCES_ERROR');
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
preferencesCommand
|
|
1903
|
+
.command('remove')
|
|
1904
|
+
.description('Remove preference entries from a section')
|
|
1905
|
+
.option('--section <section>', 'Section name or alias')
|
|
1906
|
+
.option('--match <pattern>', 'Match text (case-insensitive substring)')
|
|
1907
|
+
.option('--entry <entry>', 'Exact entry text')
|
|
1908
|
+
.option('--json', 'Output as JSON')
|
|
1909
|
+
.action((options) => {
|
|
1910
|
+
if (!options.section) {
|
|
1911
|
+
respondPreferencesError(
|
|
1912
|
+
options,
|
|
1913
|
+
'Usage: synap preferences remove --section "tags" --match "urgent"'
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
if (options.match && options.entry) {
|
|
1917
|
+
respondPreferencesError(options, 'Use only one of --match or --entry');
|
|
1918
|
+
}
|
|
1919
|
+
if (!options.match && !options.entry) {
|
|
1920
|
+
respondPreferencesError(
|
|
1921
|
+
options,
|
|
1922
|
+
'Usage: synap preferences remove --section "tags" --match "urgent"'
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
try {
|
|
1927
|
+
const result = preferences.removeFromSection(options.section, {
|
|
1928
|
+
match: options.match,
|
|
1929
|
+
entry: options.entry
|
|
1930
|
+
});
|
|
1931
|
+
const payload = { ...result, path: preferences.getPreferencesPath() };
|
|
1932
|
+
|
|
1933
|
+
if (options.json) {
|
|
1934
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1935
|
+
} else if (result.removed) {
|
|
1936
|
+
console.log(chalk.green(`Removed ${result.count} entr${result.count === 1 ? 'y' : 'ies'} from ${result.section}`));
|
|
1937
|
+
} else {
|
|
1938
|
+
console.log(chalk.yellow(`No entries matched in ${result.section}`));
|
|
1939
|
+
}
|
|
1940
|
+
} catch (err) {
|
|
1941
|
+
respondPreferencesError(options, err.message || 'Failed to remove preferences', 'PREFERENCES_ERROR');
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
preferencesCommand
|
|
1946
|
+
.command('list')
|
|
1947
|
+
.description('List preference entries')
|
|
1948
|
+
.option('--section <section>', 'Section name or alias')
|
|
1949
|
+
.option('--json', 'Output as JSON')
|
|
1950
|
+
.action((options) => {
|
|
1951
|
+
try {
|
|
1952
|
+
if (options.section) {
|
|
1953
|
+
const section = preferences.resolveSection(options.section);
|
|
1954
|
+
const entries = preferences.getEntriesInSection(section);
|
|
1955
|
+
const payload = { section, entries, count: entries.length };
|
|
1956
|
+
|
|
1957
|
+
if (options.json) {
|
|
1958
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1959
|
+
} else if (entries.length === 0) {
|
|
1960
|
+
console.log(chalk.gray(`No entries found in ${section}`));
|
|
1961
|
+
} else {
|
|
1962
|
+
console.log(chalk.bold(section));
|
|
1963
|
+
entries.forEach((entry) => {
|
|
1964
|
+
console.log(`- ${entry}`);
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const content = preferences.loadPreferences();
|
|
1971
|
+
const lines = content.split(/\r?\n/);
|
|
1972
|
+
const sections = [];
|
|
1973
|
+
let current = null;
|
|
1974
|
+
|
|
1975
|
+
for (const line of lines) {
|
|
1976
|
+
const headingMatch = line.match(/^(#{1,6})\s*(.+?)\s*$/);
|
|
1977
|
+
if (headingMatch) {
|
|
1978
|
+
const level = headingMatch[1].length;
|
|
1979
|
+
const name = headingMatch[2].trim();
|
|
1980
|
+
|
|
1981
|
+
if (level === 2) {
|
|
1982
|
+
current = { section: name, entries: [] };
|
|
1983
|
+
sections.push(current);
|
|
1984
|
+
} else if (level === 1) {
|
|
1985
|
+
current = null;
|
|
1986
|
+
}
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (!current) {
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
const trimmed = line.trim();
|
|
1995
|
+
if (!trimmed || trimmed.startsWith('<!--') || /^(#{1,6})\s+/.test(trimmed)) {
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
current.entries.push(trimmed);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const populatedSections = sections.filter(section => section.entries.length > 0);
|
|
2003
|
+
const totalEntries = populatedSections.reduce((sum, section) => sum + section.entries.length, 0);
|
|
2004
|
+
|
|
2005
|
+
if (options.json) {
|
|
2006
|
+
console.log(JSON.stringify({ sections: populatedSections, count: totalEntries }, null, 2));
|
|
2007
|
+
} else if (populatedSections.length === 0) {
|
|
2008
|
+
console.log(chalk.gray('No preference entries found'));
|
|
2009
|
+
} else {
|
|
2010
|
+
populatedSections.forEach((section, index) => {
|
|
2011
|
+
if (index > 0) {
|
|
2012
|
+
console.log('');
|
|
2013
|
+
}
|
|
2014
|
+
console.log(chalk.bold(section.section));
|
|
2015
|
+
section.entries.forEach((entry) => {
|
|
2016
|
+
console.log(`- ${entry}`);
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
respondPreferencesError(options, err.message || 'Failed to list preferences', 'PREFERENCES_ERROR');
|
|
1869
2022
|
}
|
|
1870
2023
|
});
|
|
1871
2024
|
|
|
@@ -1879,6 +2032,8 @@ async function main() {
|
|
|
1879
2032
|
.option('--json', 'Output as JSON')
|
|
1880
2033
|
.action(async (options) => {
|
|
1881
2034
|
const fs = require('fs');
|
|
2035
|
+
const os = require('os');
|
|
2036
|
+
const path = require('path');
|
|
1882
2037
|
|
|
1883
2038
|
let hasEntries = false;
|
|
1884
2039
|
if (fs.existsSync(storage.ENTRIES_FILE)) {
|
|
@@ -1890,16 +2045,9 @@ async function main() {
|
|
|
1890
2045
|
}
|
|
1891
2046
|
}
|
|
1892
2047
|
|
|
1893
|
-
let createdEntry = null;
|
|
1894
|
-
if (!hasEntries) {
|
|
1895
|
-
createdEntry = await storage.addEntry({
|
|
1896
|
-
content: 'My first thought',
|
|
1897
|
-
type: config.defaultType || 'idea',
|
|
1898
|
-
source: 'setup'
|
|
1899
|
-
});
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
2048
|
let skillResult = { prompted: false };
|
|
2049
|
+
let dataLocationResult = { configured: false };
|
|
2050
|
+
|
|
1903
2051
|
if (!options.json) {
|
|
1904
2052
|
console.log('');
|
|
1905
2053
|
console.log(chalk.bold('Welcome to synap!\n'));
|
|
@@ -1908,19 +2056,93 @@ async function main() {
|
|
|
1908
2056
|
|
|
1909
2057
|
console.log('Let\'s get you set up:\n');
|
|
1910
2058
|
|
|
1911
|
-
|
|
1912
|
-
|
|
2059
|
+
// Step 1: Data location (only ask if not already configured)
|
|
2060
|
+
const currentConfig = storage.loadConfig();
|
|
2061
|
+
console.log('[1/4] Data location...');
|
|
2062
|
+
|
|
2063
|
+
if (currentConfig.dataDir) {
|
|
2064
|
+
console.log(` ${chalk.gray('Already configured:')} ${storage.DATA_DIR}`);
|
|
2065
|
+
dataLocationResult.configured = true;
|
|
2066
|
+
dataLocationResult.path = storage.DATA_DIR;
|
|
2067
|
+
} else {
|
|
2068
|
+
const { select, input } = await import('@inquirer/prompts');
|
|
2069
|
+
const dataChoice = await select({
|
|
2070
|
+
message: 'Where should synap store your data?',
|
|
2071
|
+
choices: [
|
|
2072
|
+
{
|
|
2073
|
+
name: `Default (${path.join(os.homedir(), '.config', 'synap')})`,
|
|
2074
|
+
value: 'default',
|
|
2075
|
+
description: 'Recommended for most users'
|
|
2076
|
+
},
|
|
2077
|
+
{
|
|
2078
|
+
name: 'Custom path (for git sync, Dropbox, iCloud)',
|
|
2079
|
+
value: 'custom',
|
|
2080
|
+
description: 'Choose a custom folder for syncing across devices'
|
|
2081
|
+
}
|
|
2082
|
+
]
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
if (dataChoice === 'custom') {
|
|
2086
|
+
const defaultSuggestion = path.join(os.homedir(), 'synap-data');
|
|
2087
|
+
const customPath = await input({
|
|
2088
|
+
message: 'Enter path for synap data:',
|
|
2089
|
+
default: defaultSuggestion,
|
|
2090
|
+
validate: (val) => {
|
|
2091
|
+
if (!val.trim()) return 'Path is required';
|
|
2092
|
+
const expanded = val.replace(/^~/, os.homedir());
|
|
2093
|
+
const parentDir = path.dirname(expanded);
|
|
2094
|
+
if (!fs.existsSync(parentDir)) {
|
|
2095
|
+
return `Parent directory does not exist: ${parentDir}`;
|
|
2096
|
+
}
|
|
2097
|
+
return true;
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
const expanded = customPath.replace(/^~/, os.homedir());
|
|
2102
|
+
|
|
2103
|
+
// Create directory if needed
|
|
2104
|
+
if (!fs.existsSync(expanded)) {
|
|
2105
|
+
fs.mkdirSync(expanded, { recursive: true });
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// Save to config
|
|
2109
|
+
currentConfig.dataDir = customPath;
|
|
2110
|
+
storage.saveConfig(currentConfig);
|
|
2111
|
+
storage.refreshDataDir();
|
|
2112
|
+
|
|
2113
|
+
console.log(` ${chalk.green('✓')} Data directory set to: ${expanded}`);
|
|
2114
|
+
dataLocationResult.configured = true;
|
|
2115
|
+
dataLocationResult.path = expanded;
|
|
2116
|
+
dataLocationResult.custom = true;
|
|
2117
|
+
} else {
|
|
2118
|
+
console.log(` ${chalk.green('✓')} Using default location: ${storage.DATA_DIR}`);
|
|
2119
|
+
dataLocationResult.configured = true;
|
|
2120
|
+
dataLocationResult.path = storage.DATA_DIR;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// Step 2: Quick capture test
|
|
2125
|
+
console.log('\n[2/4] Quick capture test...');
|
|
2126
|
+
let createdEntry = null;
|
|
2127
|
+
if (!hasEntries) {
|
|
2128
|
+
createdEntry = await storage.addEntry({
|
|
2129
|
+
content: 'My first thought',
|
|
2130
|
+
type: config.defaultType || 'idea',
|
|
2131
|
+
source: 'setup'
|
|
2132
|
+
});
|
|
1913
2133
|
console.log(` synap add "My first thought"`);
|
|
1914
2134
|
console.log(` ${chalk.green('✓')} Created entry ${createdEntry.id.slice(0, 8)}`);
|
|
1915
2135
|
} else {
|
|
1916
2136
|
console.log(` ${chalk.gray('Existing entries detected, skipping.')}`);
|
|
1917
2137
|
}
|
|
1918
2138
|
|
|
1919
|
-
|
|
2139
|
+
// Step 3: Configuration
|
|
2140
|
+
console.log('\n[3/4] Configuration...');
|
|
1920
2141
|
console.log(` Default type: ${chalk.cyan(config.defaultType || 'idea')}`);
|
|
1921
2142
|
console.log(` Change with: ${chalk.cyan('synap config defaultType todo')}`);
|
|
1922
2143
|
|
|
1923
|
-
|
|
2144
|
+
// Step 4: Claude Code Integration
|
|
2145
|
+
console.log('\n[4/4] Claude Code Integration');
|
|
1924
2146
|
const { confirm } = await import('@inquirer/prompts');
|
|
1925
2147
|
const shouldInstall = await confirm({
|
|
1926
2148
|
message: 'Install Claude skill for AI assistance?',
|
|
@@ -1952,10 +2174,26 @@ async function main() {
|
|
|
1952
2174
|
console.log(` ${chalk.cyan('synap todo "Something to do"')}`);
|
|
1953
2175
|
console.log(` ${chalk.cyan('synap focus')}`);
|
|
1954
2176
|
console.log(` ${chalk.cyan('synap review daily')}`);
|
|
2177
|
+
|
|
2178
|
+
if (dataLocationResult.custom) {
|
|
2179
|
+
console.log(chalk.bold('\n📁 Sync tip:'));
|
|
2180
|
+
console.log(` Your data is stored in: ${chalk.cyan(dataLocationResult.path)}`);
|
|
2181
|
+
console.log(` To sync with git: ${chalk.gray('cd ' + dataLocationResult.path + ' && git init')}`);
|
|
2182
|
+
}
|
|
1955
2183
|
console.log('');
|
|
1956
2184
|
return;
|
|
1957
2185
|
}
|
|
1958
2186
|
|
|
2187
|
+
// 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
|
+
|
|
1959
2197
|
console.log(JSON.stringify({
|
|
1960
2198
|
success: true,
|
|
1961
2199
|
mode: hasEntries ? 'existing' : 'first-run',
|
|
@@ -1996,7 +2234,15 @@ async function main() {
|
|
|
1996
2234
|
// No key: show all config
|
|
1997
2235
|
if (!key) {
|
|
1998
2236
|
if (options.json) {
|
|
1999
|
-
console.log(JSON.stringify({
|
|
2237
|
+
console.log(JSON.stringify({
|
|
2238
|
+
success: true,
|
|
2239
|
+
config: currentConfig,
|
|
2240
|
+
defaults,
|
|
2241
|
+
paths: {
|
|
2242
|
+
configDir: storage.CONFIG_DIR,
|
|
2243
|
+
dataDir: storage.DATA_DIR
|
|
2244
|
+
}
|
|
2245
|
+
}, null, 2));
|
|
2000
2246
|
} else {
|
|
2001
2247
|
console.log(chalk.bold('Configuration:\n'));
|
|
2002
2248
|
for (const [k, v] of Object.entries(currentConfig)) {
|
|
@@ -2005,6 +2251,9 @@ async function main() {
|
|
|
2005
2251
|
const displayValue = Array.isArray(v) ? v.join(', ') || '(none)' : (v === null ? '(null)' : v);
|
|
2006
2252
|
console.log(` ${chalk.cyan(k)}: ${displayValue}${defaultNote}`);
|
|
2007
2253
|
}
|
|
2254
|
+
console.log(chalk.bold('\nPaths:'));
|
|
2255
|
+
console.log(` ${chalk.cyan('configDir')}: ${storage.CONFIG_DIR}`);
|
|
2256
|
+
console.log(` ${chalk.cyan('dataDir')}: ${storage.DATA_DIR}`);
|
|
2008
2257
|
console.log(chalk.gray('\nUse: synap config <key> <value> to set a value'));
|
|
2009
2258
|
}
|
|
2010
2259
|
return;
|
|
@@ -2048,16 +2297,42 @@ async function main() {
|
|
|
2048
2297
|
parsedValue = value.split(',').map(t => t.trim()).filter(Boolean);
|
|
2049
2298
|
} else if (key === 'editor' && (value === 'null' || value === '')) {
|
|
2050
2299
|
parsedValue = null;
|
|
2300
|
+
} else if (key === 'dataDir') {
|
|
2301
|
+
// Handle dataDir specially
|
|
2302
|
+
if (value === 'null' || value === '') {
|
|
2303
|
+
parsedValue = null;
|
|
2304
|
+
} else {
|
|
2305
|
+
// Expand ~ but keep the ~ in storage for portability
|
|
2306
|
+
const expanded = value.replace(/^~/, require('os').homedir());
|
|
2307
|
+
// Create directory if it doesn't exist
|
|
2308
|
+
if (!fs.existsSync(expanded)) {
|
|
2309
|
+
fs.mkdirSync(expanded, { recursive: true });
|
|
2310
|
+
if (!options.json) {
|
|
2311
|
+
console.log(chalk.gray(`Created directory: ${expanded}`));
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
// Store the original value (with ~ if used)
|
|
2315
|
+
parsedValue = value;
|
|
2316
|
+
}
|
|
2051
2317
|
}
|
|
2052
2318
|
|
|
2053
2319
|
currentConfig[key] = parsedValue;
|
|
2054
2320
|
storage.saveConfig(currentConfig);
|
|
2055
2321
|
|
|
2322
|
+
// If dataDir changed, refresh the storage paths
|
|
2323
|
+
if (key === 'dataDir') {
|
|
2324
|
+
storage.refreshDataDir();
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2056
2327
|
if (options.json) {
|
|
2057
2328
|
console.log(JSON.stringify({ success: true, key, value: parsedValue }));
|
|
2058
2329
|
} else {
|
|
2059
2330
|
const displayValue = Array.isArray(parsedValue) ? parsedValue.join(', ') : parsedValue;
|
|
2060
2331
|
console.log(chalk.green(`Set ${key} = ${displayValue}`));
|
|
2332
|
+
if (key === 'dataDir') {
|
|
2333
|
+
console.log(chalk.gray(`Data will be stored in: ${storage.DATA_DIR}`));
|
|
2334
|
+
console.log(chalk.yellow('\nNote: Restart synap for changes to take full effect.'));
|
|
2335
|
+
}
|
|
2061
2336
|
}
|
|
2062
2337
|
});
|
|
2063
2338
|
|
package/src/deletion-log.js
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* deletion-log.js - Audit logging for deleted entries, enables restore
|
|
3
|
+
*
|
|
4
|
+
* The deletion log is stored in DATA_DIR (syncable across devices)
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
const fs = require('fs');
|
|
6
8
|
const path = require('path');
|
|
7
|
-
const
|
|
9
|
+
const storage = require('./storage');
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Get deletion log file path (dynamic, based on DATA_DIR)
|
|
13
|
+
*/
|
|
14
|
+
function getDeletionLogPath() {
|
|
15
|
+
return path.join(storage.DATA_DIR, 'deletion-log.json');
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
|
-
* Ensure
|
|
19
|
+
* Ensure data directory exists
|
|
15
20
|
*/
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
function ensureDataDir() {
|
|
22
|
+
const dataDir = storage.DATA_DIR;
|
|
23
|
+
if (!fs.existsSync(dataDir)) {
|
|
24
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
19
25
|
}
|
|
20
26
|
}
|
|
21
27
|
|
|
@@ -32,12 +38,13 @@ function atomicWriteSync(filePath, data) {
|
|
|
32
38
|
* Load deletion log
|
|
33
39
|
*/
|
|
34
40
|
function loadLog() {
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
ensureDataDir();
|
|
42
|
+
const logFile = getDeletionLogPath();
|
|
43
|
+
if (!fs.existsSync(logFile)) {
|
|
37
44
|
return [];
|
|
38
45
|
}
|
|
39
46
|
try {
|
|
40
|
-
return JSON.parse(fs.readFileSync(
|
|
47
|
+
return JSON.parse(fs.readFileSync(logFile, 'utf8'));
|
|
41
48
|
} catch {
|
|
42
49
|
return [];
|
|
43
50
|
}
|
|
@@ -47,8 +54,8 @@ function loadLog() {
|
|
|
47
54
|
* Save deletion log
|
|
48
55
|
*/
|
|
49
56
|
function saveLog(log) {
|
|
50
|
-
|
|
51
|
-
atomicWriteSync(
|
|
57
|
+
ensureDataDir();
|
|
58
|
+
atomicWriteSync(getDeletionLogPath(), log);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
package/src/preferences.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* preferences.js - User preferences storage and helpers
|
|
3
|
+
*
|
|
4
|
+
* User preferences are stored in DATA_DIR (syncable) not CONFIG_DIR
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
const fs = require('fs');
|
|
@@ -7,17 +9,35 @@ const path = require('path');
|
|
|
7
9
|
const storage = require('./storage');
|
|
8
10
|
|
|
9
11
|
const TEMPLATE_PATH = path.join(__dirname, 'templates', 'user-preferences-template.md');
|
|
10
|
-
const PREFERENCES_FILE = path.join(storage.CONFIG_DIR, 'user-preferences.md');
|
|
11
12
|
const MAX_LINES = 500;
|
|
13
|
+
const SECTION_ALIASES = new Map([
|
|
14
|
+
['about', 'About Me'],
|
|
15
|
+
['me', 'About Me'],
|
|
16
|
+
['projects', 'Important Projects'],
|
|
17
|
+
['important', 'Important Projects'],
|
|
18
|
+
['tags', 'Tag Meanings'],
|
|
19
|
+
['tag', 'Tag Meanings'],
|
|
20
|
+
['review', 'Review Preferences'],
|
|
21
|
+
['behavior', 'Behavioral Preferences'],
|
|
22
|
+
['behavioral', 'Behavioral Preferences']
|
|
23
|
+
]);
|
|
12
24
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Get current preferences file path (dynamic, based on DATA_DIR)
|
|
27
|
+
*/
|
|
28
|
+
function getPreferencesFilePath() {
|
|
29
|
+
return path.join(storage.DATA_DIR, 'user-preferences.md');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureDataDir() {
|
|
33
|
+
const dataDir = storage.DATA_DIR;
|
|
34
|
+
if (!fs.existsSync(dataDir)) {
|
|
35
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
16
36
|
}
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
function getPreferencesPath() {
|
|
20
|
-
return
|
|
40
|
+
return getPreferencesFilePath();
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
function readTemplate() {
|
|
@@ -51,22 +71,24 @@ function savePreferences(content) {
|
|
|
51
71
|
throw new Error(validation.error);
|
|
52
72
|
}
|
|
53
73
|
|
|
54
|
-
|
|
55
|
-
const
|
|
74
|
+
ensureDataDir();
|
|
75
|
+
const preferencesFile = getPreferencesFilePath();
|
|
76
|
+
const tmpPath = `${preferencesFile}.tmp`;
|
|
56
77
|
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
57
|
-
fs.renameSync(tmpPath,
|
|
78
|
+
fs.renameSync(tmpPath, preferencesFile);
|
|
58
79
|
return content;
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
function loadPreferences() {
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
ensureDataDir();
|
|
84
|
+
const preferencesFile = getPreferencesFilePath();
|
|
85
|
+
if (!fs.existsSync(preferencesFile)) {
|
|
64
86
|
const template = readTemplate();
|
|
65
87
|
savePreferences(template);
|
|
66
88
|
return template;
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
const content = fs.readFileSync(
|
|
91
|
+
const content = fs.readFileSync(preferencesFile, 'utf8');
|
|
70
92
|
const validation = validatePreferences(content);
|
|
71
93
|
if (!validation.valid) {
|
|
72
94
|
throw new Error(validation.error);
|
|
@@ -79,7 +101,16 @@ function resetPreferences() {
|
|
|
79
101
|
return savePreferences(template);
|
|
80
102
|
}
|
|
81
103
|
|
|
104
|
+
function resolveSection(input) {
|
|
105
|
+
const target = parseSectionTarget(input);
|
|
106
|
+
const key = target.name.toLowerCase();
|
|
107
|
+
return SECTION_ALIASES.get(key) || target.name;
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
function parseSectionTarget(section) {
|
|
111
|
+
if (typeof section !== 'string') {
|
|
112
|
+
throw new Error('Section name is required');
|
|
113
|
+
}
|
|
83
114
|
const trimmed = section.trim();
|
|
84
115
|
if (!trimmed) {
|
|
85
116
|
throw new Error('Section name is required');
|
|
@@ -93,9 +124,22 @@ function parseSectionTarget(section) {
|
|
|
93
124
|
return { level: null, name: trimmed };
|
|
94
125
|
}
|
|
95
126
|
|
|
127
|
+
function normalizeEntry(entry) {
|
|
128
|
+
return entry.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isCommentLine(line) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
return trimmed.startsWith('<!--');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isHeadingLine(line) {
|
|
137
|
+
return /^(#{1,6})\s+/.test(line.trim());
|
|
138
|
+
}
|
|
139
|
+
|
|
96
140
|
function findSection(lines, target) {
|
|
97
141
|
for (let i = 0; i < lines.length; i += 1) {
|
|
98
|
-
const match = lines[i].match(/^(#{1,6})\s
|
|
142
|
+
const match = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
99
143
|
if (!match) {
|
|
100
144
|
continue;
|
|
101
145
|
}
|
|
@@ -113,6 +157,148 @@ function findSection(lines, target) {
|
|
|
113
157
|
return null;
|
|
114
158
|
}
|
|
115
159
|
|
|
160
|
+
function getSectionRange(lines, match) {
|
|
161
|
+
if (!match) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let endIndex = lines.length;
|
|
166
|
+
for (let i = match.index + 1; i < lines.length; i += 1) {
|
|
167
|
+
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
168
|
+
if (!headingMatch) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const level = headingMatch[1].length;
|
|
172
|
+
if (level <= match.level) {
|
|
173
|
+
endIndex = i;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { start: match.index, end: endIndex };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getEntriesInSection(section) {
|
|
182
|
+
const resolved = resolveSection(section);
|
|
183
|
+
const content = loadPreferences();
|
|
184
|
+
const lines = content.split(/\r?\n/);
|
|
185
|
+
const target = parseSectionTarget(resolved);
|
|
186
|
+
const match = findSection(lines, target);
|
|
187
|
+
|
|
188
|
+
if (!match) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const range = getSectionRange(lines, match);
|
|
193
|
+
const entries = [];
|
|
194
|
+
|
|
195
|
+
for (let i = match.index + 1; i < range.end; i += 1) {
|
|
196
|
+
const line = lines[i];
|
|
197
|
+
const trimmed = line.trim();
|
|
198
|
+
|
|
199
|
+
if (!trimmed || isCommentLine(trimmed) || isHeadingLine(trimmed)) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
entries.push(trimmed);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return entries;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function setEntry(section, entry) {
|
|
210
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
211
|
+
throw new Error('Entry text is required');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const resolved = resolveSection(section);
|
|
215
|
+
const normalized = normalizeEntry(entry);
|
|
216
|
+
const entries = getEntriesInSection(resolved);
|
|
217
|
+
const existed = entries.some((existing) => existing === normalized);
|
|
218
|
+
|
|
219
|
+
if (existed) {
|
|
220
|
+
return {
|
|
221
|
+
added: false,
|
|
222
|
+
existed: true,
|
|
223
|
+
section: resolved,
|
|
224
|
+
entry: normalized
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
appendToSection(resolved, normalized);
|
|
229
|
+
return {
|
|
230
|
+
added: true,
|
|
231
|
+
existed: false,
|
|
232
|
+
section: resolved,
|
|
233
|
+
entry: normalized
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function removeFromSection(section, { match, entry } = {}) {
|
|
238
|
+
const hasMatch = typeof match === 'string' && match.trim();
|
|
239
|
+
const hasEntry = typeof entry === 'string' && entry.trim();
|
|
240
|
+
|
|
241
|
+
if (!hasMatch && !hasEntry) {
|
|
242
|
+
throw new Error('Match or entry is required');
|
|
243
|
+
}
|
|
244
|
+
if (hasMatch && hasEntry) {
|
|
245
|
+
throw new Error('Use either match or entry, not both');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const resolved = resolveSection(section);
|
|
249
|
+
const content = loadPreferences();
|
|
250
|
+
const lines = content.split(/\r?\n/);
|
|
251
|
+
const target = parseSectionTarget(resolved);
|
|
252
|
+
const sectionMatch = findSection(lines, target);
|
|
253
|
+
|
|
254
|
+
if (!sectionMatch) {
|
|
255
|
+
return { removed: false, count: 0, entries: [], section: resolved };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const range = getSectionRange(lines, sectionMatch);
|
|
259
|
+
const sectionLines = lines.slice(sectionMatch.index + 1, range.end);
|
|
260
|
+
const removedEntries = [];
|
|
261
|
+
const normalizedEntry = hasEntry ? normalizeEntry(entry) : null;
|
|
262
|
+
const matchNeedle = hasMatch ? match.trim().toLowerCase() : null;
|
|
263
|
+
|
|
264
|
+
const updatedSectionLines = sectionLines.filter((line) => {
|
|
265
|
+
const trimmed = line.trim();
|
|
266
|
+
|
|
267
|
+
if (!trimmed || isCommentLine(trimmed) || isHeadingLine(trimmed)) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const matches = normalizedEntry
|
|
272
|
+
? trimmed === normalizedEntry
|
|
273
|
+
: trimmed.toLowerCase().includes(matchNeedle);
|
|
274
|
+
|
|
275
|
+
if (matches) {
|
|
276
|
+
removedEntries.push(trimmed);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return true;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (removedEntries.length === 0) {
|
|
284
|
+
return { removed: false, count: 0, entries: [], section: resolved };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const updatedLines = [
|
|
288
|
+
...lines.slice(0, sectionMatch.index + 1),
|
|
289
|
+
...updatedSectionLines,
|
|
290
|
+
...lines.slice(range.end)
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
savePreferences(updatedLines.join('\n'));
|
|
294
|
+
return {
|
|
295
|
+
removed: true,
|
|
296
|
+
count: removedEntries.length,
|
|
297
|
+
entries: removedEntries,
|
|
298
|
+
section: resolved
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
116
302
|
function appendToSection(section, text) {
|
|
117
303
|
if (typeof text !== 'string' || !text.trim()) {
|
|
118
304
|
throw new Error('Append text is required');
|
|
@@ -140,7 +326,7 @@ function appendToSection(section, text) {
|
|
|
140
326
|
|
|
141
327
|
let insertIndex = lines.length;
|
|
142
328
|
for (let i = match.index + 1; i < lines.length; i += 1) {
|
|
143
|
-
const headingMatch = lines[i].match(/^(#{1,6})\s
|
|
329
|
+
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
144
330
|
if (!headingMatch) {
|
|
145
331
|
continue;
|
|
146
332
|
}
|
|
@@ -167,9 +353,13 @@ module.exports = {
|
|
|
167
353
|
getPreferencesPath,
|
|
168
354
|
loadPreferences,
|
|
169
355
|
savePreferences,
|
|
356
|
+
resolveSection,
|
|
357
|
+
setEntry,
|
|
358
|
+
removeFromSection,
|
|
359
|
+
getEntriesInSection,
|
|
170
360
|
appendToSection,
|
|
171
361
|
resetPreferences,
|
|
172
362
|
validatePreferences,
|
|
173
|
-
PREFERENCES_FILE,
|
|
363
|
+
get PREFERENCES_FILE() { return getPreferencesFilePath(); },
|
|
174
364
|
TEMPLATE_PATH
|
|
175
365
|
};
|
package/src/storage.js
CHANGED
|
@@ -7,16 +7,30 @@ const path = require('path');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const { v4: uuidv4 } = require('uuid');
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const ARCHIVE_FILE = path.join(CONFIG_DIR, 'archive.json');
|
|
14
|
-
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Directory Resolution (XDG-compliant with backward compatibility)
|
|
12
|
+
// =============================================================================
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the config directory path
|
|
16
|
+
* Priority:
|
|
17
|
+
* 1. SYNAP_CONFIG_DIR env var (explicit override for config only)
|
|
18
|
+
* 2. SYNAP_DIR env var (legacy - affects both config and data)
|
|
19
|
+
* 3. Default: ~/.config/synap
|
|
20
|
+
*/
|
|
21
|
+
function resolveConfigDir() {
|
|
22
|
+
if (process.env.SYNAP_CONFIG_DIR) {
|
|
23
|
+
return process.env.SYNAP_CONFIG_DIR;
|
|
24
|
+
}
|
|
25
|
+
if (process.env.SYNAP_DIR) {
|
|
26
|
+
return process.env.SYNAP_DIR;
|
|
27
|
+
}
|
|
28
|
+
return path.join(os.homedir(), '.config', 'synap');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Config directory - always local, not synced
|
|
32
|
+
const CONFIG_DIR = resolveConfigDir();
|
|
33
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
20
34
|
|
|
21
35
|
/**
|
|
22
36
|
* Ensure config directory exists
|
|
@@ -27,6 +41,90 @@ function ensureConfigDir() {
|
|
|
27
41
|
}
|
|
28
42
|
}
|
|
29
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Load configuration (needed before resolving DATA_DIR)
|
|
46
|
+
*/
|
|
47
|
+
function loadConfigRaw() {
|
|
48
|
+
ensureConfigDir();
|
|
49
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the data directory path
|
|
61
|
+
* Priority:
|
|
62
|
+
* 1. SYNAP_DATA_DIR env var (explicit override for data only)
|
|
63
|
+
* 2. config.json -> dataDir setting
|
|
64
|
+
* 3. SYNAP_DIR env var (legacy - affects both config and data)
|
|
65
|
+
* 4. Default: ~/.local/share/synap (XDG compliant)
|
|
66
|
+
*/
|
|
67
|
+
function resolveDataDir() {
|
|
68
|
+
// 1. Explicit env var override for data
|
|
69
|
+
if (process.env.SYNAP_DATA_DIR) {
|
|
70
|
+
return process.env.SYNAP_DATA_DIR;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Config file setting
|
|
74
|
+
const rawConfig = loadConfigRaw();
|
|
75
|
+
if (rawConfig.dataDir) {
|
|
76
|
+
// Expand ~ to home directory
|
|
77
|
+
const expanded = rawConfig.dataDir.replace(/^~/, os.homedir());
|
|
78
|
+
return expanded;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Legacy env var (affects both config and data)
|
|
82
|
+
if (process.env.SYNAP_DIR) {
|
|
83
|
+
return process.env.SYNAP_DIR;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Default: XDG-compliant data directory
|
|
87
|
+
return path.join(os.homedir(), '.local', 'share', 'synap');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolve DATA_DIR at module load time
|
|
91
|
+
// This will be re-evaluated if config changes require restart
|
|
92
|
+
let DATA_DIR = resolveDataDir();
|
|
93
|
+
|
|
94
|
+
// Data files (syncable)
|
|
95
|
+
let ENTRIES_FILE = path.join(DATA_DIR, 'entries.json');
|
|
96
|
+
let ARCHIVE_FILE = path.join(DATA_DIR, 'archive.json');
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refresh data directory paths (call after config change)
|
|
100
|
+
*/
|
|
101
|
+
function refreshDataDir() {
|
|
102
|
+
DATA_DIR = resolveDataDir();
|
|
103
|
+
ENTRIES_FILE = path.join(DATA_DIR, 'entries.json');
|
|
104
|
+
ARCHIVE_FILE = path.join(DATA_DIR, 'archive.json');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get current data directory path
|
|
109
|
+
*/
|
|
110
|
+
function getDataDir() {
|
|
111
|
+
return DATA_DIR;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ensure data directory exists
|
|
116
|
+
*/
|
|
117
|
+
function ensureDataDir() {
|
|
118
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
119
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Valid types, statuses, and date formats
|
|
124
|
+
const VALID_TYPES = ['idea', 'project', 'feature', 'todo', 'question', 'reference', 'note'];
|
|
125
|
+
const VALID_STATUSES = ['raw', 'active', 'wip', 'someday', 'done', 'archived'];
|
|
126
|
+
const VALID_DATE_FORMATS = ['relative', 'absolute', 'locale'];
|
|
127
|
+
|
|
30
128
|
/**
|
|
31
129
|
* Atomic file write - write to temp file then rename
|
|
32
130
|
*/
|
|
@@ -40,7 +138,7 @@ function atomicWriteSync(filePath, data) {
|
|
|
40
138
|
* Load entries from file
|
|
41
139
|
*/
|
|
42
140
|
function loadEntries() {
|
|
43
|
-
|
|
141
|
+
ensureDataDir();
|
|
44
142
|
if (!fs.existsSync(ENTRIES_FILE)) {
|
|
45
143
|
return { version: 1, entries: [] };
|
|
46
144
|
}
|
|
@@ -55,7 +153,7 @@ function loadEntries() {
|
|
|
55
153
|
* Save entries to file
|
|
56
154
|
*/
|
|
57
155
|
function saveEntries(data) {
|
|
58
|
-
|
|
156
|
+
ensureDataDir();
|
|
59
157
|
atomicWriteSync(ENTRIES_FILE, data);
|
|
60
158
|
}
|
|
61
159
|
|
|
@@ -63,7 +161,7 @@ function saveEntries(data) {
|
|
|
63
161
|
* Load archived entries
|
|
64
162
|
*/
|
|
65
163
|
function loadArchive() {
|
|
66
|
-
|
|
164
|
+
ensureDataDir();
|
|
67
165
|
if (!fs.existsSync(ARCHIVE_FILE)) {
|
|
68
166
|
return { version: 1, entries: [] };
|
|
69
167
|
}
|
|
@@ -78,7 +176,7 @@ function loadArchive() {
|
|
|
78
176
|
* Save archived entries
|
|
79
177
|
*/
|
|
80
178
|
function saveArchive(data) {
|
|
81
|
-
|
|
179
|
+
ensureDataDir();
|
|
82
180
|
atomicWriteSync(ARCHIVE_FILE, data);
|
|
83
181
|
}
|
|
84
182
|
|
|
@@ -91,7 +189,8 @@ function loadConfig() {
|
|
|
91
189
|
defaultType: 'idea',
|
|
92
190
|
defaultTags: [],
|
|
93
191
|
editor: null, // Falls back to EDITOR env var in CLI
|
|
94
|
-
dateFormat: 'relative'
|
|
192
|
+
dateFormat: 'relative',
|
|
193
|
+
dataDir: null // Custom data directory (null = use default)
|
|
95
194
|
};
|
|
96
195
|
|
|
97
196
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
@@ -123,6 +222,12 @@ function loadConfig() {
|
|
|
123
222
|
.map(t => t.trim());
|
|
124
223
|
}
|
|
125
224
|
|
|
225
|
+
// Validate dataDir if set
|
|
226
|
+
if (config.dataDir !== null && typeof config.dataDir !== 'string') {
|
|
227
|
+
console.warn(`Warning: Invalid dataDir in config. Ignoring.`);
|
|
228
|
+
config.dataDir = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
126
231
|
return config;
|
|
127
232
|
} catch (err) {
|
|
128
233
|
console.warn(`Warning: Could not parse config.json: ${err.message}`);
|
|
@@ -138,7 +243,8 @@ function getDefaultConfig() {
|
|
|
138
243
|
defaultType: 'idea',
|
|
139
244
|
defaultTags: [],
|
|
140
245
|
editor: null,
|
|
141
|
-
dateFormat: 'relative'
|
|
246
|
+
dateFormat: 'relative',
|
|
247
|
+
dataDir: null
|
|
142
248
|
};
|
|
143
249
|
}
|
|
144
250
|
|
|
@@ -170,6 +276,17 @@ function validateConfigValue(key, value) {
|
|
|
170
276
|
case 'editor':
|
|
171
277
|
// Any string or null is valid
|
|
172
278
|
break;
|
|
279
|
+
case 'dataDir':
|
|
280
|
+
// Path validation - must be a valid path string or null
|
|
281
|
+
if (value !== null && value !== 'null' && typeof value === 'string') {
|
|
282
|
+
const expanded = value.replace(/^~/, os.homedir());
|
|
283
|
+
// Check if parent directory exists (we'll create the target if needed)
|
|
284
|
+
const parentDir = path.dirname(expanded);
|
|
285
|
+
if (!fs.existsSync(parentDir)) {
|
|
286
|
+
return { valid: false, error: `Parent directory does not exist: ${parentDir}` };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
173
290
|
}
|
|
174
291
|
return { valid: true };
|
|
175
292
|
}
|
|
@@ -1018,9 +1135,12 @@ module.exports = {
|
|
|
1018
1135
|
validateConfigValue,
|
|
1019
1136
|
saveConfig,
|
|
1020
1137
|
parseDate,
|
|
1138
|
+
refreshDataDir,
|
|
1139
|
+
getDataDir,
|
|
1021
1140
|
CONFIG_DIR,
|
|
1022
|
-
|
|
1023
|
-
|
|
1141
|
+
get DATA_DIR() { return DATA_DIR; },
|
|
1142
|
+
get ENTRIES_FILE() { return ENTRIES_FILE; },
|
|
1143
|
+
get ARCHIVE_FILE() { return ARCHIVE_FILE; },
|
|
1024
1144
|
VALID_TYPES,
|
|
1025
1145
|
VALID_STATUSES,
|
|
1026
1146
|
VALID_DATE_FORMATS
|