open-walnut 0.3.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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +339 -0
  3. package/bin/open-walnut.js +2 -0
  4. package/bin/walnut.js +2 -0
  5. package/dist/cli.js +137849 -0
  6. package/dist/daemon-binaries/acp-worker.js +44 -0
  7. package/dist/daemon-binaries/acp-worker.js.version +1 -0
  8. package/dist/data/skills/daily-standup/SKILL.md +16 -0
  9. package/dist/data/skills/install-plugin/SKILL.md +118 -0
  10. package/dist/data/skills/learn/SKILL.md +46 -0
  11. package/dist/data/skills/register-repo/SKILL.md +20 -0
  12. package/dist/data/skills/setup-cloud-companion/SKILL.md +326 -0
  13. package/dist/data/skills/triage/SKILL.md +23 -0
  14. package/dist/data/skills/walnut-calendar/SKILL.md +92 -0
  15. package/dist/data/skills/walnut-manage-repos/SKILL.md +107 -0
  16. package/dist/data/skills/walnut-peer-sessions/SKILL.md +61 -0
  17. package/dist/data/skills/walnut-register-repo/SKILL.md +72 -0
  18. package/dist/data/skills/walnut-register-repo/references/yaml-format.md +111 -0
  19. package/dist/data/skills/walnut-register-repo/scripts/validate-repo.py +177 -0
  20. package/dist/data/skills/walnut-routines/SKILL.md +65 -0
  21. package/dist/data/skills/weekly-review/SKILL.md +22 -0
  22. package/dist/data/walnut-calendar.swift +213 -0
  23. package/dist/data/walnut-extract.swift +80 -0
  24. package/dist/hooks/on-compact.js +976 -0
  25. package/dist/hooks/on-stop.js +1329 -0
  26. package/dist/integrations/jira/index.js +4200 -0
  27. package/dist/integrations/jira/manifest.json +26 -0
  28. package/dist/integrations/local/index.js +51 -0
  29. package/dist/integrations/local/manifest.json +10 -0
  30. package/dist/integrations/ms-todo/index.js +4672 -0
  31. package/dist/integrations/ms-todo/manifest.json +17 -0
  32. package/dist/session-server/index.js +1382 -0
  33. package/dist/web/server.js +135275 -0
  34. package/dist/web/static/assets/apl-B4CMkyY2.js +1 -0
  35. package/dist/web/static/assets/asciiarmor-Df11BRmG.js +1 -0
  36. package/dist/web/static/assets/asn1-EdZsLKOL.js +1 -0
  37. package/dist/web/static/assets/asterisk-B-8jnY81.js +1 -0
  38. package/dist/web/static/assets/brainfuck-C4LP7Hcl.js +1 -0
  39. package/dist/web/static/assets/clike-B9uivgTg.js +1 -0
  40. package/dist/web/static/assets/clojure-BMjYHr_A.js +1 -0
  41. package/dist/web/static/assets/cmake-BQqOBYOt.js +1 -0
  42. package/dist/web/static/assets/cobol-CWcv1MsR.js +1 -0
  43. package/dist/web/static/assets/coffeescript-S37ZYGWr.js +1 -0
  44. package/dist/web/static/assets/commonlisp-DBKNyK5s.js +1 -0
  45. package/dist/web/static/assets/crystal-SjHAIU92.js +1 -0
  46. package/dist/web/static/assets/css-BnMrqG3P.js +1 -0
  47. package/dist/web/static/assets/cypher-C_CwsFkJ.js +1 -0
  48. package/dist/web/static/assets/d-pRatUO7H.js +1 -0
  49. package/dist/web/static/assets/diff-DbItnlRl.js +1 -0
  50. package/dist/web/static/assets/dockerfile-BKs6k2Af.js +1 -0
  51. package/dist/web/static/assets/dtd-DF_7sFjM.js +1 -0
  52. package/dist/web/static/assets/dylan-DwRh75JA.js +1 -0
  53. package/dist/web/static/assets/ebnf-CDyGwa7X.js +1 -0
  54. package/dist/web/static/assets/ecl-Cabwm37j.js +1 -0
  55. package/dist/web/static/assets/eiffel-CnydiIhH.js +1 -0
  56. package/dist/web/static/assets/elm-vLlmbW-K.js +1 -0
  57. package/dist/web/static/assets/erlang-BNw1qcRV.js +1 -0
  58. package/dist/web/static/assets/factor-kuTfRLto.js +1 -0
  59. package/dist/web/static/assets/fcl-Kvtd6kyn.js +1 -0
  60. package/dist/web/static/assets/forth-Ffai-XNe.js +1 -0
  61. package/dist/web/static/assets/fortran-DYz_wnZ1.js +1 -0
  62. package/dist/web/static/assets/gas-Bneqetm1.js +1 -0
  63. package/dist/web/static/assets/gherkin-heZmZLOM.js +1 -0
  64. package/dist/web/static/assets/groovy-D9Dt4D0W.js +1 -0
  65. package/dist/web/static/assets/haskell-Cw1EW3IL.js +1 -0
  66. package/dist/web/static/assets/haxe-H-WmDvRZ.js +1 -0
  67. package/dist/web/static/assets/http-DBlCnlav.js +1 -0
  68. package/dist/web/static/assets/idl-BEugSyMb.js +1 -0
  69. package/dist/web/static/assets/index-B3K5yAdA.js +1 -0
  70. package/dist/web/static/assets/index-B7OvpXOs.js +632 -0
  71. package/dist/web/static/assets/index-C07UNJIJ.js +1 -0
  72. package/dist/web/static/assets/index-C8k-3wfu.js +2 -0
  73. package/dist/web/static/assets/index-CBXGsT3t.js +1 -0
  74. package/dist/web/static/assets/index-CUrO3qpX.js +1 -0
  75. package/dist/web/static/assets/index-CpTvBBAa.js +1 -0
  76. package/dist/web/static/assets/index-D4J6slTf.js +1 -0
  77. package/dist/web/static/assets/index-DUgC5K_q.js +3 -0
  78. package/dist/web/static/assets/index-DhqXE1wa.js +1 -0
  79. package/dist/web/static/assets/index-DkGqHTCk.js +1 -0
  80. package/dist/web/static/assets/index-DsxFiBic.js +1 -0
  81. package/dist/web/static/assets/index-Dz3umkfm.js +1 -0
  82. package/dist/web/static/assets/index-G2v0oaqp.js +1 -0
  83. package/dist/web/static/assets/index-GNDKJKwy.js +1 -0
  84. package/dist/web/static/assets/index-U1CVtNUH.css +32 -0
  85. package/dist/web/static/assets/index-ft4kpQXq.js +1 -0
  86. package/dist/web/static/assets/index-ls7h0u-p.js +1 -0
  87. package/dist/web/static/assets/index-nL5l5WPW.js +7 -0
  88. package/dist/web/static/assets/javascript-iXu5QeM3.js +1 -0
  89. package/dist/web/static/assets/julia-DuME0IfC.js +1 -0
  90. package/dist/web/static/assets/livescript-BwQOo05w.js +1 -0
  91. package/dist/web/static/assets/lua-VAEuO923.js +1 -0
  92. package/dist/web/static/assets/mathematica-DTrFuWx2.js +1 -0
  93. package/dist/web/static/assets/mbox-CNhZ1qSd.js +1 -0
  94. package/dist/web/static/assets/mirc-CjQqDB4T.js +1 -0
  95. package/dist/web/static/assets/mllike-CXdrOF99.js +1 -0
  96. package/dist/web/static/assets/modelica-Dc1JOy9r.js +1 -0
  97. package/dist/web/static/assets/mscgen-BA5vi2Kp.js +1 -0
  98. package/dist/web/static/assets/mumps-BT43cFF4.js +1 -0
  99. package/dist/web/static/assets/nginx-DdIZxoE0.js +1 -0
  100. package/dist/web/static/assets/nsis-LdVXkNf5.js +1 -0
  101. package/dist/web/static/assets/ntriples-BfvgReVJ.js +1 -0
  102. package/dist/web/static/assets/octave-Ck1zUtKM.js +1 -0
  103. package/dist/web/static/assets/oz-BzwKVEFT.js +1 -0
  104. package/dist/web/static/assets/pascal--L3eBynH.js +1 -0
  105. package/dist/web/static/assets/perl-CdXCOZ3F.js +1 -0
  106. package/dist/web/static/assets/pig-CevX1Tat.js +1 -0
  107. package/dist/web/static/assets/powershell-CFHJl5sT.js +1 -0
  108. package/dist/web/static/assets/properties-C78fOPTZ.js +1 -0
  109. package/dist/web/static/assets/protobuf-ChK-085T.js +1 -0
  110. package/dist/web/static/assets/pug-DeIclll2.js +1 -0
  111. package/dist/web/static/assets/puppet-DMA9R1ak.js +1 -0
  112. package/dist/web/static/assets/python-BuPzkPfP.js +1 -0
  113. package/dist/web/static/assets/q-pXgVlZs6.js +1 -0
  114. package/dist/web/static/assets/r-B6wPVr8A.js +1 -0
  115. package/dist/web/static/assets/rpm-CTu-6PCP.js +1 -0
  116. package/dist/web/static/assets/ruby-B2Rjki9n.js +1 -0
  117. package/dist/web/static/assets/sas-B4kiWyti.js +1 -0
  118. package/dist/web/static/assets/scheme-C41bIUwD.js +1 -0
  119. package/dist/web/static/assets/shell-CjFT_Tl9.js +1 -0
  120. package/dist/web/static/assets/sieve-C3Gn_uJK.js +1 -0
  121. package/dist/web/static/assets/simple-mode-GW_nhZxv.js +1 -0
  122. package/dist/web/static/assets/smalltalk-CnHTOXQT.js +1 -0
  123. package/dist/web/static/assets/solr-DehyRSwq.js +1 -0
  124. package/dist/web/static/assets/sparql-DkYu6x3z.js +1 -0
  125. package/dist/web/static/assets/spreadsheet-BCZA_wO0.js +1 -0
  126. package/dist/web/static/assets/sql-D0XecflT.js +1 -0
  127. package/dist/web/static/assets/stex-C3f8Ysf7.js +1 -0
  128. package/dist/web/static/assets/stylus-B533Al4x.js +1 -0
  129. package/dist/web/static/assets/swift-BzpIVaGY.js +1 -0
  130. package/dist/web/static/assets/tcl-DVfN8rqt.js +1 -0
  131. package/dist/web/static/assets/textile-CnDTJFAw.js +1 -0
  132. package/dist/web/static/assets/tiddlywiki-DO-Gjzrf.js +1 -0
  133. package/dist/web/static/assets/tiki-DGYXhP31.js +1 -0
  134. package/dist/web/static/assets/toml-Bm5Em-hy.js +1 -0
  135. package/dist/web/static/assets/troff-wAsdV37c.js +1 -0
  136. package/dist/web/static/assets/ttcn-CfJYG6tj.js +1 -0
  137. package/dist/web/static/assets/ttcn-cfg-B9xdYoR4.js +1 -0
  138. package/dist/web/static/assets/turtle-B1tBg_DP.js +1 -0
  139. package/dist/web/static/assets/vb-CmGdzxic.js +1 -0
  140. package/dist/web/static/assets/vbscript-BuJXcnF6.js +1 -0
  141. package/dist/web/static/assets/velocity-D8B20fx6.js +1 -0
  142. package/dist/web/static/assets/verilog-C6RDOZhf.js +1 -0
  143. package/dist/web/static/assets/vhdl-lSbBsy5d.js +1 -0
  144. package/dist/web/static/assets/webidl-ZXfAyPTL.js +1 -0
  145. package/dist/web/static/assets/xquery-DzFWVndE.js +1 -0
  146. package/dist/web/static/assets/yacas-BJ4BC0dw.js +1 -0
  147. package/dist/web/static/assets/z80-Hz9HOZM7.js +1 -0
  148. package/dist/web/static/index.html +22 -0
  149. package/dist/web/static/walnut-icon.png +0 -0
  150. package/dist/workers/git-compaction-worker.js +898 -0
  151. package/dist/workers/qmd-index-worker.js +97162 -0
  152. package/package.json +143 -0
  153. package/patches/@anthropic-ai+claude-agent-sdk+0.2.50.patch +13 -0
  154. package/scripts/patch-qmd.mjs +171 -0
  155. package/scripts/postinstall.mjs +120 -0
