Repository navigation
Expand file tree
/
Copy pathModel.js
More file actions
296 lines (256 loc) · 9.65 KB
/
Copy pathModel.js
File metadata and controls
296 lines (256 loc) · 9.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
.pragma library
// Pure functions behind Omathought. Nothing here touches the filesystem, spawns
// a process, or reads QML state: the helper script owns IO and hands this file
// records, so every decision about what a note *is* stays checkable by
// `node --test tests/`.
// ------------------------------------------------------------------ records
// The helper emits one record per note: { id, created, body }. `id` is the
// basename without ".md" and doubles as the sort key, because the filename is
// minted from the creation timestamp and never rewritten by an edit.
function parseList(raw) {
var parsed
try {
parsed = JSON.parse(raw || "[]")
} catch (e) {
return []
}
if (!Array.isArray(parsed)) return []
var out = []
for (var i = 0; i < parsed.length; i++) {
var n = normalizeNote(parsed[i])
if (n) out.push(n)
}
return sortNotes(out)
}
function normalizeNote(raw) {
if (!raw || typeof raw !== "object") return null
var id = typeof raw.id === "string" ? raw.id : ""
if (!id) return null
return {
id: id,
created: typeof raw.created === "string" ? raw.created : "",
body: typeof raw.body === "string" ? raw.body : ""
}
}
// Newest first. Ids are `YYYYMMDD-HHMMSS`, so a plain string compare is already
// chronological — no Date parsing, and it still orders notes whose frontmatter
// was mangled by an external editor.
function sortNotes(notes) {
var copy = notes.slice()
copy.sort(function (a, b) {
if (a.id === b.id) return 0
return a.id < b.id ? 1 : -1
})
return copy
}
// ------------------------------------------------------------------ display
// A voice memo has no title of its own, so the first non-empty line becomes
// one. Trailing sentence punctuation is dropped: dictation ends nearly every
// utterance with a period, and a column of titles all ending in "." reads as
// noise.
function noteTitle(body) {
var lines = String(body || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
return line.replace(/[.,;:!?\s]+$/, "") || "Untitled"
}
return "Untitled"
}
// Everything after the title line, flattened to one line for the list row.
function notePreview(body) {
var lines = String(body || "").split("\n")
var seenTitle = false
var rest = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
if (!seenTitle) { seenTitle = true; continue }
rest.push(line)
}
return rest.join(" ")
}
function truncate(text, limit) {
var s = String(text || "")
if (limit <= 0) return ""
if (s.length <= limit) return s
// Break on a word boundary when one is close enough to the limit that the
// result is not visibly shorter than the hard cut would have been.
var cut = s.slice(0, limit)
var space = cut.lastIndexOf(" ")
if (space > limit * 0.6) cut = cut.slice(0, space)
return cut.replace(/[\s.,;:]+$/, "") + "…"
}
function wordCount(body) {
var trimmed = String(body || "").trim()
if (!trimmed) return 0
return trimmed.split(/\s+/).length
}
// ----------------------------------------------------------------- calendar
// `created` is ISO 8601 with an offset, written by the helper via `date -Is`.
// A note whose frontmatter went missing falls back to the id, which carries the
// same instant in local time.
function noteDate(note) {
if (!note) return null
if (note.created) {
var parsed = new Date(note.created)
if (!isNaN(parsed.getTime())) return parsed
}
return dateFromId(note.id)
}
function dateFromId(id) {
var m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(String(id || ""))
if (!m) return null
var d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])
return isNaN(d.getTime()) ? null : d
}
function sameDay(a, b) {
return !!a && !!b
&& a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
}
// The heading a note sits under in the browser. Days are grouped rather than
// dated because a memo library is read by recency, not by calendar.
function groupLabel(date, now) {
if (!date) return "Undated"
var today = now || new Date()
if (sameDay(date, today)) return "Today"
var yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
if (sameDay(date, yesterday)) return "Yesterday"
var weekAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6)
if (date >= weekAgo && date <= today) return "Earlier this week"
if (date.getFullYear() === today.getFullYear()) return monthName(date) + " " + date.getFullYear()
return monthName(date) + " " + date.getFullYear()
}
function monthName(date) {
var names = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
return names[date.getMonth()]
}
function pad2(n) {
return (n < 10 ? "0" : "") + n
}
// Clock time for a row: the day is already carried by the group heading, so
// only same-year-but-older notes need a date at all.
function timeLabel(date, now) {
if (!date) return ""
var today = now || new Date()
var clock = pad2(date.getHours()) + ":" + pad2(date.getMinutes())
if (sameDay(date, today)) return clock
var weekAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6)
if (date >= weekAgo && date <= today) return clock
return date.getDate() + " " + monthName(date).slice(0, 3) + " " + clock
}
// Build the flat row model the browser's ListView renders: group headings
// interleaved with notes, so one view scrolls both without a section delegate.
function buildRows(notes, now) {
var rows = []
var current = null
for (var i = 0; i < notes.length; i++) {
var note = notes[i]
var date = noteDate(note)
var label = groupLabel(date, now)
if (label !== current) {
rows.push({ kind: "heading", id: "heading:" + label, label: label })
current = label
}
rows.push({
kind: "note",
id: note.id,
title: truncate(noteTitle(note.body), 64),
preview: truncate(notePreview(note.body), 96),
time: timeLabel(date, now),
words: wordCount(note.body)
})
}
return rows
}
// ------------------------------------------------------------------- search
function matchesQuery(note, query) {
var q = String(query || "").trim().toLowerCase()
if (!q) return true
var terms = q.split(/\s+/)
var hay = String(note.body || "").toLowerCase()
for (var i = 0; i < terms.length; i++) {
if (hay.indexOf(terms[i]) === -1) return false
}
return true
}
function filterNotes(notes, query) {
var out = []
for (var i = 0; i < notes.length; i++) {
if (matchesQuery(notes[i], query)) out.push(notes[i])
}
return out
}
// ---------------------------------------------------------------- selection
// Move the cursor by `delta` over note rows only, skipping headings so arrow
// keys never land on one. Clamped rather than wrapping: wrapping from the last
// note back to the top reads as a failed keypress in a list this short.
function moveSelection(rows, currentId, delta) {
var noteIds = []
for (var i = 0; i < rows.length; i++) {
if (rows[i].kind === "note") noteIds.push(rows[i].id)
}
if (noteIds.length === 0) return ""
var at = noteIds.indexOf(currentId)
if (at === -1) return noteIds[0]
var next = at + delta
if (next < 0) next = 0
if (next > noteIds.length - 1) next = noteIds.length - 1
return noteIds[next]
}
// After a delete, land on the note that took the deleted one's place — the next
// one down, or the new last note when the tail was removed.
function selectionAfterDelete(notes, deletedId) {
var at = -1
for (var i = 0; i < notes.length; i++) {
if (notes[i].id === deletedId) { at = i; break }
}
if (at === -1) return notes.length > 0 ? notes[0].id : ""
var remaining = notes.slice(0, at).concat(notes.slice(at + 1))
if (remaining.length === 0) return ""
return remaining[Math.min(at, remaining.length - 1)].id
}
function findNote(notes, id) {
for (var i = 0; i < notes.length; i++) {
if (notes[i].id === id) return notes[i]
}
return null
}
// ------------------------------------------------------------------ capture
// Voxtype types its transcript into whatever holds keyboard focus, so the
// capture card receives it as ordinary keystrokes and this only has to tidy the
// result: collapse the stray whitespace dictation leaves behind, and never save
// a memo that is only whitespace.
function cleanTranscript(text) {
return String(text || "")
.replace(/\r/g, "")
.replace(/[ \t]+/g, " ")
.replace(/ *\n */g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
}
function isSaveable(text) {
return cleanTranscript(text).length > 0
}
// What the capture card says about itself. Kept here so the wording is covered
// by tests rather than scattered through bindings.
function captureStatus(phase, hasText) {
if (phase === "recording") return "Listening… release to transcribe"
if (phase === "transcribing") return "Transcribing…"
if (phase === "saving") return "Saving…"
if (phase === "error") return "Voxtype is not responding"
if (hasText) return "Ctrl+Enter to save · Esc to discard"
return "Nothing captured · Esc to close"
}
// ------------------------------------------------------------------ summon
// Which surface a shell summon opens. The shell summons with "{}" when it has
// nothing to say (a bar panel hotkey, `omarchy-shell shell toggle`), and that
// must not switch the microphone on: capture is opt-in, browsing is the default.
function summonMode(payloadJson) {
var payload = null
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = null }
return payload && payload.mode === "capture" ? "capture" : "browse"
}