granola-toolkit 0.21.1 → 0.23.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 +38 -1
- package/dist/cli.js +1108 -151
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
4
3
|
import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
4
|
import { homedir, platform } from "node:os";
|
|
6
5
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
6
|
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
9
|
-
import {
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
11
|
//#region src/utils.ts
|
|
12
12
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
@@ -153,6 +153,60 @@ function transcriptSpeakerLabel(segment) {
|
|
|
153
153
|
return segment.source === "microphone" ? "You" : "System";
|
|
154
154
|
}
|
|
155
155
|
//#endregion
|
|
156
|
+
//#region src/cache.ts
|
|
157
|
+
function parseCacheDocument(id, value) {
|
|
158
|
+
const record = asRecord(value);
|
|
159
|
+
if (!record) return;
|
|
160
|
+
return {
|
|
161
|
+
createdAt: stringValue(record.created_at),
|
|
162
|
+
id,
|
|
163
|
+
title: stringValue(record.title),
|
|
164
|
+
updatedAt: stringValue(record.updated_at)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function parseTranscriptSegments(value) {
|
|
168
|
+
if (!Array.isArray(value)) return;
|
|
169
|
+
return value.flatMap((segment) => {
|
|
170
|
+
const record = asRecord(segment);
|
|
171
|
+
if (!record) return [];
|
|
172
|
+
return [{
|
|
173
|
+
documentId: stringValue(record.document_id),
|
|
174
|
+
endTimestamp: stringValue(record.end_timestamp),
|
|
175
|
+
id: stringValue(record.id),
|
|
176
|
+
isFinal: Boolean(record.is_final),
|
|
177
|
+
source: stringValue(record.source),
|
|
178
|
+
startTimestamp: stringValue(record.start_timestamp),
|
|
179
|
+
text: stringValue(record.text)
|
|
180
|
+
}];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function parseCacheContents(contents) {
|
|
184
|
+
const outer = parseJsonString(contents);
|
|
185
|
+
if (!outer) throw new Error("failed to parse cache JSON");
|
|
186
|
+
const rawCache = outer.cache;
|
|
187
|
+
let cachePayload;
|
|
188
|
+
if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
|
|
189
|
+
else cachePayload = asRecord(rawCache);
|
|
190
|
+
const state = cachePayload ? asRecord(cachePayload.state) : void 0;
|
|
191
|
+
if (!state) throw new Error("failed to parse cache state");
|
|
192
|
+
const rawDocuments = asRecord(state.documents) ?? {};
|
|
193
|
+
const rawTranscripts = asRecord(state.transcripts) ?? {};
|
|
194
|
+
const documents = {};
|
|
195
|
+
for (const [id, rawDocument] of Object.entries(rawDocuments)) {
|
|
196
|
+
const document = parseCacheDocument(id, rawDocument);
|
|
197
|
+
if (document) documents[id] = document;
|
|
198
|
+
}
|
|
199
|
+
const transcripts = {};
|
|
200
|
+
for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
|
|
201
|
+
const segments = parseTranscriptSegments(rawTranscript);
|
|
202
|
+
if (segments) transcripts[id] = segments;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
documents,
|
|
206
|
+
transcripts
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
156
210
|
//#region src/client/auth.ts
|
|
157
211
|
const execFileAsync$1 = promisify(execFile);
|
|
158
212
|
const DEFAULT_CLIENT_ID = "client_GranolaMac";
|
|
@@ -380,121 +434,126 @@ function createDefaultSessionStore() {
|
|
|
380
434
|
return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
|
|
381
435
|
}
|
|
382
436
|
//#endregion
|
|
383
|
-
//#region src/
|
|
384
|
-
function
|
|
385
|
-
return
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
flags: { help: { type: "boolean" } },
|
|
403
|
-
help: authHelp,
|
|
404
|
-
name: "auth",
|
|
405
|
-
async run({ commandArgs, globalFlags }) {
|
|
406
|
-
const [action] = commandArgs;
|
|
407
|
-
switch (action) {
|
|
408
|
-
case "login": return await login(globalFlags.supabase);
|
|
409
|
-
case "logout": return await logout();
|
|
410
|
-
case "status": return await status();
|
|
411
|
-
case void 0:
|
|
412
|
-
console.log(authHelp());
|
|
413
|
-
return 1;
|
|
414
|
-
default: throw new Error("invalid auth command: expected login, status, or logout");
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
};
|
|
418
|
-
async function login(supabaseFlag) {
|
|
419
|
-
const supabasePath = typeof supabaseFlag === "string" && supabaseFlag.trim() || firstExistingPath(granolaSupabaseCandidates());
|
|
420
|
-
if (!supabasePath) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
421
|
-
if (!existsSync(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
|
|
422
|
-
const sessionStore = createDefaultSessionStore();
|
|
423
|
-
const session = await new SupabaseFileSessionSource(supabasePath).loadSession();
|
|
424
|
-
await sessionStore.writeSession(session);
|
|
425
|
-
console.log(`Imported Granola session from ${supabasePath}`);
|
|
426
|
-
return 0;
|
|
427
|
-
}
|
|
428
|
-
async function status() {
|
|
429
|
-
const session = await createDefaultSessionStore().readSession();
|
|
430
|
-
if (!session) {
|
|
431
|
-
console.log("No stored Granola session");
|
|
432
|
-
return 1;
|
|
433
|
-
}
|
|
434
|
-
console.log("Stored Granola session");
|
|
435
|
-
console.log(`Client ID: ${session.clientId}`);
|
|
436
|
-
console.log(`Refresh token: ${session.refreshToken ? "available" : "missing"}`);
|
|
437
|
-
console.log(`Sign-in method: ${session.signInMethod ?? "unknown"}`);
|
|
438
|
-
return 0;
|
|
439
|
-
}
|
|
440
|
-
async function logout() {
|
|
441
|
-
await createDefaultSessionStore().clearSession();
|
|
442
|
-
console.log("Stored Granola session deleted");
|
|
443
|
-
return 0;
|
|
444
|
-
}
|
|
445
|
-
//#endregion
|
|
446
|
-
//#region src/cache.ts
|
|
447
|
-
function parseCacheDocument(id, value) {
|
|
448
|
-
const record = asRecord(value);
|
|
449
|
-
if (!record) return;
|
|
437
|
+
//#region src/client/default-auth.ts
|
|
438
|
+
function hasStoredSession(session) {
|
|
439
|
+
return Boolean(session?.accessToken.trim());
|
|
440
|
+
}
|
|
441
|
+
function resolveActiveMode(options) {
|
|
442
|
+
if (options.preferredMode === "stored-session" && options.storedSessionAvailable) return "stored-session";
|
|
443
|
+
if (options.preferredMode === "supabase-file" && options.supabaseAvailable) return "supabase-file";
|
|
444
|
+
if (options.storedSessionAvailable) return "stored-session";
|
|
445
|
+
return "supabase-file";
|
|
446
|
+
}
|
|
447
|
+
function missingSupabaseError() {
|
|
448
|
+
return /* @__PURE__ */ new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
449
|
+
}
|
|
450
|
+
function buildDefaultGranolaAuthInfo(config, options = {}) {
|
|
451
|
+
const existsSyncImpl = options.existsSyncImpl ?? existsSync;
|
|
452
|
+
const session = options.session;
|
|
453
|
+
const storedSessionAvailable = hasStoredSession(session);
|
|
454
|
+
const supabasePath = config.supabase || void 0;
|
|
455
|
+
const supabaseAvailable = Boolean(supabasePath && existsSyncImpl(supabasePath));
|
|
450
456
|
return {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
457
|
+
clientId: session?.clientId,
|
|
458
|
+
lastError: options.lastError,
|
|
459
|
+
mode: resolveActiveMode({
|
|
460
|
+
preferredMode: options.preferredMode,
|
|
461
|
+
storedSessionAvailable,
|
|
462
|
+
supabaseAvailable
|
|
463
|
+
}),
|
|
464
|
+
refreshAvailable: Boolean(session?.refreshToken?.trim()),
|
|
465
|
+
signInMethod: session?.signInMethod,
|
|
466
|
+
storedSessionAvailable,
|
|
467
|
+
supabaseAvailable,
|
|
468
|
+
supabasePath
|
|
455
469
|
};
|
|
456
470
|
}
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
id: stringValue(record.id),
|
|
466
|
-
isFinal: Boolean(record.is_final),
|
|
467
|
-
source: stringValue(record.source),
|
|
468
|
-
startTimestamp: stringValue(record.start_timestamp),
|
|
469
|
-
text: stringValue(record.text)
|
|
470
|
-
}];
|
|
471
|
+
async function inspectDefaultGranolaAuth(config, options = {}) {
|
|
472
|
+
const sessionStore = options.sessionStore ?? createDefaultSessionStore();
|
|
473
|
+
const session = options.session ?? await sessionStore.readSession();
|
|
474
|
+
return buildDefaultGranolaAuthInfo(config, {
|
|
475
|
+
existsSyncImpl: options.existsSyncImpl,
|
|
476
|
+
lastError: options.lastError,
|
|
477
|
+
preferredMode: options.preferredMode,
|
|
478
|
+
session
|
|
471
479
|
});
|
|
472
480
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
else cachePayload = asRecord(rawCache);
|
|
480
|
-
const state = cachePayload ? asRecord(cachePayload.state) : void 0;
|
|
481
|
-
if (!state) throw new Error("failed to parse cache state");
|
|
482
|
-
const rawDocuments = asRecord(state.documents) ?? {};
|
|
483
|
-
const rawTranscripts = asRecord(state.transcripts) ?? {};
|
|
484
|
-
const documents = {};
|
|
485
|
-
for (const [id, rawDocument] of Object.entries(rawDocuments)) {
|
|
486
|
-
const document = parseCacheDocument(id, rawDocument);
|
|
487
|
-
if (document) documents[id] = document;
|
|
481
|
+
var DefaultAuthController = class {
|
|
482
|
+
#lastError;
|
|
483
|
+
#preferredMode;
|
|
484
|
+
constructor(config, options = {}) {
|
|
485
|
+
this.config = config;
|
|
486
|
+
this.options = options;
|
|
488
487
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const segments = parseTranscriptSegments(rawTranscript);
|
|
492
|
-
if (segments) transcripts[id] = segments;
|
|
488
|
+
sessionStore() {
|
|
489
|
+
return this.options.sessionStore ?? createDefaultSessionStore();
|
|
493
490
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
491
|
+
readSession() {
|
|
492
|
+
return this.sessionStore().readSession();
|
|
493
|
+
}
|
|
494
|
+
resolveSupabasePath(overridePath) {
|
|
495
|
+
const supabasePath = overridePath?.trim() || this.config.supabase || "";
|
|
496
|
+
if (!supabasePath) throw missingSupabaseError();
|
|
497
|
+
if (!(this.options.existsSyncImpl ?? existsSync)(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
|
|
498
|
+
return supabasePath;
|
|
499
|
+
}
|
|
500
|
+
sessionSource(supabasePath) {
|
|
501
|
+
return this.options.sessionSourceFactory?.(supabasePath) ?? new SupabaseFileSessionSource(supabasePath);
|
|
502
|
+
}
|
|
503
|
+
async inspect() {
|
|
504
|
+
const session = await this.readSession();
|
|
505
|
+
return buildDefaultGranolaAuthInfo(this.config, {
|
|
506
|
+
existsSyncImpl: this.options.existsSyncImpl,
|
|
507
|
+
lastError: this.#lastError,
|
|
508
|
+
preferredMode: this.#preferredMode,
|
|
509
|
+
session
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async login(options = {}) {
|
|
513
|
+
const supabasePath = this.resolveSupabasePath(options.supabasePath);
|
|
514
|
+
const session = await this.sessionSource(supabasePath).loadSession();
|
|
515
|
+
await this.sessionStore().writeSession(session);
|
|
516
|
+
this.#lastError = void 0;
|
|
517
|
+
this.#preferredMode = "stored-session";
|
|
518
|
+
return await this.inspect();
|
|
519
|
+
}
|
|
520
|
+
async logout() {
|
|
521
|
+
await this.sessionStore().clearSession();
|
|
522
|
+
this.#lastError = void 0;
|
|
523
|
+
this.#preferredMode = void 0;
|
|
524
|
+
return await this.inspect();
|
|
525
|
+
}
|
|
526
|
+
async refresh() {
|
|
527
|
+
const session = await this.readSession();
|
|
528
|
+
if (!hasStoredSession(session)) {
|
|
529
|
+
this.#lastError = "no stored Granola session found";
|
|
530
|
+
throw new Error(this.#lastError);
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const refreshed = await refreshGranolaSession(session, this.options.fetchImpl);
|
|
534
|
+
await this.sessionStore().writeSession(refreshed);
|
|
535
|
+
this.#lastError = void 0;
|
|
536
|
+
this.#preferredMode = "stored-session";
|
|
537
|
+
return await this.inspect();
|
|
538
|
+
} catch (error) {
|
|
539
|
+
this.#lastError = error instanceof Error ? error.message : String(error);
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async switchMode(mode) {
|
|
544
|
+
const state = await this.inspect();
|
|
545
|
+
if (mode === "stored-session" && !state.storedSessionAvailable) {
|
|
546
|
+
this.#lastError = "no stored Granola session found";
|
|
547
|
+
throw new Error(this.#lastError);
|
|
548
|
+
}
|
|
549
|
+
if (mode === "supabase-file") this.resolveSupabasePath();
|
|
550
|
+
this.#lastError = void 0;
|
|
551
|
+
this.#preferredMode = mode;
|
|
552
|
+
return await this.inspect();
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
function createDefaultGranolaAuthController(config, options = {}) {
|
|
556
|
+
return new DefaultAuthController(config, options);
|
|
498
557
|
}
|
|
499
558
|
//#endregion
|
|
500
559
|
//#region src/client/parsers.ts
|
|
@@ -690,26 +749,16 @@ var AuthenticatedHttpClient = class {
|
|
|
690
749
|
};
|
|
691
750
|
//#endregion
|
|
692
751
|
//#region src/client/default.ts
|
|
693
|
-
async function
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
mode: hasStoredSession ? "stored-session" : "supabase-file",
|
|
698
|
-
storedSessionAvailable: hasStoredSession,
|
|
699
|
-
supabasePath: config.supabase || void 0
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
async function createDefaultGranolaRuntime(config, logger = console) {
|
|
752
|
+
async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
|
|
753
|
+
const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
|
|
754
|
+
if (!auth.storedSessionAvailable && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
755
|
+
if (!auth.storedSessionAvailable && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
703
756
|
const sessionStore = createDefaultSessionStore();
|
|
704
|
-
const auth = await inspectDefaultGranolaAuth(config);
|
|
705
|
-
const hasStoredSession = auth.storedSessionAvailable;
|
|
706
|
-
if (!hasStoredSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
|
|
707
|
-
if (!hasStoredSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
|
|
708
757
|
return {
|
|
709
758
|
auth,
|
|
710
759
|
client: new GranolaApiClient(new AuthenticatedHttpClient({
|
|
711
760
|
logger,
|
|
712
|
-
tokenProvider:
|
|
761
|
+
tokenProvider: auth.mode === "stored-session" ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
|
|
713
762
|
}))
|
|
714
763
|
};
|
|
715
764
|
}
|
|
@@ -718,6 +767,81 @@ async function loadOptionalGranolaCache(cacheFile) {
|
|
|
718
767
|
return parseCacheContents(await readFile(cacheFile, "utf8"));
|
|
719
768
|
}
|
|
720
769
|
//#endregion
|
|
770
|
+
//#region src/export-jobs.ts
|
|
771
|
+
const EXPORT_JOBS_VERSION = 1;
|
|
772
|
+
const MAX_EXPORT_JOBS = 100;
|
|
773
|
+
function normaliseJob(value) {
|
|
774
|
+
const record = asRecord(value);
|
|
775
|
+
if (!record) return;
|
|
776
|
+
const id = stringValue(record.id);
|
|
777
|
+
const kind = stringValue(record.kind);
|
|
778
|
+
const status = stringValue(record.status);
|
|
779
|
+
const format = stringValue(record.format);
|
|
780
|
+
const outputDir = stringValue(record.outputDir);
|
|
781
|
+
const startedAt = stringValue(record.startedAt);
|
|
782
|
+
const itemCount = typeof record.itemCount === "number" && Number.isFinite(record.itemCount) ? record.itemCount : 0;
|
|
783
|
+
const written = typeof record.written === "number" && Number.isFinite(record.written) ? record.written : 0;
|
|
784
|
+
const completedCount = typeof record.completedCount === "number" && Number.isFinite(record.completedCount) ? record.completedCount : written;
|
|
785
|
+
if (!id || !format || !outputDir || !startedAt || kind !== "notes" && kind !== "transcripts" || status !== "running" && status !== "completed" && status !== "failed") return;
|
|
786
|
+
return {
|
|
787
|
+
completedCount,
|
|
788
|
+
error: stringValue(record.error) || void 0,
|
|
789
|
+
finishedAt: stringValue(record.finishedAt) || void 0,
|
|
790
|
+
format,
|
|
791
|
+
id,
|
|
792
|
+
itemCount,
|
|
793
|
+
kind,
|
|
794
|
+
outputDir,
|
|
795
|
+
startedAt,
|
|
796
|
+
status,
|
|
797
|
+
written
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function normaliseJobsFile(parsed) {
|
|
801
|
+
const record = asRecord(parsed);
|
|
802
|
+
if (!record || record.version !== EXPORT_JOBS_VERSION || !Array.isArray(record.jobs)) return {
|
|
803
|
+
jobs: [],
|
|
804
|
+
version: EXPORT_JOBS_VERSION
|
|
805
|
+
};
|
|
806
|
+
return {
|
|
807
|
+
jobs: record.jobs.map((job) => normaliseJob(job)).filter((job) => Boolean(job)).slice(0, MAX_EXPORT_JOBS),
|
|
808
|
+
version: EXPORT_JOBS_VERSION
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
function createExportJobId(kind) {
|
|
812
|
+
return `${kind}-${randomUUID()}`;
|
|
813
|
+
}
|
|
814
|
+
function defaultExportJobsFilePath() {
|
|
815
|
+
const home = homedir();
|
|
816
|
+
return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
|
|
817
|
+
}
|
|
818
|
+
var FileExportJobStore = class {
|
|
819
|
+
constructor(filePath = defaultExportJobsFilePath()) {
|
|
820
|
+
this.filePath = filePath;
|
|
821
|
+
}
|
|
822
|
+
async readJobs() {
|
|
823
|
+
try {
|
|
824
|
+
return normaliseJobsFile(parseJsonString(await readFile(this.filePath, "utf8"))).jobs;
|
|
825
|
+
} catch {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async writeJobs(jobs) {
|
|
830
|
+
const payload = {
|
|
831
|
+
jobs: jobs.slice(0, MAX_EXPORT_JOBS),
|
|
832
|
+
version: EXPORT_JOBS_VERSION
|
|
833
|
+
};
|
|
834
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
835
|
+
await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
836
|
+
encoding: "utf8",
|
|
837
|
+
mode: 384
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
function createDefaultExportJobStore() {
|
|
842
|
+
return new FileExportJobStore();
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
721
845
|
//#region src/export-state.ts
|
|
722
846
|
const EXPORT_STATE_VERSION = 1;
|
|
723
847
|
function exportStatePath(outputDir, kind) {
|
|
@@ -783,7 +907,7 @@ function entryChanged(left, right) {
|
|
|
783
907
|
if (!left) return true;
|
|
784
908
|
return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
|
|
785
909
|
}
|
|
786
|
-
async function syncManagedExports({ items, kind, outputDir }) {
|
|
910
|
+
async function syncManagedExports({ items, kind, onProgress, outputDir }) {
|
|
787
911
|
await ensureDirectory(outputDir);
|
|
788
912
|
const previousEntries = (await loadExportState(outputDir, kind)).entries;
|
|
789
913
|
const used = /* @__PURE__ */ new Map();
|
|
@@ -804,6 +928,7 @@ async function syncManagedExports({ items, kind, outputDir }) {
|
|
|
804
928
|
const activeFileNames = new Set(plans.map((plan) => plan.fileName));
|
|
805
929
|
const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
806
930
|
const nextEntries = {};
|
|
931
|
+
let completed = 0;
|
|
807
932
|
let written = 0;
|
|
808
933
|
let stateChanged = false;
|
|
809
934
|
for (const plan of plans) {
|
|
@@ -822,6 +947,12 @@ async function syncManagedExports({ items, kind, outputDir }) {
|
|
|
822
947
|
};
|
|
823
948
|
nextEntries[plan.id] = nextEntry;
|
|
824
949
|
stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
|
|
950
|
+
completed += 1;
|
|
951
|
+
if (onProgress) await onProgress({
|
|
952
|
+
completed,
|
|
953
|
+
total: plans.length,
|
|
954
|
+
written
|
|
955
|
+
});
|
|
825
956
|
}
|
|
826
957
|
for (const plan of plans) {
|
|
827
958
|
const previousFileName = plan.existing?.fileName;
|
|
@@ -1106,7 +1237,7 @@ function noteFileExtension(format) {
|
|
|
1106
1237
|
case "markdown": return ".md";
|
|
1107
1238
|
}
|
|
1108
1239
|
}
|
|
1109
|
-
async function writeNotes(documents, outputDir, format = "markdown") {
|
|
1240
|
+
async function writeNotes(documents, outputDir, format = "markdown", options = {}) {
|
|
1110
1241
|
return await syncManagedExports({
|
|
1111
1242
|
items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
|
|
1112
1243
|
const note = buildNoteExport(document);
|
|
@@ -1119,6 +1250,7 @@ async function writeNotes(documents, outputDir, format = "markdown") {
|
|
|
1119
1250
|
};
|
|
1120
1251
|
}),
|
|
1121
1252
|
kind: "notes",
|
|
1253
|
+
onProgress: options.onProgress,
|
|
1122
1254
|
outputDir
|
|
1123
1255
|
});
|
|
1124
1256
|
}
|
|
@@ -1230,7 +1362,7 @@ function transcriptFileExtension(format) {
|
|
|
1230
1362
|
case "yaml": return ".yaml";
|
|
1231
1363
|
}
|
|
1232
1364
|
}
|
|
1233
|
-
async function writeTranscripts(cacheData, outputDir, format = "text") {
|
|
1365
|
+
async function writeTranscripts(cacheData, outputDir, format = "text", options = {}) {
|
|
1234
1366
|
return await syncManagedExports({
|
|
1235
1367
|
items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
|
|
1236
1368
|
const leftDocument = cacheData.documents[leftId];
|
|
@@ -1254,6 +1386,7 @@ async function writeTranscripts(cacheData, outputDir, format = "text") {
|
|
|
1254
1386
|
}];
|
|
1255
1387
|
}),
|
|
1256
1388
|
kind: "transcripts",
|
|
1389
|
+
onProgress: options.onProgress,
|
|
1257
1390
|
outputDir
|
|
1258
1391
|
});
|
|
1259
1392
|
}
|
|
@@ -1514,6 +1647,9 @@ function transcriptCount(cacheData) {
|
|
|
1514
1647
|
function cloneExportState(state) {
|
|
1515
1648
|
return state ? { ...state } : void 0;
|
|
1516
1649
|
}
|
|
1650
|
+
function cloneExportJob(job) {
|
|
1651
|
+
return { ...job };
|
|
1652
|
+
}
|
|
1517
1653
|
function cloneState(state) {
|
|
1518
1654
|
return {
|
|
1519
1655
|
auth: { ...state.auth },
|
|
@@ -1525,6 +1661,7 @@ function cloneState(state) {
|
|
|
1525
1661
|
},
|
|
1526
1662
|
documents: { ...state.documents },
|
|
1527
1663
|
exports: {
|
|
1664
|
+
jobs: state.exports.jobs.map((job) => cloneExportJob(job)),
|
|
1528
1665
|
notes: cloneExportState(state.exports.notes),
|
|
1529
1666
|
transcripts: cloneExportState(state.exports.transcripts)
|
|
1530
1667
|
},
|
|
@@ -1550,7 +1687,7 @@ function defaultState(config, auth, surface) {
|
|
|
1550
1687
|
count: 0,
|
|
1551
1688
|
loaded: false
|
|
1552
1689
|
},
|
|
1553
|
-
exports: {},
|
|
1690
|
+
exports: { jobs: [] },
|
|
1554
1691
|
ui: {
|
|
1555
1692
|
surface,
|
|
1556
1693
|
view: "idle"
|
|
@@ -1568,6 +1705,7 @@ var GranolaApp = class {
|
|
|
1568
1705
|
this.config = config;
|
|
1569
1706
|
this.deps = deps;
|
|
1570
1707
|
this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
|
|
1708
|
+
this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
|
|
1571
1709
|
}
|
|
1572
1710
|
getState() {
|
|
1573
1711
|
return cloneState(this.#state);
|
|
@@ -1586,6 +1724,24 @@ var GranolaApp = class {
|
|
|
1586
1724
|
this.emitStateUpdate();
|
|
1587
1725
|
return this.getState();
|
|
1588
1726
|
}
|
|
1727
|
+
resetDocumentsState() {
|
|
1728
|
+
this.#granolaClient = void 0;
|
|
1729
|
+
this.#documents = void 0;
|
|
1730
|
+
this.#state.documents = {
|
|
1731
|
+
count: 0,
|
|
1732
|
+
loaded: false
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
applyAuthState(auth, options = {}) {
|
|
1736
|
+
if (options.resetDocuments) this.resetDocumentsState();
|
|
1737
|
+
this.#state.auth = { ...auth };
|
|
1738
|
+
if (options.view) this.#state.ui = {
|
|
1739
|
+
...this.#state.ui,
|
|
1740
|
+
view: options.view
|
|
1741
|
+
};
|
|
1742
|
+
this.emitStateUpdate();
|
|
1743
|
+
return { ...auth };
|
|
1744
|
+
}
|
|
1589
1745
|
nowIso() {
|
|
1590
1746
|
return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
1591
1747
|
}
|
|
@@ -1604,15 +1760,121 @@ var GranolaApp = class {
|
|
|
1604
1760
|
return this.#granolaClient;
|
|
1605
1761
|
}
|
|
1606
1762
|
if (!this.deps.createGranolaClient) throw new Error("Granola API client is not configured");
|
|
1607
|
-
const runtime = await this.deps.createGranolaClient();
|
|
1763
|
+
const runtime = await this.deps.createGranolaClient(this.#state.auth.mode);
|
|
1608
1764
|
this.#granolaClient = runtime.client;
|
|
1609
|
-
this
|
|
1610
|
-
this.emitStateUpdate();
|
|
1765
|
+
this.applyAuthState(runtime.auth);
|
|
1611
1766
|
return this.#granolaClient;
|
|
1612
1767
|
}
|
|
1613
1768
|
missingCacheError() {
|
|
1614
1769
|
return /* @__PURE__ */ new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
|
|
1615
1770
|
}
|
|
1771
|
+
async persistExportJobs() {
|
|
1772
|
+
if (!this.deps.exportJobStore) return;
|
|
1773
|
+
await this.deps.exportJobStore.writeJobs(this.#state.exports.jobs);
|
|
1774
|
+
}
|
|
1775
|
+
async updateExportJob(job) {
|
|
1776
|
+
const nextJobs = [cloneExportJob(job), ...this.#state.exports.jobs.filter((candidate) => candidate.id !== job.id).map(cloneExportJob)].slice(0, 100);
|
|
1777
|
+
this.#state.exports.jobs = nextJobs;
|
|
1778
|
+
await this.persistExportJobs();
|
|
1779
|
+
this.emitStateUpdate();
|
|
1780
|
+
return cloneExportJob(job);
|
|
1781
|
+
}
|
|
1782
|
+
async startExportJob(kind, format, itemCount, outputDir) {
|
|
1783
|
+
return await this.updateExportJob({
|
|
1784
|
+
completedCount: 0,
|
|
1785
|
+
format,
|
|
1786
|
+
id: createExportJobId(kind),
|
|
1787
|
+
itemCount,
|
|
1788
|
+
kind,
|
|
1789
|
+
outputDir,
|
|
1790
|
+
startedAt: this.nowIso(),
|
|
1791
|
+
status: "running",
|
|
1792
|
+
written: 0
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
async completeExportJob(job, patch) {
|
|
1796
|
+
return await this.updateExportJob({
|
|
1797
|
+
...job,
|
|
1798
|
+
completedCount: patch.completedCount,
|
|
1799
|
+
finishedAt: this.nowIso(),
|
|
1800
|
+
status: "completed",
|
|
1801
|
+
written: patch.written
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
async failExportJob(job, error) {
|
|
1805
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1806
|
+
return await this.updateExportJob({
|
|
1807
|
+
...job,
|
|
1808
|
+
error: message,
|
|
1809
|
+
finishedAt: this.nowIso(),
|
|
1810
|
+
status: "failed"
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
async setExportJobProgress(job, patch) {
|
|
1814
|
+
return await this.updateExportJob({
|
|
1815
|
+
...job,
|
|
1816
|
+
completedCount: patch.completedCount,
|
|
1817
|
+
written: patch.written
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
requireAuthController() {
|
|
1821
|
+
if (!this.deps.authController) throw new Error("Granola auth control is not configured");
|
|
1822
|
+
return this.deps.authController;
|
|
1823
|
+
}
|
|
1824
|
+
async inspectAuth() {
|
|
1825
|
+
if (!this.deps.authController) return { ...this.#state.auth };
|
|
1826
|
+
const auth = await this.deps.authController.inspect();
|
|
1827
|
+
return this.applyAuthState(auth, { view: "auth" });
|
|
1828
|
+
}
|
|
1829
|
+
async loginAuth(options = {}) {
|
|
1830
|
+
const controller = this.requireAuthController();
|
|
1831
|
+
try {
|
|
1832
|
+
const auth = await controller.login(options);
|
|
1833
|
+
return this.applyAuthState(auth, {
|
|
1834
|
+
resetDocuments: true,
|
|
1835
|
+
view: "auth"
|
|
1836
|
+
});
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
const auth = await controller.inspect();
|
|
1839
|
+
this.applyAuthState(auth, { view: "auth" });
|
|
1840
|
+
throw error;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
async logoutAuth() {
|
|
1844
|
+
const auth = await this.requireAuthController().logout();
|
|
1845
|
+
return this.applyAuthState(auth, {
|
|
1846
|
+
resetDocuments: true,
|
|
1847
|
+
view: "auth"
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
async refreshAuth() {
|
|
1851
|
+
const controller = this.requireAuthController();
|
|
1852
|
+
try {
|
|
1853
|
+
const auth = await controller.refresh();
|
|
1854
|
+
return this.applyAuthState(auth, {
|
|
1855
|
+
resetDocuments: true,
|
|
1856
|
+
view: "auth"
|
|
1857
|
+
});
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
const auth = await controller.inspect();
|
|
1860
|
+
this.applyAuthState(auth, { view: "auth" });
|
|
1861
|
+
throw error;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
async switchAuthMode(mode) {
|
|
1865
|
+
const controller = this.requireAuthController();
|
|
1866
|
+
try {
|
|
1867
|
+
const auth = await controller.switchMode(mode);
|
|
1868
|
+
return this.applyAuthState(auth, {
|
|
1869
|
+
resetDocuments: true,
|
|
1870
|
+
view: "auth"
|
|
1871
|
+
});
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
const auth = await controller.inspect();
|
|
1874
|
+
this.applyAuthState(auth, { view: "auth" });
|
|
1875
|
+
throw error;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1616
1878
|
async listDocuments() {
|
|
1617
1879
|
if (this.#documents) return this.#documents;
|
|
1618
1880
|
const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
|
|
@@ -1701,13 +1963,42 @@ var GranolaApp = class {
|
|
|
1701
1963
|
meeting
|
|
1702
1964
|
};
|
|
1703
1965
|
}
|
|
1966
|
+
async listExportJobs(options = {}) {
|
|
1967
|
+
const limit = options.limit ?? 20;
|
|
1968
|
+
const jobs = this.#state.exports.jobs.slice(0, limit).map((job) => cloneExportJob(job));
|
|
1969
|
+
this.setUiState({ view: "exports-history" });
|
|
1970
|
+
return { jobs };
|
|
1971
|
+
}
|
|
1704
1972
|
async exportNotes(format = "markdown") {
|
|
1973
|
+
return await this.runNotesExport({
|
|
1974
|
+
format,
|
|
1975
|
+
outputDir: this.config.notes.output
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
async runNotesExport(options) {
|
|
1705
1979
|
const documents = await this.listDocuments();
|
|
1706
|
-
|
|
1980
|
+
let job = await this.startExportJob("notes", options.format, documents.length, options.outputDir);
|
|
1981
|
+
let written = 0;
|
|
1982
|
+
try {
|
|
1983
|
+
written = await writeNotes(documents, options.outputDir, options.format, { onProgress: async (progress) => {
|
|
1984
|
+
job = await this.setExportJobProgress(job, {
|
|
1985
|
+
completedCount: progress.completed,
|
|
1986
|
+
written: progress.written
|
|
1987
|
+
});
|
|
1988
|
+
} });
|
|
1989
|
+
job = await this.completeExportJob(job, {
|
|
1990
|
+
completedCount: documents.length,
|
|
1991
|
+
written
|
|
1992
|
+
});
|
|
1993
|
+
} catch (error) {
|
|
1994
|
+
await this.failExportJob(job, error);
|
|
1995
|
+
throw error;
|
|
1996
|
+
}
|
|
1707
1997
|
this.#state.exports.notes = {
|
|
1708
|
-
format,
|
|
1998
|
+
format: options.format,
|
|
1709
1999
|
itemCount: documents.length,
|
|
1710
|
-
|
|
2000
|
+
jobId: job.id,
|
|
2001
|
+
outputDir: options.outputDir,
|
|
1711
2002
|
ranAt: this.nowIso(),
|
|
1712
2003
|
written
|
|
1713
2004
|
};
|
|
@@ -1716,20 +2007,44 @@ var GranolaApp = class {
|
|
|
1716
2007
|
return {
|
|
1717
2008
|
documentCount: documents.length,
|
|
1718
2009
|
documents,
|
|
1719
|
-
format,
|
|
1720
|
-
|
|
2010
|
+
format: options.format,
|
|
2011
|
+
job,
|
|
2012
|
+
outputDir: options.outputDir,
|
|
1721
2013
|
written
|
|
1722
2014
|
};
|
|
1723
2015
|
}
|
|
1724
2016
|
async exportTranscripts(format = "text") {
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
2017
|
+
return await this.runTranscriptsExport({
|
|
2018
|
+
format,
|
|
2019
|
+
outputDir: this.config.transcripts.output
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
async runTranscriptsExport(options) {
|
|
2023
|
+
const cacheData = await this.loadCache({ required: true });
|
|
2024
|
+
if (!cacheData) throw this.missingCacheError();
|
|
1728
2025
|
const count = transcriptCount(cacheData);
|
|
2026
|
+
let job = await this.startExportJob("transcripts", options.format, count, options.outputDir);
|
|
2027
|
+
let written = 0;
|
|
2028
|
+
try {
|
|
2029
|
+
written = await writeTranscripts(cacheData, options.outputDir, options.format, { onProgress: async (progress) => {
|
|
2030
|
+
job = await this.setExportJobProgress(job, {
|
|
2031
|
+
completedCount: progress.completed,
|
|
2032
|
+
written: progress.written
|
|
2033
|
+
});
|
|
2034
|
+
} });
|
|
2035
|
+
job = await this.completeExportJob(job, {
|
|
2036
|
+
completedCount: count,
|
|
2037
|
+
written
|
|
2038
|
+
});
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
await this.failExportJob(job, error);
|
|
2041
|
+
throw error;
|
|
2042
|
+
}
|
|
1729
2043
|
this.#state.exports.transcripts = {
|
|
1730
|
-
format,
|
|
2044
|
+
format: options.format,
|
|
1731
2045
|
itemCount: count,
|
|
1732
|
-
|
|
2046
|
+
jobId: job.id,
|
|
2047
|
+
outputDir: options.outputDir,
|
|
1733
2048
|
ranAt: this.nowIso(),
|
|
1734
2049
|
written
|
|
1735
2050
|
};
|
|
@@ -1737,18 +2052,37 @@ var GranolaApp = class {
|
|
|
1737
2052
|
this.setUiState({ view: "transcripts-export" });
|
|
1738
2053
|
return {
|
|
1739
2054
|
cacheData,
|
|
1740
|
-
format,
|
|
1741
|
-
|
|
2055
|
+
format: options.format,
|
|
2056
|
+
job,
|
|
2057
|
+
outputDir: options.outputDir,
|
|
1742
2058
|
transcriptCount: count,
|
|
1743
2059
|
written
|
|
1744
2060
|
};
|
|
1745
2061
|
}
|
|
2062
|
+
async rerunExportJob(id) {
|
|
2063
|
+
const job = this.#state.exports.jobs.find((candidate) => candidate.id === id);
|
|
2064
|
+
if (!job) throw new Error(`export job not found: ${id}`);
|
|
2065
|
+
if (job.kind === "notes") return await this.runNotesExport({
|
|
2066
|
+
format: job.format,
|
|
2067
|
+
outputDir: job.outputDir
|
|
2068
|
+
});
|
|
2069
|
+
return await this.runTranscriptsExport({
|
|
2070
|
+
format: job.format,
|
|
2071
|
+
outputDir: job.outputDir
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
1746
2074
|
};
|
|
1747
2075
|
async function createGranolaApp(config, options = {}) {
|
|
2076
|
+
const auth = await inspectDefaultGranolaAuth(config);
|
|
2077
|
+
const authController = createDefaultGranolaAuthController(config);
|
|
2078
|
+
const exportJobStore = createDefaultExportJobStore();
|
|
1748
2079
|
return new GranolaApp(config, {
|
|
1749
|
-
auth
|
|
2080
|
+
auth,
|
|
2081
|
+
authController,
|
|
1750
2082
|
cacheLoader: loadOptionalGranolaCache,
|
|
1751
|
-
createGranolaClient: async () => await createDefaultGranolaRuntime(config, options.logger),
|
|
2083
|
+
createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
|
|
2084
|
+
exportJobs: await exportJobStore.readJobs(),
|
|
2085
|
+
exportJobStore,
|
|
1752
2086
|
now: options.now
|
|
1753
2087
|
}, { surface: options.surface });
|
|
1754
2088
|
}
|
|
@@ -1868,6 +2202,203 @@ async function waitForShutdown(close) {
|
|
|
1868
2202
|
});
|
|
1869
2203
|
}
|
|
1870
2204
|
//#endregion
|
|
2205
|
+
//#region src/commands/auth.ts
|
|
2206
|
+
function authHelp() {
|
|
2207
|
+
return `Granola auth
|
|
2208
|
+
|
|
2209
|
+
Usage:
|
|
2210
|
+
granola auth <login|status|logout|refresh|use> [options]
|
|
2211
|
+
|
|
2212
|
+
Subcommands:
|
|
2213
|
+
login Import credentials from the Granola desktop app
|
|
2214
|
+
status Show the current Granola auth state
|
|
2215
|
+
logout Delete the stored Granola session
|
|
2216
|
+
refresh Refresh the stored Granola session
|
|
2217
|
+
use <stored|supabase>
|
|
2218
|
+
Switch the active auth source for this toolkit instance
|
|
2219
|
+
|
|
2220
|
+
Options:
|
|
2221
|
+
--supabase <path> Path to supabase.json for auth login
|
|
2222
|
+
--config <path> Path to .granola.toml
|
|
2223
|
+
--debug Enable debug logging
|
|
2224
|
+
-h, --help Show help
|
|
2225
|
+
`;
|
|
2226
|
+
}
|
|
2227
|
+
function formatAuthSource(mode) {
|
|
2228
|
+
return mode === "stored-session" ? "stored session" : "supabase.json";
|
|
2229
|
+
}
|
|
2230
|
+
function printAuthState(state) {
|
|
2231
|
+
console.log(`Active source: ${formatAuthSource(state.mode)}`);
|
|
2232
|
+
console.log(`Stored session: ${state.storedSessionAvailable ? "available" : "missing"}`);
|
|
2233
|
+
console.log(`supabase.json: ${state.supabaseAvailable ? "available" : "missing"}`);
|
|
2234
|
+
if (state.supabasePath) console.log(`supabase path: ${state.supabasePath}`);
|
|
2235
|
+
if (state.clientId) console.log(`Client ID: ${state.clientId}`);
|
|
2236
|
+
console.log(`Refresh token: ${state.refreshAvailable ? "available" : "missing"}`);
|
|
2237
|
+
if (state.signInMethod) console.log(`Sign-in method: ${state.signInMethod}`);
|
|
2238
|
+
if (state.lastError) console.log(`Last error: ${state.lastError}`);
|
|
2239
|
+
}
|
|
2240
|
+
const authCommand = {
|
|
2241
|
+
description: "Manage stored Granola sessions",
|
|
2242
|
+
flags: { help: { type: "boolean" } },
|
|
2243
|
+
help: authHelp,
|
|
2244
|
+
name: "auth",
|
|
2245
|
+
async run({ commandArgs, globalFlags }) {
|
|
2246
|
+
const [action, value] = commandArgs;
|
|
2247
|
+
const config = await loadConfig({
|
|
2248
|
+
globalFlags,
|
|
2249
|
+
subcommandFlags: {}
|
|
2250
|
+
});
|
|
2251
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
2252
|
+
debug(config.debug, "supabase", config.supabase);
|
|
2253
|
+
const app = await createGranolaApp(config);
|
|
2254
|
+
switch (action) {
|
|
2255
|
+
case "login": {
|
|
2256
|
+
const state = await app.loginAuth();
|
|
2257
|
+
console.log(`Imported Granola session from ${state.supabasePath ?? "desktop app defaults"}`);
|
|
2258
|
+
printAuthState(state);
|
|
2259
|
+
return 0;
|
|
2260
|
+
}
|
|
2261
|
+
case "logout": {
|
|
2262
|
+
const state = await app.logoutAuth();
|
|
2263
|
+
console.log("Stored Granola session deleted");
|
|
2264
|
+
printAuthState(state);
|
|
2265
|
+
return 0;
|
|
2266
|
+
}
|
|
2267
|
+
case "refresh": {
|
|
2268
|
+
const state = await app.refreshAuth();
|
|
2269
|
+
console.log("Stored Granola session refreshed");
|
|
2270
|
+
printAuthState(state);
|
|
2271
|
+
return 0;
|
|
2272
|
+
}
|
|
2273
|
+
case "status": {
|
|
2274
|
+
const state = await app.inspectAuth();
|
|
2275
|
+
printAuthState(state);
|
|
2276
|
+
return state.storedSessionAvailable ? 0 : 1;
|
|
2277
|
+
}
|
|
2278
|
+
case "use": {
|
|
2279
|
+
const mode = resolveAuthMode(value);
|
|
2280
|
+
const state = await app.switchAuthMode(mode);
|
|
2281
|
+
console.log(`Switched auth source to ${formatAuthSource(state.mode)}`);
|
|
2282
|
+
printAuthState(state);
|
|
2283
|
+
return 0;
|
|
2284
|
+
}
|
|
2285
|
+
case void 0:
|
|
2286
|
+
console.log(authHelp());
|
|
2287
|
+
return 1;
|
|
2288
|
+
default: throw new Error("invalid auth command: expected login, status, logout, refresh, or use");
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
function resolveAuthMode(value) {
|
|
2293
|
+
switch (value) {
|
|
2294
|
+
case "stored":
|
|
2295
|
+
case "stored-session": return "stored-session";
|
|
2296
|
+
case "supabase":
|
|
2297
|
+
case "supabase-file": return "supabase-file";
|
|
2298
|
+
default: throw new Error("invalid auth mode: expected stored or supabase");
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
//#endregion
|
|
2302
|
+
//#region src/commands/exports.ts
|
|
2303
|
+
function exportsHelp() {
|
|
2304
|
+
return `Granola exports
|
|
2305
|
+
|
|
2306
|
+
Usage:
|
|
2307
|
+
granola exports <list|rerun> [options]
|
|
2308
|
+
|
|
2309
|
+
Subcommands:
|
|
2310
|
+
list Show recent export jobs
|
|
2311
|
+
rerun <job-id> Rerun a previous notes or transcripts export job
|
|
2312
|
+
|
|
2313
|
+
Options:
|
|
2314
|
+
--cache <path> Path to Granola cache JSON
|
|
2315
|
+
--format <value> list output format: text, json, yaml (default: text)
|
|
2316
|
+
--limit <n> Number of jobs to show for list (default: 20)
|
|
2317
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
2318
|
+
--supabase <path> Path to supabase.json
|
|
2319
|
+
--debug Enable debug logging
|
|
2320
|
+
--config <path> Path to .granola.toml
|
|
2321
|
+
-h, --help Show help
|
|
2322
|
+
`;
|
|
2323
|
+
}
|
|
2324
|
+
function resolveListFormat$1(value) {
|
|
2325
|
+
switch (value) {
|
|
2326
|
+
case void 0: return "text";
|
|
2327
|
+
case "json":
|
|
2328
|
+
case "text":
|
|
2329
|
+
case "yaml": return value;
|
|
2330
|
+
default: throw new Error("invalid exports format: expected text, json, or yaml");
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
function parseLimit$1(value) {
|
|
2334
|
+
if (value === void 0) return 20;
|
|
2335
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
|
|
2336
|
+
const limit = Number(value);
|
|
2337
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid exports limit: expected a positive integer");
|
|
2338
|
+
return limit;
|
|
2339
|
+
}
|
|
2340
|
+
function renderExportJobs(jobs, format) {
|
|
2341
|
+
if (format === "json") return toJson({ jobs });
|
|
2342
|
+
if (format === "yaml") return toYaml({ jobs });
|
|
2343
|
+
if (jobs.length === 0) return "No export jobs\n";
|
|
2344
|
+
return `${["ID KIND STATUS FORMAT ITEMS WRITTEN STARTED", ...jobs.map((job) => {
|
|
2345
|
+
return `${job.id.padEnd(28).slice(0, 28)} ${job.kind.padEnd(12)} ${job.status.padEnd(11)} ${job.format.padEnd(11)} ${String(job.itemCount).padEnd(7)} ${String(job.written).padEnd(8)} ${job.startedAt.slice(0, 19)}`;
|
|
2346
|
+
})].join("\n")}\n`;
|
|
2347
|
+
}
|
|
2348
|
+
const exportsCommand = {
|
|
2349
|
+
description: "List and rerun tracked export jobs",
|
|
2350
|
+
flags: {
|
|
2351
|
+
cache: { type: "string" },
|
|
2352
|
+
format: { type: "string" },
|
|
2353
|
+
help: { type: "boolean" },
|
|
2354
|
+
limit: { type: "string" },
|
|
2355
|
+
timeout: { type: "string" }
|
|
2356
|
+
},
|
|
2357
|
+
help: exportsHelp,
|
|
2358
|
+
name: "exports",
|
|
2359
|
+
async run({ commandArgs, commandFlags, globalFlags }) {
|
|
2360
|
+
const [action, id] = commandArgs;
|
|
2361
|
+
switch (action) {
|
|
2362
|
+
case "list": return await list$1(commandFlags, globalFlags);
|
|
2363
|
+
case "rerun":
|
|
2364
|
+
if (!id) throw new Error("exports rerun requires a job id");
|
|
2365
|
+
return await rerun(id, commandFlags, globalFlags);
|
|
2366
|
+
case void 0:
|
|
2367
|
+
console.log(exportsHelp());
|
|
2368
|
+
return 1;
|
|
2369
|
+
default: throw new Error("invalid exports command: expected list or rerun");
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
};
|
|
2373
|
+
async function list$1(commandFlags, globalFlags) {
|
|
2374
|
+
const format = resolveListFormat$1(commandFlags.format);
|
|
2375
|
+
const limit = parseLimit$1(commandFlags.limit);
|
|
2376
|
+
const config = await loadConfig({
|
|
2377
|
+
globalFlags,
|
|
2378
|
+
subcommandFlags: commandFlags
|
|
2379
|
+
});
|
|
2380
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
2381
|
+
const result = await (await createGranolaApp(config)).listExportJobs({ limit });
|
|
2382
|
+
console.log(renderExportJobs(result.jobs, format).trimEnd());
|
|
2383
|
+
return 0;
|
|
2384
|
+
}
|
|
2385
|
+
async function rerun(id, commandFlags, globalFlags) {
|
|
2386
|
+
const config = await loadConfig({
|
|
2387
|
+
globalFlags,
|
|
2388
|
+
subcommandFlags: commandFlags
|
|
2389
|
+
});
|
|
2390
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
2391
|
+
debug(config.debug, "supabase", config.supabase);
|
|
2392
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
2393
|
+
const result = await (await createGranolaApp(config)).rerunExportJob(id);
|
|
2394
|
+
if ("documentCount" in result) {
|
|
2395
|
+
console.log(`✓ Reran notes export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.documentCount} written)`);
|
|
2396
|
+
return 0;
|
|
2397
|
+
}
|
|
2398
|
+
console.log(`✓ Reran transcripts export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.transcriptCount} written)`);
|
|
2399
|
+
return 0;
|
|
2400
|
+
}
|
|
2401
|
+
//#endregion
|
|
1871
2402
|
//#region src/commands/meeting.ts
|
|
1872
2403
|
function meetingHelp() {
|
|
1873
2404
|
return `Granola meeting
|
|
@@ -2116,7 +2647,7 @@ const notesCommand = {
|
|
|
2116
2647
|
const app = await createGranolaApp(config);
|
|
2117
2648
|
debug(config.debug, "authMode", app.getState().auth.mode);
|
|
2118
2649
|
const result = await app.exportNotes(format);
|
|
2119
|
-
console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir}`);
|
|
2650
|
+
console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
|
|
2120
2651
|
debug(config.debug, "notes written", result.written);
|
|
2121
2652
|
return 0;
|
|
2122
2653
|
}
|
|
@@ -2152,9 +2683,11 @@ const state = {
|
|
|
2152
2683
|
|
|
2153
2684
|
const els = {
|
|
2154
2685
|
appState: document.querySelector("[data-app-state]"),
|
|
2686
|
+
authPanel: document.querySelector("[data-auth-panel]"),
|
|
2155
2687
|
detailBody: document.querySelector("[data-detail-body]"),
|
|
2156
2688
|
detailMeta: document.querySelector("[data-detail-meta]"),
|
|
2157
2689
|
empty: document.querySelector("[data-empty]"),
|
|
2690
|
+
jobsList: document.querySelector("[data-jobs-list]"),
|
|
2158
2691
|
list: document.querySelector("[data-meeting-list]"),
|
|
2159
2692
|
noteButton: document.querySelector("[data-export-notes]"),
|
|
2160
2693
|
quickOpen: document.querySelector("[data-quick-open]"),
|
|
@@ -2217,6 +2750,7 @@ function renderWorkspaceTabs() {
|
|
|
2217
2750
|
function renderAppState() {
|
|
2218
2751
|
if (!state.appState) {
|
|
2219
2752
|
els.appState.innerHTML = "<p>Waiting for server state…</p>";
|
|
2753
|
+
els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
|
|
2220
2754
|
return;
|
|
2221
2755
|
}
|
|
2222
2756
|
|
|
@@ -2238,6 +2772,74 @@ function renderAppState() {
|
|
|
2238
2772
|
'<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
|
|
2239
2773
|
"</div>",
|
|
2240
2774
|
].join("");
|
|
2775
|
+
|
|
2776
|
+
renderAuthPanel();
|
|
2777
|
+
renderExportJobs();
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function authActionButton(label, action, disabled) {
|
|
2781
|
+
return (
|
|
2782
|
+
'<button class="button button--secondary" data-auth-action="' +
|
|
2783
|
+
escapeHtml(action) +
|
|
2784
|
+
'"' +
|
|
2785
|
+
(disabled ? " disabled" : "") +
|
|
2786
|
+
">" +
|
|
2787
|
+
escapeHtml(label) +
|
|
2788
|
+
"</button>"
|
|
2789
|
+
);
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function authModeButton(label, mode, disabled) {
|
|
2793
|
+
return (
|
|
2794
|
+
'<button class="button button--secondary" data-auth-mode="' +
|
|
2795
|
+
escapeHtml(mode) +
|
|
2796
|
+
'"' +
|
|
2797
|
+
(disabled ? " disabled" : "") +
|
|
2798
|
+
">" +
|
|
2799
|
+
escapeHtml(label) +
|
|
2800
|
+
"</button>"
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function renderAuthPanel() {
|
|
2805
|
+
const auth = state.appState?.auth;
|
|
2806
|
+
if (!auth) {
|
|
2807
|
+
els.authPanel.innerHTML = '<div class="auth-card"><div class="auth-card__meta">Auth state unavailable.</div></div>';
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const activeSource = auth.mode === "stored-session" ? "Stored session" : "supabase.json";
|
|
2812
|
+
const lastError = auth.lastError
|
|
2813
|
+
? '<div class="auth-card__meta auth-card__error">' + escapeHtml(auth.lastError) + "</div>"
|
|
2814
|
+
: "";
|
|
2815
|
+
|
|
2816
|
+
els.authPanel.innerHTML = [
|
|
2817
|
+
'<div class="auth-card">',
|
|
2818
|
+
'<div class="status-grid">',
|
|
2819
|
+
'<div><span class="status-label">Active</span><strong>' + escapeHtml(activeSource) + "</strong></div>",
|
|
2820
|
+
'<div><span class="status-label">Stored</span><strong>' + escapeHtml(auth.storedSessionAvailable ? "available" : "missing") + "</strong></div>",
|
|
2821
|
+
'<div><span class="status-label">supabase.json</span><strong>' + escapeHtml(auth.supabaseAvailable ? "available" : "missing") + "</strong></div>",
|
|
2822
|
+
'<div><span class="status-label">Refresh</span><strong>' + escapeHtml(auth.refreshAvailable ? "available" : "missing") + "</strong></div>",
|
|
2823
|
+
"</div>",
|
|
2824
|
+
auth.clientId
|
|
2825
|
+
? '<div class="auth-card__meta">Client ID: ' + escapeHtml(auth.clientId) + "</div>"
|
|
2826
|
+
: "",
|
|
2827
|
+
auth.signInMethod
|
|
2828
|
+
? '<div class="auth-card__meta">Sign-in method: ' + escapeHtml(auth.signInMethod) + "</div>"
|
|
2829
|
+
: "",
|
|
2830
|
+
auth.supabasePath
|
|
2831
|
+
? '<div class="auth-card__meta">supabase path: ' + escapeHtml(auth.supabasePath) + "</div>"
|
|
2832
|
+
: "",
|
|
2833
|
+
lastError,
|
|
2834
|
+
'<div class="auth-card__actions">',
|
|
2835
|
+
authActionButton("Import desktop session", "login", !auth.supabaseAvailable),
|
|
2836
|
+
authActionButton("Refresh stored session", "refresh", !auth.storedSessionAvailable || !auth.refreshAvailable),
|
|
2837
|
+
authModeButton("Use stored session", "stored-session", !auth.storedSessionAvailable || auth.mode === "stored-session"),
|
|
2838
|
+
authModeButton("Use supabase.json", "supabase-file", !auth.supabaseAvailable || auth.mode === "supabase-file"),
|
|
2839
|
+
authActionButton("Sign out", "logout", !auth.storedSessionAvailable),
|
|
2840
|
+
"</div>",
|
|
2841
|
+
"</div>",
|
|
2842
|
+
].join("");
|
|
2241
2843
|
}
|
|
2242
2844
|
|
|
2243
2845
|
function renderMeetingList() {
|
|
@@ -2350,6 +2952,44 @@ function renderMeetingDetail() {
|
|
|
2350
2952
|
].join("");
|
|
2351
2953
|
}
|
|
2352
2954
|
|
|
2955
|
+
function renderExportJobs() {
|
|
2956
|
+
const jobs = state.appState?.exports?.jobs || [];
|
|
2957
|
+
if (jobs.length === 0) {
|
|
2958
|
+
els.jobsList.innerHTML = '<div class="job-empty">No export jobs yet.</div>';
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
els.jobsList.innerHTML = jobs
|
|
2963
|
+
.slice(0, 6)
|
|
2964
|
+
.map((job) => {
|
|
2965
|
+
const progress = job.itemCount > 0
|
|
2966
|
+
? job.completedCount + "/" + job.itemCount + " items"
|
|
2967
|
+
: "0 items";
|
|
2968
|
+
const error = job.error ? '<div class="job-card__meta">' + escapeHtml(job.error) + "</div>" : "";
|
|
2969
|
+
const rerunButton =
|
|
2970
|
+
job.status === "running"
|
|
2971
|
+
? ""
|
|
2972
|
+
: '<button class="button button--secondary" data-rerun-job-id="' + escapeHtml(job.id) + '">Rerun</button>';
|
|
2973
|
+
|
|
2974
|
+
return [
|
|
2975
|
+
'<article class="job-card">',
|
|
2976
|
+
'<div class="job-card__head">',
|
|
2977
|
+
'<div>',
|
|
2978
|
+
'<div class="job-card__title">' + escapeHtml(job.kind) + " export</div>",
|
|
2979
|
+
'<div class="job-card__meta">' + escapeHtml(job.id) + "</div>",
|
|
2980
|
+
"</div>",
|
|
2981
|
+
'<div class="job-card__status" data-status="' + escapeHtml(job.status) + '">' + escapeHtml(job.status) + "</div>",
|
|
2982
|
+
"</div>",
|
|
2983
|
+
'<div class="job-card__meta">Format: ' + escapeHtml(job.format) + " • " + escapeHtml(progress) + " • Written: " + escapeHtml(String(job.written)) + "</div>",
|
|
2984
|
+
'<div class="job-card__meta">Started: ' + escapeHtml(job.startedAt.slice(0, 19)) + "</div>",
|
|
2985
|
+
error,
|
|
2986
|
+
'<div class="job-card__actions">' + rerunButton + "</div>",
|
|
2987
|
+
"</article>",
|
|
2988
|
+
].join("");
|
|
2989
|
+
})
|
|
2990
|
+
.join("");
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2353
2993
|
async function fetchJson(path, init) {
|
|
2354
2994
|
const response = await fetch(path, init);
|
|
2355
2995
|
const payload = await response.json().catch(() => ({}));
|
|
@@ -2456,12 +3096,24 @@ async function quickOpenMeeting() {
|
|
|
2456
3096
|
|
|
2457
3097
|
async function refreshAll() {
|
|
2458
3098
|
setStatus("Refreshing…", "busy");
|
|
2459
|
-
const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
|
|
2460
|
-
state.appState =
|
|
3099
|
+
const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status"), loadMeetings()]);
|
|
3100
|
+
state.appState = {
|
|
3101
|
+
...appState,
|
|
3102
|
+
auth: authState,
|
|
3103
|
+
};
|
|
2461
3104
|
renderAppState();
|
|
2462
3105
|
setStatus("Connected", "ok");
|
|
2463
3106
|
}
|
|
2464
3107
|
|
|
3108
|
+
async function syncAuthState() {
|
|
3109
|
+
const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
|
|
3110
|
+
state.appState = {
|
|
3111
|
+
...appState,
|
|
3112
|
+
auth: authState,
|
|
3113
|
+
};
|
|
3114
|
+
renderAppState();
|
|
3115
|
+
}
|
|
3116
|
+
|
|
2465
3117
|
async function exportNotes() {
|
|
2466
3118
|
setStatus("Exporting notes…", "busy");
|
|
2467
3119
|
await fetchJson("/exports/notes", {
|
|
@@ -2482,6 +3134,76 @@ async function exportTranscripts() {
|
|
|
2482
3134
|
await refreshAll();
|
|
2483
3135
|
}
|
|
2484
3136
|
|
|
3137
|
+
async function rerunJob(id) {
|
|
3138
|
+
setStatus("Rerunning export…", "busy");
|
|
3139
|
+
try {
|
|
3140
|
+
await fetchJson("/exports/jobs/" + encodeURIComponent(id) + "/rerun", {
|
|
3141
|
+
method: "POST",
|
|
3142
|
+
});
|
|
3143
|
+
await refreshAll();
|
|
3144
|
+
} catch (error) {
|
|
3145
|
+
setStatus("Rerun failed", "error");
|
|
3146
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
3147
|
+
renderMeetingDetail();
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
async function loginAuth() {
|
|
3152
|
+
setStatus("Importing desktop session…", "busy");
|
|
3153
|
+
try {
|
|
3154
|
+
await fetchJson("/auth/login", { method: "POST" });
|
|
3155
|
+
await refreshAll();
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
await syncAuthState();
|
|
3158
|
+
setStatus("Auth import failed", "error");
|
|
3159
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
3160
|
+
renderMeetingDetail();
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
async function logoutAuth() {
|
|
3165
|
+
setStatus("Signing out…", "busy");
|
|
3166
|
+
try {
|
|
3167
|
+
await fetchJson("/auth/logout", { method: "POST" });
|
|
3168
|
+
await refreshAll();
|
|
3169
|
+
} catch (error) {
|
|
3170
|
+
await syncAuthState();
|
|
3171
|
+
setStatus("Sign out failed", "error");
|
|
3172
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
3173
|
+
renderMeetingDetail();
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
async function refreshAuth() {
|
|
3178
|
+
setStatus("Refreshing session…", "busy");
|
|
3179
|
+
try {
|
|
3180
|
+
await fetchJson("/auth/refresh", { method: "POST" });
|
|
3181
|
+
await refreshAll();
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
await syncAuthState();
|
|
3184
|
+
setStatus("Refresh failed", "error");
|
|
3185
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
3186
|
+
renderMeetingDetail();
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
async function switchAuthMode(mode) {
|
|
3191
|
+
setStatus("Switching auth source…", "busy");
|
|
3192
|
+
try {
|
|
3193
|
+
await fetchJson("/auth/mode", {
|
|
3194
|
+
body: JSON.stringify({ mode }),
|
|
3195
|
+
headers: { "content-type": "application/json" },
|
|
3196
|
+
method: "POST",
|
|
3197
|
+
});
|
|
3198
|
+
await refreshAll();
|
|
3199
|
+
} catch (error) {
|
|
3200
|
+
await syncAuthState();
|
|
3201
|
+
setStatus("Switch failed", "error");
|
|
3202
|
+
state.detailError = error instanceof Error ? error.message : String(error);
|
|
3203
|
+
renderMeetingDetail();
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
|
|
2485
3207
|
els.list.addEventListener("click", (event) => {
|
|
2486
3208
|
if (!(event.target instanceof Element)) {
|
|
2487
3209
|
return;
|
|
@@ -2492,6 +3214,49 @@ els.list.addEventListener("click", (event) => {
|
|
|
2492
3214
|
void loadMeeting(button.dataset.meetingId);
|
|
2493
3215
|
});
|
|
2494
3216
|
|
|
3217
|
+
els.jobsList.addEventListener("click", (event) => {
|
|
3218
|
+
if (!(event.target instanceof Element)) {
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
const button = event.target.closest("[data-rerun-job-id]");
|
|
3223
|
+
if (!button) {
|
|
3224
|
+
return;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
void rerunJob(button.dataset.rerunJobId);
|
|
3228
|
+
});
|
|
3229
|
+
|
|
3230
|
+
els.authPanel.addEventListener("click", (event) => {
|
|
3231
|
+
if (!(event.target instanceof Element)) {
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
const actionButton = event.target.closest("[data-auth-action]");
|
|
3236
|
+
if (actionButton) {
|
|
3237
|
+
switch (actionButton.dataset.authAction) {
|
|
3238
|
+
case "login":
|
|
3239
|
+
void loginAuth();
|
|
3240
|
+
return;
|
|
3241
|
+
case "logout":
|
|
3242
|
+
void logoutAuth();
|
|
3243
|
+
return;
|
|
3244
|
+
case "refresh":
|
|
3245
|
+
void refreshAuth();
|
|
3246
|
+
return;
|
|
3247
|
+
default:
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const modeButton = event.target.closest("[data-auth-mode]");
|
|
3253
|
+
if (!modeButton) {
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
void switchAuthMode(modeButton.dataset.authMode);
|
|
3258
|
+
});
|
|
3259
|
+
|
|
2495
3260
|
els.refreshButton.addEventListener("click", () => {
|
|
2496
3261
|
void refreshAll();
|
|
2497
3262
|
});
|
|
@@ -2690,6 +3455,20 @@ const granolaWebMarkup = String.raw`
|
|
|
2690
3455
|
</div>
|
|
2691
3456
|
<p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
|
|
2692
3457
|
</section>
|
|
3458
|
+
<section class="auth-panel">
|
|
3459
|
+
<div class="auth-panel__head">
|
|
3460
|
+
<h3>Auth Session</h3>
|
|
3461
|
+
<p>Inspect, refresh, and switch between stored session and <code>supabase.json</code>.</p>
|
|
3462
|
+
</div>
|
|
3463
|
+
<div class="auth-panel__body" data-auth-panel></div>
|
|
3464
|
+
</section>
|
|
3465
|
+
<section class="jobs-panel">
|
|
3466
|
+
<div class="jobs-panel__head">
|
|
3467
|
+
<h3>Recent Export Jobs</h3>
|
|
3468
|
+
<p>Tracked across CLI and web runs.</p>
|
|
3469
|
+
</div>
|
|
3470
|
+
<div class="jobs-list" data-jobs-list></div>
|
|
3471
|
+
</section>
|
|
2693
3472
|
<nav class="workspace-tabs">
|
|
2694
3473
|
<button class="workspace-tab" data-workspace-tab="notes">Notes</button>
|
|
2695
3474
|
<button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
|
|
@@ -2912,6 +3691,123 @@ body {
|
|
|
2912
3691
|
width: min(440px, 100%);
|
|
2913
3692
|
}
|
|
2914
3693
|
|
|
3694
|
+
.auth-panel,
|
|
3695
|
+
.jobs-panel {
|
|
3696
|
+
padding: 0 24px 18px;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
.auth-panel__head h3,
|
|
3700
|
+
.jobs-panel__head h3 {
|
|
3701
|
+
margin: 0;
|
|
3702
|
+
font-size: 0.92rem;
|
|
3703
|
+
letter-spacing: 0.08em;
|
|
3704
|
+
text-transform: uppercase;
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
.auth-panel__head p,
|
|
3708
|
+
.jobs-panel__head p {
|
|
3709
|
+
margin: 6px 0 0;
|
|
3710
|
+
color: var(--muted);
|
|
3711
|
+
font-size: 0.9rem;
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
.auth-panel__body {
|
|
3715
|
+
display: grid;
|
|
3716
|
+
gap: 12px;
|
|
3717
|
+
margin-top: 14px;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
.auth-card {
|
|
3721
|
+
display: grid;
|
|
3722
|
+
gap: 12px;
|
|
3723
|
+
padding: 14px 16px;
|
|
3724
|
+
border: 1px solid var(--line);
|
|
3725
|
+
border-radius: 18px;
|
|
3726
|
+
background: rgba(255, 255, 255, 0.72);
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
.auth-card__meta {
|
|
3730
|
+
color: var(--muted);
|
|
3731
|
+
font-size: 0.9rem;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
.auth-card__actions {
|
|
3735
|
+
display: flex;
|
|
3736
|
+
flex-wrap: wrap;
|
|
3737
|
+
gap: 8px;
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
.auth-card__error {
|
|
3741
|
+
color: var(--error);
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
.jobs-list {
|
|
3745
|
+
display: grid;
|
|
3746
|
+
gap: 10px;
|
|
3747
|
+
margin-top: 14px;
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
.job-card {
|
|
3751
|
+
display: grid;
|
|
3752
|
+
gap: 10px;
|
|
3753
|
+
padding: 14px 16px;
|
|
3754
|
+
border: 1px solid var(--line);
|
|
3755
|
+
border-radius: 18px;
|
|
3756
|
+
background: rgba(255, 255, 255, 0.72);
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
.job-card__head {
|
|
3760
|
+
display: flex;
|
|
3761
|
+
flex-wrap: wrap;
|
|
3762
|
+
align-items: center;
|
|
3763
|
+
justify-content: space-between;
|
|
3764
|
+
gap: 10px;
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
.job-card__title {
|
|
3768
|
+
font-weight: 700;
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
.job-card__meta {
|
|
3772
|
+
color: var(--muted);
|
|
3773
|
+
font-size: 0.9rem;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
.job-card__status {
|
|
3777
|
+
padding: 6px 10px;
|
|
3778
|
+
border-radius: 999px;
|
|
3779
|
+
background: var(--accent-soft);
|
|
3780
|
+
color: var(--accent);
|
|
3781
|
+
font-size: 0.82rem;
|
|
3782
|
+
font-weight: 700;
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
.job-card__status[data-status="running"] {
|
|
3786
|
+
background: rgba(163, 79, 47, 0.12);
|
|
3787
|
+
color: var(--warm);
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
.job-card__status[data-status="failed"] {
|
|
3791
|
+
background: rgba(157, 44, 44, 0.12);
|
|
3792
|
+
color: var(--error);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
.job-card__status[data-status="completed"] {
|
|
3796
|
+
background: rgba(36, 107, 79, 0.12);
|
|
3797
|
+
color: var(--ok);
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
.job-card__actions {
|
|
3801
|
+
display: flex;
|
|
3802
|
+
flex-wrap: wrap;
|
|
3803
|
+
gap: 8px;
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
.job-empty {
|
|
3807
|
+
color: var(--muted);
|
|
3808
|
+
font-size: 0.92rem;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
2915
3811
|
.workspace-tabs {
|
|
2916
3812
|
display: flex;
|
|
2917
3813
|
flex-wrap: wrap;
|
|
@@ -2952,6 +3848,11 @@ body {
|
|
|
2952
3848
|
cursor: pointer;
|
|
2953
3849
|
}
|
|
2954
3850
|
|
|
3851
|
+
.button:disabled {
|
|
3852
|
+
cursor: not-allowed;
|
|
3853
|
+
opacity: 0.56;
|
|
3854
|
+
}
|
|
3855
|
+
|
|
2955
3856
|
.button--primary {
|
|
2956
3857
|
background: var(--ink);
|
|
2957
3858
|
color: white;
|
|
@@ -3099,6 +4000,13 @@ function parseMeetingSort(value) {
|
|
|
3099
4000
|
default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
|
|
3100
4001
|
}
|
|
3101
4002
|
}
|
|
4003
|
+
function parseAuthMode(value) {
|
|
4004
|
+
switch (value) {
|
|
4005
|
+
case "stored-session":
|
|
4006
|
+
case "supabase-file": return value;
|
|
4007
|
+
default: throw new Error("invalid auth mode: expected stored-session or supabase-file");
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
3102
4010
|
function sendJson(response, body, init = {}) {
|
|
3103
4011
|
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
3104
4012
|
response.writeHead(init.status ?? 200, {
|
|
@@ -3181,6 +4089,10 @@ async function startGranolaServer(app, options = {}) {
|
|
|
3181
4089
|
sendJson(response, app.getState());
|
|
3182
4090
|
return;
|
|
3183
4091
|
}
|
|
4092
|
+
if (method === "GET" && path === "/auth/status") {
|
|
4093
|
+
sendJson(response, await app.inspectAuth());
|
|
4094
|
+
return;
|
|
4095
|
+
}
|
|
3184
4096
|
if (method === "GET" && path === "/events") {
|
|
3185
4097
|
response.writeHead(200, {
|
|
3186
4098
|
"cache-control": "no-cache, no-transform",
|
|
@@ -3234,11 +4146,41 @@ async function startGranolaServer(app, options = {}) {
|
|
|
3234
4146
|
sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
3235
4147
|
return;
|
|
3236
4148
|
}
|
|
4149
|
+
if (method === "POST" && path === "/auth/login") {
|
|
4150
|
+
const body = await readJsonBody(request);
|
|
4151
|
+
const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
|
|
4152
|
+
sendJson(response, await app.loginAuth({ supabasePath }));
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
if (method === "POST" && path === "/auth/logout") {
|
|
4156
|
+
sendJson(response, await app.logoutAuth());
|
|
4157
|
+
return;
|
|
4158
|
+
}
|
|
4159
|
+
if (method === "POST" && path === "/auth/refresh") {
|
|
4160
|
+
sendJson(response, await app.refreshAuth());
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
if (method === "POST" && path === "/auth/mode") {
|
|
4164
|
+
const body = await readJsonBody(request);
|
|
4165
|
+
sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
|
|
4166
|
+
return;
|
|
4167
|
+
}
|
|
3237
4168
|
if (method === "POST" && path === "/exports/notes") {
|
|
3238
4169
|
const body = await readJsonBody(request);
|
|
3239
4170
|
sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
|
|
3240
4171
|
return;
|
|
3241
4172
|
}
|
|
4173
|
+
if (method === "GET" && path === "/exports/jobs") {
|
|
4174
|
+
const limit = parseInteger(url.searchParams.get("limit"));
|
|
4175
|
+
sendJson(response, await app.listExportJobs({ limit }));
|
|
4176
|
+
return;
|
|
4177
|
+
}
|
|
4178
|
+
if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
|
|
4179
|
+
const id = decodeURIComponent(path.slice(14, -6));
|
|
4180
|
+
if (!id) throw new Error("export job id is required");
|
|
4181
|
+
sendJson(response, await app.rerunExportJob(id), { status: 202 });
|
|
4182
|
+
return;
|
|
4183
|
+
}
|
|
3242
4184
|
if (method === "POST" && path === "/exports/transcripts") {
|
|
3243
4185
|
const body = await readJsonBody(request);
|
|
3244
4186
|
sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
|
|
@@ -3325,11 +4267,18 @@ const serveCommand = {
|
|
|
3325
4267
|
console.log(`Granola server listening on ${server.url.href}`);
|
|
3326
4268
|
console.log("Endpoints:");
|
|
3327
4269
|
console.log(" GET /health");
|
|
4270
|
+
console.log(" GET /auth/status");
|
|
3328
4271
|
console.log(" GET /state");
|
|
3329
4272
|
console.log(" GET /events");
|
|
3330
4273
|
console.log(" GET /meetings");
|
|
3331
4274
|
console.log(" GET /meetings/:id");
|
|
4275
|
+
console.log(" GET /exports/jobs");
|
|
4276
|
+
console.log(" POST /auth/login");
|
|
4277
|
+
console.log(" POST /auth/logout");
|
|
4278
|
+
console.log(" POST /auth/mode");
|
|
4279
|
+
console.log(" POST /auth/refresh");
|
|
3332
4280
|
console.log(" POST /exports/notes");
|
|
4281
|
+
console.log(" POST /exports/jobs/:id/rerun");
|
|
3333
4282
|
console.log(" POST /exports/transcripts");
|
|
3334
4283
|
await waitForShutdown(async () => await server.close());
|
|
3335
4284
|
return 0;
|
|
@@ -3375,7 +4324,7 @@ const transcriptsCommand = {
|
|
|
3375
4324
|
const app = await createGranolaApp(config);
|
|
3376
4325
|
debug(config.debug, "authMode", app.getState().auth.mode);
|
|
3377
4326
|
const result = await app.exportTranscripts(format);
|
|
3378
|
-
console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir}`);
|
|
4327
|
+
console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir} (job ${result.job.id})`);
|
|
3379
4328
|
debug(config.debug, "transcripts written", result.written);
|
|
3380
4329
|
return 0;
|
|
3381
4330
|
}
|
|
@@ -3445,6 +4394,7 @@ Options:
|
|
|
3445
4394
|
//#region src/commands/index.ts
|
|
3446
4395
|
const commands = [
|
|
3447
4396
|
authCommand,
|
|
4397
|
+
exportsCommand,
|
|
3448
4398
|
meetingCommand,
|
|
3449
4399
|
notesCommand,
|
|
3450
4400
|
serveCommand,
|
|
@@ -3483,11 +4433,18 @@ const commands = [
|
|
|
3483
4433
|
console.log("Routes:");
|
|
3484
4434
|
console.log(" GET /");
|
|
3485
4435
|
console.log(" GET /health");
|
|
4436
|
+
console.log(" GET /auth/status");
|
|
3486
4437
|
console.log(" GET /state");
|
|
3487
4438
|
console.log(" GET /events");
|
|
3488
4439
|
console.log(" GET /meetings");
|
|
3489
4440
|
console.log(" GET /meetings/:id");
|
|
4441
|
+
console.log(" GET /exports/jobs");
|
|
4442
|
+
console.log(" POST /auth/login");
|
|
4443
|
+
console.log(" POST /auth/logout");
|
|
4444
|
+
console.log(" POST /auth/mode");
|
|
4445
|
+
console.log(" POST /auth/refresh");
|
|
3490
4446
|
console.log(" POST /exports/notes");
|
|
4447
|
+
console.log(" POST /exports/jobs/:id/rerun");
|
|
3491
4448
|
console.log(" POST /exports/transcripts");
|
|
3492
4449
|
if (openBrowser) try {
|
|
3493
4450
|
await openExternalUrl(server.url);
|