tuiboard 0.5.5 → 0.6.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/.tuiboard/config.example.yaml +17 -0
- package/README.md +61 -1
- package/bin/tuiboard.ts +7 -0
- package/package.json +1 -1
- package/src/calendar/setup.ts +337 -0
- package/src/config/loader.ts +81 -0
- package/src/input/handleKey.ts +22 -2
- package/src/parser/markdown.ts +1 -1
- package/src/store/calendar.ts +439 -0
- package/src/store/index.test.ts +49 -1
- package/src/store/index.ts +48 -0
- package/src/store/timeline.test.ts +64 -13
- package/src/store/timeline.ts +94 -21
- package/src/ui/Modal.tsx +4 -1
- package/src/ui/TimelineView.tsx +77 -18
|
@@ -47,3 +47,20 @@ archive_column: Archive
|
|
|
47
47
|
# - C:/Users/you/.config/tuiboard/code-resume.nu
|
|
48
48
|
# - "{cwd}"
|
|
49
49
|
# - "{sessionId}"
|
|
50
|
+
|
|
51
|
+
# Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
|
|
52
|
+
# Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
|
|
53
|
+
# which opens the auth flow and prints the exact block to paste here. Bring your
|
|
54
|
+
# own credentials — nothing is hosted, nothing leaves your machine. All-day
|
|
55
|
+
# events are skipped; each calendar keeps its own color. Paths support `~`.
|
|
56
|
+
#
|
|
57
|
+
# calendars:
|
|
58
|
+
# google:
|
|
59
|
+
# enabled: true
|
|
60
|
+
# token: ~/.config/tuiboard/google_token.json
|
|
61
|
+
# # color: "#e8a05c" # fallback when a calendar has no color
|
|
62
|
+
# microsoft:
|
|
63
|
+
# enabled: true
|
|
64
|
+
# config: ~/.config/tuiboard/azure_config.json
|
|
65
|
+
# token_cache: ~/.config/tuiboard/ms_token.json
|
|
66
|
+
# # color: "#b39ddb"
|
package/README.md
CHANGED
|
@@ -98,7 +98,12 @@ https://github.com/NazzarenoGiannelli/tuiboard). Set it up for me from scratch:
|
|
|
98
98
|
home path) with a `boards:` list pointing at those files by ABSOLUTE path,
|
|
99
99
|
plus `assignees: [...]`, `done_column: Done`, `archive_column: Archive`.
|
|
100
100
|
4. Do NOT configure the Agent view — tuiboard reads `~/.claude` automatically.
|
|
101
|
-
5.
|
|
101
|
+
5. Ask me whether I want to overlay my Google Calendar or Microsoft 365 events
|
|
102
|
+
on the Agenda. If yes, tell me to run `tuiboard calendar-setup google` (or
|
|
103
|
+
`microsoft`) — it interviews me, opens the browser, and prints the exact
|
|
104
|
+
`calendars:` YAML block to add. Don't try to do the OAuth yourself. If no,
|
|
105
|
+
skip it (it's optional and can be added later).
|
|
106
|
+
6. Show me the final config, then tell me to run `tuiboard`, and how to add a
|
|
102
107
|
board later (create a new `.md` and append it to the `boards:` list).
|
|
103
108
|
|
|
104
109
|
Confirm the directory and file names with me before writing any files.
|
|
@@ -137,6 +142,48 @@ archive_column: Archive
|
|
|
137
142
|
# resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
|
|
138
143
|
```
|
|
139
144
|
|
|
145
|
+
## Calendars (Agenda overlay)
|
|
146
|
+
|
|
147
|
+
The **Agenda** zone (the 24h timeline) can overlay read-only events from Google
|
|
148
|
+
Calendar and Microsoft 365 alongside your time-blocked tasks — rendered as
|
|
149
|
+
colored `📅` blocks you can't edit, so the day's real shape is visible at a
|
|
150
|
+
glance. All-day events are skipped; each calendar keeps its own color. Events
|
|
151
|
+
are cached 30 min on disk and refreshed every 5 min. **Bring your own
|
|
152
|
+
credentials** — there's nothing to sign up for and nothing leaves your machine.
|
|
153
|
+
|
|
154
|
+
Connect a calendar with the built-in setup command:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
tuiboard calendar-setup google # opens your browser (read-only scope)
|
|
158
|
+
tuiboard calendar-setup microsoft # device-code flow, no redirect
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Google** needs a one-time OAuth client (free): Google Cloud Console → enable
|
|
162
|
+
*Google Calendar API* → create an *OAuth client ID* (type **Desktop app**) →
|
|
163
|
+
download the JSON to `~/.config/tuiboard/google_credentials.json`, then run the
|
|
164
|
+
command above. **Microsoft** needs an Azure app registration (Public client,
|
|
165
|
+
`Calendars.Read` delegated) whose client ID goes in
|
|
166
|
+
`~/.config/tuiboard/azure_config.json` — running `calendar-setup microsoft` with
|
|
167
|
+
no config writes a template that walks you through it.
|
|
168
|
+
|
|
169
|
+
After connecting, the command prints the exact YAML to paste into your config:
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
calendars:
|
|
173
|
+
google:
|
|
174
|
+
enabled: true
|
|
175
|
+
token: ~/.config/tuiboard/google_token.json
|
|
176
|
+
microsoft:
|
|
177
|
+
enabled: true
|
|
178
|
+
config: ~/.config/tuiboard/azure_config.json
|
|
179
|
+
token_cache: ~/.config/tuiboard/ms_token.json
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Paths support `~` and resolve against the config dir if relative. A missing,
|
|
183
|
+
expired, or unconfigured calendar never breaks the board — it just shows no
|
|
184
|
+
events. Set either provider's `enabled: false` (or drop the block) to turn it
|
|
185
|
+
off; add a `color:` to override the fallback block color.
|
|
186
|
+
|
|
140
187
|
## Markdown board format
|
|
141
188
|
|
|
142
189
|
`tuiboard` reads and writes **plain CommonMark** with the Obsidian
|
|
@@ -223,6 +270,16 @@ session (until the next terminal resize).
|
|
|
223
270
|
| `F1` / `F2` / `F3` | Toggle visibility of Virtual / Timeline / Agents zones |
|
|
224
271
|
| `z` | Zoom active zone to full screen |
|
|
225
272
|
|
|
273
|
+
### Agenda (timeline zone)
|
|
274
|
+
|
|
275
|
+
| Key | Action |
|
|
276
|
+
|---|---|
|
|
277
|
+
| `[` / `]` | Previous / next day — shows that day's tasks **and** calendar events |
|
|
278
|
+
| `\` | Jump back to today |
|
|
279
|
+
| `c` | Arm mode: click a task, then click a slot to schedule (works from any zone) |
|
|
280
|
+
| `j` / `k` | While armed: nudge the block ±15 min |
|
|
281
|
+
| `+` / `-` | While armed: resize the block's end ±15 min |
|
|
282
|
+
|
|
226
283
|
### Task actions (work in board, virtual, AND timeline zones)
|
|
227
284
|
|
|
228
285
|
| Key | Action |
|
|
@@ -261,6 +318,9 @@ session (until the next terminal resize).
|
|
|
261
318
|
|
|
262
319
|
## Status
|
|
263
320
|
|
|
321
|
+
- **v0.6** — adds the Agenda calendar overlay (Google + Microsoft 365,
|
|
322
|
+
read-only, BYO credentials) and day-navigation (`[` / `]` / `\`) so you can
|
|
323
|
+
page tasks and events across days.
|
|
264
324
|
- **v0.5** — daily-driver ready. Kanban + virtual + timeline + agents
|
|
265
325
|
all functional, multi-select, undo, atomic file roundtrip, mouse click,
|
|
266
326
|
responsive layout. Tested on Windows with WezTerm; Linux/macOS should
|
package/bin/tuiboard.ts
CHANGED
|
@@ -17,6 +17,13 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
|
|
18
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const appPath = join(here, "..", "src", "app.tsx");
|
|
20
|
+
|
|
21
|
+
// Subcommands that run as a plain CLI (no TUI, no preload needed). Handle them
|
|
22
|
+
// here before re-exec'ing bun for the dashboard.
|
|
23
|
+
if (process.argv[2] === "calendar-setup") {
|
|
24
|
+
const { runCalendarSetup } = await import("../src/calendar/setup.ts");
|
|
25
|
+
process.exit(await runCalendarSetup(process.argv.slice(3)));
|
|
26
|
+
}
|
|
20
27
|
const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
|
|
21
28
|
|
|
22
29
|
const result = spawnSync(
|
package/package.json
CHANGED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time OAuth setup for the Agenda calendar overlay.
|
|
3
|
+
*
|
|
4
|
+
* tuiboard calendar-setup google # browser OAuth (loopback redirect)
|
|
5
|
+
* tuiboard calendar-setup microsoft # device-code flow (no redirect)
|
|
6
|
+
* tuiboard calendar-setup # show usage
|
|
7
|
+
*
|
|
8
|
+
* Dependency-light: raw `fetch` + the platform browser, no SDKs. Tokens are
|
|
9
|
+
* written to the paths your tuiboard config points at (`calendars.google.token`
|
|
10
|
+
* / `calendars.microsoft.tokenCache`); if there's no `calendars:` block yet we
|
|
11
|
+
* fall back to `~/.config/tuiboard/` and tell you what to add.
|
|
12
|
+
*
|
|
13
|
+
* Runs as a plain CLI (no TUI) — see bin/tuiboard.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { loadConfig } from "~/config/loader";
|
|
22
|
+
|
|
23
|
+
const TUIBOARD_DIR = join(homedir(), ".config", "tuiboard");
|
|
24
|
+
const GOOGLE_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
|
|
25
|
+
const MS_SCOPE = "Calendars.Read offline_access openid profile";
|
|
26
|
+
|
|
27
|
+
function expandPath(p: string, root: string): string {
|
|
28
|
+
if (p.startsWith("~")) return join(homedir(), p.slice(1).replace(/^[/\\]/, ""));
|
|
29
|
+
return isAbsolute(p) ? p : resolve(root, p);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function openBrowser(url: string): void {
|
|
33
|
+
try {
|
|
34
|
+
if (process.platform === "win32") {
|
|
35
|
+
// `start` is a cmd builtin; the empty "" is the window title arg.
|
|
36
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
37
|
+
} else if (process.platform === "darwin") {
|
|
38
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
39
|
+
} else {
|
|
40
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// fall back to the printed URL
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeJson(path: string, data: unknown): void {
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
49
|
+
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Google ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface GoogleClientSecrets {
|
|
55
|
+
client_id?: string;
|
|
56
|
+
client_secret?: string;
|
|
57
|
+
auth_uri?: string;
|
|
58
|
+
token_uri?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function setupGoogle(credsPath: string, tokenPath: string): Promise<number> {
|
|
62
|
+
console.log("\n── Google Calendar setup ──────────────────────────────────");
|
|
63
|
+
if (!existsSync(credsPath)) {
|
|
64
|
+
console.log(`OAuth client file not found:\n ${credsPath}\n
|
|
65
|
+
Create it:
|
|
66
|
+
1. Google Cloud Console → APIs & Services → enable "Google Calendar API"
|
|
67
|
+
2. Credentials → Create credentials → OAuth client ID
|
|
68
|
+
Application type: Desktop app
|
|
69
|
+
3. Download the JSON and save it as the path above
|
|
70
|
+
4. Re-run: tuiboard calendar-setup google\n`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let secrets: GoogleClientSecrets;
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(readFileSync(credsPath, "utf-8")) as {
|
|
77
|
+
installed?: GoogleClientSecrets;
|
|
78
|
+
web?: GoogleClientSecrets;
|
|
79
|
+
} & GoogleClientSecrets;
|
|
80
|
+
secrets = raw.installed ?? raw.web ?? raw;
|
|
81
|
+
} catch {
|
|
82
|
+
console.log(`Could not parse ${credsPath} as JSON.`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const clientId = secrets.client_id;
|
|
86
|
+
const clientSecret = secrets.client_secret;
|
|
87
|
+
if (!clientId || !clientSecret) {
|
|
88
|
+
console.log("client_id / client_secret missing from the OAuth client file.");
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const authUri = secrets.auth_uri || "https://accounts.google.com/o/oauth2/auth";
|
|
92
|
+
const tokenUri = secrets.token_uri || "https://oauth2.googleapis.com/token";
|
|
93
|
+
|
|
94
|
+
// Loopback server captures the ?code= redirect.
|
|
95
|
+
let resolveResult!: (v: { code?: string; error?: string }) => void;
|
|
96
|
+
const result = new Promise<{ code?: string; error?: string }>((r) => (resolveResult = r));
|
|
97
|
+
const server = Bun.serve({
|
|
98
|
+
port: 0,
|
|
99
|
+
fetch(req) {
|
|
100
|
+
const u = new URL(req.url);
|
|
101
|
+
const code = u.searchParams.get("code") ?? undefined;
|
|
102
|
+
const error = u.searchParams.get("error") ?? undefined;
|
|
103
|
+
if (code || error) {
|
|
104
|
+
resolveResult({ code, error });
|
|
105
|
+
const msg = error
|
|
106
|
+
? `Authorization failed: ${error}`
|
|
107
|
+
: "tuiboard is now connected to Google Calendar. You can close this tab.";
|
|
108
|
+
return new Response(`<!doctype html><meta charset=utf-8><body style="font:16px system-ui;padding:3rem">${msg}</body>`, {
|
|
109
|
+
headers: { "content-type": "text/html" },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return new Response("waiting for Google…", { headers: { "content-type": "text/plain" } });
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const redirectUri = `http://localhost:${server.port}`;
|
|
116
|
+
const authUrl =
|
|
117
|
+
`${authUri}?` +
|
|
118
|
+
new URLSearchParams({
|
|
119
|
+
client_id: clientId,
|
|
120
|
+
redirect_uri: redirectUri,
|
|
121
|
+
response_type: "code",
|
|
122
|
+
scope: GOOGLE_SCOPE,
|
|
123
|
+
access_type: "offline",
|
|
124
|
+
prompt: "consent",
|
|
125
|
+
}).toString();
|
|
126
|
+
|
|
127
|
+
console.log("Opening your browser to authorize Google Calendar (read-only)…");
|
|
128
|
+
console.log(`If it doesn't open, visit:\n ${authUrl}\n`);
|
|
129
|
+
openBrowser(authUrl);
|
|
130
|
+
|
|
131
|
+
const { code, error } = await result;
|
|
132
|
+
server.stop(true);
|
|
133
|
+
if (error || !code) {
|
|
134
|
+
console.log(`\nSetup cancelled${error ? `: ${error}` : ""}.`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const res = await fetch(tokenUri, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
141
|
+
body: new URLSearchParams({
|
|
142
|
+
code,
|
|
143
|
+
client_id: clientId,
|
|
144
|
+
client_secret: clientSecret,
|
|
145
|
+
redirect_uri: redirectUri,
|
|
146
|
+
grant_type: "authorization_code",
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
console.log(`Token exchange failed (${res.status}): ${await res.text()}`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
const data = (await res.json()) as {
|
|
154
|
+
access_token?: string;
|
|
155
|
+
refresh_token?: string;
|
|
156
|
+
expires_in?: number;
|
|
157
|
+
};
|
|
158
|
+
if (!data.access_token || !data.refresh_token) {
|
|
159
|
+
console.log("Google did not return a refresh token. Revoke prior access and retry.");
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
writeJson(tokenPath, {
|
|
163
|
+
token: data.access_token,
|
|
164
|
+
refresh_token: data.refresh_token,
|
|
165
|
+
token_uri: tokenUri,
|
|
166
|
+
client_id: clientId,
|
|
167
|
+
client_secret: clientSecret,
|
|
168
|
+
scopes: [GOOGLE_SCOPE],
|
|
169
|
+
expiry: data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined,
|
|
170
|
+
});
|
|
171
|
+
console.log(`\n✓ Google Calendar connected. Token saved:\n ${tokenPath}\n`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Microsoft 365 ────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
178
|
+
|
|
179
|
+
async function setupMicrosoft(configPath: string, tokenPath: string): Promise<number> {
|
|
180
|
+
console.log("\n── Microsoft 365 setup ────────────────────────────────────");
|
|
181
|
+
if (!existsSync(configPath)) {
|
|
182
|
+
writeJson(configPath, {
|
|
183
|
+
client_id: "YOUR_AZURE_APP_CLIENT_ID",
|
|
184
|
+
authority: "https://login.microsoftonline.com/common",
|
|
185
|
+
});
|
|
186
|
+
console.log(`Azure config template created:\n ${configPath}\n
|
|
187
|
+
Complete it:
|
|
188
|
+
1. Azure Portal → App registrations → New registration
|
|
189
|
+
Supported accounts: "any org directory and personal Microsoft accounts"
|
|
190
|
+
Redirect URI: Public client/native → http://localhost
|
|
191
|
+
2. Authentication → enable "Allow public client flows"
|
|
192
|
+
3. API permissions → Microsoft Graph → Delegated → Calendars.Read
|
|
193
|
+
4. Put the Application (client) ID into the file above
|
|
194
|
+
5. Re-run: tuiboard calendar-setup microsoft\n`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let cfg: { client_id?: string; authority?: string };
|
|
199
|
+
try {
|
|
200
|
+
cfg = JSON.parse(readFileSync(configPath, "utf-8")) as typeof cfg;
|
|
201
|
+
} catch {
|
|
202
|
+
console.log(`Could not parse ${configPath} as JSON.`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
const clientId = cfg.client_id;
|
|
206
|
+
if (!clientId || clientId === "YOUR_AZURE_APP_CLIENT_ID") {
|
|
207
|
+
console.log(`Set your Azure client_id in ${configPath} first.`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const authority = cfg.authority || "https://login.microsoftonline.com/common";
|
|
211
|
+
|
|
212
|
+
const dcRes = await fetch(`${authority}/oauth2/v2.0/devicecode`, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
215
|
+
body: new URLSearchParams({ client_id: clientId, scope: MS_SCOPE }),
|
|
216
|
+
});
|
|
217
|
+
if (!dcRes.ok) {
|
|
218
|
+
console.log(`Failed to start device flow (${dcRes.status}): ${await dcRes.text()}`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
const dc = (await dcRes.json()) as {
|
|
222
|
+
device_code: string;
|
|
223
|
+
user_code: string;
|
|
224
|
+
verification_uri: string;
|
|
225
|
+
message?: string;
|
|
226
|
+
interval?: number;
|
|
227
|
+
expires_in?: number;
|
|
228
|
+
};
|
|
229
|
+
console.log(`\n ${dc.message ?? `Go to ${dc.verification_uri} and enter code ${dc.user_code}`}\n`);
|
|
230
|
+
openBrowser(dc.verification_uri);
|
|
231
|
+
|
|
232
|
+
const tokenEndpoint = `${authority}/oauth2/v2.0/token`;
|
|
233
|
+
let interval = (dc.interval ?? 5) * 1000;
|
|
234
|
+
const deadline = Date.now() + (dc.expires_in ?? 900) * 1000;
|
|
235
|
+
while (Date.now() < deadline) {
|
|
236
|
+
await sleep(interval);
|
|
237
|
+
const res = await fetch(tokenEndpoint, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
240
|
+
body: new URLSearchParams({
|
|
241
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
242
|
+
client_id: clientId,
|
|
243
|
+
device_code: dc.device_code,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
const data = (await res.json()) as {
|
|
247
|
+
access_token?: string;
|
|
248
|
+
refresh_token?: string;
|
|
249
|
+
expires_in?: number;
|
|
250
|
+
error?: string;
|
|
251
|
+
error_description?: string;
|
|
252
|
+
};
|
|
253
|
+
if (data.access_token) {
|
|
254
|
+
writeJson(tokenPath, {
|
|
255
|
+
access_token: data.access_token,
|
|
256
|
+
refresh_token: data.refresh_token,
|
|
257
|
+
expiry: data.expires_in
|
|
258
|
+
? new Date(Date.now() + data.expires_in * 1000).toISOString()
|
|
259
|
+
: undefined,
|
|
260
|
+
});
|
|
261
|
+
console.log(`\n✓ Microsoft 365 connected. Token saved:\n ${tokenPath}\n`);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
if (data.error === "authorization_pending") continue;
|
|
265
|
+
if (data.error === "slow_down") {
|
|
266
|
+
interval += 5000;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
console.log(`\nAuthentication failed: ${data.error_description ?? data.error ?? "unknown error"}`);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
console.log("\nDevice code expired before authorization. Re-run to try again.");
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Entry ────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function usage(): void {
|
|
279
|
+
console.log(`tuiboard calendar-setup — connect a calendar to the Agenda overlay
|
|
280
|
+
|
|
281
|
+
Usage:
|
|
282
|
+
tuiboard calendar-setup google Browser OAuth for Google Calendar
|
|
283
|
+
tuiboard calendar-setup microsoft Device-code flow for Microsoft 365
|
|
284
|
+
|
|
285
|
+
Tokens are written to the paths in your tuiboard config (calendars.google.token
|
|
286
|
+
/ calendars.microsoft.tokenCache), or ~/.config/tuiboard/ if no calendars block
|
|
287
|
+
exists yet. After connecting, add the matching block to your config — the setup
|
|
288
|
+
prints the exact YAML.`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function runCalendarSetup(argv: string[]): Promise<number> {
|
|
292
|
+
const provider = (argv[0] ?? "").toLowerCase();
|
|
293
|
+
const cfg = loadConfig();
|
|
294
|
+
const root = cfg.root;
|
|
295
|
+
|
|
296
|
+
if (provider === "google") {
|
|
297
|
+
const g = cfg.calendars?.google;
|
|
298
|
+
const credsPath = g?.credentials
|
|
299
|
+
? expandPath(g.credentials, root)
|
|
300
|
+
: join(TUIBOARD_DIR, "google_credentials.json");
|
|
301
|
+
const tokenPath = g?.token ? expandPath(g.token, root) : join(TUIBOARD_DIR, "google_token.json");
|
|
302
|
+
const rc = await setupGoogle(credsPath, tokenPath);
|
|
303
|
+
if (rc === 0 && !g) {
|
|
304
|
+
console.log(`Add this to your tuiboard config (${cfg.loaded ? "config found" : "create ~/.config/tuiboard/config.yaml"}):
|
|
305
|
+
|
|
306
|
+
calendars:
|
|
307
|
+
google:
|
|
308
|
+
enabled: true
|
|
309
|
+
token: ${tokenPath}
|
|
310
|
+
`);
|
|
311
|
+
}
|
|
312
|
+
return rc;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (provider === "microsoft" || provider === "ms" || provider === "ms365") {
|
|
316
|
+
const m = cfg.calendars?.microsoft;
|
|
317
|
+
const configPath = m?.config ? expandPath(m.config, root) : join(TUIBOARD_DIR, "azure_config.json");
|
|
318
|
+
const tokenPath = m?.tokenCache
|
|
319
|
+
? expandPath(m.tokenCache, root)
|
|
320
|
+
: join(TUIBOARD_DIR, "ms_token.json");
|
|
321
|
+
const rc = await setupMicrosoft(configPath, tokenPath);
|
|
322
|
+
if (rc === 0 && !m) {
|
|
323
|
+
console.log(`Add this to your tuiboard config:
|
|
324
|
+
|
|
325
|
+
calendars:
|
|
326
|
+
microsoft:
|
|
327
|
+
enabled: true
|
|
328
|
+
config: ${configPath}
|
|
329
|
+
token_cache: ${tokenPath}
|
|
330
|
+
`);
|
|
331
|
+
}
|
|
332
|
+
return rc;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
usage();
|
|
336
|
+
return provider ? 1 : 0;
|
|
337
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -43,6 +43,36 @@ export interface Config {
|
|
|
43
43
|
* When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
|
|
44
44
|
*/
|
|
45
45
|
resumeCommand?: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Optional read-only calendar feeds merged into the Agenda (timeline) zone.
|
|
48
|
+
* Paths support `~` and are resolved against the config dir if relative.
|
|
49
|
+
* Tokens are produced by `tuiboard calendar-setup`.
|
|
50
|
+
*/
|
|
51
|
+
calendars?: CalendarsConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GoogleCalendarConfig {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
/** Path to the google_token.json authorized-user file. */
|
|
57
|
+
token: string;
|
|
58
|
+
/** Path to the OAuth client google_credentials.json (used by setup only). */
|
|
59
|
+
credentials?: string;
|
|
60
|
+
/** Fallback color when a calendar has none of its own. */
|
|
61
|
+
color?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MicrosoftCalendarConfig {
|
|
65
|
+
enabled?: boolean;
|
|
66
|
+
/** Path to azure_config.json ({ client_id, authority }). */
|
|
67
|
+
config: string;
|
|
68
|
+
/** Path to the MSAL serialized ms_token_cache.json. */
|
|
69
|
+
tokenCache: string;
|
|
70
|
+
color?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CalendarsConfig {
|
|
74
|
+
google?: GoogleCalendarConfig;
|
|
75
|
+
microsoft?: MicrosoftCalendarConfig;
|
|
46
76
|
}
|
|
47
77
|
|
|
48
78
|
export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
|
|
@@ -91,6 +121,56 @@ interface RawConfig {
|
|
|
91
121
|
done_column: string;
|
|
92
122
|
archive_column: string;
|
|
93
123
|
resume_command: string[];
|
|
124
|
+
calendars: {
|
|
125
|
+
google?: {
|
|
126
|
+
enabled?: boolean;
|
|
127
|
+
token?: string;
|
|
128
|
+
credentials?: string;
|
|
129
|
+
color?: string;
|
|
130
|
+
};
|
|
131
|
+
microsoft?: {
|
|
132
|
+
enabled?: boolean;
|
|
133
|
+
config?: string;
|
|
134
|
+
token_cache?: string;
|
|
135
|
+
color?: string;
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Expand `~` to the home dir; resolve relative paths against the config dir. */
|
|
141
|
+
function expandPath(p: string, root: string): string {
|
|
142
|
+
if (p === "~") return homedir();
|
|
143
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
144
|
+
return join(homedir(), p.slice(2));
|
|
145
|
+
}
|
|
146
|
+
return isAbsolute(p) ? p : resolve(root, p);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeCalendars(
|
|
150
|
+
raw: RawConfig["calendars"] | undefined,
|
|
151
|
+
root: string,
|
|
152
|
+
): CalendarsConfig | undefined {
|
|
153
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
154
|
+
const out: CalendarsConfig = {};
|
|
155
|
+
const g = raw.google;
|
|
156
|
+
if (g && g.token) {
|
|
157
|
+
out.google = {
|
|
158
|
+
enabled: g.enabled !== false,
|
|
159
|
+
token: expandPath(g.token, root),
|
|
160
|
+
credentials: g.credentials ? expandPath(g.credentials, root) : undefined,
|
|
161
|
+
color: g.color,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const m = raw.microsoft;
|
|
165
|
+
if (m && m.config && m.token_cache) {
|
|
166
|
+
out.microsoft = {
|
|
167
|
+
enabled: m.enabled !== false,
|
|
168
|
+
config: expandPath(m.config, root),
|
|
169
|
+
tokenCache: expandPath(m.token_cache, root),
|
|
170
|
+
color: m.color,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return out.google || out.microsoft ? out : undefined;
|
|
94
174
|
}
|
|
95
175
|
|
|
96
176
|
interface FoundConfig {
|
|
@@ -158,6 +238,7 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
158
238
|
Array.isArray(raw.resume_command) && raw.resume_command.length > 0
|
|
159
239
|
? raw.resume_command.map(String)
|
|
160
240
|
: undefined,
|
|
241
|
+
calendars: normalizeCalendars(raw.calendars, root),
|
|
161
242
|
};
|
|
162
243
|
}
|
|
163
244
|
|
package/src/input/handleKey.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type TuiStore,
|
|
24
24
|
} from "~/store/index";
|
|
25
25
|
import type { Board, PriorityLevel } from "~/types";
|
|
26
|
-
import { buildTimelineEntries } from "~/store/timeline";
|
|
26
|
+
import { buildTimelineEntries, formatAgendaDay } from "~/store/timeline";
|
|
27
27
|
import { buildVirtualItems } from "~/store/virtual-panel";
|
|
28
28
|
import { jumpToKanban } from "~/ui/TimelineView";
|
|
29
29
|
|
|
@@ -247,9 +247,29 @@ function handleTimelineZone(
|
|
|
247
247
|
openLater: (m: ModalKind) => void,
|
|
248
248
|
): void {
|
|
249
249
|
const ui = store.state.ui;
|
|
250
|
+
|
|
251
|
+
// Day navigation: page the Agenda to other days (tasks + calendar events).
|
|
252
|
+
// `[` previous, `]` next, `\` back to today. Handled first so they work
|
|
253
|
+
// regardless of arm/cursor state.
|
|
254
|
+
if (key.name === "[" || key.sequence === "[") {
|
|
255
|
+
store.shiftAgendaDay(-1);
|
|
256
|
+
store.flashBanner("info", `◀ ${formatAgendaDay(store.state.ui.agendaOffset, store.agendaDate())}`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (key.name === "]" || key.sequence === "]") {
|
|
260
|
+
store.shiftAgendaDay(1);
|
|
261
|
+
store.flashBanner("info", `▶ ${formatAgendaDay(store.state.ui.agendaOffset, store.agendaDate())}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (key.name === "\\" || key.sequence === "\\") {
|
|
265
|
+
store.resetAgendaDay();
|
|
266
|
+
store.flashBanner("info", "Agenda → Today");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
250
270
|
const entries = buildTimelineEntries(
|
|
251
271
|
store.state.boards.map((b) => b.board),
|
|
252
|
-
|
|
272
|
+
store.agendaDate(),
|
|
253
273
|
);
|
|
254
274
|
const target = entries[ui.row];
|
|
255
275
|
|
package/src/parser/markdown.ts
CHANGED
|
@@ -326,7 +326,7 @@ function extractBoardName(frontmatter: string, filepath: string): string {
|
|
|
326
326
|
const m = frontmatter.match(/^name:\s*(.+?)\s*$/m);
|
|
327
327
|
if (m) return m[1]!;
|
|
328
328
|
const file = basename(filepath, extname(filepath));
|
|
329
|
-
// "Tasks -
|
|
329
|
+
// "Tasks - Work" → "Work"; otherwise use as-is.
|
|
330
330
|
const dash = file.indexOf(" - ");
|
|
331
331
|
return dash >= 0 ? file.slice(dash + 3) : file;
|
|
332
332
|
}
|