daily-focus 0.1.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.
@@ -0,0 +1,99 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/watson/daily-focus/schema/items.schema.json",
4
+ "title": "Daily Focus brief",
5
+ "description": "The payload the briefing agent writes to items.json. The morning briefing prompt covers rules a schema can't express, including stable ids across runs.",
6
+ "type": "object",
7
+ "required": ["version", "generatedAt", "items"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "version": { "const": 1 },
11
+ "generatedAt": {
12
+ "type": "string",
13
+ "format": "date-time",
14
+ "description": "ISO 8601 timestamp of the run that produced this file."
15
+ },
16
+ "generatedBy": {
17
+ "type": "string",
18
+ "description": "Free-form label naming whichever agent produced this brief. The dashboard shows it, and does not care which agent it is."
19
+ },
20
+ "date": {
21
+ "type": "string",
22
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
23
+ "description": "The local date this brief is for."
24
+ },
25
+ "headline": {
26
+ "type": "string",
27
+ "description": "One or two sentences at the top of the page. Inline Markdown: **bold**, `code`, [text](url)."
28
+ },
29
+ "dayStart": {
30
+ "type": "string",
31
+ "pattern": "^\\d{2}:\\d{2}$",
32
+ "description": "Local time today's working day starts, derived from the calendar. Overrides the dashboard default."
33
+ },
34
+ "dayEnd": {
35
+ "type": "string",
36
+ "pattern": "^\\d{2}:\\d{2}$",
37
+ "description": "Local time today's working day ends. Set this when the day is short — free windows and remaining focus time are computed from it."
38
+ },
39
+ "items": { "type": "array", "items": { "$ref": "#/$defs/item" } }
40
+ },
41
+ "$defs": {
42
+ "item": {
43
+ "type": "object",
44
+ "required": ["id", "title", "source", "kind"],
45
+ "additionalProperties": false,
46
+ "properties": {
47
+ "id": {
48
+ "type": "string",
49
+ "minLength": 1,
50
+ "description": "Stable across runs — actions are keyed on this. Derive it from upstream identity, never from the title or the date. e.g. \"github:pr:acme/webapp#3421\".",
51
+ "examples": ["github:pr:acme/webapp#3421", "email:thread:18f2a9c4b7", "jira:PROJ-8842"]
52
+ },
53
+ "title": { "type": "string", "minLength": 1 },
54
+ "source": {
55
+ "enum": ["github", "email", "calendar", "jira", "slack", "workday", "tasks", "atlassian", "reminders", "messages", "eboks", "other"],
56
+ "description": "Aliases such as gmail, gh, google-tasks, confluence, imessage and e-boks are normalised by the dashboard."
57
+ },
58
+ "kind": {
59
+ "enum": ["task", "event", "info"],
60
+ "description": "task = completable; event = goes on the agenda; info = context only."
61
+ },
62
+ "detail": {
63
+ "type": "string",
64
+ "description": "A sentence or two of context. Inline Markdown supported."
65
+ },
66
+ "url": {
67
+ "type": "string",
68
+ "pattern": "^https?://",
69
+ "description": "Deep link. Non-http(s) URLs are dropped."
70
+ },
71
+ "priority": {
72
+ "type": "integer",
73
+ "minimum": 1,
74
+ "description": "Lifts the item into the Top priorities band. Use for at most three items."
75
+ },
76
+ "due": { "type": "string", "description": "ISO date or timestamp." },
77
+ "start": {
78
+ "type": "string",
79
+ "description": "Events only. A bare YYYY-MM-DD means all-day."
80
+ },
81
+ "end": { "type": "string", "description": "Events only." },
82
+ "blocking": {
83
+ "type": "boolean",
84
+ "description": "Events only. False when the event belongs on the agenda but must not consume time \u2014 a delivery window, a booking, anything you are not sat inside. Absent means blocking; only an explicit false frees the slot."
85
+ },
86
+ "tags": { "type": "array", "items": { "type": "string" } },
87
+ "people": { "type": "array", "items": { "type": "string" } },
88
+ "firstSeen": {
89
+ "type": "string",
90
+ "description": "ISO date this item was FIRST raised. Carry it forward across runs so the dashboard can age it."
91
+ },
92
+ "advancesObjective": {
93
+ "type": "boolean",
94
+ "description": "True only when completing this genuinely moves the objective in focus.md forward. Drives the \"days since objective progress\" metric, so a generous reading makes that number lie."
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleExecutable</key><string>dfcal</string>
6
+ <key>CFBundleIdentifier</key><string>com.daily-focus.dfcal</string>
7
+ <key>CFBundleName</key><string>Daily Focus Calendar</string>
8
+ <key>CFBundlePackageType</key><string>APPL</string>
9
+ <key>CFBundleShortVersionString</key><string>1.0</string>
10
+ <key>LSUIElement</key><true/>
11
+ <key>NSCalendarsFullAccessUsageDescription</key>
12
+ <string>Daily Focus reads today's events to show your agenda and work out your free time.</string>
13
+ <key>NSCalendarsUsageDescription</key>
14
+ <string>Daily Focus reads today's events to show your agenda and work out your free time.</string>
15
+ </dict>
16
+ </plist>
@@ -0,0 +1,52 @@
1
+ #!/bin/sh
2
+ # Builds the calendar helper as a .app bundle.
3
+ #
4
+ # sh tools/dfcal/build.sh # into tools/dfcal/build/, for this Mac
5
+ # sh tools/dfcal/build.sh --app OUT_DIR # into OUT_DIR/, for the menu bar app
6
+ #
7
+ # A bundle rather than a plain binary, and this is not stylistic. macOS attributes
8
+ # a calendar-access request to the *responsible process*, so a bare CLI spawned by
9
+ # the server inherits whatever launched the server — a terminal, an editor, launchd —
10
+ # and is refused outright when that process carries no calendar usage description.
11
+ # The refusal is silent: the request returns false with no error and the
12
+ # authorisation status never leaves "not determined". Running the Mach-O inside a
13
+ # bundle directly fails the same way; only launching it *as an app* gives it an
14
+ # identity of its own. Hence `open`, and hence the plist.
15
+ #
16
+ # `--app` builds for the Macs the menu bar app supports, Apple silicon on macOS 13.5
17
+ # and later, because the copy inside the app goes to other Macs. The default stays
18
+ # a build for this Mac, which is all a checkout needs.
19
+ set -eu
20
+
21
+ here=$(cd "$(dirname "$0")" && pwd)
22
+
23
+ for_app=no
24
+ if [ "${1:-}" = "--app" ]; then
25
+ for_app=yes
26
+ shift
27
+ fi
28
+ out=${1:-"$here/build"}
29
+
30
+ [ "$(uname -s)" = "Darwin" ] || { echo "dfcal is macOS-only; nothing to build" >&2; exit 0; }
31
+ command -v swiftc >/dev/null 2>&1 || { echo "swiftc not found — install the Xcode command line tools" >&2; exit 1; }
32
+
33
+ mkdir -p "$out"
34
+ app="$(cd "$out" && pwd)/Daily Focus Calendar.app"
35
+
36
+ rm -rf "$app"
37
+ mkdir -p "$app/Contents/MacOS"
38
+ cp "$here/Info.plist" "$app/Contents/Info.plist"
39
+ if [ "$for_app" = yes ]; then
40
+ swiftc -O -target arm64-apple-macos13.5 "$here/main.swift" -o "$app/Contents/MacOS/dfcal"
41
+ else
42
+ swiftc -O "$here/main.swift" -o "$app/Contents/MacOS/dfcal"
43
+ fi
44
+
45
+ # Ad-hoc signing is enough for TCC to keep a grant against this bundle id, but the
46
+ # grant is keyed to the code as well: rebuilding changes the hash, and macOS may
47
+ # ask again. That is a prompt nobody is watching when the server runs headless, so
48
+ # a rebuild is worth doing while you are at the keyboard. The menu bar app signs
49
+ # its copy again, with its own identity.
50
+ codesign --force --sign - "$app" >/dev/null
51
+
52
+ echo "built $app"
@@ -0,0 +1,133 @@
1
+ // dfcal — dumps today's calendar facts as JSON, for the dashboard's agenda.
2
+ //
3
+ // Deliberately thin. Everything that can be decided without EventKit — which
4
+ // calendars count, which duplicates to fold, what a declined invitation means —
5
+ // is decided in src/calendar.ts, where it can be tested without a real calendar.
6
+ // What lives here is only what cannot: the framework call, and the reduction of
7
+ // attendees to a single self-status so that no colleague's address is ever
8
+ // written to disk.
9
+ //
10
+ // Usage: dfcal <output.json> [self@example.com,other@example.com]
11
+ //
12
+ // Always exits 0 after writing the output file, including on refusal. A caller
13
+ // reading "denied" from the file can say so; a caller seeing no file at all
14
+ // cannot tell refusal from a crash.
15
+
16
+ import EventKit
17
+ import Foundation
18
+
19
+ let args = CommandLine.arguments
20
+ guard args.count > 1 else {
21
+ FileHandle.standardError.write("usage: dfcal <output.json> [self-addresses]\n".data(using: .utf8)!)
22
+ exit(64)
23
+ }
24
+ let outPath = args[1]
25
+ let selfAddresses = Set(
26
+ (args.count > 2 ? args[2] : "")
27
+ .split(separator: ",")
28
+ .map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
29
+ .filter { !$0.isEmpty }
30
+ )
31
+
32
+ func isoString(_ date: Date?) -> Any {
33
+ guard let date else { return NSNull() }
34
+ let f = ISO8601DateFormatter()
35
+ f.formatOptions = [.withInternetDateTime]
36
+ f.timeZone = TimeZone.current
37
+ return f.string(from: date)
38
+ }
39
+
40
+ func write(_ payload: [String: Any]) -> Never {
41
+ guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
42
+ exit(70)
43
+ }
44
+ // Sibling temp then rename, so a reader never sees half a file.
45
+ let tmp = outPath + ".tmp"
46
+ do {
47
+ try data.write(to: URL(fileURLWithPath: tmp))
48
+ _ = try FileManager.default.replaceItemAt(URL(fileURLWithPath: outPath), withItemAt: URL(fileURLWithPath: tmp))
49
+ } catch {
50
+ try? data.write(to: URL(fileURLWithPath: outPath))
51
+ }
52
+ exit(0)
53
+ }
54
+
55
+ let store = EKEventStore()
56
+ let gate = DispatchSemaphore(value: 0)
57
+ var granted = false
58
+ var failure: String?
59
+
60
+ let answered: (Bool, Error?) -> Void = { ok, err in
61
+ granted = ok
62
+ if let err { failure = String(describing: err) }
63
+ gate.signal()
64
+ }
65
+ // Full access is how macOS 14 and later ask. The menu bar app carries this helper
66
+ // down to macOS 13, where the older request is the only one there is.
67
+ if #available(macOS 14.0, *) {
68
+ store.requestFullAccessToEvents(completion: answered)
69
+ } else {
70
+ store.requestAccess(to: .event, completion: answered)
71
+ }
72
+
73
+ // Long enough for a person to answer the TCC prompt the first time, bounded so a
74
+ // launcher that can never show one doesn't hang the poller forever.
75
+ if gate.wait(timeout: .now() + 120) == .timedOut {
76
+ write(["error": "timeout", "detail": "no answer to the calendar access prompt"])
77
+ }
78
+ guard granted else {
79
+ write([
80
+ "error": "denied",
81
+ "detail": failure ?? "macOS refused calendar access to this binary",
82
+ "status": EKEventStore.authorizationStatus(for: .event).rawValue,
83
+ ])
84
+ }
85
+
86
+ /// The user's own response to an invitation, or nil when it can't be established.
87
+ ///
88
+ /// `isCurrentUser` is not usable here: on a Google account synced through
89
+ /// Calendar.app it is false for every attendee of every event, so the only
90
+ /// reliable route is matching the address the caller told us about.
91
+ func selfStatus(_ event: EKEvent) -> Int? {
92
+ guard let attendees = event.attendees, !attendees.isEmpty else { return nil }
93
+ for attendee in attendees {
94
+ let mail = attendee.url.absoluteString
95
+ .replacingOccurrences(of: "mailto:", with: "", options: .caseInsensitive)
96
+ .lowercased()
97
+ if selfAddresses.contains(mail) { return attendee.participantStatus.rawValue }
98
+ }
99
+ return nil
100
+ }
101
+
102
+ let calendars = store.calendars(for: .event).map { calendar -> [String: Any] in
103
+ [
104
+ "id": calendar.calendarIdentifier,
105
+ "title": calendar.title,
106
+ "source": calendar.source?.title ?? "",
107
+ ]
108
+ }
109
+
110
+ let cal = Calendar.current
111
+ let dayStart = cal.startOfDay(for: Date())
112
+ let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
113
+ let predicate = store.predicateForEvents(withStart: dayStart, end: dayEnd, calendars: nil)
114
+
115
+ let events = store.events(matching: predicate).map { event -> [String: Any] in
116
+ var row: [String: Any] = [
117
+ // Series-level for a recurring event, so it is never unique on its own —
118
+ // src/calendar.ts pairs it with the start to identify one occurrence.
119
+ "externalId": event.calendarItemExternalIdentifier ?? event.calendarItemIdentifier,
120
+ "calendarId": event.calendar?.calendarIdentifier ?? "",
121
+ "title": event.title ?? "",
122
+ "start": isoString(event.startDate),
123
+ "end": isoString(event.endDate),
124
+ "allDay": event.isAllDay,
125
+ // EKEventAvailability: notSupported -1, busy 0, free 1, tentative 2, unavailable 3.
126
+ "availability": event.availability.rawValue,
127
+ ]
128
+ if let status = selfStatus(event) { row["selfStatus"] = status }
129
+ if let url = event.url?.absoluteString, !url.isEmpty { row["url"] = url }
130
+ return row
131
+ }
132
+
133
+ write(["generatedAt": isoString(Date()), "calendars": calendars, "events": events])