@arach/lattices 0.1.0 → 0.2.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.
Files changed (39) hide show
  1. package/README.md +28 -28
  2. package/app/Sources/ActionRow.swift +61 -0
  3. package/app/Sources/App.swift +1 -40
  4. package/app/Sources/AppDelegate.swift +154 -24
  5. package/app/Sources/CheatSheetHUD.swift +1 -0
  6. package/app/Sources/CommandModeState.swift +40 -19
  7. package/app/Sources/CommandModeView.swift +27 -2
  8. package/app/Sources/DaemonServer.swift +8 -0
  9. package/app/Sources/DiagnosticLog.swift +19 -1
  10. package/app/Sources/EventBus.swift +1 -0
  11. package/app/Sources/HotkeyManager.swift +1 -0
  12. package/app/Sources/HotkeyStore.swift +9 -1
  13. package/app/Sources/LatticesApi.swift +210 -0
  14. package/app/Sources/MainView.swift +46 -86
  15. package/app/Sources/MainWindow.swift +13 -0
  16. package/app/Sources/OcrModel.swift +309 -0
  17. package/app/Sources/OcrStore.swift +295 -0
  18. package/app/Sources/OmniSearchState.swift +283 -0
  19. package/app/Sources/OmniSearchView.swift +288 -0
  20. package/app/Sources/OmniSearchWindow.swift +105 -0
  21. package/app/Sources/PaletteCommand.swift +11 -1
  22. package/app/Sources/PermissionChecker.swift +12 -2
  23. package/app/Sources/Preferences.swift +44 -0
  24. package/app/Sources/ScreenMapState.swift +7 -17
  25. package/app/Sources/ScreenMapView.swift +3 -0
  26. package/app/Sources/SettingsView.swift +534 -122
  27. package/app/Sources/Theme.swift +39 -0
  28. package/app/Sources/WindowTiler.swift +59 -56
  29. package/bin/lattices-app.js +23 -7
  30. package/bin/lattices.js +123 -0
  31. package/docs/api.md +390 -249
  32. package/docs/app.md +75 -28
  33. package/docs/concepts.md +45 -136
  34. package/docs/config.md +8 -7
  35. package/docs/layers.md +16 -18
  36. package/docs/ocr.md +185 -0
  37. package/docs/overview.md +39 -34
  38. package/docs/quickstart.md +34 -35
  39. package/package.json +6 -2
