saveinme 1.3.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/editor.js CHANGED
@@ -1,9 +1,10 @@
1
- import { input, select, confirm } from '@inquirer/prompts';
1
+ import { input, select, confirm, checkbox } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
- import { getAllNotes, saveNote, getNoteByTitle, getDefaultNotebook } from './store.js';
4
- import { showHeader, showSuccess, showError, showInfo } from './display.js';
3
+ import { getAllNotes, saveNote, getNoteByTitle, getDefaultNotebook, setDefaultNotebook, deleteNoteById } from './store.js';
4
+ import { showHeader, showSuccess, showError, showInfo, showNote } from './display.js';
5
5
  import { multilineEditor } from './multiline.js';
6
6
  import { getClipboardText } from './clipboard.js';
7
+ import { triggerAutoSync } from './sync.js';
7
8
 
8
9
  // Helper for generating unique, readable default titles
9
10
  function getTimestamp() {
@@ -49,30 +50,56 @@ export async function saveFromClipboard(titleArg, notebookOverride) {
49
50
  }
50
51
  console.log(chalk.dim(' └──────────────────────────────────────────────────\n'));
51
52
 
52
- // Resolve title
53
- const title = (titleArg && titleArg.trim()) || `Clipboard Note - ${getTimestamp()}`;
54
- const existingNote = getNoteByTitle(title);
55
-
56
53
  // Resolve notebook target
57
54
  const defaultNotebook = getDefaultNotebook();
58
- const category = (notebookOverride !== null) ? notebookOverride.trim() : (existingNote?.category ?? defaultNotebook);
55
+ const tempCategory = (notebookOverride !== null) ? notebookOverride.trim() : defaultNotebook;
56
+
57
+ // Resolve title
58
+ let title = (titleArg && titleArg.trim()) || '';
59
+ let isAppend = false;
60
+
61
+ if (!title) {
62
+ if (tempCategory) {
63
+ title = tempCategory;
64
+ isAppend = true;
65
+ } else {
66
+ title = `Clipboard Note - ${getTimestamp()}`;
67
+ }
68
+ }
69
+
70
+ const existingNoteResolve = getNoteByTitle(title);
71
+ const category = (notebookOverride !== null) ? notebookOverride.trim() : (existingNoteResolve?.category ?? defaultNotebook);
72
+ let finalContent = clipboardContent;
73
+
74
+ if (existingNoteResolve && isAppend) {
75
+ finalContent = existingNoteResolve.content + '\n' + clipboardContent;
76
+ }
59
77
 
60
78
  saveNote({
61
- id: existingNote?.id, // updates if title matches exactly
79
+ id: existingNoteResolve?.id, // updates if title matches exactly
62
80
  title: title.trim(),
63
- content: clipboardContent,
81
+ content: finalContent,
64
82
  category: category.trim(),
65
- tags: existingNote?.tags ?? [],
66
- priority: existingNote?.priority ?? 'medium',
67
- pinned: existingNote?.pinned ?? false,
83
+ tags: existingNoteResolve?.tags ?? [],
84
+ priority: existingNoteResolve?.priority ?? 'medium',
85
+ pinned: existingNoteResolve?.pinned ?? false,
68
86
  });
87
+ triggerAutoSync();
69
88
 
70
89
  const wc = clipboardContent.split(/\s+/).filter(Boolean).length;
71
- showSuccess(
72
- `Clipboard note "${title.trim()}" saved!` +
73
- (category ? ` [Notebook: ${category.trim()}]` : '') +
74
- chalk.dim(` (${wc} words)`)
75
- );
90
+ if (existingNoteResolve && isAppend) {
91
+ showSuccess(
92
+ `Appended clipboard text to note "${title.trim()}"` +
93
+ (category ? ` in notebook "${category.trim()}"` : '') +
94
+ chalk.dim(` (+${wc} words)`)
95
+ );
96
+ } else {
97
+ showSuccess(
98
+ `Clipboard note "${title.trim()}" saved!` +
99
+ (category ? ` [Notebook: ${category.trim()}]` : '') +
100
+ chalk.dim(` (${wc} words)`)
101
+ );
102
+ }
76
103
  }
