mirror of
https://github.com/arshiaecho/obsidian-granola-api-sync.git
synced 2026-07-22 08:31:27 +00:00
- 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>
470 lines
14 KiB
JavaScript
470 lines
14 KiB
JavaScript
// test/obsidian-shim.mjs
|
|
var Plugin = class {
|
|
};
|
|
var PluginSettingTab = class {
|
|
};
|
|
var Notice = class {
|
|
};
|
|
var TFile = class {
|
|
};
|
|
var TFolder = class {
|
|
};
|
|
var normalizePath = (p) => p;
|
|
var requestUrl = () => {
|
|
};
|
|
|
|
// src/main.ts
|
|
var API_BASE = "https://public-api.granola.ai/v1";
|
|
var UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/;
|
|
var DEFAULT_SETTINGS = {
|
|
apiKey: "",
|
|
notesFolder: "Granola",
|
|
syncTranscripts: true,
|
|
transcriptsFolder: "Granola/transcripts",
|
|
intervalMinutes: 5,
|
|
daysBack: 7,
|
|
myEmail: "",
|
|
myLabel: "Me",
|
|
showNotices: true
|
|
};
|
|
function sanitizeFilename(title) {
|
|
let name = title.replace(/\//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";
|
|
}
|
|
function yamlTitle(title) {
|
|
if (/[:#[\]{}"']|^[-?&*!|>%@`]/.test(title)) {
|
|
return '"' + title.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
}
|
|
return title;
|
|
}
|
|
function guestLabel(attendees, myEmail) {
|
|
const others = (attendees ?? []).filter(
|
|
(a) => (a.email ?? "").toLowerCase() !== myEmail.toLowerCase()
|
|
);
|
|
if (others.length === 1) {
|
|
return others[0].name || others[0].email || "Guest";
|
|
}
|
|
return "Guest";
|
|
}
|
|
function formatTranscriptBody(segments, guest, myLabel) {
|
|
const blocks = [];
|
|
let curSrc = null;
|
|
let curTs = "";
|
|
let curTexts = [];
|
|
const flush = () => {
|
|
if (curTexts.length) {
|
|
const name = curSrc === "microphone" ? myLabel : guest;
|
|
blocks.push(`### ${name} (${curTs})
|
|
|
|
${curTexts.join(" ")}`);
|
|
}
|
|
};
|
|
for (const seg of segments) {
|
|
const src = seg.speaker?.source ?? "speaker";
|
|
const text = (seg.text ?? "").trim();
|
|
if (!text) continue;
|
|
if (src !== curSrc) {
|
|
flush();
|
|
curSrc = src;
|
|
curTs = seg.start_time ?? "";
|
|
curTexts = [text];
|
|
} else {
|
|
curTexts.push(text);
|
|
}
|
|
}
|
|
flush();
|
|
return blocks.join("\n\n");
|
|
}
|
|
function otherEmails(attendees, myEmail) {
|
|
return (attendees ?? []).map((a) => a.email ?? "").filter((e) => e && e.toLowerCase() !== myEmail.toLowerCase());
|
|
}
|
|
function buildNoteMd(note, uuid, fname, settings) {
|
|
const emails = otherEmails(note.attendees, settings.myEmail);
|
|
const att = emails.length ? "\n" + emails.map((e) => `- ${e}`).join("\n") : " []";
|
|
const lines = [
|
|
"---",
|
|
`granola_id: ${uuid}`,
|
|
`title: ${yamlTitle(note.title)}`,
|
|
"type: note",
|
|
`created: ${note.created_at}`,
|
|
`updated: ${note.updated_at}`,
|
|
`attendees:${att}`
|
|
];
|
|
if (settings.syncTranscripts && note.transcript?.length) {
|
|
lines.push(
|
|
`transcript: '[[${settings.transcriptsFolder}/${fname}-transcript.md]]'`
|
|
);
|
|
}
|
|
lines.push(
|
|
`granola_url: ${note.web_url ?? ""}`,
|
|
"source: granola-api-sync",
|
|
"---",
|
|
"",
|
|
note.summary_markdown || note.summary_text || "_No summary available yet._",
|
|
""
|
|
);
|
|
return lines.join("\n");
|
|
}
|
|
function buildTranscriptMd(note, uuid, fname, settings) {
|
|
const emails = otherEmails(note.attendees, settings.myEmail);
|
|
const att = emails.length ? "\n" + emails.map((e) => ` - ${e}`).join("\n") : " []";
|
|
const body = formatTranscriptBody(
|
|
note.transcript ?? [],
|
|
guestLabel(note.attendees, settings.myEmail),
|
|
settings.myLabel
|
|
);
|
|
return [
|
|
"---",
|
|
`granola_id: ${uuid}`,
|
|
`title: ${yamlTitle(note.title + " - Transcript")}`,
|
|
"type: transcript",
|
|
`created: ${note.created_at}`,
|
|
`updated: ${note.updated_at}`,
|
|
`attendees:${att}`,
|
|
`note: "[[${settings.notesFolder}/${fname}.md]]"`,
|
|
"source: granola-api-sync",
|
|
"---",
|
|
"",
|
|
`# Transcript for: ${note.title}`,
|
|
"",
|
|
body,
|
|
""
|
|
].join("\n");
|
|
}
|
|
var GranolaApiSyncPlugin = class extends Plugin {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.settings = { ...DEFAULT_SETTINGS };
|
|
this.state = {};
|
|
this.syncing = false;
|
|
this.intervalId = null;
|
|
this.lastSyncAt = 0;
|
|
}
|
|
async onload() {
|
|
await this.loadPluginData();
|
|
this.addSettingTab(new GranolaSyncSettingTab(this.app, this));
|
|
this.addCommand({
|
|
id: "sync-now",
|
|
name: "Sync now",
|
|
callback: () => void this.sync(true)
|
|
});
|
|
this.app.workspace.onLayoutReady(() => {
|
|
void this.sync(false);
|
|
this.scheduleInterval();
|
|
});
|
|
this.registerDomEvent(window, "focus", () => {
|
|
if (Date.now() - this.lastSyncAt > 6e4) void this.sync(false);
|
|
});
|
|
}
|
|
scheduleInterval() {
|
|
if (this.intervalId !== null) {
|
|
window.clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
const minutes = Math.max(1, this.settings.intervalMinutes);
|
|
this.intervalId = window.setInterval(
|
|
() => void this.sync(false),
|
|
minutes * 60 * 1e3
|
|
);
|
|
this.registerInterval(this.intervalId);
|
|
}
|
|
async loadPluginData() {
|
|
const data = await this.loadData();
|
|
this.settings = { ...DEFAULT_SETTINGS, ...data?.settings ?? {} };
|
|
this.state = data?.state ?? {};
|
|
}
|
|
async savePluginData() {
|
|
await this.saveData({ settings: this.settings, state: this.state });
|
|
}
|
|
notice(msg, always = false) {
|
|
if (always || this.settings.showNotices) {
|
|
new Notice(`Granola sync: ${msg}`);
|
|
}
|
|
}
|
|
// ---------- API ----------
|
|
async apiGet(path, params = {}) {
|
|
const qs = new URLSearchParams(params).toString();
|
|
const resp = await requestUrl({
|
|
url: `${API_BASE}${path}${qs ? "?" + qs : ""}`,
|
|
headers: {
|
|
Authorization: `Bearer ${this.settings.apiKey.trim()}`,
|
|
Accept: "application/json"
|
|
},
|
|
throw: false
|
|
});
|
|
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."
|
|
);
|
|
}
|
|
if (resp.status === 429) {
|
|
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;
|
|
}
|
|
async listRecentNotes() {
|
|
const createdAfter = new Date(
|
|
Date.now() - this.settings.daysBack * 864e5
|
|
).toISOString();
|
|
const notes = [];
|
|
let cursor = null;
|
|
for (; ; ) {
|
|
const params = {
|
|
created_after: createdAfter
|
|
};
|
|
if (cursor) params.cursor = cursor;
|
|
const data = await this.apiGet(
|
|
"/notes",
|
|
params
|
|
);
|
|
notes.push(...data.notes ?? []);
|
|
if (!data.hasMore || !data.cursor) return notes;
|
|
cursor = data.cursor;
|
|
}
|
|
}
|
|
// ---------- vault ----------
|
|
async ensureFolder(path) {
|
|
const p = normalizePath(path);
|
|
if (!this.app.vault.getAbstractFileByPath(p)) {
|
|
await this.app.vault.createFolder(p).catch(() => {
|
|
});
|
|
}
|
|
}
|
|
/** granola_id -> note TFile, for adoption/dedupe. */
|
|
indexVault() {
|
|
const idx = /* @__PURE__ */ new Map();
|
|
const folder = this.app.vault.getAbstractFileByPath(
|
|
normalizePath(this.settings.notesFolder)
|
|
);
|
|
if (!(folder instanceof TFolder)) return idx;
|
|
for (const child of folder.children) {
|
|
if (!(child instanceof TFile) || child.extension !== "md") continue;
|
|
const fm = this.app.metadataCache.getFileCache(child)?.frontmatter;
|
|
const gid = fm?.granola_id;
|
|
if (typeof gid === "string" && gid) idx.set(gid, child);
|
|
}
|
|
return idx;
|
|
}
|
|
async writeFile(path, content) {
|
|
const p = normalizePath(path);
|
|
const existing = this.app.vault.getAbstractFileByPath(p);
|
|
if (existing instanceof TFile) {
|
|
if (await this.app.vault.read(existing) !== content) {
|
|
await this.app.vault.modify(existing, content);
|
|
}
|
|
} else {
|
|
await this.app.vault.create(p, content);
|
|
}
|
|
}
|
|
// ---------- sync ----------
|
|
async sync(manual) {
|
|
if (this.syncing) return;
|
|
if (!this.settings.apiKey.trim()) {
|
|
if (manual) {
|
|
this.notice(
|
|
"no API key set. Create one in Granola under Settings \u2192 API (Business plan), then paste it in the plugin settings.",
|
|
true
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
this.syncing = true;
|
|
this.lastSyncAt = Date.now();
|
|
try {
|
|
const notes = await this.listRecentNotes();
|
|
const pending = notes.filter(
|
|
(n) => this.state[n.id] !== n.updated_at
|
|
);
|
|
if (!pending.length) {
|
|
if (manual) this.notice("up to date.");
|
|
return;
|
|
}
|
|
await this.ensureFolder(this.settings.notesFolder);
|
|
if (this.settings.syncTranscripts) {
|
|
await this.ensureFolder(this.settings.transcriptsFolder);
|
|
}
|
|
const idx = this.indexVault();
|
|
let synced = 0;
|
|
let errors = 0;
|
|
for (const stub of pending) {
|
|
try {
|
|
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);
|
|
if (adopted) {
|
|
fname = adopted.basename;
|
|
} else {
|
|
fname = sanitizeFilename(note.title);
|
|
const clash = this.app.vault.getAbstractFileByPath(
|
|
normalizePath(
|
|
`${this.settings.notesFolder}/${fname}.md`
|
|
)
|
|
);
|
|
if (clash) {
|
|
const d = new Date(note.created_at);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
fname += `-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
|
}
|
|
}
|
|
const notePath = `${this.settings.notesFolder}/${fname}.md`;
|
|
await this.writeFile(
|
|
notePath,
|
|
buildNoteMd(note, uuid, fname, this.settings)
|
|
);
|
|
if (this.settings.syncTranscripts && note.transcript?.length) {
|
|
await this.writeFile(
|
|
`${this.settings.transcriptsFolder}/${fname}-transcript.md`,
|
|
buildTranscriptMd(note, uuid, fname, this.settings)
|
|
);
|
|
}
|
|
const written = this.app.vault.getAbstractFileByPath(
|
|
normalizePath(notePath)
|
|
);
|
|
if (written instanceof TFile) idx.set(uuid, written);
|
|
this.state[stub.id] = stub.updated_at;
|
|
synced++;
|
|
} catch (e) {
|
|
errors++;
|
|
console.error(
|
|
`[granola-api-sync] failed on "${stub.title}":`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
await this.savePluginData();
|
|
if (errors) {
|
|
this.notice(
|
|
`${synced} synced, ${errors} failed. See developer console.`,
|
|
true
|
|
);
|
|
} else {
|
|
this.notice(`${synced} note${synced === 1 ? "" : "s"} synced.`);
|
|
}
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[granola-api-sync]", e);
|
|
this.notice(msg, manual);
|
|
} finally {
|
|
this.syncing = false;
|
|
}
|
|
}
|
|
};
|
|
var GranolaSyncSettingTab = class extends PluginSettingTab {
|
|
constructor(app, plugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
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;
|
|
}
|
|
await this.plugin.savePluginData();
|
|
if (key === "intervalMinutes") this.plugin.scheduleInterval();
|
|
if (key === "syncTranscripts") this.update();
|
|
}
|
|
};
|
|
export {
|
|
buildNoteMd,
|
|
buildTranscriptMd,
|
|
GranolaApiSyncPlugin as default,
|
|
formatTranscriptBody,
|
|
sanitizeFilename,
|
|
yamlTitle
|
|
};
|