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/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
+ }