77
104
 
78
105
  // ─── Save piped stdin directly ────────────────────────────────────────────────
@@ -96,25 +123,50 @@ export async function savePipedInput(inputContent, titleArg, notebookOverride) {
96
123
  const category = (notebookOverride !== null) ? notebookOverride.trim() : defaultNotebook;
97
124
 
98
125
  // Resolve title
99
- const title = (titleArg && titleArg.trim()) || `Piped Note - ${getTimestamp()}`;
100
- const existingNote = getNoteByTitle(title);
126
+ let title = (titleArg && titleArg.trim()) || '';
127
+ let isAppend = false;
128
+
129
+ if (!title) {
130
+ if (category) {
131
+ title = category;
132
+ isAppend = true;
133
+ } else {
134
+ title = `Piped Note - ${getTimestamp()}`;
135
+ }
136
+ }
137
+
138
+ const existingNoteResolve = getNoteByTitle(title);
139
+ let finalContent = content;
140
+
141
+ if (existingNoteResolve && isAppend) {
142
+ finalContent = existingNoteResolve.content + '\n' + content;
143
+ }
101
144
 
102
145
  saveNote({
103
- id: existingNote?.id,
146
+ id: existingNoteResolve?.id,
104
147
  title: title.trim(),
105
- content: content,
148
+ content: finalContent,
106
149
  category: category,
107
- tags: existingNote?.tags ?? [],
108
- priority: existingNote?.priority ?? 'medium',
109
- pinned: existingNote?.pinned ?? false,
150
+ tags: existingNoteResolve?.tags ?? [],
151
+ priority: existingNoteResolve?.priority ?? 'medium',
152
+ pinned: existingNoteResolve?.pinned ?? false,
110
153
  });
154
+ triggerAutoSync();
111
155
 
112
156
  const wc = content.split(/\s+/).filter(Boolean).length;
113
- showSuccess(
114
- `Saved piped note "${title.trim()}"` +
115
- (category ? ` to notebook "${category}"` : '') +
116
- chalk.dim(` (${wc} words)`)
117
- );
157
+ if (existingNoteResolve && isAppend) {
158
+ showSuccess(
159
+ `Appended piped text to note "${title.trim()}"` +
160
+ (category ? ` in notebook "${category}"` : '') +
161
+ chalk.dim(` (+${wc} words)`)
162
+ );
163
+ } else {
164
+ showSuccess(
165
+ `Saved piped note "${title.trim()}"` +
166
+ (category ? ` to notebook "${category}"` : '') +
167
+ chalk.dim(` (${wc} words)`)
168
+ );
169
+ }
118
170
  }
119
171
 
120
172
  // ─── Main editor entry point ──────────────────────────────────────────────────
@@ -302,6 +354,7 @@ export async function openEditor(titleArg, notebookOverride) {
302
354
  priority,
303
355
  pinned,
304
356
  });
357
+ triggerAutoSync();
305
358
 
306
359
  const wc = finalContent.trim().split(/\s+/).filter(Boolean).length;
307
360
  showSuccess(
@@ -310,3 +363,146 @@ export async function openEditor(titleArg, notebookOverride) {
310
363
  chalk.dim(` (${wc} words)`)
311
364
  );
312
365
  }
