apple-notes-tui 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/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/notes.js +4 -0
- package/package.json +40 -0
- package/src/app.js +775 -0
- package/src/config.js +27 -0
- package/src/keys.js +172 -0
- package/src/store.js +174 -0
- package/src/term.js +103 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
// Main TUI: state, key handling, and rendering for all views.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import * as t from './term.js';
|
|
8
|
+
import { loadConfig, saveConfig, configPath } from './config.js';
|
|
9
|
+
import { parseInput, effectiveBindings, actionFor, prettyKey } from './keys.js';
|
|
10
|
+
import { fetchNoteList, fetchNoteText, openInNotes, openAttachment, saveNoteText, createNote } from './store.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
14
|
+
|
|
15
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
16
|
+
const KEYMAPS = ['hybrid', 'vim', 'emacs'];
|
|
17
|
+
const SORTS = ['modified', 'created', 'title'];
|
|
18
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
19
|
+
|
|
20
|
+
const LOCKED_TEXT =
|
|
21
|
+
'🔒 This note is locked.\n\n' +
|
|
22
|
+
'macOS only lets Notes.app itself take the password or Touch ID prompt.\n\n' +
|
|
23
|
+
'Press o to open it in Notes.app and unlock it there, then come back\n' +
|
|
24
|
+
'and press r to reload the text.';
|
|
25
|
+
|
|
26
|
+
const state = {
|
|
27
|
+
config: null,
|
|
28
|
+
bindings: null,
|
|
29
|
+
view: 'list', // list | note | help | settings | error
|
|
30
|
+
returnView: 'list',
|
|
31
|
+
notes: [],
|
|
32
|
+
loading: true,
|
|
33
|
+
error: null,
|
|
34
|
+
sel: 0,
|
|
35
|
+
query: '',
|
|
36
|
+
searching: false,
|
|
37
|
+
digits: '',
|
|
38
|
+
digitTimer: null,
|
|
39
|
+
pending: null, // chord prefix (e.g. 'g')
|
|
40
|
+
note: null,
|
|
41
|
+
noteText: null,
|
|
42
|
+
noteAtts: [],
|
|
43
|
+
noteScroll: 0,
|
|
44
|
+
toast: null,
|
|
45
|
+
bodyCache: new Map(),
|
|
46
|
+
settingsSel: 0,
|
|
47
|
+
spinner: 0,
|
|
48
|
+
spinTimer: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export async function run(argv) {
|
|
52
|
+
if (argv.includes('-h') || argv.includes('--help')) return printUsage();
|
|
53
|
+
if (argv.includes('-v') || argv.includes('--version')) return console.log(pkg.version);
|
|
54
|
+
if (argv[0] === 'list' || !process.stdout.isTTY || !process.stdin.isTTY) return plainList();
|
|
55
|
+
startTui();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printUsage() {
|
|
59
|
+
console.log(`notes ${pkg.version} — a terminal UI for Apple Notes
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
notes open the interactive UI
|
|
63
|
+
notes list print all notes as plain text
|
|
64
|
+
notes --help show this help
|
|
65
|
+
|
|
66
|
+
Config: ${configPath}
|
|
67
|
+
Keys: press ? inside the UI for keybindings`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function plainList() {
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
const notes = sorted(await fetchNoteList(), config.sort);
|
|
73
|
+
const folderW = Math.min(20, Math.max(5, ...notes.map((n) => n.folder.length)));
|
|
74
|
+
for (const n of notes) {
|
|
75
|
+
console.log(`${fmtDate(n.modified).padEnd(13)} ${n.folder.padEnd(folderW)} ${n.locked ? '🔒 ' : ''}${n.title}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- lifecycle
|
|
80
|
+
|
|
81
|
+
function startTui() {
|
|
82
|
+
state.config = loadConfig();
|
|
83
|
+
state.bindings = effectiveBindings(state.config);
|
|
84
|
+
|
|
85
|
+
process.stdin.setRawMode(true);
|
|
86
|
+
process.stdin.resume();
|
|
87
|
+
process.stdin.on('data', (chunk) => {
|
|
88
|
+
for (const key of parseInput(chunk.toString('utf8'))) handleKey(key);
|
|
89
|
+
});
|
|
90
|
+
process.stdout.on('resize', render);
|
|
91
|
+
process.on('uncaughtException', (err) => {
|
|
92
|
+
restoreTerminal();
|
|
93
|
+
console.error(err);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
process.stdout.write(t.altOn + t.mouseOn);
|
|
98
|
+
render();
|
|
99
|
+
loadList();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function restoreTerminal() {
|
|
103
|
+
clearInterval(state.spinTimer);
|
|
104
|
+
clearTimeout(state.digitTimer);
|
|
105
|
+
process.stdout.write(t.mouseOff + t.altOff);
|
|
106
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function exit() {
|
|
110
|
+
restoreTerminal();
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function startSpinner() {
|
|
115
|
+
if (state.spinTimer) return;
|
|
116
|
+
state.spinTimer = setInterval(() => {
|
|
117
|
+
state.spinner++;
|
|
118
|
+
render();
|
|
119
|
+
}, 80);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stopSpinner() {
|
|
123
|
+
clearInterval(state.spinTimer);
|
|
124
|
+
state.spinTimer = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// -------------------------------------------------------------------- data
|
|
128
|
+
|
|
129
|
+
function sorted(notes, sort) {
|
|
130
|
+
const copy = [...notes];
|
|
131
|
+
if (sort === 'title') copy.sort((a, b) => a.title.localeCompare(b.title));
|
|
132
|
+
else if (sort === 'created') copy.sort((a, b) => (b.created || '').localeCompare(a.created || ''));
|
|
133
|
+
else copy.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''));
|
|
134
|
+
return copy;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function filtered() {
|
|
138
|
+
if (!state.query) return state.notes;
|
|
139
|
+
const q = state.query.toLowerCase();
|
|
140
|
+
return state.notes.filter((n) => n.title.toLowerCase().includes(q));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadList() {
|
|
144
|
+
state.loading = true;
|
|
145
|
+
state.error = null;
|
|
146
|
+
startSpinner();
|
|
147
|
+
try {
|
|
148
|
+
state.notes = sorted(await fetchNoteList(), state.config.sort);
|
|
149
|
+
state.sel = Math.max(0, Math.min(state.sel, filtered().length - 1));
|
|
150
|
+
if (state.view === 'error') state.view = 'list';
|
|
151
|
+
} catch (err) {
|
|
152
|
+
state.error = err.message;
|
|
153
|
+
state.view = 'error';
|
|
154
|
+
} finally {
|
|
155
|
+
state.loading = false;
|
|
156
|
+
stopSpinner();
|
|
157
|
+
render();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function openNote(item) {
|
|
162
|
+
state.view = 'note';
|
|
163
|
+
state.note = item;
|
|
164
|
+
state.noteScroll = 0;
|
|
165
|
+
const cached = state.bodyCache.get(item.id);
|
|
166
|
+
state.noteText = cached?.text ?? null;
|
|
167
|
+
state.noteAtts = cached?.attachments ?? [];
|
|
168
|
+
if (cached) return render();
|
|
169
|
+
|
|
170
|
+
startSpinner();
|
|
171
|
+
try {
|
|
172
|
+
const { text, attachments } = await fetchNoteText(item.id);
|
|
173
|
+
if (item.locked && !text.trim()) {
|
|
174
|
+
// Locked notes read as empty until unlocked in Notes.app. Don't cache,
|
|
175
|
+
// so reopening after an unlock picks up the real text.
|
|
176
|
+
if (state.note?.id === item.id) state.noteText = LOCKED_TEXT;
|
|
177
|
+
} else {
|
|
178
|
+
state.bodyCache.set(item.id, { text, attachments });
|
|
179
|
+
if (state.note?.id === item.id) { state.noteText = text; state.noteAtts = attachments; }
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (state.note?.id === item.id) state.noteText = `⚠ Could not load note: ${err.message}`;
|
|
183
|
+
} finally {
|
|
184
|
+
stopSpinner();
|
|
185
|
+
render();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -------------------------------------------------------------------- keys
|
|
190
|
+
|
|
191
|
+
function handleKey(key) {
|
|
192
|
+
state.toast = null;
|
|
193
|
+
if (typeof key !== 'string') return handleClick(key);
|
|
194
|
+
if (key === 'wheelup' || key === 'wheeldown') return handleWheel(key);
|
|
195
|
+
if (key === 'ctrl+c') return exit();
|
|
196
|
+
if (state.searching) return handleSearchKey(key);
|
|
197
|
+
if (state.view === 'list' && /^[0-9]$/.test(key)) return handleDigit(key);
|
|
198
|
+
if (state.view === 'note' && /^[1-9]$/.test(key)) return openFileAt(+key - 1);
|
|
199
|
+
|
|
200
|
+
const { action, pending } = actionFor(state.bindings, key, state.pending);
|
|
201
|
+
state.pending = pending;
|
|
202
|
+
if (!action) return;
|
|
203
|
+
|
|
204
|
+
switch (action) {
|
|
205
|
+
case 'quit': return exit();
|
|
206
|
+
case 'help':
|
|
207
|
+
if (state.view === 'help') state.view = state.returnView;
|
|
208
|
+
else { state.returnView = state.view; state.view = 'help'; }
|
|
209
|
+
break;
|
|
210
|
+
case 'settings':
|
|
211
|
+
if (state.view !== 'settings') { state.returnView = state.view === 'help' ? state.returnView : state.view; state.view = 'settings'; state.settingsSel = 0; }
|
|
212
|
+
break;
|
|
213
|
+
case 'refresh':
|
|
214
|
+
if (state.view === 'list' || state.view === 'error') { state.view = 'list'; loadList(); return; }
|
|
215
|
+
if (state.view === 'note' && state.note) { state.bodyCache.delete(state.note.id); openNote(state.note); return; }
|
|
216
|
+
break;
|
|
217
|
+
case 'openExternal': {
|
|
218
|
+
const target = state.view === 'note' ? state.note : state.view === 'list' ? filtered()[state.sel] : null;
|
|
219
|
+
if (target) openInNotes(target.id).catch(() => {});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'search':
|
|
223
|
+
if (state.view === 'list') { state.searching = true; }
|
|
224
|
+
break;
|
|
225
|
+
case 'back': handleBack(); break;
|
|
226
|
+
case 'edit': {
|
|
227
|
+
const target = state.view === 'note' ? state.note : state.view === 'list' ? filtered()[state.sel] : null;
|
|
228
|
+
if (target) editNote(target);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
case 'new':
|
|
232
|
+
if (state.view === 'list' || state.view === 'note') newNote();
|
|
233
|
+
return;
|
|
234
|
+
default: handleViewAction(action); break;
|
|
235
|
+
}
|
|
236
|
+
render();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Screen rows are 1-based: row 2 is the header (with the × close button at
|
|
240
|
+
// the right edge), rows 4+ are the list/settings rows (blank, header, box top).
|
|
241
|
+
function handleClick({ x, y }) {
|
|
242
|
+
const { W } = layout();
|
|
243
|
+
if (y === 2 && x >= W - 2 && x <= W + 2) return exit();
|
|
244
|
+
if (state.view === 'list') {
|
|
245
|
+
const page = currentPageItems();
|
|
246
|
+
const idx = y - 4;
|
|
247
|
+
if (idx >= 0 && idx < page.length) {
|
|
248
|
+
state.sel = pageStart() + idx;
|
|
249
|
+
openNote(page[idx]);
|
|
250
|
+
}
|
|
251
|
+
} else if (state.view === 'settings') {
|
|
252
|
+
const idx = y - 4;
|
|
253
|
+
if (idx === 0 || idx === 1) {
|
|
254
|
+
if (state.settingsSel === idx) cycleSetting(1);
|
|
255
|
+
else state.settingsSel = idx;
|
|
256
|
+
render();
|
|
257
|
+
}
|
|
258
|
+
} else if (state.view === 'note') {
|
|
259
|
+
const idx = attachmentAtClick(x, y);
|
|
260
|
+
if (idx >= 0) openFileAt(idx);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Maps a click in the note body to an attachment index. Clicking anywhere on
|
|
265
|
+
// a line holding a [📎 …] marker counts as clicking the file; when a line has
|
|
266
|
+
// several markers, x picks the one left of the click. Body rows start at
|
|
267
|
+
// screen row 5 (blank, title, meta, box top).
|
|
268
|
+
function attachmentAtClick(x, y) {
|
|
269
|
+
const { bodyRows } = layout();
|
|
270
|
+
const body = noteLines();
|
|
271
|
+
const lineIdx = y - 5 + state.noteScroll;
|
|
272
|
+
if (y < 5 || y >= 5 + bodyRows || lineIdx < 0 || lineIdx >= body.length) return -1;
|
|
273
|
+
const line = body[lineIdx];
|
|
274
|
+
if (!line.includes('📎')) return -1;
|
|
275
|
+
|
|
276
|
+
let before = 0;
|
|
277
|
+
for (let i = 0; i < lineIdx; i++) before += (body[i].match(/📎/g) || []).length;
|
|
278
|
+
const rel = x - 4; // box border + padding sit left of the text
|
|
279
|
+
let col = 0, seen = 0, pick = 0;
|
|
280
|
+
for (const ch of line) {
|
|
281
|
+
if (ch === '📎' && (col <= rel || seen === 0)) pick = seen;
|
|
282
|
+
if (ch === '📎') seen++;
|
|
283
|
+
col += t.strWidth(ch);
|
|
284
|
+
}
|
|
285
|
+
return before + pick;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function handleWheel(key) {
|
|
289
|
+
const action = key === 'wheelup' ? 'up' : 'down';
|
|
290
|
+
const steps = state.view === 'note' ? 3 : 1;
|
|
291
|
+
for (let i = 0; i < steps; i++) handleViewAction(action);
|
|
292
|
+
render();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function handleBack() {
|
|
296
|
+
if (state.view === 'note') { state.view = 'list'; state.note = null; }
|
|
297
|
+
else if (state.view === 'help' || state.view === 'settings') state.view = state.returnView;
|
|
298
|
+
else if (state.view === 'list' && state.query) { state.query = ''; state.sel = 0; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function handleViewAction(action) {
|
|
302
|
+
if (state.view === 'list') return listAction(action);
|
|
303
|
+
if (state.view === 'note') return noteAction(action);
|
|
304
|
+
if (state.view === 'settings') return settingsAction(action);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function listAction(action) {
|
|
308
|
+
const items = filtered();
|
|
309
|
+
const page = listPageSize();
|
|
310
|
+
const max = Math.max(0, items.length - 1);
|
|
311
|
+
const clamp = (n) => Math.max(0, Math.min(n, max));
|
|
312
|
+
switch (action) {
|
|
313
|
+
case 'down': state.sel = clamp(state.sel + 1); break;
|
|
314
|
+
case 'up': state.sel = clamp(state.sel - 1); break;
|
|
315
|
+
case 'pageDown': case 'next': state.sel = clamp(state.sel + page); break;
|
|
316
|
+
case 'pageUp': case 'prev': state.sel = clamp(state.sel - page); break;
|
|
317
|
+
case 'top': state.sel = 0; break;
|
|
318
|
+
case 'bottom': state.sel = max; break;
|
|
319
|
+
case 'open':
|
|
320
|
+
if (state.digits) commitDigits();
|
|
321
|
+
else if (items[state.sel]) openNote(items[state.sel]);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function noteAction(action) {
|
|
327
|
+
const { bodyRows } = layout();
|
|
328
|
+
const total = noteLines().length;
|
|
329
|
+
const maxScroll = Math.max(0, total - bodyRows);
|
|
330
|
+
const clamp = (n) => Math.max(0, Math.min(n, maxScroll));
|
|
331
|
+
switch (action) {
|
|
332
|
+
case 'down': state.noteScroll = clamp(state.noteScroll + 1); break;
|
|
333
|
+
case 'up': state.noteScroll = clamp(state.noteScroll - 1); break;
|
|
334
|
+
case 'pageDown': state.noteScroll = clamp(state.noteScroll + bodyRows); break;
|
|
335
|
+
case 'pageUp': state.noteScroll = clamp(state.noteScroll - bodyRows); break;
|
|
336
|
+
case 'top': state.noteScroll = 0; break;
|
|
337
|
+
case 'bottom': state.noteScroll = maxScroll; break;
|
|
338
|
+
case 'prev': stepNote(-1); break;
|
|
339
|
+
case 'next': stepNote(1); break;
|
|
340
|
+
case 'openFile': openFileAt(0); break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Exports the i-th attachment through Notes.app and opens it with the
|
|
345
|
+
// default macOS app for its file type.
|
|
346
|
+
async function openFileAt(i) {
|
|
347
|
+
const att = state.noteAtts[i];
|
|
348
|
+
if (!att || !state.note) return;
|
|
349
|
+
state.toast = `Opening ${att.name}…`;
|
|
350
|
+
render();
|
|
351
|
+
try {
|
|
352
|
+
await openAttachment(state.note.id, att);
|
|
353
|
+
state.toast = null;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
state.toast = `⚠ Could not open: ${(err.message || 'failed').split('\n')[0]}`;
|
|
356
|
+
}
|
|
357
|
+
render();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function stepNote(dir) {
|
|
361
|
+
const items = filtered();
|
|
362
|
+
const idx = items.findIndex((n) => n.id === state.note?.id);
|
|
363
|
+
const target = items[idx + dir];
|
|
364
|
+
if (target) { state.sel = idx + dir; openNote(target); }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Hands the note text to $EDITOR in the real terminal (TUI suspended), then
|
|
368
|
+
// writes any changes back to Apple Notes. The overwrite flattens rich
|
|
369
|
+
// formatting and drops inline attachments — accepted trade-off for `e`.
|
|
370
|
+
async function editNote(item) {
|
|
371
|
+
if (item.locked) { state.toast = '⚠ Locked note — unlock it in Notes.app first (o)'; return render(); }
|
|
372
|
+
let text = state.bodyCache.get(item.id)?.text;
|
|
373
|
+
if (text === undefined) {
|
|
374
|
+
startSpinner();
|
|
375
|
+
try { text = (await fetchNoteText(item.id)).text; }
|
|
376
|
+
catch (err) { state.toast = `⚠ ${err.message.split('\n')[0]}`; return render(); }
|
|
377
|
+
finally { stopSpinner(); }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const edited = await runEditor(text);
|
|
381
|
+
if (edited === null) return render();
|
|
382
|
+
if (edited === text) { state.toast = 'No changes'; return render(); }
|
|
383
|
+
|
|
384
|
+
state.toast = 'Saving…';
|
|
385
|
+
render();
|
|
386
|
+
try {
|
|
387
|
+
await saveNoteText(item.id, edited);
|
|
388
|
+
state.bodyCache.set(item.id, { text: edited, attachments: [] });
|
|
389
|
+
if (state.note?.id === item.id) {
|
|
390
|
+
state.noteText = edited;
|
|
391
|
+
state.noteAtts = [];
|
|
392
|
+
state.note.title = (edited.split('\n', 1)[0] || 'Untitled').trim() || 'Untitled';
|
|
393
|
+
}
|
|
394
|
+
state.toast = '✓ Saved to Apple Notes';
|
|
395
|
+
loadList(); // pick up new title/modified date in the list
|
|
396
|
+
} catch (err) {
|
|
397
|
+
state.toast = `⚠ Save failed: ${err.message.split('\n')[0]}`;
|
|
398
|
+
}
|
|
399
|
+
render();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Suspends the TUI and runs $EDITOR on a temp file seeded with `initial`.
|
|
403
|
+
// Returns the buffer contents, or null (with a toast set) on editor failure.
|
|
404
|
+
async function runEditor(initial) {
|
|
405
|
+
const file = path.join(os.tmpdir(), `notes-edit-${process.pid}.txt`);
|
|
406
|
+
fs.writeFileSync(file, initial);
|
|
407
|
+
const [cmd, ...args] = (process.env.VISUAL || process.env.EDITOR || 'vim').split(' ');
|
|
408
|
+
suspendTui();
|
|
409
|
+
const code = await new Promise((resolve) => {
|
|
410
|
+
const child = spawn(cmd, [...args, file], { stdio: 'inherit' });
|
|
411
|
+
child.on('exit', resolve);
|
|
412
|
+
child.on('error', () => resolve(-1));
|
|
413
|
+
});
|
|
414
|
+
resumeTui();
|
|
415
|
+
|
|
416
|
+
let edited = null;
|
|
417
|
+
try { edited = fs.readFileSync(file, 'utf8'); fs.unlinkSync(file); } catch {}
|
|
418
|
+
if (code !== 0 || edited === null) {
|
|
419
|
+
state.toast = `⚠ ${cmd} ${code === -1 ? 'could not be started' : `exited with ${code}`} — nothing saved`;
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
return edited;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Drafts a new note in $EDITOR; saving a non-empty buffer creates it in the
|
|
426
|
+
// default folder and selects it in the list.
|
|
427
|
+
async function newNote() {
|
|
428
|
+
const edited = await runEditor('');
|
|
429
|
+
if (edited === null) return render();
|
|
430
|
+
if (!edited.trim()) { state.toast = 'Empty buffer — no note created'; return render(); }
|
|
431
|
+
|
|
432
|
+
state.toast = 'Creating…';
|
|
433
|
+
render();
|
|
434
|
+
try {
|
|
435
|
+
const id = await createNote(edited);
|
|
436
|
+
state.view = 'list';
|
|
437
|
+
state.note = null;
|
|
438
|
+
await loadList();
|
|
439
|
+
const idx = filtered().findIndex((note) => note.id === id);
|
|
440
|
+
if (idx >= 0) state.sel = idx;
|
|
441
|
+
state.toast = '✓ Note created';
|
|
442
|
+
} catch (err) {
|
|
443
|
+
state.toast = `⚠ Create failed: ${err.message.split('\n')[0]}`;
|
|
444
|
+
}
|
|
445
|
+
render();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function suspendTui() {
|
|
449
|
+
stopSpinner();
|
|
450
|
+
process.stdout.write(t.mouseOff + t.altOff);
|
|
451
|
+
process.stdin.setRawMode(false);
|
|
452
|
+
process.stdin.pause();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function resumeTui() {
|
|
456
|
+
process.stdin.setRawMode(true);
|
|
457
|
+
process.stdin.resume();
|
|
458
|
+
process.stdout.write(t.altOn + t.mouseOn);
|
|
459
|
+
render();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function settingsAction(action) {
|
|
463
|
+
switch (action) {
|
|
464
|
+
case 'down': state.settingsSel = Math.min(1, state.settingsSel + 1); break;
|
|
465
|
+
case 'up': state.settingsSel = Math.max(0, state.settingsSel - 1); break;
|
|
466
|
+
case 'open': case 'next': cycleSetting(1); break;
|
|
467
|
+
case 'prev': cycleSetting(-1); break;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function cycleSetting(dir) {
|
|
472
|
+
if (state.settingsSel === 0) {
|
|
473
|
+
const i = KEYMAPS.indexOf(state.config.keymap);
|
|
474
|
+
state.config.keymap = KEYMAPS[(i + dir + KEYMAPS.length) % KEYMAPS.length];
|
|
475
|
+
state.bindings = effectiveBindings(state.config);
|
|
476
|
+
} else {
|
|
477
|
+
const i = SORTS.indexOf(state.config.sort);
|
|
478
|
+
state.config.sort = SORTS[(i + dir + SORTS.length) % SORTS.length];
|
|
479
|
+
state.notes = sorted(state.notes, state.config.sort);
|
|
480
|
+
state.sel = 0;
|
|
481
|
+
}
|
|
482
|
+
saveConfig(state.config);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function handleSearchKey(key) {
|
|
486
|
+
if (key === 'enter') state.searching = false;
|
|
487
|
+
else if (key === 'escape' || key === 'ctrl+g') { state.searching = false; state.query = ''; }
|
|
488
|
+
else if (key === 'backspace') state.query = state.query.slice(0, -1);
|
|
489
|
+
else if (key === 'space') state.query += ' ';
|
|
490
|
+
else if (key === 'up' || key === 'down') { state.searching = false; return handleKey(key); }
|
|
491
|
+
else if (!/^(ctrl|meta)\+/.test(key) && [...key].length === 1) state.query += key;
|
|
492
|
+
state.sel = Math.max(0, Math.min(state.sel, filtered().length - 1));
|
|
493
|
+
render();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Number keys jump straight to a note on the current page. Multi-digit input
|
|
497
|
+
// commits as soon as no further digit could still name a visible note,
|
|
498
|
+
// otherwise after a short pause.
|
|
499
|
+
function handleDigit(digit) {
|
|
500
|
+
if (!state.digits && digit === '0') return;
|
|
501
|
+
const next = state.digits + digit;
|
|
502
|
+
const count = currentPageItems().length;
|
|
503
|
+
if (parseInt(next, 10) > count) { state.digits = ''; return render(); }
|
|
504
|
+
state.digits = next;
|
|
505
|
+
clearTimeout(state.digitTimer);
|
|
506
|
+
if (parseInt(next, 10) * 10 > count) commitDigits();
|
|
507
|
+
else { state.digitTimer = setTimeout(commitDigits, 500); render(); }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function commitDigits() {
|
|
511
|
+
clearTimeout(state.digitTimer);
|
|
512
|
+
const n = parseInt(state.digits, 10);
|
|
513
|
+
state.digits = '';
|
|
514
|
+
if (!n) return render();
|
|
515
|
+
const item = currentPageItems()[n - 1];
|
|
516
|
+
if (item) { state.sel = pageStart() + n - 1; openNote(item); }
|
|
517
|
+
else render();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function listPageSize() {
|
|
521
|
+
return Math.max(3, (process.stdout.rows || 24) - 6);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function pageStart() {
|
|
525
|
+
return Math.floor(state.sel / listPageSize()) * listPageSize();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function currentPageItems() {
|
|
529
|
+
return filtered().slice(pageStart(), pageStart() + listPageSize());
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// --------------------------------------------------------------- rendering
|
|
533
|
+
|
|
534
|
+
function layout() {
|
|
535
|
+
const cols = process.stdout.columns || 80;
|
|
536
|
+
const rows = process.stdout.rows || 24;
|
|
537
|
+
const W = Math.max(44, Math.min(cols - 2, 100));
|
|
538
|
+
return { cols, rows, W, inner: W - 4, bodyRows: Math.max(3, rows - 7) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function render() {
|
|
542
|
+
const l = layout();
|
|
543
|
+
let lines;
|
|
544
|
+
if (state.view === 'note') lines = renderNote(l);
|
|
545
|
+
else if (state.view === 'help') lines = renderHelp(l);
|
|
546
|
+
else if (state.view === 'settings') lines = renderSettings(l);
|
|
547
|
+
else if (state.view === 'error') lines = renderError(l);
|
|
548
|
+
else lines = renderList(l);
|
|
549
|
+
|
|
550
|
+
if (lines.length > l.rows - 1) lines = lines.slice(0, l.rows - 1);
|
|
551
|
+
process.stdout.write(t.home + lines.map((x) => x + t.clearLine).join('\r\n') + '\r\n' + t.clearBelow);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function header(title, right, W) {
|
|
555
|
+
const rightPlain = right ? right + ' ×' : '×';
|
|
556
|
+
const leftPlain = ' ✳ ' + title;
|
|
557
|
+
const pad = Math.max(1, W - t.strWidth(leftPlain) - t.strWidth(rightPlain));
|
|
558
|
+
return ' ' + t.accent('✳ ') + t.bold(title) + ' '.repeat(pad) + t.dim(rightPlain);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function boxTop(W) { return ' ' + t.dim('╭' + '─'.repeat(W - 2) + '╮'); }
|
|
562
|
+
function boxBottom(W) { return ' ' + t.dim('╰' + '─'.repeat(W - 2) + '╯'); }
|
|
563
|
+
function boxRow(content, inner) { return ' ' + t.dim('│') + ' ' + content + ' ' + t.dim('│'); }
|
|
564
|
+
|
|
565
|
+
function renderList(l) {
|
|
566
|
+
const { W, inner } = l;
|
|
567
|
+
const items = filtered();
|
|
568
|
+
const page = currentPageItems();
|
|
569
|
+
const pageSize = listPageSize();
|
|
570
|
+
const pages = Math.max(1, Math.ceil(items.length / pageSize));
|
|
571
|
+
const pageNo = Math.floor(pageStart() / pageSize) + 1;
|
|
572
|
+
|
|
573
|
+
let info;
|
|
574
|
+
if (state.loading) info = `${SPINNER[state.spinner % SPINNER.length]} loading`;
|
|
575
|
+
else if (state.query) info = `${items.length}/${state.notes.length} · “${t.truncate(state.query, 18)}”`;
|
|
576
|
+
else info = `${state.notes.length} notes · by ${state.config.sort}`;
|
|
577
|
+
if (pages > 1) info += ` · page ${pageNo}/${pages}`;
|
|
578
|
+
|
|
579
|
+
const lines = ['', header('Apple Notes', info, W), boxTop(W)];
|
|
580
|
+
|
|
581
|
+
if (state.loading && !items.length) {
|
|
582
|
+
lines.push(boxRow(t.padRowPlain(` ${SPINNER[state.spinner % SPINNER.length]} Loading your notes…`, inner, t.dim), inner));
|
|
583
|
+
} else if (!items.length) {
|
|
584
|
+
const msg = state.query ? `No matches for “${state.query}”` : 'No notes found';
|
|
585
|
+
lines.push(boxRow(t.padRowPlain(msg, inner, t.dim), inner));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
page.forEach((item, i) => {
|
|
589
|
+
const isSel = pageStart() + i === state.sel;
|
|
590
|
+
const num = String(i + 1).padStart(2, ' ');
|
|
591
|
+
const marker = isSel ? '❯' : ' ';
|
|
592
|
+
const metaPlain = `${item.folder} · ${fmtDate(item.modified)}`;
|
|
593
|
+
const meta = t.truncate(metaPlain, Math.max(10, Math.floor(inner * 0.4)));
|
|
594
|
+
const titleW = inner - 2 - 1 - 1 - 1 - t.strWidth(meta) - 1;
|
|
595
|
+
const title = t.truncate((item.locked ? '🔒 ' : '') + item.title, Math.max(5, titleW));
|
|
596
|
+
const pad = Math.max(1, inner - 2 - 1 - 1 - 1 - t.strWidth(title) - t.strWidth(meta));
|
|
597
|
+
const row =
|
|
598
|
+
t.dim(num) + ' ' +
|
|
599
|
+
(isSel ? t.accent(marker) : ' ') + ' ' +
|
|
600
|
+
(isSel ? t.bold(title) : title) +
|
|
601
|
+
' '.repeat(pad) + t.dim(meta);
|
|
602
|
+
lines.push(boxRow(row, inner));
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
lines.push(boxBottom(W));
|
|
606
|
+
lines.push(listFooter(inner));
|
|
607
|
+
return lines;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function listFooter(inner) {
|
|
611
|
+
if (state.searching) return ' ' + t.accent('/ ') + state.query + t.accent('█');
|
|
612
|
+
if (state.toast) return ' ' + t.accent(t.truncate(state.toast, inner + 2));
|
|
613
|
+
let hints = '↵ open · 1-9 jump · ←→ page · / search · n new · e edit · r refresh · s settings · ? help · q quit';
|
|
614
|
+
if (state.digits) return ' ' + t.accent(`→ ${state.digits}`) + ' ' + t.dim(t.truncate(hints, inner - 8));
|
|
615
|
+
return ' ' + t.dim(t.truncate(hints, inner + 2));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function noteLines() {
|
|
619
|
+
if (state.noteText === null) return [];
|
|
620
|
+
const { inner } = layout();
|
|
621
|
+
let text = state.noteText;
|
|
622
|
+
const first = text.split('\n', 1)[0];
|
|
623
|
+
if (state.note && first?.trim() === state.note.title.trim()) {
|
|
624
|
+
text = text.slice(first.length).replace(/^\n+/, '');
|
|
625
|
+
}
|
|
626
|
+
return t.wrap(text, inner);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function renderNote(l) {
|
|
630
|
+
const { W, inner, bodyRows } = l;
|
|
631
|
+
const note = state.note;
|
|
632
|
+
const meta = `${note.folder} · edited ${fmtDate(note.modified)}` +
|
|
633
|
+
(note.created ? ` · created ${fmtDate(note.created)}` : '');
|
|
634
|
+
|
|
635
|
+
const title = t.truncate((note.locked ? '🔒 ' : '') + note.title, W - 8);
|
|
636
|
+
const titlePad = Math.max(1, W - t.strWidth(' ✳ ' + title) - 1);
|
|
637
|
+
const lines = [
|
|
638
|
+
'',
|
|
639
|
+
' ' + t.accent('✳ ') + t.bold(title) + ' '.repeat(titlePad) + t.dim('×'),
|
|
640
|
+
' ' + t.dim(t.truncate(meta, W - 2)),
|
|
641
|
+
boxTop(W),
|
|
642
|
+
];
|
|
643
|
+
|
|
644
|
+
const body = noteLines();
|
|
645
|
+
if (state.noteText === null) {
|
|
646
|
+
lines.push(boxRow(t.padRowPlain(` ${SPINNER[state.spinner % SPINNER.length]} Loading…`, inner, t.dim), inner));
|
|
647
|
+
} else if (!body.length || (body.length === 1 && !body[0])) {
|
|
648
|
+
lines.push(boxRow(t.padRowPlain('(empty note)', inner, t.dim), inner));
|
|
649
|
+
} else {
|
|
650
|
+
const maxScroll = Math.max(0, body.length - bodyRows);
|
|
651
|
+
state.noteScroll = Math.min(state.noteScroll, maxScroll);
|
|
652
|
+
for (const line of body.slice(state.noteScroll, state.noteScroll + bodyRows)) {
|
|
653
|
+
lines.push(boxRow(t.padRowPlain(line, inner, null), inner));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
lines.push(boxBottom(W));
|
|
658
|
+
const pct = body.length
|
|
659
|
+
? Math.min(100, Math.round(((state.noteScroll + bodyRows) / Math.max(body.length, bodyRows)) * 100))
|
|
660
|
+
: 100;
|
|
661
|
+
const fileHint = state.noteAtts.length ? 'a/1-9/click open file · ' : '';
|
|
662
|
+
const footer = `${pct}% · ${fileHint}↑↓ scroll · space page · ←→ prev/next · e edit · o Notes.app · esc back · q quit`;
|
|
663
|
+
lines.push(' ' + (state.toast
|
|
664
|
+
? t.accent(t.truncate(state.toast, inner + 2))
|
|
665
|
+
: t.dim(t.truncate(footer, inner + 2))));
|
|
666
|
+
return lines;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function renderHelp(l) {
|
|
670
|
+
const { W, cols, rows } = l;
|
|
671
|
+
const entries = [
|
|
672
|
+
['1-9', 'Open note / attachment by number'],
|
|
673
|
+
['down', 'Move down'],
|
|
674
|
+
['up', 'Move up'],
|
|
675
|
+
['pageDown', 'Page down'],
|
|
676
|
+
['pageUp', 'Page up'],
|
|
677
|
+
['top', 'Go to top'],
|
|
678
|
+
['bottom', 'Go to bottom'],
|
|
679
|
+
['open', 'Open note'],
|
|
680
|
+
['prev', 'Page back (list) / previous note (note view)'],
|
|
681
|
+
['next', 'Page forward (list) / next note (note view)'],
|
|
682
|
+
['search', 'Search titles'],
|
|
683
|
+
['back', 'Back / clear search'],
|
|
684
|
+
['openExternal', 'Open in Notes.app (unlock locked notes there)'],
|
|
685
|
+
['openFile', 'Open attachment in its default app (note view)'],
|
|
686
|
+
['edit', 'Edit in $EDITOR, saved back to Apple Notes'],
|
|
687
|
+
['new', 'New note drafted in $EDITOR'],
|
|
688
|
+
['refresh', 'Refresh notes / reload note'],
|
|
689
|
+
['settings', 'Settings'],
|
|
690
|
+
['help', 'Toggle this help'],
|
|
691
|
+
['quit', 'Quit'],
|
|
692
|
+
].map(([action, desc]) => {
|
|
693
|
+
const keys = action === '1-9'
|
|
694
|
+
? '1-9'
|
|
695
|
+
: (state.bindings[action] || []).map(prettyKey).join(' ');
|
|
696
|
+
return [keys, desc];
|
|
697
|
+
});
|
|
698
|
+
entries.push(['click', 'Open note / attachment / setting'], ['click ×', 'Quit'], ['wheel', 'Scroll']);
|
|
699
|
+
|
|
700
|
+
const lines = ['', header('Keyboard', `keymap: ${state.config.keymap}`, W), ''];
|
|
701
|
+
const tail = ['', ' ' + t.dim(`Add your own keys in ${configPath}`), '', ' ' + t.dim('esc back')];
|
|
702
|
+
|
|
703
|
+
// Wrap into extra columns when the rows won't fit the terminal height.
|
|
704
|
+
const avail = Math.max(3, rows - 1 - lines.length - tail.length);
|
|
705
|
+
const maxKeyW = Math.min(30, Math.max(...entries.map(([keys]) => t.strWidth(keys))));
|
|
706
|
+
const nCols = Math.max(1, Math.min(
|
|
707
|
+
Math.ceil(entries.length / avail),
|
|
708
|
+
Math.floor((cols - 3) / 34), // each column needs room for keys + a short description
|
|
709
|
+
));
|
|
710
|
+
const colH = Math.ceil(entries.length / nCols);
|
|
711
|
+
const cellW = Math.floor((cols - 3) / nCols) - 2;
|
|
712
|
+
const keyW = nCols === 1 ? maxKeyW : Math.min(maxKeyW, Math.max(12, cellW - 20));
|
|
713
|
+
|
|
714
|
+
for (let r = 0; r < colH; r++) {
|
|
715
|
+
let line = ' ';
|
|
716
|
+
for (let c = 0; c < nCols; c++) {
|
|
717
|
+
const entry = entries[c * colH + r];
|
|
718
|
+
if (!entry) break;
|
|
719
|
+
const [keys, desc] = entry;
|
|
720
|
+
const d = t.truncate(desc, Math.max(6, (nCols === 1 ? cols - 4 : cellW) - keyW - 1));
|
|
721
|
+
line += t.accent(t.padEnd(t.truncate(keys, keyW), keyW)) + ' ' + d;
|
|
722
|
+
if (c < nCols - 1) line += ' '.repeat(Math.max(2, cellW - keyW - 1 - t.strWidth(d) + 2));
|
|
723
|
+
}
|
|
724
|
+
lines.push(line);
|
|
725
|
+
}
|
|
726
|
+
lines.push(...tail);
|
|
727
|
+
return lines;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function renderSettings(l) {
|
|
731
|
+
const { W } = l;
|
|
732
|
+
const rows = [
|
|
733
|
+
['Keymap', state.config.keymap, KEYMAPS],
|
|
734
|
+
['Sort', state.config.sort, SORTS],
|
|
735
|
+
];
|
|
736
|
+
const lines = ['', header('Settings', '', W), ''];
|
|
737
|
+
rows.forEach(([label, value, options], i) => {
|
|
738
|
+
const isSel = state.settingsSel === i;
|
|
739
|
+
const marker = isSel ? t.accent('❯') : ' ';
|
|
740
|
+
const val = isSel ? t.accent(`‹ ${value} ›`) : value;
|
|
741
|
+
lines.push(` ${marker} ${t.padEnd(label, 8)} ${val} ${t.dim('(' + options.join(' / ') + ')')}`);
|
|
742
|
+
});
|
|
743
|
+
lines.push('');
|
|
744
|
+
lines.push(' ' + t.dim(`Saved to ${configPath}`));
|
|
745
|
+
lines.push('');
|
|
746
|
+
lines.push(' ' + t.dim('↑↓ select · ↵/←→ change · esc back'));
|
|
747
|
+
return lines;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function renderError(l) {
|
|
751
|
+
const { W } = l;
|
|
752
|
+
const lines = ['', header('Apple Notes', '', W), ''];
|
|
753
|
+
for (const line of t.wrap(state.error || 'Something went wrong.', W - 6)) {
|
|
754
|
+
lines.push(' ' + line);
|
|
755
|
+
}
|
|
756
|
+
lines.push('');
|
|
757
|
+
lines.push(' ' + t.dim('r retry · q quit'));
|
|
758
|
+
return lines;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ------------------------------------------------------------------- dates
|
|
762
|
+
|
|
763
|
+
function fmtDate(iso) {
|
|
764
|
+
if (!iso) return '';
|
|
765
|
+
const d = new Date(iso);
|
|
766
|
+
const now = new Date();
|
|
767
|
+
if (d.toDateString() === now.toDateString()) {
|
|
768
|
+
let h = d.getHours();
|
|
769
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
770
|
+
h = h % 12 || 12;
|
|
771
|
+
return `${h}:${String(d.getMinutes()).padStart(2, '0')} ${ampm}`;
|
|
772
|
+
}
|
|
773
|
+
const base = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
774
|
+
return d.getFullYear() === now.getFullYear() ? base : `${base}, ${d.getFullYear()}`;
|
|
775
|
+
}
|