// Transcript frontmatter + auto-tag derivation. // // A transcript's "frontmatter" is a flat object of metadata that can be // exported as YAML for Obsidian or other Markdown consumers. Auto-tags are // derived deterministically from existing fields (date, duration, source, // text length) so they stay in sync without migration. // // Storage model: // - Source of truth is the existing transcript fields (id, title, date, // duration, source, text, segments). // - Manual tags live on `item.manualTags: string[]`. // - Auto-tags are never stored — derived on demand. const DURATION_BUCKETS = [ { max: 60, tag: "duration:short" }, // < 1 minute { max: 300, tag: "duration:medium" }, // < 5 minutes { max: 1800, tag: "duration:long" }, // < 30 minutes { max: Infinity, tag: "duration:very-long" }, ]; const WORD_BUCKETS = [ { max: 50, tag: "words:short" }, { max: 300, tag: "words:medium" }, { max: 1500, tag: "words:long" }, { max: Infinity, tag: "words:very-long" }, ]; function durationTag(seconds: number): string | null { if (!Number.isFinite(seconds) || seconds <= 0) return null; return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null; } function wordCountTag(text: string): string | null { if (!text || typeof text !== "string") return null; const count = text.trim().split(/\s+/).filter(Boolean).length; return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null; } // Resolve time-of-day bucket from an ISO date or a legacy string like // "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's // machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night. function timeOfDayTag(dateStr: string): string | null { if (!dateStr) return null; let ts = Date.parse(dateStr); if (Number.isNaN(ts)) { // Try DD/MM/YYYY, HH:MM:SS (UK local format used by Lumotia history rows). const match = String(dateStr).match( /(\d{1,2})\/(\d{1,2})\/(\d{4})[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?/, ); if (!match) return null; const [, dd, mm, yyyy, hh, min, ss] = match; const d = new Date( Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(min), Number(ss || 0), ); ts = d.getTime(); } const hour = new Date(ts).getHours(); if (hour >= 6 && hour < 12) return "time:morning"; if (hour >= 12 && hour < 18) return "time:afternoon"; if (hour >= 18 && hour < 22) return "time:evening"; return "time:night"; } function sourceTag(source: string): string | null { if (!source) return null; const s = String(source).toLowerCase(); if (s.includes("file")) return "source:file"; if (s.includes("live") || s.includes("mic")) return "source:live"; return `source:${s.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")}`; } // Returns tags to display as chips. Intentionally empty by default: the // metadata these tags used to encode (duration, date, source, starred) is // already shown elsewhere in the History row, so chips would duplicate // information and add cognitive load without improving retrieval. The // function is kept as a hook for one future AI-derived content tag // (`topic:*`) once lumotia-llm wires up real llama-cpp-2 in Phase 3. export function deriveAutoTags(_item: TranscriptEntry): string[] { return []; } export function normaliseTag(raw: string): string { return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-"); } // Build the flat frontmatter object that represents a transcript's metadata. // Shown in the expanded History row and serialised when exporting to .md. export function buildFrontmatter(item: TranscriptEntry | null) { if (!item) return {}; const auto = deriveAutoTags(item); const manual = Array.isArray(item.manualTags) ? item.manualTags : []; const llm = Array.isArray(item.llmTags) ? item.llmTags : []; const tags = Array.from(new Set([ ...auto, ...manual.map(normaliseTag), ...llm.map(normaliseTag), ])).filter(Boolean); const wordCount = item.text ? item.text.trim().split(/\s+/).filter(Boolean).length : 0; return { id: item.id, title: item.title || null, date: item.createdAt || item.date || null, duration_s: Number.isFinite(item.duration) ? item.duration : null, source: item.source || null, word_count: wordCount, tags, }; } // Escape a YAML scalar. Keeps things simple — quote if it contains any // character that would otherwise need escaping in plain scalars. function yamlScalar(value: unknown): string { if (value === null || value === undefined) return "null"; if (typeof value === "number" || typeof value === "boolean") return String(value); const s = String(value); if (s === "") return '""'; if (/^[A-Za-z0-9._/:\- ]+$/.test(s) && !/^\s|\s$/.test(s)) return s; return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } export function serialiseFrontmatter(fm: Record): string { const lines = ["---"]; for (const [key, value] of Object.entries(fm)) { if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${key}: []`); } else { lines.push(`${key}:`); for (const v of value) lines.push(` - ${yamlScalar(v)}`); } } else { lines.push(`${key}: ${yamlScalar(value)}`); } } lines.push("---"); return lines.join("\n"); } // Produce an Obsidian-flavoured markdown document for a transcript. export function buildMarkdown(item: TranscriptEntry): string { const fm = buildFrontmatter(item); const header = serialiseFrontmatter(fm); const title = fm.title || "Transcript"; const body = item?.text || ""; return `${header}\n\n# ${title}\n\n${body}\n`; } import type { TranscriptEntry } from "$lib/types/app";