366
+
367
+ // ─── Interactive Group Delete ─────────────────────────────────────────────────
368
+
369
+ /**
370
+ * Renders a checkbox menu to select and delete multiple notes at once.
371
+ */
372
+ export async function interactiveGroupDelete() {
373
+ const notes = getAllNotes();
374
+ if (notes.length === 0) {
375
+ showError('No notes saved to delete.');
376
+ return;
377
+ }
378
+
379
+ // Sort notes: pinned first, then by last modified
380
+ const sorted = [...notes].sort((a, b) => {
381
+ if (a.pinned && !b.pinned) return -1;
382
+ if (!a.pinned && b.pinned) return 1;
383
+ return new Date(b.updatedAt) - new Date(a.updatedAt);
384
+ });
385
+
386
+ const selectedIds = await checkbox({
387
+ message: 'Select notes to remove (Space to select, Enter to confirm):',
388
+ choices: sorted.map(n => ({
389
+ name: `${n.pinned ? '📌 ' : ''}${chalk.bold(n.title)} ${chalk.dim(`(${n.category || 'uncategorized'} · ${n.wordCount}w)`)}`,
390
+ value: n.id,
391
+ })),
392
+ });
393
+
394
+ if (selectedIds.length === 0) {
395
+ showInfo('No notes selected. Nothing deleted.');
396
+ return;
397
+ }
398
+
399
+ const confirmDelete = await confirm({
400
+ message: `Are you sure you want to delete ${selectedIds.length} note(s)?`,
401
+ default: false,
402
+ });
403
+
404
+ if (!confirmDelete) {
405
+ showInfo('Deletion cancelled.');
406
+ return;
407
+ }
408
+
409
+ let count = 0;
410
+ for (const id of selectedIds) {
411
+ if (deleteNoteById(id)) {
412
+ count++;
413
+ }
414
+ }
415
+
416
+ if (count > 0) {
417
+ triggerAutoSync();
418
+ }
419
+
420
+ showSuccess(`Successfully removed ${count} note(s).`);
421
+ }
422
+
423
+ /**
424
+ * Renders a select menu to set the default notebook target.
425
+ */
426
+ export async function interactiveSetDefaultNotebook() {
427
+ const notes = getAllNotes();
428
+ const current = getDefaultNotebook();
429
+ const existingCats = [...new Set(notes.map(n => n.category).filter(Boolean))];
430
+
431
+ const choices = [];
432
+ if (existingCats.length > 0) {
433
+ choices.push(...existingCats.map(c => ({ name: `📁 ${c}`, value: c })));
434
+ }
435
+ choices.push(
436
+ { name: chalk.cyan('+ Create new notebook…'), value: '__new__' },
437
+ { name: chalk.red('❌ Clear default notebook'), value: '__clear__' },
438
+ { name: chalk.dim('↩️ Keep current default'), value: '__keep__' }
439
+ );
440
+
441
+ console.log('');
442
+ if (current) {
443
+ console.log(chalk.cyan(` Current default notebook: "${current}"`));
444
+ } else {
445
+ console.log(chalk.dim(' No default notebook currently set.'));
446
+ }
447
+ console.log('');
448
+
449
+ const choice = await select({
450
+ message: 'Select default notebook:',
451
+ choices,
452
+ });
453
+
454
+ if (choice === '__keep__') {
455
+ showInfo('No changes made to default notebook.');
456
+ return;
457
+ }
458
+
459
+ if (choice === '__clear__') {
460
+ setDefaultNotebook('');
461
+ showSuccess('Default notebook cleared.');
462
+ return;
463
+ }
464
+
465
+ let notebookName = choice;
466
+ if (choice === '__new__') {
467
+ const typed = await input({
468
+ message: 'New notebook name:',
469
+ validate: v => v.trim().length > 0 || 'Notebook name cannot be empty.',
470
+ });
471
+ notebookName = typed.trim();
472
+ }
473
+
474
+ setDefaultNotebook(notebookName);
475
+ showSuccess(`Target notebook set to "${notebookName}".\n New notes will default to this notebook.`);
476
+ }
477
+
478
+ /**
479
+ * Renders a select menu to pick a note to view.
480
+ */
481
+ export async function interactiveViewNote() {
482
+ const notes = getAllNotes();
483
+ if (notes.length === 0) {
484
+ showError('No notes saved to view.');
485
+ return;
486
+ }
487
+
488
+ const sorted = [...notes].sort((a, b) => {
489
+ if (a.pinned && !b.pinned) return -1;
490
+ if (!a.pinned && b.pinned) return 1;
491
+ return new Date(b.updatedAt) - new Date(a.updatedAt);
492
+ });
493
+
494
+ const choices = sorted.map(n => ({
495
+ name: `${n.pinned ? '📌 ' : ' '}${chalk.bold(n.title)} ${chalk.dim(`(${n.category || 'uncategorized'} · ${n.wordCount}w)`)}`,
496
+ value: n.id,
497
+ }));
498
+
499
+ const selectedId = await select({
500
+ message: 'Select a note to view:',
501
+ choices,
502
+ });
503
+
504
+ const note = notes.find(n => n.id === selectedId);
505
+ if (note) {
506
+ showNote(note);
507
+ }
508
+ }
package/src/store.js CHANGED
@@ -111,19 +111,27 @@ export function getDataPath() {
111
111
  return DATA_FILE;
112
112
  }
