diff --git a/CLAUDE.md b/CLAUDE.md
index fa4be45..7607f12 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,6 +93,17 @@
- Each provider entry includes `transcribe_model` and `summarize_model` — frontend passes these to all API calls
- Provider only appears if its API key is configured; widget hidden when fewer than 2 providers available
+## Obsidian Export
+- "Obsidian" button exports directly to Obsidian via `obsidian://new` URI + clipboard
+- First click prompts for vault name + optional subfolder, stored in `localStorage` keys `tm_obsidian_vault` and `tm_obsidian_subfolder`
+- `exportToObsidian(id)` replaces `copyObsidianMarkdown(id)` — copies markdown to clipboard, then opens `obsidian://new?vault=...&file=...&clipboard&overwrite`
+- `buildObsidianMarkdown(id)` builds the frontmatter + body (reuses `formatObsidianDate`, `escapeYamlString`, `getFullRecord`)
+- `slugify(title)` generates the note filename (lowercase, hyphens, max 80 chars)
+- `showObsidianConfig(id)` / `addObsidianConfigUI(id, card)` — horizontal path bar (`VaultName / folder/path [Connect]`) with one-time setup hint
+- `clearObsidianConfig()` removes localStorage keys; "Reset vault" link appears in card actions when vault is configured
+- Fallback: if Obsidian isn't installed, markdown is still on clipboard for manual paste
+- Toast: "Sent to Obsidian"
+
## Duration Limit
- "First N min" toggle + input in the frontend, sends `duration_limit` in minutes
- API converts minutes → seconds (`* 60`) before storage and passing to `prepare_chunks()`
diff --git a/README.md b/README.md
index efa801d..e4f43c6 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@
- **History** with status tracking — persists across page refreshes and server restarts
- **Show in Finder** — reveal any saved transcript file on disk
- **Copy / Download** — tab-aware copy (transcript or summary) to clipboard, or save as `.txt`
+- **Obsidian export** — export directly to Obsidian vault via URI scheme (vault config remembered in localStorage)
- **Markdown-based storage** — each transcript is a `.md` file, no database
- **Playlist rejection** — only single video URLs accepted
diff --git a/app/api.py b/app/api.py
index 28a19cd..b656941 100644
--- a/app/api.py
+++ b/app/api.py
@@ -234,8 +234,9 @@ async def demo_summarize(record_id: str, req: SummarizeRequest):
return JSONResponse({"error": "Record is not completed"}, status_code=400)
await asyncio.sleep(2)
prompt = req.prompt.strip()
- summary_with_title = f"{record['title']}\n\n{DEMO_SUMMARY}"
- save_summary(record_id, summary_with_title, prompt)
+ summary_with_title = f"# {record['title']}\n\n{DEMO_SUMMARY}"
+ if not save_summary(record_id, summary_with_title, prompt):
+ return JSONResponse({"error": "Failed to save summary"}, status_code=500)
return {"summary": summary_with_title, "prompt": prompt}
@@ -506,8 +507,9 @@ async def summarize(record_id: str, req: SummarizeRequest):
logger.error("Summarize error for %s: %s", record_id, e, exc_info=True)
return JSONResponse({"error": f"Summarization failed: {e}"}, status_code=500)
prompt = req.prompt.strip()
- summary_with_title = f"{record['title']}\n\n{summary}"
- save_summary(record_id, summary_with_title, prompt)
+ summary_with_title = f"# {record['title']}\n\n{summary}"
+ if not save_summary(record_id, summary_with_title, prompt):
+ return JSONResponse({"error": "Failed to save summary"}, status_code=500)
return {"summary": summary_with_title, "prompt": prompt}
diff --git a/app/main.py b/app/main.py
index eec5d63..0b21958 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,7 +1,7 @@
from pathlib import Path
from fastapi import FastAPI
-from fastapi.responses import FileResponse
+from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.api import router
@@ -18,7 +18,13 @@ def create_app() -> FastAPI:
@app.get("/")
async def index():
- return FileResponse(STATIC_DIR / "index.html")
+ html = (STATIC_DIR / "index.html").read_text()
+ for name in ("app.js", "style.css"):
+ path = STATIC_DIR / name
+ if path.exists():
+ stamp = int(path.stat().st_mtime)
+ html = html.replace(f"/static/{name}", f"/static/{name}?v={stamp}")
+ return HTMLResponse(html)
return app
diff --git a/app/static/app.js b/app/static/app.js
index 654dcb6..35dcad3 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -43,6 +43,7 @@ const ICONS = {
copy: '',
download: '',
sparkle: '🦄',
+ obsidian: '',
};
// ─── Helpers ───
@@ -65,6 +66,36 @@ function escapeHtml(str) {
return div.innerHTML;
}
+marked.setOptions({ breaks: true });
+function renderMarkdown(text) {
+ return marked.parse(text);
+}
+
+const speakerColors = ["#f8bbd0", "#64b5f6", "#c6d84a", "#ffb74d", "#ce93d8", "#4dd0e1", "#ff8a65", "#aed581"];
+function renderTranscript(text) {
+ return escapeHtml(text).replace(
+ /^([A-Z]):(\s)/gm,
+ (_, letter, sp) => {
+ const idx = letter.charCodeAt(0) - 65;
+ const color = speakerColors[idx % speakerColors.length];
+ return `${letter}:${sp}`;
+ }
+ );
+}
+
+function formatObsidianDate(createdAt) {
+ if (!createdAt) return new Date().toISOString().slice(0, 10);
+ return new Date(createdAt + "Z").toISOString().slice(0, 10);
+}
+
+function escapeYamlString(str) {
+ if (!str) return '""';
+ if (/[:#"'|>\[\]{},%&!@`]/.test(str) || str.trim() !== str) {
+ return '"' + str.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
+ }
+ return str;
+}
+
function formatDuration(seconds) {
if (!seconds) return "";
const h = Math.floor(seconds / 3600);
@@ -255,6 +286,7 @@ function renderCard(record, opts = {}) {
quickSummarize = ``;
quickCopy = ``;
actions = `
+
@@ -270,7 +302,7 @@ function renderCard(record, opts = {}) {
let bodyHtml = "";
if (expanded && opts.bodyText) {
- bodyHtml = `
${escapeHtml(opts.bodyText)}
`;
+ bodyHtml = `${renderTranscript(opts.bodyText)}
`;
}
const hasSummary = record.has_summary ? "true" : "false";
@@ -335,18 +367,159 @@ async function copyRecordText(id) {
}
async function downloadRecordText(id, title) {
- const text = await getRecordBody(id);
- if (!text) return;
+ const card = document.querySelector(`.history-card[data-id="${id}"]`);
+ const activeTab = card?.querySelector(".card-tab.active");
+ const isSummary = activeTab?.dataset.tab === "summary";
+
+ let text;
+ let suffix;
+ if (isSummary) {
+ const data = await getRecordSummary(id);
+ if (!data?.summary) return;
+ text = data.summary;
+ suffix = "_summary";
+ } else {
+ text = await getRecordBody(id);
+ if (!text) return;
+ suffix = "";
+ }
const blob = new Blob([text], { type: "text/plain" });
const a = document.createElement("a");
const objectUrl = URL.createObjectURL(blob);
a.href = objectUrl;
const filename = (title || "transcript").replace(/[^a-zA-Z0-9_\- ]/g, "").trim() || "transcript";
- a.download = `${filename}.txt`;
+ a.download = `${filename}${suffix}.txt`;
a.click();
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000);
}
+async function buildObsidianMarkdown(id) {
+ const card = document.querySelector(`.history-card[data-id="${id}"]`);
+ const activeTab = card?.querySelector(".card-tab.active");
+ const isSummary = activeTab?.dataset.tab === "summary";
+
+ const record = await getFullRecord(id);
+ if (!record) return null;
+
+ const lines = ["---"];
+ lines.push(`title: ${escapeYamlString(record.title || "")}`);
+ lines.push(`url: ${record.url || ""}`);
+ lines.push(`date: ${formatObsidianDate(record.created_at)}`);
+
+ let body;
+ if (isSummary) {
+ const data = await getRecordSummary(id);
+ if (!data?.summary) return null;
+ body = data.summary;
+ lines.push("type: summary");
+ lines.push(`model: ${record.model || ""}`);
+ if (record.duration) lines.push(`duration: ${formatDuration(record.duration)}`);
+ if (record.words) lines.push(`words: ${record.words}`);
+ lines.push(`prompt: ${escapeYamlString(data.prompt || "")}`);
+ } else {
+ body = record.body || await getRecordBody(id);
+ if (!body) return null;
+ lines.push("type: transcript");
+ lines.push(`model: ${record.model || ""}`);
+ if (record.duration) lines.push(`duration: ${formatDuration(record.duration)}`);
+ if (record.words) lines.push(`words: ${record.words}`);
+ }
+
+ lines.push("tags:");
+ lines.push(" - transcript-maker");
+ lines.push("---");
+ lines.push("");
+ lines.push(body);
+
+ return { markdown: lines.join("\n"), title: record.title, isSummary };
+}
+
+async function exportToObsidian(id) {
+ const vault = localStorage.getItem("tm_obsidian_vault");
+ if (!vault) {
+ showObsidianConfig(id);
+ return;
+ }
+
+ const result = await buildObsidianMarkdown(id);
+ if (!result) return;
+
+ await navigator.clipboard.writeText(result.markdown);
+
+ const subfolder = localStorage.getItem("tm_obsidian_subfolder") || "";
+ const filename = (result.title || "Untitled").replace(/[\\/:*?"<>|]/g, "-").trim();
+ const filePath = subfolder ? `${subfolder}/${filename}` : filename;
+
+ const uri = `obsidian://new?vault=${encodeURIComponent(vault)}&file=${encodeURIComponent(filePath)}&clipboard&overwrite`;
+ window.location.href = uri;
+
+ showToast("Sent to Obsidian");
+}
+
+function showObsidianConfig(id) {
+ // If already open, close it
+ const existing = document.querySelector(".obsidian-modal");
+ if (existing) {
+ existing.remove();
+ return;
+ }
+
+ const savedVault = localStorage.getItem("tm_obsidian_vault") || "";
+ const savedSubfolder = localStorage.getItem("tm_obsidian_subfolder") || "";
+
+ const modal = document.createElement("div");
+ modal.className = "obsidian-modal";
+ modal.innerHTML = `
+
+
+ `;
+
+ modal.querySelector(".obsidian-modal-backdrop").addEventListener("click", () => modal.remove());
+
+ document.body.appendChild(modal);
+ modal.querySelector(".obsidian-vault-input").focus();
+}
+
+function saveObsidianConfig(id) {
+ const modal = document.querySelector(".obsidian-modal");
+ if (!modal) return;
+
+ const vaultInput = modal.querySelector(".obsidian-vault-input");
+ const subfolderInput = modal.querySelector(".obsidian-subfolder-input");
+ const vault = vaultInput?.value.trim();
+ const subfolder = subfolderInput?.value.trim();
+
+ if (!vault) {
+ vaultInput.focus();
+ return;
+ }
+
+ localStorage.setItem("tm_obsidian_vault", vault);
+ if (subfolder) {
+ localStorage.setItem("tm_obsidian_subfolder", subfolder);
+ } else {
+ localStorage.removeItem("tm_obsidian_subfolder");
+ }
+
+ modal.remove();
+ exportToObsidian(id);
+}
+
+function clearObsidianConfig() {
+ localStorage.removeItem("tm_obsidian_vault");
+ localStorage.removeItem("tm_obsidian_subfolder");
+ showToast("Obsidian vault config cleared");
+}
+
async function getRecordBody(id) {
if (bodyCache.has(id)) return bodyCache.get(id);
const res = await fetch(`/api/history/${id}`);
@@ -357,6 +530,12 @@ async function getRecordBody(id) {
return body;
}
+async function getFullRecord(id) {
+ const res = await fetch(`/api/history/${id}`);
+ if (!res.ok) return null;
+ return await res.json();
+}
+
// ─── Transcription ───
async function runSSE(fetchUrl, fetchBody) {
@@ -605,7 +784,7 @@ async function toggleCardBody(id, cardEl) {
const bodyDiv = document.createElement("div");
bodyDiv.className = "card-body";
bodyDiv.style.display = "none";
- bodyDiv.textContent = body;
+ bodyDiv.innerHTML = renderTranscript(body);
bodyDiv.addEventListener("click", (e) => e.stopPropagation());
cardEl.appendChild(bodyDiv);
@@ -614,7 +793,7 @@ async function toggleCardBody(id, cardEl) {
} else {
const div = document.createElement("div");
div.className = "card-body";
- div.textContent = body;
+ div.innerHTML = renderTranscript(body);
div.addEventListener("click", (e) => e.stopPropagation());
cardEl.appendChild(div);
}
@@ -653,7 +832,7 @@ function buildSummaryDiv(summaryText) {
const div = document.createElement("div");
div.className = "card-summary";
div.addEventListener("click", (e) => e.stopPropagation());
- div.innerHTML = `${escapeHtml(summaryText)}
`;
+ div.innerHTML = `${renderMarkdown(summaryText)}
`;
return div;
}
@@ -695,7 +874,7 @@ function addPromptUI(id, card) {
prompt.addEventListener("click", (e) => e.stopPropagation());
prompt.innerHTML = `
-
+
+