saveinme 1.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/README.md +63 -0
- package/bin/saveinme.js +127 -0
- package/package.json +35 -0
- package/src/display.js +262 -0
- package/src/editor.js +170 -0
- package/src/multiline.js +90 -0
- package/src/store.js +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# 💾 saveinme
|
|
2
|
+
|
|
3
|
+
> Your personal terminal notebook — save anything, permanently.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd path/to/saveinme
|
|
9
|
+
npm install
|
|
10
|
+
npm link # makes `saveinme` available globally in any terminal
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
| Command | What it does |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `saveinme` | List all your saved notes |
|
|
18
|
+
| `saveinme -s` | Create a new note or edit an existing one |
|
|
19
|
+
| `saveinme -s <title>` | Jump straight to editing a note by title |
|
|
20
|
+
| `saveinme -v <title>` | View a note in full |
|
|
21
|
+
| `saveinme -d <title>` | Delete a note |
|
|
22
|
+
| `saveinme -p <title>` | Toggle pin (pinned notes appear at the top) |
|
|
23
|
+
| `saveinme -f <term>` | Search by title, content, tags, or category |
|
|
24
|
+
| `saveinme --path` | Show where your notes are stored |
|
|
25
|
+
| `saveinme -h` | Help |
|
|
26
|
+
|
|
27
|
+
## What gets saved per note
|
|
28
|
+
|
|
29
|
+
- **Title** — a short name for the note
|
|
30
|
+
- **Content** — multi-line text, written right in the terminal
|
|
31
|
+
- **Category** — group notes together (e.g. `work`, `personal`)
|
|
32
|
+
- **Tags** — multiple tags for cross-cutting topics (e.g. `#todo`, `#idea`)
|
|
33
|
+
- **Priority** — Low / Medium / High
|
|
34
|
+
- **Pinned** — pinned notes always appear at the top of the list
|
|
35
|
+
- **Created date** — set automatically
|
|
36
|
+
- **Updated date** — updated automatically on every save
|
|
37
|
+
- **Word count** and **char count** — calculated automatically
|
|
38
|
+
|
|
39
|
+
## In-terminal editor
|
|
40
|
+
|
|
41
|
+
When you run `saveinme -s`, you'll be guided through prompts. When you reach the content area:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
❯ This is line one
|
|
45
|
+
❯ This is line two
|
|
46
|
+
❯ :save
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Type `:save` on a new blank line and press Enter to save.
|
|
50
|
+
Type `:cancel` to discard.
|
|
51
|
+
Type `:keep` (when editing) to keep the existing content unchanged.
|
|
52
|
+
|
|
53
|
+
Works on **Windows** (PowerShell / CMD) and **Linux/macOS** — no external editor opens.
|
|
54
|
+
|
|
55
|
+
## Storage
|
|
56
|
+
|
|
57
|
+
Notes are stored permanently at:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
~/.saveinme/notes.json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This file persists across sessions and reboots. Back it up any time.
|
package/bin/saveinme.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getAllNotes,
|
|
4
|
+
getNoteByTitle,
|
|
5
|
+
deleteNoteById,
|
|
6
|
+
togglePin,
|
|
7
|
+
searchNotes,
|
|
8
|
+
getDataPath,
|
|
9
|
+
} from '../src/store.js';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
showNotesList,
|
|
13
|
+
showNote,
|
|
14
|
+
showSuccess,
|
|
15
|
+
showError,
|
|
16
|
+
showInfo,
|
|
17
|
+
showHelp,
|
|
18
|
+
} from '../src/display.js';
|
|
19
|
+
|
|
20
|
+
import { openEditor } from '../src/editor.js';
|
|
21
|
+
|
|
22
|
+
// ─── Parse args ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const flag = argv[0];
|
|
26
|
+
const rest = argv.slice(1).join(' ').trim();
|
|
27
|
+
|
|
28
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
switch (flag) {
|
|
32
|
+
// ── List all ───────────────────────────────────────────────────────────
|
|
33
|
+
case undefined:
|
|
34
|
+
case '--list':
|
|
35
|
+
case '-l': {
|
|
36
|
+
showNotesList(getAllNotes());
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Save / edit ────────────────────────────────────────────────────────
|
|
41
|
+
case '-s':
|
|
42
|
+
case '--save': {
|
|
43
|
+
await openEditor(rest || undefined);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── View ───────────────────────────────────────────────────────────────
|
|
48
|
+
case '-v':
|
|
49
|
+
case '--view': {
|
|
50
|
+
if (!rest) { showError('Provide a title: saveinme -v <title>'); break; }
|
|
51
|
+
const note = getNoteByTitle(rest);
|
|
52
|
+
if (!note) showError(`No note found with title "${rest}".`);
|
|
53
|
+
else showNote(note);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Delete ─────────────────────────────────────────────────────────────
|
|
58
|
+
case '-d':
|
|
59
|
+
case '--delete': {
|
|
60
|
+
if (!rest) { showError('Provide a title: saveinme -d <title>'); break; }
|
|
61
|
+
const note = getNoteByTitle(rest);
|
|
62
|
+
if (!note) {
|
|
63
|
+
showError(`No note found with title "${rest}".`);
|
|
64
|
+
} else {
|
|
65
|
+
const ok = deleteNoteById(note.id);
|
|
66
|
+
if (ok) showSuccess(`Note "${rest}" deleted.`);
|
|
67
|
+
else showError('Delete failed.');
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Pin / unpin ────────────────────────────────────────────────────────
|
|
73
|
+
case '-p':
|
|
74
|
+
case '--pin': {
|
|
75
|
+
if (!rest) { showError('Provide a title: saveinme -p <title>'); break; }
|
|
76
|
+
const note = getNoteByTitle(rest);
|
|
77
|
+
if (!note) {
|
|
78
|
+
showError(`No note found with title "${rest}".`);
|
|
79
|
+
} else {
|
|
80
|
+
const nowPinned = togglePin(note.id);
|
|
81
|
+
showSuccess(`Note "${rest}" ${nowPinned ? '📌 pinned' : 'unpinned'}.`);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Search ─────────────────────────────────────────────────────────────
|
|
87
|
+
case '-f':
|
|
88
|
+
case '--find':
|
|
89
|
+
case '--search': {
|
|
90
|
+
if (!rest) { showError('Provide a search term: saveinme -f <term>'); break; }
|
|
91
|
+
const results = searchNotes(rest);
|
|
92
|
+
if (results.length === 0) showError(`No notes matched "${rest}".`);
|
|
93
|
+
else showNotesList(results, `Search results for "${rest}"`);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Storage path ───────────────────────────────────────────────────────
|
|
98
|
+
case '--path': {
|
|
99
|
+
showInfo(`Notes stored at:\n ${getDataPath()}`);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Help ───────────────────────────────────────────────────────────────
|
|
104
|
+
case '-h':
|
|
105
|
+
case '-help':
|
|
106
|
+
case '--help': {
|
|
107
|
+
showHelp();
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Unknown ────────────────────────────────────────────────────────────
|
|
112
|
+
default: {
|
|
113
|
+
showError(`Unknown flag "${flag}". Run saveinme -h for help.`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch(err => {
|
|
120
|
+
// Suppress Ctrl+C / forced-exit noise from inquirer
|
|
121
|
+
if (err?.name === 'ExitPromptError' || err?.message === 'cancelled') {
|
|
122
|
+
process.stdout.write('\n');
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
console.error(err);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "saveinme",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Your personal terminal notebook — save anything, permanently.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./bin/saveinme.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"saveinme": "bin/saveinme.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node bin/saveinme.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@inquirer/prompts": "^5.0.0",
|
|
20
|
+
"boxen": "^7.1.1",
|
|
21
|
+
"chalk": "^5.3.0",
|
|
22
|
+
"cli-table3": "^0.6.3"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"cli",
|
|
29
|
+
"notes",
|
|
30
|
+
"terminal",
|
|
31
|
+
"notebook",
|
|
32
|
+
"saveinme"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|
package/src/display.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import boxen from 'boxen';
|
|
3
|
+
import Table from 'cli-table3';
|
|
4
|
+
|
|
5
|
+
// ─── Color palettes ────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const PRIORITY_BADGE = {
|
|
8
|
+
high: chalk.bgRed.white.bold(' HIGH '),
|
|
9
|
+
medium: chalk.bgYellow.black.bold(' MED '),
|
|
10
|
+
low: chalk.bgGreen.black.bold(' LOW '),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const PRIORITY_COLOR = {
|
|
14
|
+
high: chalk.red,
|
|
15
|
+
medium: chalk.yellow,
|
|
16
|
+
low: chalk.green,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const CAT_COLORS = [
|
|
20
|
+
chalk.cyan, chalk.magenta, chalk.blue,
|
|
21
|
+
chalk.green, chalk.yellow, chalk.redBright,
|
|
22
|
+
];
|
|
23
|
+
const catColorCache = {};
|
|
24
|
+
let catColorIdx = 0;
|
|
25
|
+
|
|
26
|
+
function catColor(cat) {
|
|
27
|
+
if (!cat) return s => chalk.dim(s);
|
|
28
|
+
if (!catColorCache[cat]) {
|
|
29
|
+
catColorCache[cat] = CAT_COLORS[catColorIdx++ % CAT_COLORS.length];
|
|
30
|
+
}
|
|
31
|
+
return catColorCache[cat];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Date formatting ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export function formatDate(iso) {
|
|
37
|
+
if (!iso) return '—';
|
|
38
|
+
const d = new Date(iso);
|
|
39
|
+
const diff = Date.now() - d.getTime();
|
|
40
|
+
if (diff < 60_000) return chalk.dim('just now');
|
|
41
|
+
if (diff < 3_600_000) return chalk.dim(`${Math.floor(diff / 60_000)}m ago`);
|
|
42
|
+
if (diff < 86_400_000) return chalk.dim(`${Math.floor(diff / 3_600_000)}h ago`);
|
|
43
|
+
if (diff < 604_800_000) return chalk.dim(`${Math.floor(diff / 86_400_000)}d ago`);
|
|
44
|
+
return chalk.dim(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Header ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function showHeader() {
|
|
50
|
+
const logo =
|
|
51
|
+
chalk.bold.cyanBright('💾 saveinme') +
|
|
52
|
+
chalk.dim(' v1.0.0') +
|
|
53
|
+
'\n' +
|
|
54
|
+
chalk.dim('your personal terminal notebook');
|
|
55
|
+
|
|
56
|
+
console.log(
|
|
57
|
+
boxen(logo, {
|
|
58
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
59
|
+
margin: { top: 1, bottom: 0 },
|
|
60
|
+
borderStyle: 'round',
|
|
61
|
+
borderColor: 'cyan',
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Notes list ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function showNotesList(notes, heading = null) {
|
|
69
|
+
showHeader();
|
|
70
|
+
|
|
71
|
+
if (heading) {
|
|
72
|
+
console.log('\n' + chalk.bold.yellow(` ${heading}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (notes.length === 0) {
|
|
76
|
+
console.log(
|
|
77
|
+
boxen(
|
|
78
|
+
chalk.dim('No notes yet.\n') +
|
|
79
|
+
chalk.cyanBright('Run ') +
|
|
80
|
+
chalk.bold.white('saveinme -s') +
|
|
81
|
+
chalk.cyanBright(' to create your first note.'),
|
|
82
|
+
{
|
|
83
|
+
padding: 1,
|
|
84
|
+
margin: { top: 1, bottom: 1 },
|
|
85
|
+
borderStyle: 'round',
|
|
86
|
+
borderColor: 'gray',
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sort: pinned first, then by updatedAt desc
|
|
94
|
+
const sorted = [...notes].sort((a, b) => {
|
|
95
|
+
if (a.pinned && !b.pinned) return -1;
|
|
96
|
+
if (!a.pinned && b.pinned) return 1;
|
|
97
|
+
return new Date(b.updatedAt) - new Date(a.updatedAt);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
|
|
102
|
+
const table = new Table({
|
|
103
|
+
head: [
|
|
104
|
+
chalk.bold.cyanBright('#'),
|
|
105
|
+
chalk.bold.cyanBright('Title'),
|
|
106
|
+
chalk.bold.cyanBright('Category'),
|
|
107
|
+
chalk.bold.cyanBright('Tags'),
|
|
108
|
+
chalk.bold.cyanBright('Priority'),
|
|
109
|
+
chalk.bold.cyanBright('Words'),
|
|
110
|
+
chalk.bold.cyanBright('Updated'),
|
|
111
|
+
],
|
|
112
|
+
style: { head: [], border: ['cyan'] },
|
|
113
|
+
colWidths: [4, 26, 14, 26, 9, 7, 16],
|
|
114
|
+
wordWrap: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
sorted.forEach((note, i) => {
|
|
118
|
+
const pin = note.pinned ? chalk.yellow('📌') : ' ';
|
|
119
|
+
const title = `${pin} ${chalk.bold(truncate(note.title, 20))}`;
|
|
120
|
+
const cat = note.category
|
|
121
|
+
? catColor(note.category)(truncate(note.category, 11))
|
|
122
|
+
: chalk.dim('—');
|
|
123
|
+
const tags = (note.tags ?? []).length
|
|
124
|
+
? note.tags.map(t => chalk.magentaBright(`#${t}`)).join(' ').slice(0, 24)
|
|
125
|
+
: chalk.dim('—');
|
|
126
|
+
const pri = PRIORITY_BADGE[note.priority] ?? chalk.dim('—');
|
|
127
|
+
const words = chalk.dim(String(note.wordCount ?? 0));
|
|
128
|
+
const upd = formatDate(note.updatedAt);
|
|
129
|
+
|
|
130
|
+
table.push([String(i + 1), title, cat, tags, pri, words, upd]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log(table.toString());
|
|
134
|
+
|
|
135
|
+
console.log(
|
|
136
|
+
chalk.dim(
|
|
137
|
+
`\n ${notes.length} note${notes.length !== 1 ? 's' : ''} saved` +
|
|
138
|
+
` · saveinme -s add/edit` +
|
|
139
|
+
` · saveinme -v <title> view` +
|
|
140
|
+
` · saveinme -f <term> search` +
|
|
141
|
+
` · saveinme -h help\n`
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Full note view ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function showNote(note) {
|
|
149
|
+
const cc = catColor(note.category);
|
|
150
|
+
|
|
151
|
+
const metaParts = [
|
|
152
|
+
note.category ? `📁 ${cc(note.category)}` : null,
|
|
153
|
+
(note.tags ?? []).length
|
|
154
|
+
? note.tags.map(t => chalk.magentaBright(`#${t}`)).join(' ')
|
|
155
|
+
: null,
|
|
156
|
+
note.priority
|
|
157
|
+
? `${PRIORITY_COLOR[note.priority] ?? chalk.white}(●) ${note.priority}`
|
|
158
|
+
: null,
|
|
159
|
+
note.pinned ? chalk.yellow('📌 pinned') : null,
|
|
160
|
+
].filter(Boolean).join(' ');
|
|
161
|
+
|
|
162
|
+
const statsParts = [
|
|
163
|
+
chalk.dim(`${note.wordCount ?? 0} words`),
|
|
164
|
+
chalk.dim(`${note.charCount ?? 0} chars`),
|
|
165
|
+
chalk.dim(`created ${formatDate(note.createdAt)}`),
|
|
166
|
+
chalk.dim(`updated ${formatDate(note.updatedAt)}`),
|
|
167
|
+
].join(' · ');
|
|
168
|
+
|
|
169
|
+
const header =
|
|
170
|
+
chalk.bold.white(note.title) +
|
|
171
|
+
(metaParts ? '\n' + metaParts : '') +
|
|
172
|
+
'\n' + statsParts;
|
|
173
|
+
|
|
174
|
+
console.log(
|
|
175
|
+
boxen(header, {
|
|
176
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
177
|
+
margin: { top: 1, bottom: 0 },
|
|
178
|
+
borderStyle: 'round',
|
|
179
|
+
borderColor: 'cyan',
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
console.log(
|
|
184
|
+
boxen(chalk.white(note.content), {
|
|
185
|
+
padding: 1,
|
|
186
|
+
margin: { top: 0, bottom: 1 },
|
|
187
|
+
borderStyle: 'round',
|
|
188
|
+
borderColor: 'gray',
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Feedback boxes ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export function showSuccess(msg) {
|
|
196
|
+
console.log(
|
|
197
|
+
boxen(chalk.greenBright('✔ ') + chalk.bold.white(msg), {
|
|
198
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
199
|
+
margin: { top: 1, bottom: 1 },
|
|
200
|
+
borderStyle: 'round',
|
|
201
|
+
borderColor: 'green',
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function showError(msg) {
|
|
207
|
+
console.log(
|
|
208
|
+
boxen(chalk.redBright('✖ ') + chalk.bold.white(msg), {
|
|
209
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
210
|
+
margin: { top: 1, bottom: 1 },
|
|
211
|
+
borderStyle: 'round',
|
|
212
|
+
borderColor: 'red',
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function showInfo(msg) {
|
|
218
|
+
console.log(
|
|
219
|
+
boxen(chalk.cyanBright('ℹ ') + chalk.white(msg), {
|
|
220
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
221
|
+
margin: { top: 1, bottom: 1 },
|
|
222
|
+
borderStyle: 'round',
|
|
223
|
+
borderColor: 'cyan',
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Help ─────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export function showHelp() {
|
|
231
|
+
showHeader();
|
|
232
|
+
console.log(`
|
|
233
|
+
${chalk.bold.cyanBright('Usage:')}
|
|
234
|
+
|
|
235
|
+
${chalk.bold.white('saveinme')} List all saved notes
|
|
236
|
+
${chalk.bold.white('saveinme -s')} Create or edit a note (interactive)
|
|
237
|
+
${chalk.bold.white('saveinme -s')} ${chalk.italic('<title>')} Edit a specific note by title
|
|
238
|
+
${chalk.bold.white('saveinme -v')} ${chalk.italic('<title>')} View a note in full
|
|
239
|
+
${chalk.bold.white('saveinme -d')} ${chalk.italic('<title>')} Delete a note
|
|
240
|
+
${chalk.bold.white('saveinme -p')} ${chalk.italic('<title>')} Toggle pin on a note
|
|
241
|
+
${chalk.bold.white('saveinme -f')} ${chalk.italic('<term>')} Search by title, content, tags, category
|
|
242
|
+
${chalk.bold.white('saveinme -h')} Show this help
|
|
243
|
+
|
|
244
|
+
${chalk.bold.cyanBright('Note fields saved:')}
|
|
245
|
+
|
|
246
|
+
${chalk.cyan('·')} Title ${chalk.cyan('·')} Content (multi-line)
|
|
247
|
+
${chalk.cyan('·')} Category ${chalk.cyan('·')} Tags (multiple)
|
|
248
|
+
${chalk.cyan('·')} Priority ${chalk.cyan('·')} Pinned
|
|
249
|
+
${chalk.cyan('·')} Created date ${chalk.cyan('·')} Updated date
|
|
250
|
+
${chalk.cyan('·')} Word count ${chalk.cyan('·')} Char count
|
|
251
|
+
|
|
252
|
+
${chalk.bold.cyanBright('Storage:')}
|
|
253
|
+
|
|
254
|
+
${chalk.dim('~/.saveinme/notes.json')} ${chalk.dim('(permanent, survives reboots)')}
|
|
255
|
+
`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function truncate(str, n) {
|
|
261
|
+
return str.length > n ? str.slice(0, n - 1) + '…' : str;
|
|
262
|
+
}
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { input, select, confirm } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getAllNotes, saveNote, getNoteByTitle } from './store.js';
|
|
4
|
+
import { showHeader, showSuccess, showError } from './display.js';
|
|
5
|
+
import { multilineEditor } from './multiline.js';
|
|
6
|
+
|
|
7
|
+
// ─── Main editor entry point ──────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Opens the interactive note editor.
|
|
11
|
+
* Flow: Title → Content → (optional details) → Save
|
|
12
|
+
* @param {string|undefined} titleArg If provided, jump straight to editing that note.
|
|
13
|
+
*/
|
|
14
|
+
export async function openEditor(titleArg) {
|
|
15
|
+
showHeader();
|
|
16
|
+
console.log('');
|
|
17
|
+
|
|
18
|
+
const notes = getAllNotes();
|
|
19
|
+
let existingNote = null;
|
|
20
|
+
|
|
21
|
+
// ── Resolve which note we're working on ───────────────────────────────────
|
|
22
|
+
if (titleArg) {
|
|
23
|
+
existingNote = getNoteByTitle(titleArg);
|
|
24
|
+
if (!existingNote) {
|
|
25
|
+
console.log(
|
|
26
|
+
chalk.yellow(` Note "${titleArg}" not found — creating a new note with this title.\n`)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
} else if (notes.length > 0) {
|
|
30
|
+
const action = await select({
|
|
31
|
+
message: 'What would you like to do?',
|
|
32
|
+
choices: [
|
|
33
|
+
{ name: '✨ Create a new note', value: 'new' },
|
|
34
|
+
{ name: '✏️ Edit an existing note', value: 'edit' },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (action === 'edit') {
|
|
39
|
+
const sorted = [...notes].sort((a, b) => {
|
|
40
|
+
if (a.pinned && !b.pinned) return -1;
|
|
41
|
+
if (!a.pinned && b.pinned) return 1;
|
|
42
|
+
return new Date(b.updatedAt) - new Date(a.updatedAt);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const selectedId = await select({
|
|
46
|
+
message: 'Select a note to edit:',
|
|
47
|
+
choices: sorted.map(n => ({
|
|
48
|
+
name:
|
|
49
|
+
(n.pinned ? chalk.yellow('📌 ') : ' ') +
|
|
50
|
+
chalk.bold(n.title) +
|
|
51
|
+
chalk.dim(` (${n.category || 'uncategorized'} · ${n.wordCount}w)`),
|
|
52
|
+
value: n.id,
|
|
53
|
+
})),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
existingNote = notes.find(n => n.id === selectedId) ?? null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log('');
|
|
61
|
+
|
|
62
|
+
// ── 1. Title ──────────────────────────────────────────────────────────────
|
|
63
|
+
const title = await input({
|
|
64
|
+
message: 'Title:',
|
|
65
|
+
default: existingNote?.title ?? (titleArg || ''),
|
|
66
|
+
validate: v => v.trim().length > 0 || 'Title cannot be empty.',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── 2. Content (straight to the editor) ───────────────────────────────────
|
|
70
|
+
console.log('');
|
|
71
|
+
const content = await multilineEditor(existingNote?.content ?? '');
|
|
72
|
+
|
|
73
|
+
if (content === null) {
|
|
74
|
+
showError('Note discarded.');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const finalContent =
|
|
79
|
+
content.trim() === '' && existingNote?.content
|
|
80
|
+
? existingNote.content
|
|
81
|
+
: content;
|
|
82
|
+
|
|
83
|
+
if (!finalContent.trim()) {
|
|
84
|
+
showError('Content cannot be empty. Note not saved.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 3. Optional details ───────────────────────────────────────────────────
|
|
89
|
+
console.log('');
|
|
90
|
+
const addDetails = await confirm({
|
|
91
|
+
message: chalk.dim('Add tags / category / priority / pin? (optional)'),
|
|
92
|
+
default: false,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let tags = existingNote?.tags ?? [];
|
|
96
|
+
let category = existingNote?.category ?? '';
|
|
97
|
+
let priority = existingNote?.priority ?? 'medium';
|
|
98
|
+
let pinned = existingNote?.pinned ?? false;
|
|
99
|
+
|
|
100
|
+
if (addDetails) {
|
|
101
|
+
console.log('');
|
|
102
|
+
|
|
103
|
+
// Category
|
|
104
|
+
const existingCats = [...new Set(notes.map(n => n.category).filter(Boolean))];
|
|
105
|
+
if (existingCats.length > 0) {
|
|
106
|
+
const catChoice = await select({
|
|
107
|
+
message: 'Category:',
|
|
108
|
+
choices: [
|
|
109
|
+
...existingCats.map(c => ({ name: c, value: c })),
|
|
110
|
+
{ name: chalk.cyan('+ New category…'), value: '__new__' },
|
|
111
|
+
{ name: chalk.dim('— None'), value: '' },
|
|
112
|
+
],
|
|
113
|
+
default: existingNote?.category || '',
|
|
114
|
+
});
|
|
115
|
+
category =
|
|
116
|
+
catChoice === '__new__'
|
|
117
|
+
? await input({ message: 'New category name:' })
|
|
118
|
+
: catChoice;
|
|
119
|
+
} else {
|
|
120
|
+
category = await input({
|
|
121
|
+
message: 'Category (optional):',
|
|
122
|
+
default: existingNote?.category ?? '',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Tags
|
|
127
|
+
const tagsRaw = await input({
|
|
128
|
+
message: 'Tags (comma-separated):',
|
|
129
|
+
default: (existingNote?.tags ?? []).join(', '),
|
|
130
|
+
});
|
|
131
|
+
tags = tagsRaw
|
|
132
|
+
.split(',')
|
|
133
|
+
.map(t => t.trim().toLowerCase().replace(/^#/, ''))
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
|
|
136
|
+
// Priority
|
|
137
|
+
priority = await select({
|
|
138
|
+
message: 'Priority:',
|
|
139
|
+
choices: [
|
|
140
|
+
{ name: chalk.green('🟢 Low'), value: 'low' },
|
|
141
|
+
{ name: chalk.yellow('🟡 Medium'), value: 'medium' },
|
|
142
|
+
{ name: chalk.red('🔴 High'), value: 'high' },
|
|
143
|
+
],
|
|
144
|
+
default: existingNote?.priority ?? 'medium',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Pin
|
|
148
|
+
pinned = await confirm({
|
|
149
|
+
message: 'Pin this note?',
|
|
150
|
+
default: existingNote?.pinned ?? false,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Persist ───────────────────────────────────────────────────────────────
|
|
155
|
+
saveNote({
|
|
156
|
+
id: existingNote?.id,
|
|
157
|
+
title: title.trim(),
|
|
158
|
+
content: finalContent,
|
|
159
|
+
tags,
|
|
160
|
+
category: category.trim(),
|
|
161
|
+
priority,
|
|
162
|
+
pinned,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const wc = finalContent.trim().split(/\s+/).filter(Boolean).length;
|
|
166
|
+
showSuccess(
|
|
167
|
+
`Note "${title.trim()}" saved!` +
|
|
168
|
+
chalk.dim(` (${wc} words)`)
|
|
169
|
+
);
|
|
170
|
+
}
|
package/src/multiline.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cross-platform multi-line in-terminal text editor.
|
|
6
|
+
*
|
|
7
|
+
* The user types line by line. Pressing Enter on a line with just
|
|
8
|
+
* ":save" saves the content. ":cancel" discards it.
|
|
9
|
+
*
|
|
10
|
+
* Works on Windows (PowerShell / CMD) and Linux/macOS terminals alike
|
|
11
|
+
* since it only uses readline — no raw-mode or OS-specific APIs needed.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} [defaultContent=''] Pre-filled content when editing an existing note
|
|
14
|
+
* @returns {Promise<string|null>} The collected text, or null if cancelled
|
|
15
|
+
*/
|
|
16
|
+
export async function multilineEditor(defaultContent = '') {
|
|
17
|
+
// Show existing content as a dimmed preview if editing
|
|
18
|
+
if (defaultContent) {
|
|
19
|
+
console.log(chalk.dim('\n ── current content ──────────────────'));
|
|
20
|
+
defaultContent.split('\n').forEach(line => {
|
|
21
|
+
console.log(chalk.dim(` │ ${line}`));
|
|
22
|
+
});
|
|
23
|
+
console.log(chalk.dim(' ─────────────────────────────────────'));
|
|
24
|
+
console.log(
|
|
25
|
+
chalk.dim(' Start typing to replace, or type ') +
|
|
26
|
+
chalk.bold.white(':keep') +
|
|
27
|
+
chalk.dim(' to keep as-is.\n')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(
|
|
32
|
+
chalk.bold.cyanBright(' ╭─ Write your note ─────────────────────────────╮') + '\n' +
|
|
33
|
+
chalk.cyanBright(' │') + chalk.dim(' Press Enter for a new line ') + chalk.cyanBright('│') + '\n' +
|
|
34
|
+
chalk.cyanBright(' │') + chalk.dim(' Type ') + chalk.bold.white(':save') + chalk.dim(' on a new line → save ') + chalk.cyanBright('│') + '\n' +
|
|
35
|
+
chalk.cyanBright(' │') + chalk.dim(' Type ') + chalk.bold.white(':cancel') + chalk.dim(' on a new line → discard ') + chalk.cyanBright('│') + '\n' +
|
|
36
|
+
(defaultContent
|
|
37
|
+
? chalk.cyanBright(' │') + chalk.dim(' Type ') + chalk.bold.white(':keep') + chalk.dim(' on a new line → keep original ') + chalk.cyanBright('│') + '\n'
|
|
38
|
+
: '') +
|
|
39
|
+
chalk.bold.cyanBright(' ╰────────────────────────────────────────────────╯') + '\n'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
const rl = createInterface({
|
|
44
|
+
input: process.stdin,
|
|
45
|
+
output: process.stdout,
|
|
46
|
+
terminal: true,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const lines = [];
|
|
50
|
+
|
|
51
|
+
const prompt = () => process.stdout.write(chalk.cyanBright(' ❯ '));
|
|
52
|
+
prompt();
|
|
53
|
+
|
|
54
|
+
rl.on('line', line => {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
|
|
57
|
+
if (trimmed === ':save') {
|
|
58
|
+
rl.close();
|
|
59
|
+
resolve(lines.join('\n'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (trimmed === ':cancel') {
|
|
64
|
+
rl.close();
|
|
65
|
+
resolve(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (trimmed === ':keep' && defaultContent) {
|
|
70
|
+
rl.close();
|
|
71
|
+
resolve(defaultContent);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(line);
|
|
76
|
+
prompt();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Handle Ctrl+C gracefully — treat as cancel
|
|
80
|
+
rl.on('SIGINT', () => {
|
|
81
|
+
rl.close();
|
|
82
|
+
resolve(null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Handle Ctrl+D (EOF) on Linux/macOS — treat as save
|
|
86
|
+
rl.on('close', () => {
|
|
87
|
+
if (lines.length > 0) resolve(lines.join('\n'));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
|
|
6
|
+
const DATA_DIR = join(homedir(), '.saveinme');
|
|
7
|
+
const DATA_FILE = join(DATA_DIR, 'notes.json');
|
|
8
|
+
|
|
9
|
+
function ensureDir() {
|
|
10
|
+
if (!existsSync(DATA_DIR)) {
|
|
11
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readAll() {
|
|
16
|
+
ensureDir();
|
|
17
|
+
if (!existsSync(DATA_FILE)) return [];
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(DATA_FILE, 'utf-8'));
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeAll(notes) {
|
|
26
|
+
ensureDir();
|
|
27
|
+
writeFileSync(DATA_FILE, JSON.stringify(notes, null, 2), 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function countWords(text) {
|
|
31
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getAllNotes() {
|
|
35
|
+
return readAll();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getNoteByTitle(title) {
|
|
39
|
+
const notes = readAll();
|
|
40
|
+
return notes.find(n => n.title.toLowerCase() === title.toLowerCase()) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getNoteById(id) {
|
|
44
|
+
const notes = readAll();
|
|
45
|
+
return notes.find(n => n.id === id) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveNote(noteData) {
|
|
49
|
+
const notes = readAll();
|
|
50
|
+
const now = new Date().toISOString();
|
|
51
|
+
const existingIdx = notes.findIndex(n => n.id === noteData.id);
|
|
52
|
+
|
|
53
|
+
if (existingIdx !== -1) {
|
|
54
|
+
notes[existingIdx] = {
|
|
55
|
+
...notes[existingIdx],
|
|
56
|
+
...noteData,
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
wordCount: countWords(noteData.content),
|
|
59
|
+
charCount: noteData.content.length,
|
|
60
|
+
};
|
|
61
|
+
} else {
|
|
62
|
+
notes.push({
|
|
63
|
+
id: randomUUID(),
|
|
64
|
+
title: noteData.title,
|
|
65
|
+
content: noteData.content,
|
|
66
|
+
tags: noteData.tags ?? [],
|
|
67
|
+
category: noteData.category ?? '',
|
|
68
|
+
priority: noteData.priority ?? 'medium',
|
|
69
|
+
pinned: noteData.pinned ?? false,
|
|
70
|
+
color: noteData.color ?? '',
|
|
71
|
+
createdAt: now,
|
|
72
|
+
updatedAt: now,
|
|
73
|
+
wordCount: countWords(noteData.content),
|
|
74
|
+
charCount: noteData.content.length,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
writeAll(notes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function deleteNoteById(id) {
|
|
82
|
+
const notes = readAll();
|
|
83
|
+
const next = notes.filter(n => n.id !== id);
|
|
84
|
+
writeAll(next);
|
|
85
|
+
return next.length < notes.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function togglePin(id) {
|
|
89
|
+
const notes = readAll();
|
|
90
|
+
const idx = notes.findIndex(n => n.id === id);
|
|
91
|
+
if (idx === -1) return false;
|
|
92
|
+
notes[idx].pinned = !notes[idx].pinned;
|
|
93
|
+
writeAll(notes);
|
|
94
|
+
return notes[idx].pinned;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function searchNotes(term) {
|
|
98
|
+
const notes = readAll();
|
|
99
|
+
const q = term.toLowerCase();
|
|
100
|
+
return notes.filter(n =>
|
|
101
|
+
n.title.toLowerCase().includes(q) ||
|
|
102
|
+
n.content.toLowerCase().includes(q) ||
|
|
103
|
+
(n.tags ?? []).some(t => t.toLowerCase().includes(q)) ||
|
|
104
|
+
(n.category ?? '').toLowerCase().includes(q)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getDataPath() {
|
|
109
|
+
return DATA_FILE;
|
|
110
|
+
}
|