113
113
 
114
- export function getDefaultNotebook() {
114
+ export function getConfig() {
115
115
  ensureDir();
116
- if (!existsSync(CONFIG_FILE)) return '';
116
+ if (!existsSync(CONFIG_FILE)) return {};
117
117
  try {
118
- const config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
119
- return config.defaultNotebook ?? '';
118
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) ?? {};
120
119
  } catch {
121
- return '';
120
+ return {};
122
121
  }
123
122
  }
124
123
 
125
- export function setDefaultNotebook(name) {
124
+ export function saveConfig(updates) {
126
125
  ensureDir();
127
- const config = { defaultNotebook: name.trim() };
128
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
126
+ const current = getConfig();
127
+ const next = { ...current, ...updates };
128
+ writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf-8');
129
+ }
130
+
131
+ export function getDefaultNotebook() {
132
+ return getConfig().defaultNotebook ?? '';
133
+ }
134
+
135
+ export function setDefaultNotebook(name) {
136
+ saveConfig({ defaultNotebook: name.trim() });
129
137
  }
package/src/sync.js ADDED
@@ -0,0 +1,165 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, writeFileSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { confirm, input } from '@inquirer/prompts';
5
+ import chalk from 'chalk';
6
+ import { getDataPath, getConfig, saveConfig } from './store.js';
7
+ import { showSuccess, showError, showInfo } from './display.js';
8
+
9
+ // Get base directory of saveinme database (~/.saveinme)
10
+ const DATA_DIR = dirname(getDataPath());
11
+
12
+ /**
13
+ * Runs a git command inside the saveinme directory.
14
+ * @param {string} cmd Git subcommand
15
+ * @returns {string|null} command output or null if failed
16
+ */
17
+ function runGit(cmd) {
18
+ try {
19
+ return execSync(`git ${cmd}`, { cwd: DATA_DIR, stdio: 'pipe', encoding: 'utf-8' }).trim();
20
+ } catch (err) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Checks if Git is installed on the system.
27
+ */
28
+ function isGitInstalled() {
29
+ try {
30
+ execSync('git --version', { stdio: 'ignore' });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Initializes Git repository in the database directory if not already initialized.
39
+ */
40
+ function ensureGitRepo() {
41
+ if (!existsSync(join(DATA_DIR, '.git'))) {
42
+ showInfo('Initializing local Git repository...');
43
+ runGit('init');
44
+
45
+ // Create .gitignore to prevent uploading local configs (like API keys)
46
+ const gitignorePath = join(DATA_DIR, '.gitignore');
47
+ writeFileSync(gitignorePath, 'config.json\n', 'utf-8');
48
+ runGit('add .gitignore');
49
+ runGit('commit -m "Initial commit: setup gitignore"');
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Main Git Sync function. Pulls latest notes, commits local changes, and pushes them.
55
+ * @param {boolean} forceSetup If true, prompt for repository configuration even if configured.
56
+ */
57
+ export async function syncNotes(forceSetup = false) {
58
+ if (!isGitInstalled()) {
59
+ showError('Git is not installed or not in your PATH. Please install Git first.');
60
+ return;
61
+ }
62
+
63
+ ensureGitRepo();
64
+
65
+ // Check if remote is configured
66
+ let remoteUrl = runGit('remote get-url origin');
67
+ const config = getConfig();
68
+
69
+ if (!remoteUrl || forceSetup) {
70
+ console.log(chalk.yellow('\n🌐 Git Sync Remote Setup'));
71
+ const setupRemote = await confirm({
72
+ message: 'Would you like to configure a remote Git repository (e.g. on GitHub) to sync notes?',
73
+ default: true,
74
+ });
75
+
76
+ if (!setupRemote) {
77
+ showInfo('Git Sync cancelled.');
78
+ return;
79
+ }
80
+
81
+ const url = await input({
82
+ message: 'Enter remote repository URL (SSH preferred, e.g. git@github.com:username/notes-repo.git):',
83
+ validate: v => v.trim().length > 0 || 'Repository URL cannot be empty.',
84
+ });
85
+
86
+ if (remoteUrl) {
87
+ runGit('remote remove origin');
88
+ }
89
+
90
+ const added = runGit(`remote add origin ${url.trim()}`);
91
+ if (added === null) {
92
+ showError('Failed to configure remote repository URL.');
93
+ return;
94
+ }
95
+ remoteUrl = url.trim();
96
+
97
+ // Enable auto-sync by default if remote is set up
98
+ saveConfig({ autoSync: true });
99
+ showSuccess('Remote repository configured successfully. Auto-sync enabled.');
100
+ }
101
+
102
+ // Get current active branch (default to main/master)
103
+ let branch = runGit('branch --show-current');
104
+ if (!branch) {
105
+ // Check if there are branches, or default to main
106
+ branch = 'main';
107
+ }
108
+
109
+ showInfo(`Syncing notes with ${chalk.cyan(remoteUrl)} [branch: ${chalk.cyan(branch)}]...`);
110
+
111
+ // Stage changes
112
+ runGit('add notes.json');
113
+
114
+ // Check if there are staged changes to commit
115
+ const status = runGit('status --porcelain');
116
+ if (status && status.includes('notes.json')) {
117
+ runGit('commit -m "sync: update notes"');
118
+ showInfo('Committed local changes.');
119
+ } else {
120
+ showInfo('No local changes to commit.');
121
+ }
122
+
123
+ // Try to pull remote changes
124
+ showInfo('Pulling latest changes from remote...');
125
+ // Use --rebase to keep note history clean.
126
+ const pullResult = runGit(`pull --rebase origin ${branch}`);
127
+ if (pullResult === null) {
128
+ showError('Failed to pull from remote. You may have merge conflicts.');
129
+ showInfo('Aborting rebase...');
130
+ runGit('rebase --abort');
131
+ return;
132
+ }
133
+
134
+ // Push local changes to remote
135
+ showInfo('Pushing local updates to remote...');
136
+ const pushResult = runGit(`push -u origin ${branch}`);
137
+ if (pushResult === null) {
138
+ showError('Failed to push to remote repository.');
139
+ return;
140
+ }
141
+
142
+ showSuccess('Sync complete! All notes are up to date.');
143
+ }
144
+
145
+ /**
146
+ * Triggers background/silent auto-sync when notes are modified.
147
+ */
148
+ export function triggerAutoSync() {
149
+ const config = getConfig();
150
+ if (!config.autoSync) return;
151
+
152
+ if (!isGitInstalled() || !existsSync(join(DATA_DIR, '.git'))) return;
153
+
154
+ // Run auto-sync asynchronously so it doesn't block the CLI interface
155
+ import('child_process').then(({ exec }) => {
156
+ let branch = runGit('branch --show-current') || 'main';
157
+ exec(`git add notes.json && git commit -m "sync: auto-update notes" && git pull --rebase origin ${branch} && git push origin ${branch}`, {
158
+ cwd: DATA_DIR,
159
+ }, (error) => {
160
+ if (error) {
161
+ // Fail silently in background to avoid disrupting user workflow
162
+ }
163
+ });
164
+ });
165
+ }