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.
- package/LICENSE +21 -0
- package/README.md +339 -0
- package/bin/open-walnut.js +2 -0
- package/bin/walnut.js +2 -0
- package/dist/cli.js +137849 -0
- package/dist/daemon-binaries/acp-worker.js +44 -0
- package/dist/daemon-binaries/acp-worker.js.version +1 -0
- package/dist/data/skills/daily-standup/SKILL.md +16 -0
- package/dist/data/skills/install-plugin/SKILL.md +118 -0
- package/dist/data/skills/learn/SKILL.md +46 -0
- package/dist/data/skills/register-repo/SKILL.md +20 -0
- package/dist/data/skills/setup-cloud-companion/SKILL.md +326 -0
- package/dist/data/skills/triage/SKILL.md +23 -0
- package/dist/data/skills/walnut-calendar/SKILL.md +92 -0
- package/dist/data/skills/walnut-manage-repos/SKILL.md +107 -0
- package/dist/data/skills/walnut-peer-sessions/SKILL.md +61 -0
- package/dist/data/skills/walnut-register-repo/SKILL.md +72 -0
- package/dist/data/skills/walnut-register-repo/references/yaml-format.md +111 -0
- package/dist/data/skills/walnut-register-repo/scripts/validate-repo.py +177 -0
- package/dist/data/skills/walnut-routines/SKILL.md +65 -0
- package/dist/data/skills/weekly-review/SKILL.md +22 -0
- package/dist/data/walnut-calendar.swift +213 -0
- package/dist/data/walnut-extract.swift +80 -0
- package/dist/hooks/on-compact.js +976 -0
- package/dist/hooks/on-stop.js +1329 -0
- package/dist/integrations/jira/index.js +4200 -0
- package/dist/integrations/jira/manifest.json +26 -0
- package/dist/integrations/local/index.js +51 -0
- package/dist/integrations/local/manifest.json +10 -0
- package/dist/integrations/ms-todo/index.js +4672 -0
- package/dist/integrations/ms-todo/manifest.json +17 -0
- package/dist/session-server/index.js +1382 -0
- package/dist/web/server.js +135275 -0
- package/dist/web/static/assets/apl-B4CMkyY2.js +1 -0
- package/dist/web/static/assets/asciiarmor-Df11BRmG.js +1 -0
- package/dist/web/static/assets/asn1-EdZsLKOL.js +1 -0
- package/dist/web/static/assets/asterisk-B-8jnY81.js +1 -0
- package/dist/web/static/assets/brainfuck-C4LP7Hcl.js +1 -0
- package/dist/web/static/assets/clike-B9uivgTg.js +1 -0
- package/dist/web/static/assets/clojure-BMjYHr_A.js +1 -0
- package/dist/web/static/assets/cmake-BQqOBYOt.js +1 -0
- package/dist/web/static/assets/cobol-CWcv1MsR.js +1 -0
- package/dist/web/static/assets/coffeescript-S37ZYGWr.js +1 -0
- package/dist/web/static/assets/commonlisp-DBKNyK5s.js +1 -0
- package/dist/web/static/assets/crystal-SjHAIU92.js +1 -0
- package/dist/web/static/assets/css-BnMrqG3P.js +1 -0
- package/dist/web/static/assets/cypher-C_CwsFkJ.js +1 -0
- package/dist/web/static/assets/d-pRatUO7H.js +1 -0
- package/dist/web/static/assets/diff-DbItnlRl.js +1 -0
- package/dist/web/static/assets/dockerfile-BKs6k2Af.js +1 -0
- package/dist/web/static/assets/dtd-DF_7sFjM.js +1 -0
- package/dist/web/static/assets/dylan-DwRh75JA.js +1 -0
- package/dist/web/static/assets/ebnf-CDyGwa7X.js +1 -0
- package/dist/web/static/assets/ecl-Cabwm37j.js +1 -0
- package/dist/web/static/assets/eiffel-CnydiIhH.js +1 -0
- package/dist/web/static/assets/elm-vLlmbW-K.js +1 -0
- package/dist/web/static/assets/erlang-BNw1qcRV.js +1 -0
- package/dist/web/static/assets/factor-kuTfRLto.js +1 -0
- package/dist/web/static/assets/fcl-Kvtd6kyn.js +1 -0
- package/dist/web/static/assets/forth-Ffai-XNe.js +1 -0
- package/dist/web/static/assets/fortran-DYz_wnZ1.js +1 -0
- package/dist/web/static/assets/gas-Bneqetm1.js +1 -0
- package/dist/web/static/assets/gherkin-heZmZLOM.js +1 -0
- package/dist/web/static/assets/groovy-D9Dt4D0W.js +1 -0
- package/dist/web/static/assets/haskell-Cw1EW3IL.js +1 -0
- package/dist/web/static/assets/haxe-H-WmDvRZ.js +1 -0
- package/dist/web/static/assets/http-DBlCnlav.js +1 -0
- package/dist/web/static/assets/idl-BEugSyMb.js +1 -0
- package/dist/web/static/assets/index-B3K5yAdA.js +1 -0
- package/dist/web/static/assets/index-B7OvpXOs.js +632 -0
- package/dist/web/static/assets/index-C07UNJIJ.js +1 -0
- package/dist/web/static/assets/index-C8k-3wfu.js +2 -0
- package/dist/web/static/assets/index-CBXGsT3t.js +1 -0
- package/dist/web/static/assets/index-CUrO3qpX.js +1 -0
- package/dist/web/static/assets/index-CpTvBBAa.js +1 -0
- package/dist/web/static/assets/index-D4J6slTf.js +1 -0
- package/dist/web/static/assets/index-DUgC5K_q.js +3 -0
- package/dist/web/static/assets/index-DhqXE1wa.js +1 -0
- package/dist/web/static/assets/index-DkGqHTCk.js +1 -0
- package/dist/web/static/assets/index-DsxFiBic.js +1 -0
- package/dist/web/static/assets/index-Dz3umkfm.js +1 -0
- package/dist/web/static/assets/index-G2v0oaqp.js +1 -0
- package/dist/web/static/assets/index-GNDKJKwy.js +1 -0
- package/dist/web/static/assets/index-U1CVtNUH.css +32 -0
- package/dist/web/static/assets/index-ft4kpQXq.js +1 -0
- package/dist/web/static/assets/index-ls7h0u-p.js +1 -0
- package/dist/web/static/assets/index-nL5l5WPW.js +7 -0
- package/dist/web/static/assets/javascript-iXu5QeM3.js +1 -0
- package/dist/web/static/assets/julia-DuME0IfC.js +1 -0
- package/dist/web/static/assets/livescript-BwQOo05w.js +1 -0
- package/dist/web/static/assets/lua-VAEuO923.js +1 -0
- package/dist/web/static/assets/mathematica-DTrFuWx2.js +1 -0
- package/dist/web/static/assets/mbox-CNhZ1qSd.js +1 -0
- package/dist/web/static/assets/mirc-CjQqDB4T.js +1 -0
- package/dist/web/static/assets/mllike-CXdrOF99.js +1 -0
- package/dist/web/static/assets/modelica-Dc1JOy9r.js +1 -0
- package/dist/web/static/assets/mscgen-BA5vi2Kp.js +1 -0
- package/dist/web/static/assets/mumps-BT43cFF4.js +1 -0
- package/dist/web/static/assets/nginx-DdIZxoE0.js +1 -0
- package/dist/web/static/assets/nsis-LdVXkNf5.js +1 -0
- package/dist/web/static/assets/ntriples-BfvgReVJ.js +1 -0
- package/dist/web/static/assets/octave-Ck1zUtKM.js +1 -0
- package/dist/web/static/assets/oz-BzwKVEFT.js +1 -0
- package/dist/web/static/assets/pascal--L3eBynH.js +1 -0
- package/dist/web/static/assets/perl-CdXCOZ3F.js +1 -0
- package/dist/web/static/assets/pig-CevX1Tat.js +1 -0
- package/dist/web/static/assets/powershell-CFHJl5sT.js +1 -0
- package/dist/web/static/assets/properties-C78fOPTZ.js +1 -0
- package/dist/web/static/assets/protobuf-ChK-085T.js +1 -0
- package/dist/web/static/assets/pug-DeIclll2.js +1 -0
- package/dist/web/static/assets/puppet-DMA9R1ak.js +1 -0
- package/dist/web/static/assets/python-BuPzkPfP.js +1 -0
- package/dist/web/static/assets/q-pXgVlZs6.js +1 -0
- package/dist/web/static/assets/r-B6wPVr8A.js +1 -0
- package/dist/web/static/assets/rpm-CTu-6PCP.js +1 -0
- package/dist/web/static/assets/ruby-B2Rjki9n.js +1 -0
- package/dist/web/static/assets/sas-B4kiWyti.js +1 -0
- package/dist/web/static/assets/scheme-C41bIUwD.js +1 -0
- package/dist/web/static/assets/shell-CjFT_Tl9.js +1 -0
- package/dist/web/static/assets/sieve-C3Gn_uJK.js +1 -0
- package/dist/web/static/assets/simple-mode-GW_nhZxv.js +1 -0
- package/dist/web/static/assets/smalltalk-CnHTOXQT.js +1 -0
- package/dist/web/static/assets/solr-DehyRSwq.js +1 -0
- package/dist/web/static/assets/sparql-DkYu6x3z.js +1 -0
- package/dist/web/static/assets/spreadsheet-BCZA_wO0.js +1 -0
- package/dist/web/static/assets/sql-D0XecflT.js +1 -0
- package/dist/web/static/assets/stex-C3f8Ysf7.js +1 -0
- package/dist/web/static/assets/stylus-B533Al4x.js +1 -0
- package/dist/web/static/assets/swift-BzpIVaGY.js +1 -0
- package/dist/web/static/assets/tcl-DVfN8rqt.js +1 -0
- package/dist/web/static/assets/textile-CnDTJFAw.js +1 -0
- package/dist/web/static/assets/tiddlywiki-DO-Gjzrf.js +1 -0
- package/dist/web/static/assets/tiki-DGYXhP31.js +1 -0
- package/dist/web/static/assets/toml-Bm5Em-hy.js +1 -0
- package/dist/web/static/assets/troff-wAsdV37c.js +1 -0
- package/dist/web/static/assets/ttcn-CfJYG6tj.js +1 -0
- package/dist/web/static/assets/ttcn-cfg-B9xdYoR4.js +1 -0
- package/dist/web/static/assets/turtle-B1tBg_DP.js +1 -0
- package/dist/web/static/assets/vb-CmGdzxic.js +1 -0
- package/dist/web/static/assets/vbscript-BuJXcnF6.js +1 -0
- package/dist/web/static/assets/velocity-D8B20fx6.js +1 -0
- package/dist/web/static/assets/verilog-C6RDOZhf.js +1 -0
- package/dist/web/static/assets/vhdl-lSbBsy5d.js +1 -0
- package/dist/web/static/assets/webidl-ZXfAyPTL.js +1 -0
- package/dist/web/static/assets/xquery-DzFWVndE.js +1 -0
- package/dist/web/static/assets/yacas-BJ4BC0dw.js +1 -0
- package/dist/web/static/assets/z80-Hz9HOZM7.js +1 -0
- package/dist/web/static/index.html +22 -0
- package/dist/web/static/walnut-icon.png +0 -0
- package/dist/workers/git-compaction-worker.js +898 -0
- package/dist/workers/qmd-index-worker.js +97162 -0
- package/package.json +143 -0
- package/patches/@anthropic-ai+claude-agent-sdk+0.2.50.patch +13 -0
- package/scripts/patch-qmd.mjs +171 -0
- 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)!)
|