@@ -0,0 +1,309 @@
1
+ import AppKit
2
+ import CryptoKit
3
+ import Vision
4
+
5
+ // MARK: - Data Types
6
+
7
+ struct OcrTextBlock {
8
+ let text: String
9
+ let confidence: Float // 0.0–1.0
10
+ let boundingBox: CGRect // normalized coordinates within window
11
+ }
12
+
13
+ struct OcrWindowResult {
14
+ let wid: UInt32
15
+ let app: String
16
+ let title: String
17
+ let frame: WindowFrame
18
+ let texts: [OcrTextBlock]
19
+ let fullText: String
20
+ let timestamp: Date
21
+ }
22
+
23
+ // MARK: - OCR Scanner
24
+
25
+ final class OcrModel: ObservableObject {
26
+ static let shared = OcrModel()
27
+
28
+ @Published private(set) var results: [UInt32: OcrWindowResult] = [:]
29
+ @Published private(set) var isScanning: Bool = false
30
+ @Published var interval: TimeInterval = 60
31
+ @Published var enabled: Bool = true
32
+
33
+ private var timer: Timer?
34
+ private var deepTimer: Timer?
35
+ private let queue = DispatchQueue(label: "com.arach.lattices.ocr", qos: .background)
36
+ private var imageHashes: [UInt32: Data] = [:]
37
+ private var scanGeneration: Int = 0
38
+
39
+ private let myPid = ProcessInfo.processInfo.processIdentifier
40
+
41
+ private var prefs: Preferences { Preferences.shared }
42
+
43
+ func start(interval: TimeInterval? = nil) {
44
+ guard timer == nil else { return }
45
+ if let interval { self.interval = interval }
46
+ self.interval = prefs.ocrQuickInterval
47
+ self.enabled = prefs.ocrEnabled
48
+ guard enabled else {
49
+ DiagnosticLog.shared.info("OcrModel: disabled by user preference")
50
+ return
51
+ }
52
+ let deepInterval = prefs.ocrDeepInterval
53
+ // Defer initial scan — let the first timer tick handle it (grace period on launch)
54
+ DiagnosticLog.shared.info("OcrModel: starting (quick=\(self.interval)s/\(prefs.ocrQuickLimit)win, deep=\(deepInterval)s/\(prefs.ocrDeepLimit)win)")
55
+ timer = Timer.scheduledTimer(withTimeInterval: self.interval, repeats: true) { [weak self] _ in
56
+ guard let self, self.enabled else { return }
57
+ self.quickScan()
58
+ }
59
+ // Deep scan on a slower cadence
60
+ deepTimer = Timer.scheduledTimer(withTimeInterval: deepInterval, repeats: true) { [weak self] _ in
61
+ guard let self, self.enabled else { return }
62
+ self.scan()
63
+ }
64
+ }
65
+
66
+ func stop() {
67
+ timer?.invalidate()
68
+ timer = nil
69
+ deepTimer?.invalidate()
70
+ deepTimer = nil
71
+ }
72
+
73
+ func setEnabled(_ on: Bool) {
74
+ enabled = on
75
+ prefs.ocrEnabled = on
76
+ if on && timer == nil {
77
+ start()
78
+ } else if !on {
79
+ stop()
80
+ }
81
+ }
82
+
83
+ // MARK: - Scan
84
+
85
+ /// Quick scan: only the topmost frontmost windows (called every 60s)
86
+ func quickScan() {
87
+ scanWithLimit(prefs.ocrQuickLimit)
88
+ }
89
+
90
+ /// Deep scan: all visible windows (called every 2h, or manually via ocr.scan)
91
+ func scan() {
92
+ scanWithLimit(prefs.ocrDeepLimit)
93
+ }
94
+
95
+ private func scanWithLimit(_ limit: Int) {
96
+ guard !isScanning else { return }
97
+ DispatchQueue.main.async { self.isScanning = true }
98
+ scanGeneration += 1
99
+ let generation = scanGeneration
100
+
101
+ queue.async { [weak self] in
102
+ guard let self else { return }
103
+ var windows = self.enumerateWindows()
104
+
105
+ // Cap windows — CGWindowList returns front-to-back order,
106
+ // so prefix gives us the topmost/frontmost windows first
107
+ if windows.count > limit {
108
+ windows = Array(windows.prefix(limit))
109
+ }
110
+
111
+ // For quick scans, merge new results into existing rather than replacing
112
+ let previousResults = self.results
113
+ let fresh: [UInt32: OcrWindowResult] = limit < self.prefs.ocrDeepLimit ? previousResults : [:]
114
+ let newHashes: [UInt32: Data] = [:]
115
+ let totalBlocks = 0
116
+
117
+ self.processNextWindow(
118
+ windows: windows,
119
+ index: 0,
120
+ generation: generation,
121
+ previousResults: previousResults,
122
+ fresh: fresh,
123
+ newHashes: newHashes,
124
+ totalBlocks: totalBlocks,
125
+ changedResults: []
126
+ )
127
+ }
128
+ }
129
+
130
+ /// Process one window at a time, yielding back to the queue between each.
131
+ /// This lets GCD schedule higher-priority work between windows.
132
+ private func processNextWindow(
133
+ windows: [WindowEntry],
134
+ index: Int,
135
+ generation: Int,
136
+ previousResults: [UInt32: OcrWindowResult],
137
+ fresh: [UInt32: OcrWindowResult],
138
+ newHashes: [UInt32: Data],
139
+ totalBlocks: Int,
140
+ changedResults: [OcrWindowResult]
141
+ ) {
142
+ // Stale scan — a newer one started, abandon this one
143
+ guard generation == scanGeneration else {
144
+ DispatchQueue.main.async { self.isScanning = false }
145
+ return
146
+ }
147
+
148
+ // All windows processed — publish results & persist diffs
149
+ guard index < windows.count else {
150
+ self.imageHashes = newHashes
151
+
152
+ if !changedResults.isEmpty {
153
+ OcrStore.shared.insert(results: changedResults)
154
+ }
155
+
156
+ DispatchQueue.main.async {
157
+ self.results = fresh
158
+ self.isScanning = false
159
+ }
160
+
161
+ EventBus.shared.post(.ocrScanComplete(
162
+ windowCount: fresh.count,
163
+ totalBlocks: totalBlocks
164
+ ))
165
+ return
166
+ }
167
+
168
+ var fresh = fresh
169
+ var newHashes = newHashes
170
+ var totalBlocks = totalBlocks
171
+ var changedResults = changedResults
172
+
173
+ let win = windows[index]
174
+
175
+ if let cgImage = CGWindowListCreateImage(
176
+ .null,
177
+ .optionIncludingWindow,
178
+ CGWindowID(win.wid),
179
+ [.boundsIgnoreFraming, .bestResolution]
180
+ ) {
181
+ let hash = imageHash(cgImage)
182
+ newHashes[win.wid] = hash
183
+
184
+ if hash == imageHashes[win.wid], let prev = previousResults[win.wid] {
185
+ // Unchanged — reuse cached result
186
+ fresh[win.wid] = prev
187
+ totalBlocks += prev.texts.count
188
+ } else {
189
+ // Changed — run OCR
190
+ let blocks = recognizeText(in: cgImage)
191
+ let fullText = blocks.map(\.text).joined(separator: "\n")
192
+ totalBlocks += blocks.count
193
+
194
+ let result = OcrWindowResult(
195
+ wid: win.wid,
196
+ app: win.app,
197
+ title: win.title,
198
+ frame: win.frame,
199
+ texts: blocks,
200
+ fullText: fullText,
201
+ timestamp: Date()
202
+ )
203
+ fresh[win.wid] = result
204
+ changedResults.append(result)
205
+ }
206
+ }
207
+
208
+ // Throttle: 100ms delay between windows to reduce CPU bursts
209
+ queue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
210
+ self?.processNextWindow(
211
+ windows: windows,
212
+ index: index + 1,
213
+ generation: generation,
214
+ previousResults: previousResults,
215
+ fresh: fresh,
216
+ newHashes: newHashes,
217
+ totalBlocks: totalBlocks,
218
+ changedResults: changedResults
219
+ )
220
+ }
221
+ }
222
+
223
+ // MARK: - Window Enumeration
224
+
225
+ private func enumerateWindows() -> [WindowEntry] {
226
+ guard let list = CGWindowListCopyWindowInfo(
227
+ [.optionOnScreenOnly, .excludeDesktopElements],
228
+ kCGNullWindowID
229
+ ) as? [[String: Any]] else { return [] }
230
+
231
+ var entries: [WindowEntry] = []
232
+
233
+ for info in list {
234
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
235
+ let ownerName = info[kCGWindowOwnerName as String] as? String,
236
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
237
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
238
+ else { continue }
239
+
240
+ // Skip own windows
241
+ guard pid != myPid else { continue }
242
+
243
+ var rect = CGRect.zero
244
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
245
+ rect.width >= 50, rect.height >= 50 else { continue }
246
+
247
+ let title = info[kCGWindowName as String] as? String ?? ""
248
+ let layer = info[kCGWindowLayer as String] as? Int ?? 0
249
+ guard layer == 0 else { continue }
250
+
251
+ let frame = WindowFrame(
252
+ x: Double(rect.origin.x),
253
+ y: Double(rect.origin.y),
254
+ w: Double(rect.width),
255
+ h: Double(rect.height)
256
+ )
257
+
258
+ entries.append(WindowEntry(
259
+ wid: wid,
260
+ app: ownerName,
261
+ pid: pid,
262
+ title: title,
263
+ frame: frame,
264
+ spaceIds: [],
265
+ isOnScreen: true,
266
+ latticesSession: nil
267
+ ))
268
+ }
269
+
270
+ return entries
271
+ }
272
+
273
+ // MARK: - Image Hashing
274
+
275
+ private func imageHash(_ image: CGImage) -> Data {
276
+ guard let dataProvider = image.dataProvider,
277
+ let data = dataProvider.data as Data? else {
278
+ return Data()
279
+ }
280
+ let digest = SHA256.hash(data: data as Data)
281
+ return Data(digest)
282
+ }
283
+
284
+ // MARK: - Vision OCR
285
+
286
+ private func recognizeText(in image: CGImage) -> [OcrTextBlock] {
287
+ let handler = VNImageRequestHandler(cgImage: image, options: [:])
288
+ let request = VNRecognizeTextRequest()
289
+ request.recognitionLevel = prefs.ocrAccuracy == "fast" ? .fast : .accurate
290
+ request.usesLanguageCorrection = true
291
+
292
+ do {
293
+ try handler.perform([request])
294
+ } catch {
295
+ return []
296
+ }
297
+
298
+ guard let observations = request.results else { return [] }
299
+
300
+ return observations.compactMap { obs in
301
+ guard let candidate = obs.topCandidates(1).first else { return nil }
302
+ return OcrTextBlock(
303
+ text: candidate.string,
304
+ confidence: candidate.confidence,
305
+ boundingBox: obs.boundingBox
306
+ )
307
+ }
308
+ }
309
+ }
@@ -0,0 +1,295 @@
1
+ import Foundation
2
+ import SQLite3
3
+
4
+ // MARK: - Search Result
5
+
6
+ struct OcrSearchResult {
7
+ let id: Int64
8
+ let wid: UInt32
9
+ let app: String
10
+ let title: String
11
+ let frame: WindowFrame
12
+ let fullText: String
13
+ let snippet: String
14
+ let timestamp: Date
15
+ }
16
+
17
+ // MARK: - SQLite OCR Store
18
+
19
+ final class OcrStore {
20
+ static let shared = OcrStore()
21
+
22
+ private var db: OpaquePointer?
23
+ private let queue = DispatchQueue(label: "com.arach.lattices.ocrstore", qos: .background)
24
+
25
+ // Cached prepared statements
26
+ private var insertStmt: OpaquePointer?
27
+ private var cleanupStmt: OpaquePointer?
28
+
29
+ private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
30
+
31
+ // MARK: - Open / Schema
32
+
33
+ func open() {
34
+ queue.sync {
35
+ guard db == nil else { return }
36
+
37
+ let dir = NSHomeDirectory() + "/.lattices"
38
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
39
+ let path = dir + "/ocr.db"
40
+
41
+ guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
42
+ DiagnosticLog.shared.error("OcrStore: failed to open \(path)")
43
+ return
44
+ }
45
+
46
+ // WAL mode for concurrent reads/writes
47
+ exec("PRAGMA journal_mode=WAL")
48
+ exec("PRAGMA synchronous=NORMAL")
49
+
50
+ // Main table
51
+ exec("""
52
+ CREATE TABLE IF NOT EXISTS ocr_entry (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ wid INTEGER NOT NULL,
55
+ app TEXT NOT NULL,
56
+ title TEXT NOT NULL,
57
+ frame_x REAL,
58
+ frame_y REAL,
59
+ frame_w REAL,
60
+ frame_h REAL,
61
+ full_text TEXT NOT NULL,
62
+ timestamp REAL NOT NULL
63
+ )
64
+ """)
65
+ exec("CREATE INDEX IF NOT EXISTS idx_ocr_entry_timestamp ON ocr_entry(timestamp)")
66
+ exec("CREATE INDEX IF NOT EXISTS idx_ocr_entry_wid ON ocr_entry(wid)")
67
+
68
+ // FTS5 content-sync table
69
+ exec("""
70
+ CREATE VIRTUAL TABLE IF NOT EXISTS ocr_fts USING fts5(
71
+ full_text, app, title,
72
+ content='ocr_entry', content_rowid='id'
73
+ )
74
+ """)
75
+
76
+ // Triggers to keep FTS in sync
77
+ exec("""
78
+ CREATE TRIGGER IF NOT EXISTS ocr_fts_ai AFTER INSERT ON ocr_entry BEGIN
79
+ INSERT INTO ocr_fts(rowid, full_text, app, title)
80
+ VALUES (new.id, new.full_text, new.app, new.title);
81
+ END
82
+ """)
83
+ exec("""
84
+ CREATE TRIGGER IF NOT EXISTS ocr_fts_ad AFTER DELETE ON ocr_entry BEGIN
85
+ INSERT INTO ocr_fts(ocr_fts, rowid, full_text, app, title)
86
+ VALUES ('delete', old.id, old.full_text, old.app, old.title);
87
+ END
88
+ """)
89
+ exec("""
90
+ CREATE TRIGGER IF NOT EXISTS ocr_fts_au AFTER UPDATE ON ocr_entry BEGIN
91
+ INSERT INTO ocr_fts(ocr_fts, rowid, full_text, app, title)
92
+ VALUES ('delete', old.id, old.full_text, old.app, old.title);
93
+ INSERT INTO ocr_fts(rowid, full_text, app, title)
94
+ VALUES (new.id, new.full_text, new.app, new.title);
95
+ END
96
+ """)
97
+
98
+ // Prepare cached statements
99
+ prepareInsert()
100
+ prepareCleanup()
101
+
102
+ // Run cleanup on open
103
+ cleanupSync(olderThanDays: 3)
104
+
105
+ DiagnosticLog.shared.info("OcrStore: opened \(path)")
106
+ }
107
+ }
108
+
109
+ // MARK: - Insert (batch, async)
110
+
111
+ func insert(results: [OcrWindowResult]) {
112
+ guard !results.isEmpty else { return }
113
+ queue.async { [weak self] in
114
+ guard let self, let db = self.db, let stmt = self.insertStmt else { return }
115
+
116
+ sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil)
117
+
118
+ for r in results {
119
+ let ts = r.timestamp.timeIntervalSince1970
120
+ sqlite3_bind_int(stmt, 1, Int32(r.wid))
121
+ self.bindText(stmt, 2, r.app)
122
+ self.bindText(stmt, 3, r.title)
123
+ sqlite3_bind_double(stmt, 4, r.frame.x)
124
+ sqlite3_bind_double(stmt, 5, r.frame.y)
125
+ sqlite3_bind_double(stmt, 6, r.frame.w)
126
+ sqlite3_bind_double(stmt, 7, r.frame.h)
127
+ self.bindText(stmt, 8, r.fullText)
128
+ sqlite3_bind_double(stmt, 9, ts)
129
+ sqlite3_step(stmt)
130
+ sqlite3_reset(stmt)
131
+ sqlite3_clear_bindings(stmt)
132
+ }
133
+
134
+ sqlite3_exec(db, "COMMIT", nil, nil, nil)
135
+ }
136
+ }
137
+
138
+ // MARK: - Search (FTS5, synchronous)
139
+
140
+ func search(query: String, app: String? = nil, limit: Int = 50) -> [OcrSearchResult] {
141
+ guard let db else { return [] }
142
+
143
+ var sql = """
144
+ SELECT e.id, e.wid, e.app, e.title,
145
+ e.frame_x, e.frame_y, e.frame_w, e.frame_h,
146
+ e.full_text, e.timestamp,
147
+ snippet(ocr_fts, 0, '»', '«', '…', 32) AS snip
148
+ FROM ocr_fts f
149
+ JOIN ocr_entry e ON e.id = f.rowid
150
+ WHERE ocr_fts MATCH ?1
151
+ """
152
+ if app != nil { sql += " AND e.app = ?2" }
153
+ sql += " ORDER BY rank LIMIT ?3"
154
+
155
+ var stmt: OpaquePointer?
156
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
157
+ defer { sqlite3_finalize(stmt) }
158
+
159
+ bindText(stmt!, 1, query)
160
+ if let app { bindText(stmt!, 2, app) }
161
+ sqlite3_bind_int(stmt!, 3, Int32(limit))
162
+
163
+ var results: [OcrSearchResult] = []
164
+ while sqlite3_step(stmt) == SQLITE_ROW {
165
+ results.append(rowToSearchResult(stmt!))
166
+ }
167
+ return results
168
+ }
169
+
170
+ // MARK: - History (per-window, synchronous)
171
+
172
+ func history(wid: UInt32, limit: Int = 50) -> [OcrSearchResult] {
173
+ guard let db else { return [] }
174
+
175
+ let sql = """
176
+ SELECT id, wid, app, title,
177
+ frame_x, frame_y, frame_w, frame_h,
178
+ full_text, timestamp, '' AS snip
179
+ FROM ocr_entry
180
+ WHERE wid = ?1
181
+ ORDER BY timestamp DESC
182
+ LIMIT ?2
183
+ """
184
+
185
+ var stmt: OpaquePointer?
186
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
187
+ defer { sqlite3_finalize(stmt) }
188
+
189
+ sqlite3_bind_int(stmt!, 1, Int32(wid))
190
+ sqlite3_bind_int(stmt!, 2, Int32(limit))
191
+
192
+ var results: [OcrSearchResult] = []
193
+ while sqlite3_step(stmt) == SQLITE_ROW {
194
+ results.append(rowToSearchResult(stmt!))
195
+ }
196
+ return results
197
+ }
198
+
199
+ // MARK: - Recent (chronological, synchronous)
200
+
201
+ func recent(limit: Int = 50) -> [OcrSearchResult] {
202
+ guard let db else { return [] }
203
+
204
+ let sql = """
205
+ SELECT id, wid, app, title,
206
+ frame_x, frame_y, frame_w, frame_h,
207
+ full_text, timestamp, '' AS snip
208
+ FROM ocr_entry
209
+ ORDER BY timestamp DESC
210
+ LIMIT ?1
211
+ """
212
+
213
+ var stmt: OpaquePointer?
214
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
215
+ defer { sqlite3_finalize(stmt) }
216
+
217
+ sqlite3_bind_int(stmt!, 1, Int32(limit))
218
+
219
+ var results: [OcrSearchResult] = []
220
+ while sqlite3_step(stmt) == SQLITE_ROW {
221
+ results.append(rowToSearchResult(stmt!))
222
+ }
223
+ return results
224
+ }
225
+
226
+ // MARK: - Cleanup
227
+
228
+ private func cleanupSync(olderThanDays days: Int) {
229
+ guard let db, let stmt = cleanupStmt else { return }
230
+ let cutoff = Date().timeIntervalSince1970 - Double(days * 86400)
231
+ sqlite3_bind_double(stmt, 1, cutoff)
232
+ sqlite3_step(stmt)
233
+ sqlite3_reset(stmt)
234
+ sqlite3_clear_bindings(stmt)
235
+
236
+ let deleted = sqlite3_changes(db)
237
+ if deleted > 0 {
238
+ DiagnosticLog.shared.info("OcrStore: cleaned up \(deleted) entries older than \(days) days")
239
+ }
240
+ }
241
+
242
+ // MARK: - Helpers
243
+
244
+ private func exec(_ sql: String) {
245
+ guard let db else { return }
246
+ var err: UnsafeMutablePointer<CChar>?
247
+ if sqlite3_exec(db, sql, nil, nil, &err) != SQLITE_OK {
248
+ let msg = err.map { String(cString: $0) } ?? "unknown"
249
+ DiagnosticLog.shared.error("OcrStore SQL error: \(msg)")
250
+ sqlite3_free(err)
251
+ }
252
+ }
253
+
254
+ private func bindText(_ stmt: OpaquePointer, _ index: Int32, _ value: String) {
255
+ sqlite3_bind_text(stmt, index, value, -1, sqliteTransient)
256
+ }
257
+
258
+ private func prepareInsert() {
259
+ let sql = """
260
+ INSERT INTO ocr_entry (wid, app, title, frame_x, frame_y, frame_w, frame_h, full_text, timestamp)
261
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
262
+ """
263
+ sqlite3_prepare_v2(db, sql, -1, &insertStmt, nil)
264
+ }
265
+
266
+ private func prepareCleanup() {
267
+ let sql = "DELETE FROM ocr_entry WHERE timestamp < ?1"
268
+ sqlite3_prepare_v2(db, sql, -1, &cleanupStmt, nil)
269
+ }
270
+
271
+ private func columnText(_ stmt: OpaquePointer, _ index: Int32) -> String {
272
+ if let cStr = sqlite3_column_text(stmt, index) {
273
+ return String(cString: cStr)
274
+ }
275
+ return ""
276
+ }
277
+
278
+ private func rowToSearchResult(_ stmt: OpaquePointer) -> OcrSearchResult {
279
+ OcrSearchResult(
280
+ id: sqlite3_column_int64(stmt, 0),
281
+ wid: UInt32(sqlite3_column_int(stmt, 1)),
282
+ app: columnText(stmt, 2),
283
+ title: columnText(stmt, 3),
284
+ frame: WindowFrame(
285
+ x: sqlite3_column_double(stmt, 4),
286
+ y: sqlite3_column_double(stmt, 5),
287
+ w: sqlite3_column_double(stmt, 6),
288
+ h: sqlite3_column_double(stmt, 7)
289
+ ),
290
+ fullText: columnText(stmt, 8),
291
+ snippet: columnText(stmt, 10),
292
+ timestamp: Date(timeIntervalSince1970: sqlite3_column_double(stmt, 9))
293
+ )
294
+ }
295
+ }