arshiaecho_obsidian-granola.../test/_bundle.mjs
Arshia Navabi cc1daf13a5
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>
2026-07-16 23:58:37 -07:00

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
};