@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.
- package/README.md +28 -28
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +1 -40
- package/app/Sources/AppDelegate.swift +154 -24
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CommandModeState.swift +40 -19
- package/app/Sources/CommandModeView.swift +27 -2
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DiagnosticLog.swift +19 -1
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HotkeyManager.swift +1 -0
- package/app/Sources/HotkeyStore.swift +9 -1
- package/app/Sources/LatticesApi.swift +210 -0
- package/app/Sources/MainView.swift +46 -86
- package/app/Sources/MainWindow.swift +13 -0
- package/app/Sources/OcrModel.swift +309 -0
- package/app/Sources/OcrStore.swift +295 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/PaletteCommand.swift +11 -1
- package/app/Sources/PermissionChecker.swift +12 -2
- package/app/Sources/Preferences.swift +44 -0
- package/app/Sources/ScreenMapState.swift +7 -17
- package/app/Sources/ScreenMapView.swift +3 -0
- package/app/Sources/SettingsView.swift +534 -122
- package/app/Sources/Theme.swift +39 -0
- package/app/Sources/WindowTiler.swift +59 -56
- package/bin/lattices-app.js +23 -7
- package/bin/lattices.js +123 -0
- package/docs/api.md +390 -249
- package/docs/app.md +75 -28
- package/docs/concepts.md +45 -136
- package/docs/config.md +8 -7
- package/docs/layers.md +16 -18
- package/docs/ocr.md +185 -0
- package/docs/overview.md +39 -34
- package/docs/quickstart.md +34 -35
- 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
|
+
}
|