@@ -0,0 +1,213 @@
1
+ // walnut-calendar — EventKit bridge for Open Walnut's calendar view.
2
+ //
3
+ // Reads/writes the Mac's system calendars (ALL accounts the user added in
4
+ // System Settings → Internet Accounts: iCloud, Google, Exchange, …), so
5
+ // Walnut needs no per-provider OAuth; macOS owns sync back to the cloud.
6
+ //
7
+ // Subcommands (all output JSON on stdout; errors as {"error":..., "code":...}):
8
+ // calendars → [{id,title,account,color,readonly}]
9
+ // list <fromISO> <toISO> → [{id,calendarId,calendarName,account,
10
+ // title,start,end,allDay,location,readonly}]
11
+ // update <eventId> <startISO> <endISO> [title]
12
+ // create <calendarId> <title> <startISO> <endISO> [allDay]
13
+ // delete <eventId>
14
+ //
15
+ // Dates are tz-less LOCAL wall time ("2026-08-05T09:00:00") to match Walnut's
16
+ // task-date contract. Recurring events: `list` expands occurrences (EventKit
17
+ // does this natively); `update`/`delete` touch only that occurrence
18
+ // (span:.thisEvent).
19
+ //
20
+ // Compiled lazily by src/core/calendar/sources/eventkit.ts via
21
+ // `xcrun swiftc -O` into WALNUT_HOME/cache (same pattern as walnut-extract).
22
+
23
+ import EventKit
24
+ import Foundation
25
+
26
+ let store = EKEventStore()
27
+
28
+ func fail(_ message: String, code: String) -> Never {
29
+ let payload: [String: String] = ["error": message, "code": code]
30
+ let data = try! JSONSerialization.data(withJSONObject: payload)
31
+ FileHandle.standardOutput.write(data)
32
+ exit(1)
33
+ }
34
+
35
+ // ── date helpers (local wall time, no tz suffix) ────────────────────────────
36
+
37
+ let localFormatter: DateFormatter = {
38
+ let f = DateFormatter()
39
+ f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
40
+ f.timeZone = TimeZone.current
41
+ f.locale = Locale(identifier: "en_US_POSIX")
42
+ return f
43
+ }()
44
+
45
+ let dayFormatter: DateFormatter = {
46
+ let f = DateFormatter()
47
+ f.dateFormat = "yyyy-MM-dd"
48
+ f.timeZone = TimeZone.current
49
+ f.locale = Locale(identifier: "en_US_POSIX")
50
+ return f
51
+ }()
52
+
53
+ func parseLocal(_ s: String) -> Date? {
54
+ if s.contains("T") { return localFormatter.date(from: s) }
55
+ return dayFormatter.date(from: s)
56
+ }
57
+
58
+ func formatLocal(_ d: Date) -> String { localFormatter.string(from: d) }
59
+
60
+ // ── access ──────────────────────────────────────────────────────────────────
61
+
62
+ func requestAccess() {
63
+ let sema = DispatchSemaphore(value: 0)
64
+ var granted = false
65
+ var accessError: Error?
66
+ let handler: (Bool, Error?) -> Void = { ok, err in
67
+ granted = ok
68
+ accessError = err
69
+ sema.signal()
70
+ }
71
+ if #available(macOS 14.0, *) {
72
+ store.requestFullAccessToEvents(completion: handler)
73
+ } else {
74
+ store.requestAccess(to: .event, completion: handler)
75
+ }
76
+ _ = sema.wait(timeout: .now() + 30)
77
+ if !granted {
78
+ let detail = accessError.map { " (\($0.localizedDescription))" } ?? ""
79
+ fail("Calendar access denied\(detail). Grant access in System Settings → Privacy & Security → Calendars.", code: "permission-denied")
80
+ }
81
+ }
82
+
83
+ func colorHex(_ calendar: EKCalendar) -> String {
84
+ guard let cg = calendar.cgColor, let comps = cg.components, comps.count >= 3 else { return "#0A84FF" }
85
+ let r = Int((comps[0] * 255).rounded()), g = Int((comps[1] * 255).rounded()), b = Int((comps[2] * 255).rounded())
86
+ return String(format: "#%02X%02X%02X", r, g, b)
87
+ }
88
+
89
+ func calendarJson(_ c: EKCalendar) -> [String: Any] {
90
+ return [
91
+ "id": c.calendarIdentifier,
92
+ "title": c.title,
93
+ "account": c.source?.title ?? "Local",
94
+ "color": colorHex(c),
95
+ "readonly": !c.allowsContentModifications,
96
+ ]
97
+ }
98
+
99
+ func eventJson(_ e: EKEvent) -> [String: Any] {
100
+ // Occurrences of a recurring event share eventIdentifier; suffix the start
101
+ // timestamp so every rendered chip has a unique, re-findable id.
102
+ let baseId = e.eventIdentifier ?? "unknown"
103
+ let occId = e.hasRecurrenceRules || e.isDetached
104
+ ? "\(baseId)#\(Int(e.startDate.timeIntervalSince1970))"
105
+ : baseId
106
+ var out: [String: Any] = [
107
+ "id": occId,
108
+ "calendarId": e.calendar.calendarIdentifier,
109
+ "calendarName": e.calendar.title,
110
+ "account": e.calendar.source?.title ?? "Local",
111
+ "title": e.title ?? "(untitled)",
112
+ "start": e.isAllDay ? dayFormatter.string(from: e.startDate) : formatLocal(e.startDate),
113
+ "end": e.isAllDay ? dayFormatter.string(from: e.endDate) : formatLocal(e.endDate),
114
+ "allDay": e.isAllDay,
115
+ "readonly": !e.calendar.allowsContentModifications,
116
+ ]
117
+ if let loc = e.location, !loc.isEmpty { out["location"] = loc }
118
+ return out
119
+ }
120
+
121
+ /// Resolve an occurrence id ("<ekid>" or "<ekid>#<epoch>") to the concrete
122
+ /// EKEvent instance, searching around the occurrence time for recurring events.
123
+ func findEvent(_ occId: String) -> EKEvent? {
124
+ let parts = occId.split(separator: "#", maxSplits: 1)
125
+ let baseId = String(parts[0])
126
+ guard let base = store.event(withIdentifier: baseId) else { return nil }
127
+ if parts.count == 1 { return base }
128
+ guard let epoch = Double(parts[1]) else { return base }
129
+ let target = Date(timeIntervalSince1970: epoch)
130
+ let predicate = store.predicateForEvents(
131
+ withStart: target.addingTimeInterval(-1),
132
+ end: target.addingTimeInterval(24 * 3600),
133
+ calendars: [base.calendar]
134
+ )
135
+ return store.events(matching: predicate).first {
136
+ $0.eventIdentifier == baseId && abs($0.startDate.timeIntervalSince1970 - epoch) < 1
137
+ } ?? base
138
+ }
139
+
140
+ func output(_ obj: Any) {
141
+ let data = try! JSONSerialization.data(withJSONObject: obj)
142
+ FileHandle.standardOutput.write(data)
143
+ }
144
+
145
+ // ── main ────────────────────────────────────────────────────────────────────
146
+
147
+ let args = CommandLine.arguments
148
+ guard args.count >= 2 else { fail("usage: walnut-calendar <calendars|list|update|create|delete> …", code: "usage") }
149
+ requestAccess()
150
+
151
+ switch args[1] {
152
+ case "calendars":
153
+ output(store.calendars(for: .event).map(calendarJson))
154
+
155
+ case "list":
156
+ guard args.count >= 4, let from = parseLocal(args[2]), let toDay = parseLocal(args[3]) else {
157
+ fail("usage: list <fromISO> <toISO>", code: "usage")
158
+ }
159
+ // `to` is an inclusive day string → extend to end of that day.
160
+ let to = args[3].contains("T") ? toDay : toDay.addingTimeInterval(24 * 3600)
161
+ let predicate = store.predicateForEvents(withStart: from, end: to, calendars: nil)
162
+ output(store.events(matching: predicate).map(eventJson))
163
+
164
+ case "update":
165
+ guard args.count >= 5, let start = parseLocal(args[3]), let end = parseLocal(args[4]) else {
166
+ fail("usage: update <eventId> <startISO> <endISO> [title]", code: "usage")
167
+ }
168
+ guard let event = findEvent(args[2]) else { fail("event not found: \(args[2])", code: "not-found") }
169
+ if !event.calendar.allowsContentModifications { fail("calendar is read-only", code: "readonly") }
170
+ let allDay = !args[3].contains("T")
171
+ event.startDate = start
172
+ // All-day "end" arrives as an inclusive day → extend to end-of-day so
173
+ // EventKit doesn't get a zero-length event.
174
+ event.endDate = allDay && !args[4].contains("T") && end <= start ? end.addingTimeInterval(24 * 3600 - 1) : end
175
+ event.isAllDay = allDay
176
+ if args.count >= 6 && !args[5].isEmpty { event.title = args[5] }
177
+ do {
178
+ try store.save(event, span: .thisEvent, commit: true)
179
+ output(eventJson(event))
180
+ } catch { fail("save failed: \(error.localizedDescription)", code: "save-failed") }
181
+
182
+ case "create":
183
+ guard args.count >= 6, let start = parseLocal(args[4]), let endRaw = parseLocal(args[5]) else {
184
+ fail("usage: create <calendarId> <title> <startISO> <endISO> [allDay]", code: "usage")
185
+ }
186
+ guard let calendar = store.calendar(withIdentifier: args[2]) else {
187
+ fail("calendar not found: \(args[2])", code: "not-found")
188
+ }
189
+ if !calendar.allowsContentModifications { fail("calendar is read-only", code: "readonly") }
190
+ let event = EKEvent(eventStore: store)
191
+ event.calendar = calendar
192
+ event.title = args[3]
193
+ event.startDate = start
194
+ let allDay = args.count >= 7 && args[6] == "true"
195
+ event.isAllDay = allDay
196
+ // All-day "end" is an inclusive day → EventKit wants end-of-day.
197
+ event.endDate = allDay && !args[5].contains("T") ? endRaw.addingTimeInterval(24 * 3600 - 1) : endRaw
198
+ do {
199
+ try store.save(event, span: .thisEvent, commit: true)
200
+ output(eventJson(event))
201
+ } catch { fail("save failed: \(error.localizedDescription)", code: "save-failed") }
202
+
203
+ case "delete":
204
+ guard args.count >= 3 else { fail("usage: delete <eventId>", code: "usage") }
205
+ guard let event = findEvent(args[2]) else { fail("event not found: \(args[2])", code: "not-found") }
206
+ do {
207
+ try store.remove(event, span: .thisEvent, commit: true)
208
+ output(["ok": true])
209
+ } catch { fail("delete failed: \(error.localizedDescription)", code: "save-failed") }
210
+
211
+ default:
212
+ fail("unknown subcommand: \(args[1])", code: "usage")
213
+ }
@@ -0,0 +1,80 @@
1
+ // walnut-extract — extract searchable text from a PDF or image, entirely with
2
+ // macOS system frameworks (PDFKit text layer, Vision OCR). No third-party deps.
3
+ //
4
+ // walnut-extract <file> → UTF-8 text on stdout, exit 0
5
+ // exit 2: unsupported/unreadable input
6
+ //
7
+ // PDFs: use the embedded text layer when present; pages without one (scans)
8
+ // are rendered at 2x and OCR'd. Images: straight to OCR. Recognition languages
9
+ // cover the vault's content (Simplified Chinese + English).
10
+ import Foundation
11
+ import PDFKit
12
+ import Vision
13
+ import AppKit
14
+
15
+ let MAX_OCR_PAGES = 20
16
+ let OCR_LANGS = ["zh-Hans", "en-US"]
17
+
18
+ func ocr(_ cgImage: CGImage) -> String {
19
+ let request = VNRecognizeTextRequest()
20
+ request.recognitionLevel = .accurate
21
+ request.recognitionLanguages = OCR_LANGS
22
+ request.usesLanguageCorrection = true
23
+ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
24
+ guard (try? handler.perform([request])) != nil else { return "" }
25
+ let lines = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }
26
+ return lines.joined(separator: "\n")
27
+ }
28
+
29
+ func renderPage(_ page: PDFPage) -> CGImage? {
30
+ let bounds = page.bounds(for: .mediaBox)
31
+ let scale: CGFloat = 2.0
32
+ let size = CGSize(width: bounds.width * scale, height: bounds.height * scale)
33
+ guard size.width > 4, size.height > 4,
34
+ let ctx = CGContext(data: nil, width: Int(size.width), height: Int(size.height),
35
+ bitsPerComponent: 8, bytesPerRow: 0,
36
+ space: CGColorSpaceCreateDeviceRGB(),
37
+ bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
38
+ else { return nil }
39
+ ctx.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
40
+ ctx.fill(CGRect(origin: .zero, size: size))
41
+ ctx.scaleBy(x: scale, y: scale)
42
+ ctx.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
43
+ page.draw(with: .mediaBox, to: ctx)
44
+ return ctx.makeImage()
45
+ }
46
+
47
+ func extractPdf(_ url: URL) -> String? {
48
+ guard let doc = PDFDocument(url: url) else { return nil }
49
+ var parts: [String] = []
50
+ var ocrBudget = MAX_OCR_PAGES
51
+ for i in 0..<doc.pageCount {
52
+ guard let page = doc.page(at: i) else { continue }
53
+ let text = page.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
54
+ if text.count >= 16 {
55
+ parts.append(text)
56
+ } else if ocrBudget > 0, let img = renderPage(page) {
57
+ ocrBudget -= 1
58
+ let recognized = ocr(img)
59
+ if !recognized.isEmpty { parts.append(recognized) }
60
+ }
61
+ }
62
+ return parts.joined(separator: "\n\n")
63
+ }
64
+
65
+ func extractImage(_ url: URL) -> String? {
66
+ guard let image = NSImage(contentsOf: url),
67
+ let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
68
+ else { return nil }
69
+ return ocr(cg)
70
+ }
71
+
72
+ guard CommandLine.arguments.count == 2 else {
73
+ FileHandle.standardError.write("usage: walnut-extract <file>\n".data(using: .utf8)!)
74
+ exit(2)
75
+ }
76
+ let url = URL(fileURLWithPath: CommandLine.arguments[1])
77
+ let ext = url.pathExtension.lowercased()
78
+ let result: String? = ext == "pdf" ? extractPdf(url) : extractImage(url)
79
+ guard let text = result else { exit(2) }
80
+ FileHandle.standardOutput.write(text.data(using: .utf8)!)