| | #!/usr/bin/env node | | | // plaud-backup.mjs — back up every Plaud recording (audio, transcripts, summaries) | | | // from one or more Plaud accounts, using Plaud's official CLI (@plaud-ai/cli). | | | // | | | // Usage (PowerShell): | | | // node plaud-backup.mjs -> prompts for an account label, logs in, backs up |
| | // node plaud-backup.mjs --out "D:\PlaudBackup" |
| | // node plaud-backup.mjs --account work -> skip the prompt |
| | // node plaud-backup.mjs --account work --yes -> also skip the "is this the right account?" check |
| | // node plaud-backup.mjs --no-audio -> skip audio files |
| | // node plaud-backup.mjs --no-images -> skip photos from highlights/summaries |
| | // | | | // Each account gets its own login profile under <out>_profiles<label>, so you can | | | // back up several accounts without logging out/in. Re-running resumes: recordings | | | // already finished are skipped (but photos missing from older runs are fetched). | | | | | | import { spawnSync, execSync } from "node:child_process"; | | | import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, createWriteStream, unlinkSync, readdirSync } from "node:fs"; |
| | import { join, extname } from "node:path"; |
| | import { homedir } from "node:os"; |
| | import { createInterface } from "node:readline/promises"; |
| | import { Readable } from "node:stream"; |
| | import { pipeline } from "node:stream/promises"; |
| | |
| | // ---------- args ---------- |
| | const args = process.argv.slice(2); |
| | const argVal = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; |
| | const OUT_ROOT = argVal("--out") ?? join(homedir(), "Documents", "PlaudBackup"); |
| | let ACCOUNT = argVal("--account"); |
| | const SKIP_AUDIO = args.includes("--no-audio"); |
| | const AUTO_YES = args.includes("--yes"); |
| | const SKIP_IMAGES = args.includes("--no-images"); |
| | |
| | // ---------- locate the official CLI ---------- |
| | function findCli() { |
| | if (process.env.PLAUD_CLI_PATH) return process.env.PLAUD_CLI_PATH; | | | let root; |
| | try { root = execSync("npm root -g", { encoding: "utf8" }).trim(); } catch { root = ""; } |
| | const p = join(root, "@plaud-ai", "cli", "dist", "index.js"); |
| | if (!existsSync(p)) { |
| | console.error("Plaud CLI not found. Install it first: npm install -g @plaud-ai/cli"); |
| | process.exit(1); |
| | } | | | return p; | | | } | | | const CLI = findCli(); | | | |
| | const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; |
| | const strip = (s) => (s ?? "").replace(ANSI, ""); |
| | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| | const safe = (s, max = 60) => |
| | (s \|\| "untitled").replace(/[<>:"/\\\|?*\x00-\x1f]/g, "_").replace(/\s+/g, " ").trim().replace(/[. ]+$/, "").slice(0, max) \|\| "untitled"; |
| | | | | // ---------- run CLI under a per-account home ---------- | | | let PROFILE_HOME; | | | function cliEnv() { | | | return { | | | ...process.env, | | | USERPROFILE: PROFILE_HOME, // Windows: os.homedir() -> tokens in <PROFILE_HOME>.plaud | | | HOME: PROFILE_HOME, // macOS/Linux | | | FORCE_COLOR: "0", | | | NO_COLOR: "1", | | | PLAUD_TELEMETRY_DISABLED: "1", | | | PLAUD_NO_UPDATE_NOTIFIER: "1", | | | }; | | | } |
| | function runCli(cliArgs, { inherit = false } = {}) { |
| | const r = spawnSync(process.execPath, [CLI, ...cliArgs], { |
| | env: cliEnv(), |
| | encoding: "utf8", | | | stdio: inherit ? "inherit" : "pipe", | | | maxBuffer: 256 * 1024 * 1024, |
| | }); |
| | return { code: r.status ?? 1, out: strip(r.stdout), err: strip(r.stderr) }; |
| | } |
| | // Retry on network (3) / timeout (4) / generic (1); stop hard on auth (2). |
| | async function cli(cliArgs, tries = 4) { |
| | let last; |
| | for (let i = 0; i < tries; i++) { |
| | last = runCli(cliArgs); |
| | if (last.code === 0) return last; |
| | if (last.code === 2) throw new Error("AUTH: login expired or invalid — re-run the script to log in again."); |
| | await sleep(2000 * (i + 1)); |
| | } | | | return last; | | | } | | | |
| | // ---------- parsing ---------- |
| | function parseFilesPage(out) { |
| | const ids = []; |
| | let inTable = false; |
| | for (const line of out.split(/\r?\n/)) { |
| | if (line.includes("\u2500\u2500\u2500")) { inTable = true; continue; } |
| | if (!inTable) continue; |
| | if (/^\s*Page \d+/.test(line)) break; |
| | const m = line.match(/^\s{2}(\S+)\s{2,}/); |
| | if (m) ids.push(m[1]); |
| | } |
| | const countMatch = out.match(/Files on this page:\s*(\d+)/); |
| | return { ids, count: countMatch ? Number(countMatch[1]) : ids.length }; |
| | } |
| | function parseDetails(out) { |
| | const get = (k) => { const m = out.match(new RegExp(`^\\s*${k}:\\s*(.*)$`, "m")); return m ? m[1].trim() : ""; }; |
| | return { name: get("name"), created_at: get("created_at"), start_at: get("start_at"), duration: get("duration") }; |
| | } |
| | function stamp(iso) { |
| | const d = new Date(iso); |
| | if (isNaN(d)) return "unknown-date"; |
| | const p = (n) => String(n).padStart(2, "0"); |
| | return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`; |
| | } |
| | function audioExt(url, contentType) { |
| | const fromUrl = extname(new URL(url).pathname).toLowerCase(); |
| | if (fromUrl && fromUrl.length <= 6) return fromUrl; |
| | const ct = (contentType \|\| "").toLowerCase(); |
| | if (ct.includes("mpeg")) return ".mp3"; |
| | if (ct.includes("ogg") \|\| ct.includes("opus")) return ".opus"; |
| | if (ct.includes("wav")) return ".wav"; |
| | if (ct.includes("mp4") \|\| ct.includes("m4a") \|\| ct.includes("aac")) return ".m4a"; |
| | return ".audio"; | | | } | | | |
| | // ---------- images (highlight photos + images embedded in summaries) ---------- |
| | function imageExt(url, contentType) { |
| | let fromUrl = ""; |
| | try { fromUrl = extname(new URL(url).pathname).toLowerCase(); } catch {} |
| | if (/^\.(png\|jpe?g\|webp\|gif\|heic\|bmp)$/.test(fromUrl)) return fromUrl; |
| | const ct = (contentType \|\| "").toLowerCase(); |
| | if (ct.includes("png")) return ".png"; |
| | if (ct.includes("jpeg") \|\| ct.includes("jpg")) return ".jpg"; |
| | if (ct.includes("webp")) return ".webp"; |
| | if (ct.includes("gif")) return ".gif"; |
| | if (ct.includes("heic")) return ".heic"; |
| | return ".img"; | | | } |
| | function mmss(ms) { |
| | const s = Math.floor((Number(ms) \|\| 0) / 1000); |
| | return `${String(Math.floor(s / 60)).padStart(2, "0")}m${String(s % 60).padStart(2, "0")}s`; |
| | } |
| | function findImageLinks(dir) { |
| | const links = []; |
| | const hl = join(dir, "highlights.txt"); |
| | if (existsSync(hl)) { |
| | try { |
| | const items = JSON.parse(readFileSync(hl, "utf8")); |
| | if (Array.isArray(items)) items.forEach((it, i) => { |
| | const url = it?.picture_full_url || it?.picture_link; |
| | if (typeof url === "string" && /^https?:\/\//.test(url)) |
| | links.push({ url, base: `highlight_${mmss(it.timestamp)}_${String(i + 1).padStart(2, "0")}` }); |
| | }); |
| | } catch {} |
| | } |
| | const sm = join(dir, "summary_and_notes.md"); |
| | if (existsSync(sm)) { |
| | const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)/g; |
| | let m, k = 0; |
| | const text = readFileSync(sm, "utf8"); |
| | while ((m = re.exec(text))) links.push({ url: m[1], base: `summary_${String(++k).padStart(2, "0")}` }); |
| | } | | | return links; | | | } |
| | async function downloadImages(dir) { |
| | const problems = []; |
| | const imgDir = join(dir, "images"); |
| | if (existsSync(join(imgDir, ".done"))) return problems; |
| | const links = findImageLinks(dir); |
| | if (!links.length) return problems; |
| | mkdirSync(imgDir, { recursive: true }); |
| | for (const { url, base } of links) { |
| | if (readdirSync(imgDir).some((f) => f.startsWith(base + "."))) continue; // already have it |
| | try { |
| | const res = await fetch(url); |
| | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| | const target = join(imgDir, base + imageExt(url, res.headers.get("content-type"))); |
| | await pipeline(Readable.fromWeb(res.body), createWriteStream(target + ".part")); |
| | renameSync(target + ".part", target); |
| | } catch (e) { |
| | problems.push(`image ${base}: ${e.message}`); |
| | } | | | } | | | if (!problems.length) writeFileSync(join(imgDir, ".done"), new Date().toISOString()); | | | return problems; | | | } | | | | | | // ---------- main ---------- | | | let rl; |
| | const ask = async (q) => { rl ??= createInterface({ input: process.stdin, output: process.stdout }); return rl.question(q); }; |
| | if (!ACCOUNT) ACCOUNT = (await ask("Label for this Plaud account (e.g. personal, work): ")).trim(); |
| | ACCOUNT = safe(ACCOUNT, 40); |
| | PROFILE_HOME = join(OUT_ROOT, "_profiles", ACCOUNT); |
| | mkdirSync(PROFILE_HOME, { recursive: true }); |
| | const ACCT_DIR = join(OUT_ROOT, ACCOUNT); |
| | mkdirSync(ACCT_DIR, { recursive: true }); |
| | const ERRLOG = join(ACCT_DIR, "errors.log"); |
| | |
| | console.log(\nBacking up account "${ACCOUNT}" to ${ACCT_DIR}\n); |
| | |
| | // Login if needed (each account has its own token store) |
| | let me = runCli(["me"]); |
| | if (me.code !== 0) { |
| | console.log("Signing in. A browser window will open."); | | | console.log("IMPORTANT: if your browser is already signed in to a DIFFERENT Plaud account, sign out of"); | | | console.log("web.plaud.ai first, or copy the printed URL into an InPrivate/Incognito window.\n"); |
| | runCli(["login"], { inherit: true }); |
| | me = runCli(["me"]); |
| | if (me.code !== 0) { console.error("Login failed:\n" + me.err); process.exit(2); } |
| | } |
| | console.log(me.out.trim()); |
| | const ok = AUTO_YES ? "y" : (await ask("\nIs this the account you want to back up? (y/n): ")).trim().toLowerCase(); |
| | if (ok !== "y") { |
| | console.log(Logging this profile out. Re-run and sign in with the right account.); |
| | runCli(["logout"], { inherit: true }); |
| | process.exit(0); |
| | } | | | rl?.close(); | | | | | | // 1) List every recording |
| | const allIds = []; |
| | for (let page = 1; page <= 1000; page++) { |
| | const r = await cli(["files", "-p", String(page), "-s", "100"]); |
| | if (r.code !== 0) { console.error(`Failed listing page ${page}:\n${r.err}`); process.exit(1); } |
| | const { ids, count } = parseFilesPage(r.out); |
| | allIds.push(...ids); |
| | process.stdout.write(`\rListed ${allIds.length} recordings...`); |
| | if (count < 100) break; |
| | } |
| | const ids = [...new Set(allIds)]; |
| | console.log(`\nFound ${ids.length} recordings.\n`); |
| | |
| | // Map already-finished folders (resume support) |
| | const doneById = new Map(); |
| | for (const d of readdirSync(ACCT_DIR, { withFileTypes: true })) { |
| | if (d.isDirectory() && existsSync(join(ACCT_DIR, d.name, ".done"))) { |
| | const m = d.name.match(/__(.{1,8})$/); |
| | if (m) doneById.set(m[1], d.name); |
| | } | | | } | | | |
| | const indexPath = join(ACCT_DIR, "index.csv"); |
| | if (!existsSync(indexPath)) writeFileSync(indexPath, "id,date,name,duration,folder,audio,transcript,summary,status\n"); |
| | const csv = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; |
| | | | | // Catch-up pass: recordings finished by an older version of this script may have | | | // photos that were never downloaded. Refresh their links and fetch the images. |
| | if (!SKIP_IMAGES) { |
| | const needImages = [...doneById.entries()].filter(([, folder]) => { |
| | const dir = join(ACCT_DIR, folder); |
| | return !existsSync(join(dir, "images", ".done")) && findImageLinks(dir).length > 0; |
| | }); |
| | if (needImages.length) console.log(`Fetching images for ${needImages.length} previously saved recording(s)...`); |
| | for (const [tag, folder] of needImages) { |
| | const dir = join(ACCT_DIR, folder); |
| | const fullId = ids.find((x) => x.endsWith(tag)); |
| | if (fullId) await cli(["transcript", fullId, "--block", "mark_memo", "-o", join(dir, "highlights.txt")]); // fresh links |
| | const probs = await downloadImages(dir); |
| | if (probs.length) appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${folder} (images)\n ` + probs.join("\n ") + "\n"); |
| | } | | | } | | | |
| | let n = 0, failed = 0, skipped = 0; |
| | for (const id of ids) { |
| | n++; |
| | const tag = id.slice(-8); |
| | if (doneById.has(tag)) { skipped++; continue; } |
| | | | | const problems = []; | | | try { |
| | const det = await cli(["file", id]); |
| | if (det.code !== 0) throw new Error(`file details: ${det.err.trim()}`); |
| | const info = parseDetails(det.out); |
| | const folderName = `${stamp(info.start_at && info.start_at !== "-" ? info.start_at : info.created_at)}_${safe(info.name)}__${tag}`; |
| | const dir = join(ACCT_DIR, folderName); |
| | mkdirSync(dir, { recursive: true }); |
| | console.log(`[${n}/${ids.length}] ${info.name \|\| id}`); |
| | writeFileSync(join(dir, "details.txt"), `id: ${id}\n` + det.out.trim() + "\n"); |
| | | | | // Transcripts (missing blocks are simply skipped by the CLI) | | | const blocks = [ | | | ["transaction", "transcript.txt"], | | | ["transaction_polish", "transcript_polished.txt"], | | | ["outline", "outline.txt"], | | | ["mark_memo", "highlights.txt"], |
| | ]; |
| | for (const [block, fname] of blocks) { |
| | const r = await cli(["transcript", id, "--block", block, "-o", join(dir, fname)]); |
| | if (r.code !== 0) problems.push(`transcript(${block}): ${r.err.trim()}`); |
| | } | | | | | | // Summaries / notes: readable + raw JSON |
| | let r = await cli(["summary", id, "--all", "-o", join(dir, "summary_and_notes.md")]); |
| | if (r.code !== 0) problems.push(`summary: ${r.err.trim()}`); |
| | r = await cli(["summary", id, "--json", "-o", join(dir, "notes.json")]); |
| | if (r.code !== 0) problems.push(`notes json: ${r.err.trim()}`); |
| | | | | // Images (photos from highlights + images embedded in summaries) | | | if (!SKIP_IMAGES) problems.push(...await downloadImages(dir)); | | | | | | // Audio |
| | let gotAudio = false, noAudio = false; |
| | if (!SKIP_AUDIO) { |
| | for (let attempt = 0; attempt < 3 && !gotAudio; attempt++) { |
| | const a = await cli(["audio", id]); |
| | const url = (a.out.match(/https?:\/\/\S+/) \|\| [])[0]; |
| | if (!url) { |
| | if (/not available for this recording/i.test(a.out)) { noAudio = true; break; } // genuinely no audio |
| | await sleep(5000); |
| | continue; | | | } | | | try { |
| | const res = await fetch(url); |
| | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| | const target = join(dir, "audio" + audioExt(url, res.headers.get("content-type"))); |
| | const part = target + ".part"; |
| | await pipeline(Readable.fromWeb(res.body), createWriteStream(part)); |
| | renameSync(part, target); |
| | gotAudio = true; |
| | } catch (e) { |
| | problems.push(`audio download attempt ${attempt + 1}: ${e.message}`); |
| | for (const f of readdirSync(dir)) if (f.endsWith(".part")) try { unlinkSync(join(dir, f)); } catch {} |
| | await sleep(5000); |
| | } | | | } | | | if (!gotAudio && !noAudio) problems.push("audio: not downloaded"); | | | } | | | |
| | const has = (f) => existsSync(join(dir, f)) ? "yes" : "no"; |
| | const audioFile = readdirSync(dir).find((f) => f.startsWith("audio.")); |
| | const status = problems.length ? "partial" : "ok"; |
| | appendFileSync(indexPath, [id, (info.start_at && info.start_at !== "-" ? info.start_at : info.created_at), info.name, info.duration, folderName, |
| | audioFile ? "yes" : "no", has("transcript.txt"), has("summary_and_notes.md"), status].map(csv).join(",") + "\n"); |
| | | | | if (problems.length) { | | | failed++; |
| | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id} ${info.name}\n ` + problems.join("\n ") + "\n"); |
| | } else { |
| | writeFileSync(join(dir, ".done"), new Date().toISOString()); |
| | } | | | } catch (e) { | | | failed++; |
| | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id}\n ${e.message}\n`); |
| | console.error(` ! ${e.message}`); |
| | if (e.message.startsWith("AUTH")) process.exit(2); |
| | } | | | } | | | |
| | console.log(`\nDone. ${ids.length} recordings: ${ids.length - failed - skipped} saved, ${skipped} already done, ${failed} with problems.`); |
| | if (failed) console.log(`See ${ERRLOG}. Re-run the same command to retry anything incomplete.`); |
| | console.log(`Backup folder: ${ACCT_DIR}`); |