mirror of
https://github.com/arshiaecho/obsidian-granola-api-sync.git
synced 2026-07-22 08:31:27 +00:00
1.0.2: address community-directory review findings
- Settings: migrate to declarative getSettingDefinitions API (1.13+), drop deprecated display()/setDynamicTooltip; minAppVersion -> 1.13.0 - Types: generic apiGet<T>, typed list response, no unsafe any assignment - Promises: void-mark fire-and-forget sync() call sites - sanitizeFilename: replace control-char regex range with charCode filter - Build: replace builtin-modules dep with node:module builtinModules - Release: GitHub Actions workflow with artifact attestations; release assets now only main.js + manifest.json (versions.json stays in repo) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
85d6a4f95e
commit
cc1daf13a5
12 changed files with 312 additions and 260 deletions
51
.github/workflows/release.yml
vendored
Normal file
51
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
- name: Verify tag matches manifest version
|
||||
run: |
|
||||
MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
|
||||
if [ "$MANIFEST_VERSION" != "${GITHUB_REF_NAME}" ]; then
|
||||
echo "Tag ${GITHUB_REF_NAME} does not match manifest version ${MANIFEST_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
manifest.json
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${GITHUB_REF_NAME}" \
|
||||
--title "${GITHUB_REF_NAME}" \
|
||||
--generate-notes \
|
||||
main.js manifest.json
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "node:module";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
|
|
@ -28,7 +28,7 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
...builtinModules,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"id": "granola-api-sync",
|
||||
"name": "Granola API Sync",
|
||||
"version": "1.0.1",
|
||||
"minAppVersion": "1.5.0",
|
||||
"version": "1.0.2",
|
||||
"minAppVersion": "1.13.0",
|
||||
"description": "Sync meeting notes and full transcripts from Granola into your vault using the official Granola API. Just paste an API key, no credential scraping.",
|
||||
"author": "Arshia Navabi",
|
||||
"authorUrl": "https://github.com/ArshiaEcho",
|
||||
|
|
|
|||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -1,16 +1,15 @@
|
|||
{
|
||||
"name": "granola-api-sync",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "granola-api-sync",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.6.0",
|
||||
|
|
@ -512,19 +511,6 @@
|
|||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz",
|
||||
"integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "granola-api-sync",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Sync meeting notes and full transcripts from Granola into Obsidian using the official Granola API.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -18,7 +18,6 @@
|
|||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.6.0",
|
||||
|
|
|
|||
285
src/main.ts
285
src/main.ts
|
|
@ -3,7 +3,7 @@ import {
|
|||
Notice,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
SettingDefinitionItem,
|
||||
TFile,
|
||||
TFolder,
|
||||
normalizePath,
|
||||
|
|
@ -43,6 +43,12 @@ interface GranolaNote extends GranolaNoteStub {
|
|||
summary_text?: string;
|
||||
}
|
||||
|
||||
interface GranolaListResponse {
|
||||
notes?: GranolaNoteStub[];
|
||||
hasMore?: boolean;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
interface GranolaSyncSettings {
|
||||
apiKey: string;
|
||||
notesFolder: string;
|
||||
|
|
@ -78,7 +84,10 @@ interface PluginData {
|
|||
|
||||
export function sanitizeFilename(title: string): string {
|
||||
let name = title.replace(/\//g, "__");
|
||||
name = name.replace(/[\\:*?"<>|\u0000-\u001f]/g, "");
|
||||
name = name.replace(/[\\:*?"<>|]/g, "");
|
||||
name = Array.from(name)
|
||||
.filter((c) => c.charCodeAt(0) > 0x1f)
|
||||
.join("");
|
||||
name = name.replace(/\s+/g, " ").trim().replace(/^\.+|\.+$/g, "");
|
||||
return name || "Untitled Granola Note";
|
||||
}
|
||||
|
|
@ -233,11 +242,11 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
this.addCommand({
|
||||
id: "sync-now",
|
||||
name: "Sync now",
|
||||
callback: () => this.sync(true),
|
||||
callback: () => void this.sync(true),
|
||||
});
|
||||
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.sync(false);
|
||||
void this.sync(false);
|
||||
this.scheduleInterval();
|
||||
});
|
||||
|
||||
|
|
@ -245,7 +254,7 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
// backgrounded Obsidian can miss every interval tick. Catch up the
|
||||
// moment the window is used again (throttled to once a minute).
|
||||
this.registerDomEvent(window, "focus", () => {
|
||||
if (Date.now() - this.lastSyncAt > 60_000) this.sync(false);
|
||||
if (Date.now() - this.lastSyncAt > 60_000) void this.sync(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +265,7 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
}
|
||||
const minutes = Math.max(1, this.settings.intervalMinutes);
|
||||
this.intervalId = window.setInterval(
|
||||
() => this.sync(false),
|
||||
() => void this.sync(false),
|
||||
minutes * 60 * 1000
|
||||
);
|
||||
this.registerInterval(this.intervalId);
|
||||
|
|
@ -280,10 +289,10 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
|
||||
// ---------- API ----------
|
||||
|
||||
private async apiGet(
|
||||
private async apiGet<T>(
|
||||
path: string,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<T> {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const resp = await requestUrl({
|
||||
url: `${API_BASE}${path}${qs ? "?" + qs : ""}`,
|
||||
|
|
@ -295,16 +304,18 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
});
|
||||
if (resp.status === 401) {
|
||||
throw new Error(
|
||||
"API key rejected (401). Check the key in settings — note that Granola API keys require a Business plan."
|
||||
"API key rejected (401). Check the key in settings. Note that Granola API keys require a Business plan."
|
||||
);
|
||||
}
|
||||
if (resp.status === 429) {
|
||||
throw new Error("Rate limited by Granola (429) — will retry on next sync.");
|
||||
throw new Error(
|
||||
"Rate limited by Granola (429). Will retry on next sync."
|
||||
);
|
||||
}
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`Granola API error ${resp.status} on ${path}`);
|
||||
}
|
||||
return resp.json as Record<string, unknown>;
|
||||
return resp.json as T;
|
||||
}
|
||||
|
||||
private async listRecentNotes(): Promise<GranolaNoteStub[]> {
|
||||
|
|
@ -318,10 +329,13 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
created_after: createdAfter,
|
||||
};
|
||||
if (cursor) params.cursor = cursor;
|
||||
const data = await this.apiGet("/notes", params);
|
||||
notes.push(...((data.notes as GranolaNoteStub[]) ?? []));
|
||||
if (!data.hasMore) return notes;
|
||||
cursor = data.cursor as string;
|
||||
const data = await this.apiGet<GranolaListResponse>(
|
||||
"/notes",
|
||||
params
|
||||
);
|
||||
notes.push(...(data.notes ?? []));
|
||||
if (!data.hasMore || !data.cursor) return notes;
|
||||
cursor = data.cursor;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +344,9 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
private async ensureFolder(path: string) {
|
||||
const p = normalizePath(path);
|
||||
if (!this.app.vault.getAbstractFileByPath(p)) {
|
||||
await this.app.vault.createFolder(p).catch(() => {});
|
||||
await this.app.vault.createFolder(p).catch(() => {
|
||||
// folder may have been created concurrently; harmless
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,9 +359,8 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
if (!(folder instanceof TFolder)) return idx;
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile) || child.extension !== "md") continue;
|
||||
const gid =
|
||||
this.app.metadataCache.getFileCache(child)?.frontmatter
|
||||
?.granola_id;
|
||||
const fm = this.app.metadataCache.getFileCache(child)?.frontmatter;
|
||||
const gid: unknown = fm?.granola_id;
|
||||
if (typeof gid === "string" && gid) idx.set(gid, child);
|
||||
}
|
||||
return idx;
|
||||
|
|
@ -398,11 +413,11 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
let errors = 0;
|
||||
for (const stub of pending) {
|
||||
try {
|
||||
const note = (await this.apiGet(`/notes/${stub.id}`, {
|
||||
include: "transcript",
|
||||
})) as unknown as GranolaNote;
|
||||
const uuid =
|
||||
note.web_url?.match(UUID_RE)?.[0] ?? note.id;
|
||||
const note = await this.apiGet<GranolaNote>(
|
||||
`/notes/${stub.id}`,
|
||||
{ include: "transcript" }
|
||||
);
|
||||
const uuid = note.web_url?.match(UUID_RE)?.[0] ?? note.id;
|
||||
|
||||
// adopt existing file by granola_id, else create
|
||||
// (date-suffix on title collision with a different id)
|
||||
|
|
@ -430,7 +445,10 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
notePath,
|
||||
buildNoteMd(note, uuid, fname, this.settings)
|
||||
);
|
||||
if (this.settings.syncTranscripts && note.transcript?.length) {
|
||||
if (
|
||||
this.settings.syncTranscripts &&
|
||||
note.transcript?.length
|
||||
) {
|
||||
await this.writeFile(
|
||||
`${this.settings.transcriptsFolder}/${fname}-transcript.md`,
|
||||
buildTranscriptMd(note, uuid, fname, this.settings)
|
||||
|
|
@ -453,7 +471,7 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
await this.savePluginData();
|
||||
if (errors) {
|
||||
this.notice(
|
||||
`${synced} synced, ${errors} failed — see developer console.`,
|
||||
`${synced} synced, ${errors} failed. See developer console.`,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
|
|
@ -469,7 +487,7 @@ export default class GranolaApiSyncPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- settings UI ----------
|
||||
// ---------- settings UI (declarative API, Obsidian 1.13+) ----------
|
||||
|
||||
class GranolaSyncSettingTab extends PluginSettingTab {
|
||||
plugin: GranolaApiSyncPlugin;
|
||||
|
|
@ -479,132 +497,99 @@ class GranolaSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
getSettingDefinitions(): SettingDefinitionItem[] {
|
||||
return [
|
||||
{
|
||||
name: "Granola API key",
|
||||
desc: "Create one in the Granola app under Settings → API → Create new key (requires a Granola Business plan). Stored locally in this vault.",
|
||||
aliases: ["token", "authentication"],
|
||||
control: { type: "text", key: "apiKey", placeholder: "grn_..." },
|
||||
},
|
||||
{
|
||||
name: "Notes folder",
|
||||
desc: "Vault folder where meeting notes are created.",
|
||||
control: { type: "text", key: "notesFolder" },
|
||||
},
|
||||
{
|
||||
name: "Sync transcripts",
|
||||
desc: "Also save the full meeting transcript as a linked note.",
|
||||
control: { type: "toggle", key: "syncTranscripts" },
|
||||
},
|
||||
{
|
||||
name: "Transcripts folder",
|
||||
visible: () => this.plugin.settings.syncTranscripts,
|
||||
control: { type: "text", key: "transcriptsFolder" },
|
||||
},
|
||||
{
|
||||
name: "Sync interval (minutes)",
|
||||
desc: "How often to check for new or updated meetings.",
|
||||
control: {
|
||||
type: "slider",
|
||||
key: "intervalMinutes",
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Days to look back",
|
||||
desc: "Meetings created within this window are kept in sync (including late summary updates).",
|
||||
control: {
|
||||
type: "slider",
|
||||
key: "daysBack",
|
||||
min: 1,
|
||||
max: 90,
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Your email",
|
||||
desc: "Optional. Excludes you from attendee lists and labels your transcript lines with the name below.",
|
||||
control: {
|
||||
type: "text",
|
||||
key: "myEmail",
|
||||
placeholder: "you@company.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Your name in transcripts",
|
||||
control: { type: "text", key: "myLabel" },
|
||||
},
|
||||
{
|
||||
name: "Show sync notifications",
|
||||
desc: "Errors are always shown.",
|
||||
control: { type: "toggle", key: "showNotices" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Granola API key")
|
||||
.setDesc(
|
||||
"Create one in the Granola app under Settings → API → Create new key (requires a Granola Business plan). Stored locally in this vault."
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("grn_...")
|
||||
.setValue(this.plugin.settings.apiKey)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.apiKey = v.trim();
|
||||
await this.plugin.savePluginData();
|
||||
});
|
||||
text.inputEl.type = "password";
|
||||
});
|
||||
getControlValue(key: string): unknown {
|
||||
return this.plugin.settings[key as keyof GranolaSyncSettings];
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Notes folder")
|
||||
.setDesc("Vault folder where meeting notes are created.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.notesFolder)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.notesFolder =
|
||||
normalizePath(v || "Granola");
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync transcripts")
|
||||
.setDesc("Also save the full meeting transcript as a linked note.")
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.syncTranscripts)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.syncTranscripts = v;
|
||||
await this.plugin.savePluginData();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
|
||||
if (this.plugin.settings.syncTranscripts) {
|
||||
new Setting(containerEl)
|
||||
.setName("Transcripts folder")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.transcriptsFolder)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.transcriptsFolder =
|
||||
normalizePath(v || "Granola/transcripts");
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
async setControlValue(key: string, value: unknown): Promise<void> {
|
||||
const s = this.plugin.settings as unknown as Record<string, unknown>;
|
||||
switch (key) {
|
||||
case "apiKey":
|
||||
case "myEmail":
|
||||
s[key] = String(value ?? "").trim();
|
||||
break;
|
||||
case "myLabel":
|
||||
s[key] = String(value ?? "").trim() || "Me";
|
||||
break;
|
||||
case "notesFolder":
|
||||
s[key] = normalizePath(String(value ?? "") || "Granola");
|
||||
break;
|
||||
case "transcriptsFolder":
|
||||
s[key] = normalizePath(
|
||||
String(value ?? "") || "Granola/transcripts"
|
||||
);
|
||||
break;
|
||||
default:
|
||||
s[key] = value;
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync interval (minutes)")
|
||||
.setDesc("How often to check for new or updated meetings.")
|
||||
.addSlider((s) =>
|
||||
s
|
||||
.setLimits(1, 60, 1)
|
||||
.setValue(this.plugin.settings.intervalMinutes)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.intervalMinutes = v;
|
||||
await this.plugin.savePluginData();
|
||||
this.plugin.scheduleInterval();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Days to look back")
|
||||
.setDesc(
|
||||
"Meetings created within this window are kept in sync (including late summary updates)."
|
||||
)
|
||||
.addSlider((s) =>
|
||||
s
|
||||
.setLimits(1, 90, 1)
|
||||
.setValue(this.plugin.settings.daysBack)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.daysBack = v;
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Your email")
|
||||
.setDesc(
|
||||
"Optional. Excludes you from attendee lists and labels your transcript lines with the name below."
|
||||
)
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder("you@company.com")
|
||||
.setValue(this.plugin.settings.myEmail)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.myEmail = v.trim();
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Your name in transcripts")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.myLabel)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.myLabel = v.trim() || "Me";
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show sync notifications")
|
||||
.setDesc("Errors are always shown.")
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.showNotices)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.showNotices = v;
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
await this.plugin.savePluginData();
|
||||
if (key === "intervalMinutes") this.plugin.scheduleInterval();
|
||||
if (key === "syncTranscripts") this.update();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
test/_bundle.mjs
196
test/_bundle.mjs
|
|
@ -5,8 +5,6 @@ var PluginSettingTab = class {
|
|||
};
|
||||
var Notice = class {
|
||||
};
|
||||
var Setting = class {
|
||||
};
|
||||
var TFile = class {
|
||||
};
|
||||
var TFolder = class {
|
||||
|
|
@ -31,7 +29,8 @@ var DEFAULT_SETTINGS = {
|
|||
};
|
||||
function sanitizeFilename(title) {
|
||||
let name = title.replace(/\//g, "__");
|
||||
name = name.replace(/[\\:*?"<>|\u0000-\u001f]/g, "");
|
||||
name = name.replace(/[\\:*?"<>|]/g, "");
|
||||
name = Array.from(name).filter((c) => c.charCodeAt(0) > 31).join("");
|
||||
name = name.replace(/\s+/g, " ").trim().replace(/^\.+|\.+$/g, "");
|
||||
return name || "Untitled Granola Note";
|
||||
}
|
||||
|
|
@ -150,14 +149,14 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
this.addCommand({
|
||||
id: "sync-now",
|
||||
name: "Sync now",
|
||||
callback: () => this.sync(true)
|
||||
callback: () => void this.sync(true)
|
||||
});
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.sync(false);
|
||||
void this.sync(false);
|
||||
this.scheduleInterval();
|
||||
});
|
||||
this.registerDomEvent(window, "focus", () => {
|
||||
if (Date.now() - this.lastSyncAt > 6e4) this.sync(false);
|
||||
if (Date.now() - this.lastSyncAt > 6e4) void this.sync(false);
|
||||
});
|
||||
}
|
||||
scheduleInterval() {
|
||||
|
|
@ -167,7 +166,7 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
}
|
||||
const minutes = Math.max(1, this.settings.intervalMinutes);
|
||||
this.intervalId = window.setInterval(
|
||||
() => this.sync(false),
|
||||
() => void this.sync(false),
|
||||
minutes * 60 * 1e3
|
||||
);
|
||||
this.registerInterval(this.intervalId);
|
||||
|
|
@ -198,11 +197,13 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
});
|
||||
if (resp.status === 401) {
|
||||
throw new Error(
|
||||
"API key rejected (401). Check the key in settings \u2014 note that Granola API keys require a Business plan."
|
||||
"API key rejected (401). Check the key in settings. Note that Granola API keys require a Business plan."
|
||||
);
|
||||
}
|
||||
if (resp.status === 429) {
|
||||
throw new Error("Rate limited by Granola (429) \u2014 will retry on next sync.");
|
||||
throw new Error(
|
||||
"Rate limited by Granola (429). Will retry on next sync."
|
||||
);
|
||||
}
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`Granola API error ${resp.status} on ${path}`);
|
||||
|
|
@ -220,9 +221,12 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
created_after: createdAfter
|
||||
};
|
||||
if (cursor) params.cursor = cursor;
|
||||
const data = await this.apiGet("/notes", params);
|
||||
const data = await this.apiGet(
|
||||
"/notes",
|
||||
params
|
||||
);
|
||||
notes.push(...data.notes ?? []);
|
||||
if (!data.hasMore) return notes;
|
||||
if (!data.hasMore || !data.cursor) return notes;
|
||||
cursor = data.cursor;
|
||||
}
|
||||
}
|
||||
|
|
@ -243,7 +247,8 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
if (!(folder instanceof TFolder)) return idx;
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile) || child.extension !== "md") continue;
|
||||
const gid = this.app.metadataCache.getFileCache(child)?.frontmatter?.granola_id;
|
||||
const fm = this.app.metadataCache.getFileCache(child)?.frontmatter;
|
||||
const gid = fm?.granola_id;
|
||||
if (typeof gid === "string" && gid) idx.set(gid, child);
|
||||
}
|
||||
return idx;
|
||||
|
|
@ -291,9 +296,10 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
let errors = 0;
|
||||
for (const stub of pending) {
|
||||
try {
|
||||
const note = await this.apiGet(`/notes/${stub.id}`, {
|
||||
include: "transcript"
|
||||
});
|
||||
const note = await this.apiGet(
|
||||
`/notes/${stub.id}`,
|
||||
{ include: "transcript" }
|
||||
);
|
||||
const uuid = note.web_url?.match(UUID_RE)?.[0] ?? note.id;
|
||||
let fname;
|
||||
const adopted = idx.get(uuid);
|
||||
|
|
@ -340,7 +346,7 @@ var GranolaApiSyncPlugin = class extends Plugin {
|
|||
await this.savePluginData();
|
||||
if (errors) {
|
||||
this.notice(
|
||||
`${synced} synced, ${errors} failed \u2014 see developer console.`,
|
||||
`${synced} synced, ${errors} failed. See developer console.`,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
|
|
@ -360,74 +366,98 @@ var GranolaSyncSettingTab = class extends PluginSettingTab {
|
|||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
new Setting(containerEl).setName("Granola API key").setDesc(
|
||||
"Create one in the Granola app under Settings \u2192 API \u2192 Create new key (requires a Granola Business plan). Stored locally in this vault."
|
||||
).addText((text) => {
|
||||
text.setPlaceholder("grn_...").setValue(this.plugin.settings.apiKey).onChange(async (v) => {
|
||||
this.plugin.settings.apiKey = v.trim();
|
||||
await this.plugin.savePluginData();
|
||||
});
|
||||
text.inputEl.type = "password";
|
||||
});
|
||||
new Setting(containerEl).setName("Notes folder").setDesc("Vault folder where meeting notes are created.").addText(
|
||||
(t) => t.setValue(this.plugin.settings.notesFolder).onChange(async (v) => {
|
||||
this.plugin.settings.notesFolder = normalizePath(v || "Granola");
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl).setName("Sync transcripts").setDesc("Also save the full meeting transcript as a linked note.").addToggle(
|
||||
(t) => t.setValue(this.plugin.settings.syncTranscripts).onChange(async (v) => {
|
||||
this.plugin.settings.syncTranscripts = v;
|
||||
await this.plugin.savePluginData();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
if (this.plugin.settings.syncTranscripts) {
|
||||
new Setting(containerEl).setName("Transcripts folder").addText(
|
||||
(t) => t.setValue(this.plugin.settings.transcriptsFolder).onChange(async (v) => {
|
||||
this.plugin.settings.transcriptsFolder = normalizePath(v || "Granola/transcripts");
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
getSettingDefinitions() {
|
||||
return [
|
||||
{
|
||||
name: "Granola API key",
|
||||
desc: "Create one in the Granola app under Settings \u2192 API \u2192 Create new key (requires a Granola Business plan). Stored locally in this vault.",
|
||||
aliases: ["token", "authentication"],
|
||||
control: { type: "text", key: "apiKey", placeholder: "grn_..." }
|
||||
},
|
||||
{
|
||||
name: "Notes folder",
|
||||
desc: "Vault folder where meeting notes are created.",
|
||||
control: { type: "text", key: "notesFolder" }
|
||||
},
|
||||
{
|
||||
name: "Sync transcripts",
|
||||
desc: "Also save the full meeting transcript as a linked note.",
|
||||
control: { type: "toggle", key: "syncTranscripts" }
|
||||
},
|
||||
{
|
||||
name: "Transcripts folder",
|
||||
visible: () => this.plugin.settings.syncTranscripts,
|
||||
control: { type: "text", key: "transcriptsFolder" }
|
||||
},
|
||||
{
|
||||
name: "Sync interval (minutes)",
|
||||
desc: "How often to check for new or updated meetings.",
|
||||
control: {
|
||||
type: "slider",
|
||||
key: "intervalMinutes",
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Days to look back",
|
||||
desc: "Meetings created within this window are kept in sync (including late summary updates).",
|
||||
control: {
|
||||
type: "slider",
|
||||
key: "daysBack",
|
||||
min: 1,
|
||||
max: 90,
|
||||
step: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Your email",
|
||||
desc: "Optional. Excludes you from attendee lists and labels your transcript lines with the name below.",
|
||||
control: {
|
||||
type: "text",
|
||||
key: "myEmail",
|
||||
placeholder: "you@company.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Your name in transcripts",
|
||||
control: { type: "text", key: "myLabel" }
|
||||
},
|
||||
{
|
||||
name: "Show sync notifications",
|
||||
desc: "Errors are always shown.",
|
||||
control: { type: "toggle", key: "showNotices" }
|
||||
}
|
||||
];
|
||||
}
|
||||
getControlValue(key) {
|
||||
return this.plugin.settings[key];
|
||||
}
|
||||
async setControlValue(key, value) {
|
||||
const s = this.plugin.settings;
|
||||
switch (key) {
|
||||
case "apiKey":
|
||||
case "myEmail":
|
||||
s[key] = String(value ?? "").trim();
|
||||
break;
|
||||
case "myLabel":
|
||||
s[key] = String(value ?? "").trim() || "Me";
|
||||
break;
|
||||
case "notesFolder":
|
||||
s[key] = normalizePath(String(value ?? "") || "Granola");
|
||||
break;
|
||||
case "transcriptsFolder":
|
||||
s[key] = normalizePath(
|
||||
String(value ?? "") || "Granola/transcripts"
|
||||
);
|
||||
break;
|
||||
default:
|
||||
s[key] = value;
|
||||
}
|
||||
new Setting(containerEl).setName("Sync interval (minutes)").setDesc("How often to check for new or updated meetings.").addSlider(
|
||||
(s) => s.setLimits(1, 60, 1).setValue(this.plugin.settings.intervalMinutes).setDynamicTooltip().onChange(async (v) => {
|
||||
this.plugin.settings.intervalMinutes = v;
|
||||
await this.plugin.savePluginData();
|
||||
this.plugin.scheduleInterval();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl).setName("Days to look back").setDesc(
|
||||
"Meetings created within this window are kept in sync (including late summary updates)."
|
||||
).addSlider(
|
||||
(s) => s.setLimits(1, 90, 1).setValue(this.plugin.settings.daysBack).setDynamicTooltip().onChange(async (v) => {
|
||||
this.plugin.settings.daysBack = v;
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl).setName("Your email").setDesc(
|
||||
"Optional. Excludes you from attendee lists and labels your transcript lines with the name below."
|
||||
).addText(
|
||||
(t) => t.setPlaceholder("you@company.com").setValue(this.plugin.settings.myEmail).onChange(async (v) => {
|
||||
this.plugin.settings.myEmail = v.trim();
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl).setName("Your name in transcripts").addText(
|
||||
(t) => t.setValue(this.plugin.settings.myLabel).onChange(async (v) => {
|
||||
this.plugin.settings.myLabel = v.trim() || "Me";
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
new Setting(containerEl).setName("Show sync notifications").setDesc("Errors are always shown.").addToggle(
|
||||
(t) => t.setValue(this.plugin.settings.showNotices).onChange(async (v) => {
|
||||
this.plugin.settings.showNotices = v;
|
||||
await this.plugin.savePluginData();
|
||||
})
|
||||
);
|
||||
await this.plugin.savePluginData();
|
||||
if (key === "intervalMinutes") this.plugin.scheduleInterval();
|
||||
if (key === "syncTranscripts") this.update();
|
||||
}
|
||||
};
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ granola_id: 874b7bfd-0aa2-43a4-94c2-247d0edba2ef
|
|||
title: Finn Grahe and Arshia Navabi
|
||||
type: note
|
||||
created: 2026-07-16T18:59:50.339Z
|
||||
updated: 2026-07-16T20:06:42.499Z
|
||||
updated: 2026-07-16T23:53:10.393Z
|
||||
attendees:
|
||||
- finn@resonumai.com
|
||||
transcript: '[[inbox/granola/transcripts/Finn Grahe and Arshia Navabi-transcript.md]]'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ granola_id: 874b7bfd-0aa2-43a4-94c2-247d0edba2ef
|
|||
title: Finn Grahe and Arshia Navabi
|
||||
type: note
|
||||
created: 2026-07-16T18:59:50.339Z
|
||||
updated: 2026-07-16T20:06:42.499Z
|
||||
updated: 2026-07-16T23:53:10.393Z
|
||||
attendees:
|
||||
- finn@resonumai.com
|
||||
transcript: '[[inbox/granola/transcripts/Finn Grahe and Arshia Navabi-transcript.md]]'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ granola_id: 874b7bfd-0aa2-43a4-94c2-247d0edba2ef
|
|||
title: Finn Grahe and Arshia Navabi - Transcript
|
||||
type: transcript
|
||||
created: 2026-07-16T18:59:50.339Z
|
||||
updated: 2026-07-16T20:06:42.499Z
|
||||
updated: 2026-07-16T23:53:10.393Z
|
||||
attendees:
|
||||
- finn@resonumai.com
|
||||
note: "[[inbox/granola/Finn Grahe and Arshia Navabi.md]]"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ granola_id: 874b7bfd-0aa2-43a4-94c2-247d0edba2ef
|
|||
title: Finn Grahe and Arshia Navabi - Transcript
|
||||
type: transcript
|
||||
created: 2026-07-16T18:59:50.339Z
|
||||
updated: 2026-07-16T20:06:42.499Z
|
||||
updated: 2026-07-16T23:53:10.393Z
|
||||
attendees:
|
||||
- finn@resonumai.com
|
||||
note: "[[inbox/granola/Finn Grahe and Arshia Navabi.md]]"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"1.0.0": "1.5.0",
|
||||
"1.0.1": "1.5.0"
|
||||
"1.0.1": "1.5.0",
|
||||
"1.0.2": "1.13.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue