saveinme 1.4.0 → 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() {
@@ -83,6 +84,7 @@ export async function saveFromClipboard(titleArg, notebookOverride) {
83
84
  priority: existingNoteResolve?.priority ?? 'medium',
84
85
  pinned: existingNoteResolve?.pinned ?? false,
85
86
  });
87
+ triggerAutoSync();
86
88
 
87
89
  const wc = clipboardContent.split(/\s+/).filter(Boolean).length;
88
90
  if (existingNoteResolve && isAppend) {
@@ -149,6 +151,7 @@ export async function savePipedInput(inputContent, titleArg, notebookOverride) {
149
151
  priority: existingNoteResolve?.priority ?? 'medium',
150
152
  pinned: existingNoteResolve?.pinned ?? false,
151
153
  });
154
+ triggerAutoSync();
152
155
 
153
156
  const wc = content.split(/\s+/).filter(Boolean).length;
154
157
  if (existingNoteResolve && isAppend) {
@@ -351,6 +354,7 @@ export async function openEditor(titleArg, notebookOverride) {
351
354
  priority,
352
355
  pinned,
353
356
  });
357
+ triggerAutoSync();
354
358
 
355
359
  const wc = finalContent.trim().split(/\s+/).filter(Boolean).length;
356
360
  showSuccess(
@@ -359,3 +363,146 @@ export async function openEditor(titleArg, notebookOverride) {
359
363
  chalk.dim(` (${wc} words)`)
360
364
  );
361
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
+ }