granola-toolkit 0.10.0 → 0.12.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 +1 -0
- package/dist/cli.js +80 -37
- package/package.json +4 -1
package/README.md
CHANGED
package/dist/cli.js
CHANGED
|
@@ -5,12 +5,17 @@ import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { homedir, platform } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
//#region src/utils.ts
|
|
10
11
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
11
12
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
|
12
13
|
const MULTIPLE_UNDERSCORES = /_+/g;
|
|
13
14
|
const MULTIPLE_BLANK_LINES = /\n{3,}/g;
|
|
15
|
+
const htmlMarkdown = new NodeHtmlMarkdown({
|
|
16
|
+
bulletMarker: "-",
|
|
17
|
+
ignore: ["script", "style"]
|
|
18
|
+
});
|
|
14
19
|
function normaliseNewlines(value) {
|
|
15
20
|
return value.replace(/\r\n?/g, "\n");
|
|
16
21
|
}
|
|
@@ -117,25 +122,9 @@ function formatTimestampForTranscript(timestamp) {
|
|
|
117
122
|
if (Number.isNaN(parsed.getTime())) return timestamp;
|
|
118
123
|
return parsed.toISOString().slice(11, 19);
|
|
119
124
|
}
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
function htmlToMarkdownFallback(value) {
|
|
124
|
-
let output = normaliseNewlines(decodeHtmlEntities(value));
|
|
125
|
-
output = output.replace(/<br\s*\/?>/gi, "\n");
|
|
126
|
-
output = output.replace(/<li\b[^>]*>/gi, "- ");
|
|
127
|
-
output = output.replace(/<\/li>/gi, "\n");
|
|
128
|
-
output = output.replace(/<h1\b[^>]*>/gi, "# ");
|
|
129
|
-
output = output.replace(/<h2\b[^>]*>/gi, "## ");
|
|
130
|
-
output = output.replace(/<h3\b[^>]*>/gi, "### ");
|
|
131
|
-
output = output.replace(/<h4\b[^>]*>/gi, "#### ");
|
|
132
|
-
output = output.replace(/<h5\b[^>]*>/gi, "##### ");
|
|
133
|
-
output = output.replace(/<h6\b[^>]*>/gi, "###### ");
|
|
134
|
-
output = output.replace(/<\/(p|div|section|article|ul|ol|blockquote|h[1-6])>/gi, "\n\n");
|
|
135
|
-
output = output.replace(/<[^>]+>/g, "");
|
|
136
|
-
output = output.replace(/[ \t]+\n/g, "\n");
|
|
137
|
-
output = output.replace(MULTIPLE_BLANK_LINES, "\n\n");
|
|
138
|
-
return output.trim();
|
|
125
|
+
function htmlToMarkdown(value) {
|
|
126
|
+
if (!value.trim()) return "";
|
|
127
|
+
return normaliseNewlines(htmlMarkdown.translate(value)).replace(/[ \t]+\n/g, "\n").replace(MULTIPLE_BLANK_LINES, "\n\n").trim();
|
|
139
128
|
}
|
|
140
129
|
function latestDocumentTimestamp(document) {
|
|
141
130
|
const candidates = [
|
|
@@ -551,13 +540,23 @@ function parseDocument(value) {
|
|
|
551
540
|
}
|
|
552
541
|
//#endregion
|
|
553
542
|
//#region src/client/granola.ts
|
|
554
|
-
const
|
|
555
|
-
const CLIENT_VERSION = "5.354.0";
|
|
543
|
+
const DEFAULT_CLIENT_VERSION = "5.354.0";
|
|
556
544
|
const DOCUMENTS_URL = "https://api.granola.ai/v2/get-documents";
|
|
545
|
+
function resolveClientVersion(value) {
|
|
546
|
+
return value?.trim() || process.env.GRANOLA_CLIENT_VERSION?.trim() || DEFAULT_CLIENT_VERSION;
|
|
547
|
+
}
|
|
557
548
|
var GranolaApiClient = class {
|
|
558
|
-
|
|
549
|
+
clientVersion;
|
|
550
|
+
documentsUrl;
|
|
551
|
+
constructor(httpClient, options = DOCUMENTS_URL) {
|
|
559
552
|
this.httpClient = httpClient;
|
|
560
|
-
|
|
553
|
+
if (typeof options === "string") {
|
|
554
|
+
this.documentsUrl = options;
|
|
555
|
+
this.clientVersion = resolveClientVersion();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.documentsUrl = options.documentsUrl ?? DOCUMENTS_URL;
|
|
559
|
+
this.clientVersion = resolveClientVersion(options.clientVersion);
|
|
561
560
|
}
|
|
562
561
|
async listDocuments(options) {
|
|
563
562
|
const documents = [];
|
|
@@ -570,8 +569,8 @@ var GranolaApiClient = class {
|
|
|
570
569
|
offset
|
|
571
570
|
}, {
|
|
572
571
|
headers: {
|
|
573
|
-
"User-Agent":
|
|
574
|
-
"X-Client-Version":
|
|
572
|
+
"User-Agent": `Granola/${this.clientVersion}`,
|
|
573
|
+
"X-Client-Version": this.clientVersion
|
|
575
574
|
},
|
|
576
575
|
timeoutMs: options.timeoutMs
|
|
577
576
|
});
|
|
@@ -591,35 +590,79 @@ var GranolaApiClient = class {
|
|
|
591
590
|
};
|
|
592
591
|
//#endregion
|
|
593
592
|
//#region src/client/http.ts
|
|
593
|
+
const RETRYABLE_STATUS_CODES = new Set([
|
|
594
|
+
429,
|
|
595
|
+
500,
|
|
596
|
+
502,
|
|
597
|
+
503,
|
|
598
|
+
504
|
|
599
|
+
]);
|
|
600
|
+
function sleep(delayMs) {
|
|
601
|
+
return new Promise((resolve) => {
|
|
602
|
+
setTimeout(resolve, delayMs);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function parseRetryAfter(headerValue) {
|
|
606
|
+
if (!headerValue?.trim()) return;
|
|
607
|
+
if (/^\d+$/.test(headerValue.trim())) return Number(headerValue.trim()) * 1e3;
|
|
608
|
+
const retryAt = Date.parse(headerValue);
|
|
609
|
+
if (Number.isNaN(retryAt)) return;
|
|
610
|
+
return Math.max(0, retryAt - Date.now());
|
|
611
|
+
}
|
|
594
612
|
var AuthenticatedHttpClient = class {
|
|
595
613
|
fetchImpl;
|
|
596
614
|
constructor(options) {
|
|
597
615
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
598
616
|
this.logger = options.logger;
|
|
617
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
618
|
+
this.retryBaseDelayMs = options.retryBaseDelayMs ?? 500;
|
|
619
|
+
this.retryMaxDelayMs = options.retryMaxDelayMs ?? 5e3;
|
|
620
|
+
this.sleepImpl = options.sleepImpl ?? sleep;
|
|
599
621
|
this.tokenProvider = options.tokenProvider;
|
|
600
622
|
}
|
|
601
623
|
logger;
|
|
624
|
+
maxRetries;
|
|
625
|
+
retryBaseDelayMs;
|
|
626
|
+
retryMaxDelayMs;
|
|
627
|
+
sleepImpl;
|
|
602
628
|
tokenProvider;
|
|
603
|
-
async
|
|
629
|
+
async retry(options, attempt, reason, response) {
|
|
630
|
+
const retryAfterMs = parseRetryAfter(response?.headers.get("retry-after") ?? null);
|
|
631
|
+
const delayMs = Math.min(retryAfterMs ?? this.retryBaseDelayMs * 2 ** attempt, this.retryMaxDelayMs);
|
|
632
|
+
this.logger?.warn?.(`${reason}; retrying in ${delayMs}ms (${attempt + 1}/${this.maxRetries})`);
|
|
633
|
+
await this.sleepImpl(delayMs);
|
|
634
|
+
return this.request(options, attempt + 1);
|
|
635
|
+
}
|
|
636
|
+
async request(options, attempt = 0) {
|
|
604
637
|
const { retryOnUnauthorized = true, timeoutMs, url } = options;
|
|
605
638
|
const accessToken = await this.tokenProvider.getAccessToken();
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
639
|
+
let response;
|
|
640
|
+
try {
|
|
641
|
+
response = await this.fetchImpl(url, {
|
|
642
|
+
body: options.body,
|
|
643
|
+
headers: {
|
|
644
|
+
...options.headers,
|
|
645
|
+
Authorization: `Bearer ${accessToken}`
|
|
646
|
+
},
|
|
647
|
+
method: options.method ?? "GET",
|
|
648
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
649
|
+
});
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (attempt < this.maxRetries) {
|
|
652
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
653
|
+
return this.retry(options, attempt, `request failed: ${message}`);
|
|
654
|
+
}
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
615
657
|
if (response.status === 401 && retryOnUnauthorized) {
|
|
616
658
|
this.logger?.warn?.("request returned 401; invalidating token provider and retrying once");
|
|
617
659
|
await this.tokenProvider.invalidate();
|
|
618
660
|
return this.request({
|
|
619
661
|
...options,
|
|
620
662
|
retryOnUnauthorized: false
|
|
621
|
-
});
|
|
663
|
+
}, attempt);
|
|
622
664
|
}
|
|
665
|
+
if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < this.maxRetries) return this.retry(options, attempt, `request returned ${response.status} ${response.statusText || ""}`.trim(), response);
|
|
623
666
|
return response;
|
|
624
667
|
}
|
|
625
668
|
async postJson(url, body, options = { timeoutMs: 3e4 }) {
|
|
@@ -998,7 +1041,7 @@ function selectNoteContent(document) {
|
|
|
998
1041
|
content: lastViewedPanel,
|
|
999
1042
|
source: "lastViewedPanel.content"
|
|
1000
1043
|
};
|
|
1001
|
-
const originalContent =
|
|
1044
|
+
const originalContent = htmlToMarkdown(document.lastViewedPanel?.originalContent ?? "").trim();
|
|
1002
1045
|
if (originalContent) return {
|
|
1003
1046
|
content: originalContent,
|
|
1004
1047
|
source: "lastViewedPanel.originalContent"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granola-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "CLI toolkit for exporting and working with Granola notes and transcripts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
"typecheck": "vp exec tsc --noEmit",
|
|
50
50
|
"prepare": "vp config"
|
|
51
51
|
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"node-html-markdown": "^2.0.0"
|
|
54
|
+
},
|
|
52
55
|
"devDependencies": {
|
|
53
56
|
"@types/node": "^25.5.2",
|
|
54
57
|
"typescript": "^5.9.3",
|