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/README.md +64 -6
- package/bin/saveinme.js +99 -24
- package/package.json +2 -1
- package/src/ai.js +447 -0
- package/src/display.js +48 -10
- package/src/editor.js +150 -3
- package/src/store.js +16 -8
- package/src/sync.js +165 -0
- package/src/tui.js +336 -0
package/src/tui.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getAllNotes, togglePin, deleteNoteById, getNoteById } from './store.js';
|
|
4
|
+
import { showNote, showError, showSuccess } from './display.js';
|
|
5
|
+
import { openEditor } from './editor.js';
|
|
6
|
+
import { triggerAutoSync } from './sync.js';
|
|
7
|
+
|
|
8
|
+
let notes = [];
|
|
9
|
+
let selectedIndex = 0;
|
|
10
|
+
let searchQuery = '';
|
|
11
|
+
let searchMode = false;
|
|
12
|
+
let screenHeight = process.stdout.rows;
|
|
13
|
+
let screenWidth = process.stdout.columns;
|
|
14
|
+
let deleteConfirmId = null; // Stored note ID when asking for delete confirmation
|
|
15
|
+
let tuiSuspended = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps text into lines of maximum width.
|
|
19
|
+
*/
|
|
20
|
+
function wrapText(text, limit) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
const rawLines = text.split('\n');
|
|
23
|
+
for (const line of rawLines) {
|
|
24
|
+
if (line.length <= limit) {
|
|
25
|
+
lines.push(line);
|
|
26
|
+
} else {
|
|
27
|
+
let current = line;
|
|
28
|
+
while (current.length > limit) {
|
|
29
|
+
let spaceIdx = current.lastIndexOf(' ', limit);
|
|
30
|
+
if (spaceIdx === -1 || spaceIdx === 0) spaceIdx = limit;
|
|
31
|
+
lines.push(current.slice(0, spaceIdx));
|
|
32
|
+
current = current.slice(spaceIdx).trim();
|
|
33
|
+
}
|
|
34
|
+
if (current) lines.push(current);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Loads and sorts notes, applying search filters if active.
|
|
42
|
+
*/
|
|
43
|
+
function loadNotes() {
|
|
44
|
+
const allNotes = getAllNotes();
|
|
45
|
+
// Sort: pinned first, then by updatedAt desc
|
|
46
|
+
const sorted = allNotes.sort((a, b) => {
|
|
47
|
+
if (a.pinned && !b.pinned) return -1;
|
|
48
|
+
if (!a.pinned && b.pinned) return 1;
|
|
49
|
+
return new Date(b.updatedAt) - new Date(a.updatedAt);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (searchQuery.trim()) {
|
|
53
|
+
const q = searchQuery.toLowerCase();
|
|
54
|
+
notes = sorted.filter(n =>
|
|
55
|
+
n.title.toLowerCase().includes(q) ||
|
|
56
|
+
n.content.toLowerCase().includes(q) ||
|
|
57
|
+
(n.tags ?? []).some(t => t.toLowerCase().includes(q)) ||
|
|
58
|
+
(n.category ?? '').toLowerCase().includes(q)
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
notes = sorted;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Adjust selected index to stay in bounds
|
|
65
|
+
if (selectedIndex >= notes.length) {
|
|
66
|
+
selectedIndex = Math.max(0, notes.length - 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Renders the dashboard screen.
|
|
72
|
+
*/
|
|
73
|
+
function draw() {
|
|
74
|
+
screenWidth = process.stdout.columns;
|
|
75
|
+
screenHeight = process.stdout.rows;
|
|
76
|
+
|
|
77
|
+
// Clear screen and move cursor to top-left
|
|
78
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
79
|
+
|
|
80
|
+
// Draw Header
|
|
81
|
+
const header = ` ๐พ saveinme Dashboard [v2.0.0] ยท ${notes.length} note(s) shown`;
|
|
82
|
+
console.log(chalk.bgCyan.black.bold(header.padEnd(screenWidth)));
|
|
83
|
+
|
|
84
|
+
const leftWidth = Math.max(30, Math.min(50, Math.floor(screenWidth * 0.35)));
|
|
85
|
+
const rightWidth = screenWidth - leftWidth - 3;
|
|
86
|
+
const contentHeight = screenHeight - 3; // Subtract header and bottom bar
|
|
87
|
+
|
|
88
|
+
const selectedNote = notes[selectedIndex];
|
|
89
|
+
let rightLines = [];
|
|
90
|
+
|
|
91
|
+
if (selectedNote) {
|
|
92
|
+
// Generate right pane preview lines
|
|
93
|
+
rightLines.push(chalk.bold.yellow(selectedNote.title));
|
|
94
|
+
const meta = [
|
|
95
|
+
selectedNote.category ? `๐ ${selectedNote.category}` : null,
|
|
96
|
+
(selectedNote.tags ?? []).length ? selectedNote.tags.map(t => `#${t}`).join(' ') : null,
|
|
97
|
+
selectedNote.pinned ? '๐ pinned' : null,
|
|
98
|
+
].filter(Boolean).join(' ยท ');
|
|
99
|
+
if (meta) rightLines.push(chalk.dim(meta));
|
|
100
|
+
rightLines.push(chalk.dim(`Words: ${selectedNote.wordCount ?? 0} ยท Updated: ${new Date(selectedNote.updatedAt).toLocaleDateString()}`));
|
|
101
|
+
rightLines.push(chalk.gray('โ'.repeat(rightWidth)));
|
|
102
|
+
rightLines.push('');
|
|
103
|
+
|
|
104
|
+
const contentWrapped = wrapText(selectedNote.content, rightWidth);
|
|
105
|
+
rightLines.push(...contentWrapped);
|
|
106
|
+
} else {
|
|
107
|
+
rightLines.push(chalk.dim('No notes found.'));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Draw content rows
|
|
111
|
+
for (let r = 0; r < contentHeight; r++) {
|
|
112
|
+
// Left Pane (Notes list)
|
|
113
|
+
let leftContent = '';
|
|
114
|
+
if (r < notes.length) {
|
|
115
|
+
const note = notes[r];
|
|
116
|
+
const pin = note.pinned ? '๐' : ' ';
|
|
117
|
+
const label = `${pin} ${note.title}`;
|
|
118
|
+
const truncated = label.slice(0, leftWidth - 2).padEnd(leftWidth - 2);
|
|
119
|
+
|
|
120
|
+
if (r === selectedIndex) {
|
|
121
|
+
leftContent = chalk.bgBlue.white.bold(` > ${truncated} `);
|
|
122
|
+
} else {
|
|
123
|
+
leftContent = ` ${truncated} `;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
leftContent = ' '.repeat(leftWidth + 2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Right Pane
|
|
130
|
+
let rightContent = '';
|
|
131
|
+
if (r < rightLines.length) {
|
|
132
|
+
rightContent = rightLines[r];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Output combined line
|
|
136
|
+
const border = chalk.cyan('โ');
|
|
137
|
+
console.log(`${leftContent}${border} ${rightContent}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Draw Bottom Bar
|
|
141
|
+
let bottomBar = '';
|
|
142
|
+
if (deleteConfirmId) {
|
|
143
|
+
bottomBar = chalk.bgRed.white.bold(` โ ๏ธ Are you sure you want to delete this note? (y/n) `);
|
|
144
|
+
} else if (searchMode) {
|
|
145
|
+
bottomBar = chalk.bgYellow.black.bold(` ๐ Search: ${searchQuery}_ `);
|
|
146
|
+
} else {
|
|
147
|
+
bottomBar = chalk.bgGray.black(
|
|
148
|
+
` [โ
] Nav ยท [Enter] Edit ยท [c] Create ยท [p] Pin ยท [d] Delete ยท [/] Search ยท [q] Quit`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write(bottomBar.padEnd(screenWidth));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Cleans up terminal settings and exits raw mode.
|
|
156
|
+
*/
|
|
157
|
+
function exitTUI() {
|
|
158
|
+
process.stdin.setRawMode(false);
|
|
159
|
+
process.stdin.pause();
|
|
160
|
+
// Clear screen and show cursor
|
|
161
|
+
process.stdout.write('\x1b[2J\x1b[H\x1b[?25h');
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Suspends TUI key listeners to run an interactive terminal edit/create session.
|
|
167
|
+
*/
|
|
168
|
+
async function runInteractiveCommand(callback) {
|
|
169
|
+
tuiSuspended = true;
|
|
170
|
+
// Detach keypress listener to avoid conflicts with inquirer prompts
|
|
171
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
172
|
+
|
|
173
|
+
// Exit raw mode and show cursor
|
|
174
|
+
process.stdin.setRawMode(false);
|
|
175
|
+
process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await callback();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(err);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Restore raw mode and hide cursor
|
|
184
|
+
process.stdin.setRawMode(true);
|
|
185
|
+
process.stdin.resume();
|
|
186
|
+
process.stdin.on('keypress', handleKeypress);
|
|
187
|
+
process.stdout.write('\x1b[?25l');
|
|
188
|
+
tuiSuspended = false;
|
|
189
|
+
|
|
190
|
+
loadNotes();
|
|
191
|
+
draw();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Handle keypress events for the TUI.
|
|
196
|
+
*/
|
|
197
|
+
async function handleKeypress(str, key) {
|
|
198
|
+
if (tuiSuspended) return;
|
|
199
|
+
if (!key) return;
|
|
200
|
+
|
|
201
|
+
// Graceful exit on Ctrl+C
|
|
202
|
+
if (key.ctrl && key.name === 'c') {
|
|
203
|
+
exitTUI();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Mode: Delete Confirmation ---
|
|
208
|
+
if (deleteConfirmId) {
|
|
209
|
+
if (key.name === 'y') {
|
|
210
|
+
deleteNoteById(deleteConfirmId);
|
|
211
|
+
triggerAutoSync();
|
|
212
|
+
deleteConfirmId = null;
|
|
213
|
+
loadNotes();
|
|
214
|
+
draw();
|
|
215
|
+
} else if (key.name === 'n' || key.name === 'escape') {
|
|
216
|
+
deleteConfirmId = null;
|
|
217
|
+
draw();
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Mode: Search Input ---
|
|
223
|
+
if (searchMode) {
|
|
224
|
+
if (key.name === 'escape') {
|
|
225
|
+
searchQuery = '';
|
|
226
|
+
searchMode = false;
|
|
227
|
+
loadNotes();
|
|
228
|
+
draw();
|
|
229
|
+
} else if (key.name === 'enter' || key.name === 'return') {
|
|
230
|
+
searchMode = false;
|
|
231
|
+
draw();
|
|
232
|
+
} else if (key.name === 'backspace') {
|
|
233
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
234
|
+
loadNotes();
|
|
235
|
+
draw();
|
|
236
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
237
|
+
searchQuery += str;
|
|
238
|
+
loadNotes();
|
|
239
|
+
draw();
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Mode: Normal Navigation ---
|
|
245
|
+
switch (key.name) {
|
|
246
|
+
case 'up':
|
|
247
|
+
case 'k':
|
|
248
|
+
if (selectedIndex > 0) {
|
|
249
|
+
selectedIndex--;
|
|
250
|
+
draw();
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'down':
|
|
255
|
+
case 'j':
|
|
256
|
+
if (selectedIndex < notes.length - 1) {
|
|
257
|
+
selectedIndex++;
|
|
258
|
+
draw();
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case 'p': {
|
|
263
|
+
const note = notes[selectedIndex];
|
|
264
|
+
if (note) {
|
|
265
|
+
togglePin(note.id);
|
|
266
|
+
triggerAutoSync();
|
|
267
|
+
loadNotes();
|
|
268
|
+
draw();
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case 'd': {
|
|
274
|
+
const note = notes[selectedIndex];
|
|
275
|
+
if (note) {
|
|
276
|
+
deleteConfirmId = note.id;
|
|
277
|
+
draw();
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case '/':
|
|
283
|
+
searchMode = true;
|
|
284
|
+
draw();
|
|
285
|
+
break;
|
|
286
|
+
|
|
287
|
+
case 'c':
|
|
288
|
+
setImmediate(async () => {
|
|
289
|
+
await runInteractiveCommand(async () => {
|
|
290
|
+
await openEditor();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
break;
|
|
294
|
+
|
|
295
|
+
case 'enter':
|
|
296
|
+
case 'return': {
|
|
297
|
+
const note = notes[selectedIndex];
|
|
298
|
+
if (note) {
|
|
299
|
+
setImmediate(async () => {
|
|
300
|
+
await runInteractiveCommand(async () => {
|
|
301
|
+
await openEditor(note.title);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'q':
|
|
309
|
+
case 'escape':
|
|
310
|
+
exitTUI();
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Entry point to launch the TUI Dashboard.
|
|
317
|
+
*/
|
|
318
|
+
export function launchTUI() {
|
|
319
|
+
loadNotes();
|
|
320
|
+
|
|
321
|
+
// Enter raw mode and hide cursor
|
|
322
|
+
process.stdin.setRawMode(true);
|
|
323
|
+
readline.emitKeypressEvents(process.stdin);
|
|
324
|
+
process.stdin.resume();
|
|
325
|
+
process.stdout.write('\x1b[?25l'); // Hide cursor
|
|
326
|
+
|
|
327
|
+
draw();
|
|
328
|
+
|
|
329
|
+
// Listen for terminal resize
|
|
330
|
+
process.stdout.on('resize', () => {
|
|
331
|
+
draw();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Handle keypresses
|
|
335
|
+
process.stdin.on('keypress', handleKeypress);
|
|
336
|
+
}
|