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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +80 -37
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -214,6 +214,7 @@ Supported environment variables:
214
214
  - `TIMEOUT`
215
215
  - `CACHE_FILE`
216
216
  - `TRANSCRIPT_OUTPUT`
217
+ - `GRANOLA_CLIENT_VERSION`
217
218
 
218
219
  ## Development Checks
219
220
 
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 decodeHtmlEntities(value) {
121
- return value.replaceAll("&nbsp;", " ").replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&#39;", "'");
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 USER_AGENT = "Granola/5.354.0";
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
- constructor(httpClient, documentsUrl = DOCUMENTS_URL) {
549
+ clientVersion;
550
+ documentsUrl;
551
+ constructor(httpClient, options = DOCUMENTS_URL) {
559
552
  this.httpClient = httpClient;
560
- this.documentsUrl = documentsUrl;
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": USER_AGENT,
574
- "X-Client-Version": 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 request(options) {
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
- const response = await this.fetchImpl(url, {
607
- body: options.body,
608
- headers: {
609
- ...options.headers,
610
- Authorization: `Bearer ${accessToken}`
611
- },
612
- method: options.method ?? "GET",
613
- signal: AbortSignal.timeout(timeoutMs)
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 = htmlToMarkdownFallback(document.lastViewedPanel?.originalContent ?? "").trim();
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.10.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",