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 = ` +
+
+
Enter your Obsidian vault name and an optional folder path. This is a one-time setup — your choice will be remembered for all future exports.
+
+ + / + + + +
+
+ `; + + 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 = `
- +
+ diff --git a/app/static/style.css b/app/static/style.css index 0f95d9f..0bbafa4 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -6,6 +6,11 @@ padding: 0; } +button, label, .card-actions, .card-tabs, .card-meta, .card-top, .history-header, .hero, .provider-selector { + -webkit-user-select: none; + user-select: none; +} + :root { /* Backgrounds */ --bg: #131318; @@ -766,6 +771,136 @@ button:active:not(:disabled) { color: var(--red); } +/* ─── Obsidian config modal ─── */ + +.obsidian-modal { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: obsidian-fade-in 200ms ease; +} + +.obsidian-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.obsidian-modal-content { + position: relative; + width: 100%; + max-width: 540px; + margin: 0 20px; + background: var(--surface); + border: 1px solid var(--border-hover); + border-radius: var(--radius-lg); + padding: 20px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + animation: obsidian-slide-up 300ms var(--ease-out-expo); +} + +.obsidian-config-hint { + font-size: 0.8rem; + line-height: 1.5; + color: var(--text-muted); + margin-bottom: 14px; +} + +.obsidian-path-bar { + display: flex; + align-items: center; + gap: 0; + background: var(--surface-hover); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 4px 4px 12px; + transition: border-color 300ms ease, box-shadow 300ms ease; +} + +.obsidian-path-bar:focus-within { + border-color: rgba(108, 142, 239, 0.4); + box-shadow: 0 0 0 3px rgba(108, 142, 239, 0.1); +} + +.obsidian-vault-input, +.obsidian-subfolder-input { + font-family: var(--font); + font-size: 0.85rem; + background: transparent; + color: var(--text); + border: none; + outline: none; + padding: 8px 0; + min-width: 0; +} + +.obsidian-vault-input { + width: 110px; + flex-shrink: 0; +} + +.obsidian-subfolder-input { + flex: 1; + min-width: 80px; +} + +.obsidian-vault-input::placeholder, +.obsidian-subfolder-input::placeholder { + color: var(--text-dim); +} + +.obsidian-path-sep { + color: var(--text-dim); + font-size: 0.9rem; + padding: 0 6px; + flex-shrink: 0; +} + +.obsidian-connect-btn { + padding: 7px 14px; + font-size: 0.78rem; + font-weight: 600; + background: var(--blue); + color: #fff; + border: 1px solid var(--blue); + border-radius: 8px; + flex-shrink: 0; + margin-left: 4px; +} + +.obsidian-connect-btn:hover { + background: #5a7de0; +} + +.obsidian-cancel-btn { + padding: 7px 10px; + font-size: 0.78rem; + font-weight: 500; + background: transparent; + color: var(--text-dim); + border: none; + border-radius: 8px; + flex-shrink: 0; +} + +.obsidian-cancel-btn:hover { + color: var(--text-muted); +} + +@keyframes obsidian-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes obsidian-slide-up { + from { opacity: 0; transform: translateY(12px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + /* Quick summarize — header-level star button next to copy */ .history-card .quick-summarize { @@ -816,6 +951,10 @@ button:active:not(:disabled) { border-color: var(--border-hover); } +.card-body, .card-summary, .card-tabs { + cursor: default; +} + .card-body { width: 100%; padding: 12px 16px; @@ -862,6 +1001,7 @@ button:active:not(:disabled) { .card-tab { padding: 8px 8px; margin-bottom: -1px; + cursor: pointer; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em; @@ -871,7 +1011,6 @@ button:active:not(:disabled) { border: none; border-bottom: 2px solid transparent; border-radius: 0; - cursor: pointer; transition: color 200ms ease, border-color 200ms ease; } @@ -979,6 +1118,12 @@ button:active:not(:disabled) { border-color: var(--border-hover); } +/* ─── Speaker labels ─── */ + +.speaker-label { + font-weight: 600; +} + /* ─── Card summary ─── */ .card-summary { @@ -1010,7 +1155,77 @@ button:active:not(:disabled) { font-size: 0.85rem; line-height: 1.7; color: var(--text-muted); - white-space: pre-wrap; +} + +.card-summary-text p { margin: 0 0 0.6em; } +.card-summary-text p:last-child { margin-bottom: 0; } +.card-summary-text h1, .card-summary-text h2, .card-summary-text h3, +.card-summary-text h4, .card-summary-text h5, .card-summary-text h6 { + color: var(--text); + margin: 0.8em 0 0.4em; + line-height: 1.3; +} +.card-summary-text h1:first-child, .card-summary-text h2:first-child, +.card-summary-text h3:first-child { margin-top: 0; } +.card-summary-text h1 { font-size: 1.1rem; } +.card-summary-text h2 { font-size: 1rem; } +.card-summary-text h3 { font-size: 0.92rem; } +.card-summary-text h4, .card-summary-text h5, .card-summary-text h6 { font-size: 0.85rem; } +.card-summary-text ul, .card-summary-text ol { + margin: 0.4em 0; + padding-left: 1.5em; +} +.card-summary-text li { margin: 0.2em 0; } +.card-summary-text code { + font-family: "SF Mono", Monaco, Consolas, monospace; + font-size: 0.8rem; + background: rgba(255, 255, 255, 0.06); + padding: 0.15em 0.35em; + border-radius: 4px; +} +.card-summary-text pre { + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.7em 1em; + overflow-x: auto; + margin: 0.5em 0; +} +.card-summary-text pre code { + background: none; + padding: 0; +} +.card-summary-text blockquote { + border-left: 3px solid var(--border); + margin: 0.5em 0; + padding: 0.3em 0.8em; + color: var(--text-muted); +} +.card-summary-text a { + color: var(--blue); + text-decoration: none; +} +.card-summary-text a:hover { text-decoration: underline; } +.card-summary-text strong { color: var(--text); } +.card-summary-text table { + border-collapse: collapse; + margin: 0.5em 0; + width: 100%; + font-size: 0.8rem; +} +.card-summary-text th, .card-summary-text td { + border: 1px solid var(--border); + padding: 0.35em 0.6em; + text-align: left; +} +.card-summary-text th { + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} +.card-summary-text hr { + border: none; + border-top: 1px solid var(--border); + margin: 0.8em 0; } /* ─── Toast ─── */ diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index fe28545..47dde7c 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, AsyncMock import pytest from fastapi.testclient import TestClient @@ -130,61 +130,68 @@ def test_invalid_id(self, client): res = client.post("/api/history/ZZZZZZZZ/summarize", json={"prompt": ""}) assert res.status_code == 400 - def test_not_done_record(self, client, tmp_path, monkeypatch): - results_dir = tmp_path / "results" - results_dir.mkdir(exist_ok=True) - monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + def test_not_done_record(self, client): rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) res = client.post(f"/api/history/{rid}/summarize", json={"prompt": ""}) assert res.status_code == 400 - def test_successful_summarize(self, client, tmp_path, monkeypatch): - results_dir = tmp_path / "results" - results_dir.mkdir(exist_ok=True) - monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + def test_successful_summarize(self, client): rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) complete_record(rid, "This is a test transcript with multiple words.") - with patch("app.api.summarize_text", return_value="Mocked summary"): + with patch("app.api.summarize_text", new_callable=AsyncMock, return_value="Mocked summary") as mock_fn: res = client.post(f"/api/history/{rid}/summarize", json={"prompt": "Custom"}) assert res.status_code == 200 data = res.json() - assert data["summary"] == "Test\n\nMocked summary" + assert data["summary"] == "# Test\n\nMocked summary" + assert data["prompt"] == "Custom" + mock_fn.assert_called_once_with( + "This is a test transcript with multiple words.", "Custom", model="" + ) # Verify it was saved saved = get_summary(rid) assert saved is not None - assert saved["summary"] == "Test\n\nMocked summary" + assert saved["summary"] == "# Test\n\nMocked summary" - def test_get_summary(self, client, tmp_path, monkeypatch): - results_dir = tmp_path / "results" - results_dir.mkdir(exist_ok=True) - monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + def test_get_summary(self, client): rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) complete_record(rid, "Transcript text") - with patch("app.api.summarize_text", return_value="The summary"): + with patch("app.api.summarize_text", new_callable=AsyncMock, return_value="The summary"): client.post(f"/api/history/{rid}/summarize", json={"prompt": ""}) res = client.get(f"/api/history/{rid}/summary") assert res.status_code == 200 - assert res.json()["summary"] == "Test\n\nThe summary" + assert res.json()["summary"] == "# Test\n\nThe summary" - def test_get_summary_404(self, client, tmp_path, monkeypatch): - results_dir = tmp_path / "results" - results_dir.mkdir(exist_ok=True) - monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + def test_get_summary_404(self, client): rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) complete_record(rid, "Text") res = client.get(f"/api/history/{rid}/summary") assert res.status_code == 404 - def test_demo_summarize(self, client, tmp_path, monkeypatch): - results_dir = tmp_path / "results" - results_dir.mkdir(exist_ok=True) - monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + def test_demo_summarize(self, client): rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) complete_record(rid, "Transcript text") - res = client.post(f"/api/demo/history/{rid}/summarize", json={"prompt": ""}) + res = client.post(f"/api/demo/history/{rid}/summarize", json={"prompt": "Custom"}) assert res.status_code == 200 data = res.json() + assert data["summary"].startswith("# Test") assert "Key Points" in data["summary"] + assert "prompt" in data + # Verify summary was saved + saved = get_summary(rid) + assert saved is not None + + def test_delete_cascades_to_summary(self, client): + rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Transcript text for summary test.") + with patch("app.api.summarize_text", new_callable=AsyncMock, return_value="Summary text"): + res = client.post(f"/api/history/{rid}/summarize", json={"prompt": ""}) + assert res.status_code == 200 + # Summary exists + assert client.get(f"/api/history/{rid}/summary").status_code == 200 + # Delete record + assert client.delete(f"/api/history/{rid}").status_code == 200 + # Summary is gone too + assert client.get(f"/api/history/{rid}/summary").status_code == 404 class TestModelStorage: