/** * Reactive store for the doocus tree browser. * * Loads the whole-drive tree from /api/tree (index.json), builds a folder * hierarchy from the flat file list, lazily fetches per-file detail on select, * and tracks the packaging selection. Read-only over the pipeline output. */ import { reactive, computed } from 'vue' export interface FileNode { path: string // composite: "/" (API resolution) collection?: string root?: string // shared source path (groups docs + meetings of a source) rel?: string // path within the source (for the merged tree) name: string ext: string family: string mode: 'extracted' | 'meeting' | 'link' bytes: number modified: string url: string | null out?: string words?: number title?: string warnings?: string[] } export interface CollectionInfo { id: string name: string available: boolean count: number root: string | null only: string meetings: number } /** A source = one downloaded drive, possibly split into a docs collection and a * meetings collection that share the same `root`. */ export interface SourceInfo { root: string label: string collectionIds: string[] docs: number meetings: number } export interface Detail { node: FileNode content: string | null // extracted search text (heavy formats), markdown meta: Record | null text: string | null // inline text for text-native originals mime: string originalUrl: string } /** A folder or file in the display tree. */ export interface TreeItem { name: string path: string dir: boolean children?: TreeItem[] node?: FileNode } export interface Target { key: string label: string maxFiles: number maxBytes: number } export const TARGETS: Target[] = [ { key: 'gemini', label: 'Gemini web', maxFiles: 10, maxBytes: 100 * 1024 * 1024 }, { key: 'notebooklm', label: 'NotebookLM', maxFiles: 50, maxBytes: 200 * 1024 * 1024 }, ] interface State { collections: CollectionInfo[] activeCollections: Set files: FileNode[] tree: TreeItem[] counts: Record selectedPath: string | null detail: Detail | null loading: boolean selected: Set collapsed: Set // collapsed folder paths (folders default open) treeCollapsed: boolean // top tree pane collapsed to a bar detailCollapsed: boolean // bottom viewer collapsed to a bar targetKey: string error: string query: string matches: Set | null // null = not searching; else the matched paths searching: boolean searchStats: { count: number; bytes: number } scanning: boolean scanError: string } export const store = reactive({ collections: [], activeCollections: new Set(), files: [], tree: [], counts: {}, selectedPath: null, detail: null, loading: false, selected: new Set(), collapsed: new Set(), treeCollapsed: false, detailCollapsed: false, targetKey: TARGETS[0].key, error: '', query: '', matches: null, searching: false, searchStats: { count: 0, bytes: 0 }, scanning: false, scanError: '', }) let searchTimer: ReturnType | null = null /** Search the cached text (transcripts, extracted docs, raw files) and prune the * tree to matches. Debounced; reliability over speed (server reads full text). */ export function runSearch(q: string): void { store.query = q if (searchTimer) clearTimeout(searchTimer) if (!q.trim()) { store.matches = null store.searching = false store.searchStats = { count: 0, bytes: 0 } return } store.searching = true searchTimer = setTimeout(async () => { try { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&collections=${encodeURIComponent(activeCsv())}`) if (!res.ok) return const d = await res.json() if (store.query !== q) return // a newer query superseded this one store.matches = new Set(d.matches ?? []) store.searchStats = { count: d.count ?? 0, bytes: d.bytes ?? 0 } } finally { if (store.query === q) store.searching = false } }, 250) } /** Tree pruned to search matches (folders auto-expand while searching). */ export const visibleTree = computed(() => store.matches ? buildTree(store.files.filter((f) => store.matches!.has(f.path))) : store.tree) /** Put the current match set into the package selection (one package for now). */ export function selectMatches(): void { if (store.matches) store.selected = new Set(store.matches) } export function toggleFolder(path: string): void { if (store.collapsed.has(path)) store.collapsed.delete(path) else store.collapsed.add(path) } /** Unique display label per source root (top-level tree group). */ function sourceLabels(files: FileNode[]): Map { const roots = [...new Set(files.map((f) => f.root).filter(Boolean))] as string[] const labels = new Map() const seen = new Set() for (const r of roots.sort()) { const base = r.split('/').filter(Boolean).pop() || r let label = base, n = 1 while (seen.has(label)) { n++; label = `${base}-${n}` } seen.add(label) labels.set(r, label) } return labels } /** Build the tree grouped by source root — so a source's docs + meetings * interleave under one folder — while each file keeps its composite `node.path` * for API calls. Folder items are keyed by their display path. */ function buildTree(files: FileNode[]): TreeItem[] { const labels = sourceLabels(files) const root: TreeItem = { name: '', path: '', dir: true, children: [] } for (const f of files) { const src = (f.root && labels.get(f.root)) || f.collection || 'source' const display = `${src}/${f.rel ?? f.path}` const parts = display.split('/') let cur = root for (let i = 0; i < parts.length - 1; i++) { const seg = parts[i] const p = parts.slice(0, i + 1).join('/') let next = cur.children!.find((c) => c.dir && c.name === seg) if (!next) { next = { name: seg, path: p, dir: true, children: [] } cur.children!.push(next) } cur = next } cur.children!.push({ name: f.name, path: display, dir: false, node: f }) } const sort = (items: TreeItem[]): TreeItem[] => { items.sort((a, b) => a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1) for (const it of items) if (it.dir) sort(it.children!) return items } return sort(root.children!) } function activeCsv(): string { return [...store.activeCollections].join(',') } /** Load available collections; default every available one to active. */ export async function loadCollections(): Promise { try { const res = await fetch('/api/collections') const data = await res.json() store.collections = data.collections ?? [] // Default: all available collections on. store.activeCollections = new Set(store.collections.filter((c) => c.available).map((c) => c.id)) } catch (e) { store.error = String(e) } } /** Re-discover sources (picks up newly-scanned collections) without a page * reload; preserves current toggles and turns on any brand-new source. */ export async function refreshSources(): Promise { const prevActive = new Set(store.activeCollections) const known = new Set(store.collections.map((c) => c.id)) await loadCollections() // sets active = all available store.activeCollections = new Set( store.collections .filter((c) => c.available && (prevActive.has(c.id) || !known.has(c.id))) .map((c) => c.id), ) await loadTree() if (store.query) runSearch(store.query) } export async function loadTree(): Promise { store.loading = true store.error = '' try { if (!store.collections.length) await loadCollections() const res = await fetch(`/api/tree?collections=${encodeURIComponent(activeCsv())}`) if (!res.ok) { store.error = (await res.json()).error ?? `HTTP ${res.status}`; return } const index = await res.json() store.files = index.files store.counts = index.counts store.tree = buildTree(index.files) } catch (e) { store.error = String(e) } finally { store.loading = false } } /** Collections grouped by shared root — the selector unit. */ export const sources = computed(() => { const byRoot = new Map() const seen = new Set() for (const c of store.collections) { if (!c.root) continue let s = byRoot.get(c.root) if (!s) { const base = c.root.split('/').filter(Boolean).pop() || c.root let label = base, n = 1 while (seen.has(label)) { n++; label = `${base}-${n}` } seen.add(label) s = { root: c.root, label, collectionIds: [], docs: 0, meetings: 0 } byRoot.set(c.root, s) } s.collectionIds.push(c.id) s.docs += c.count - c.meetings s.meetings += c.meetings } return [...byRoot.values()] }) export function sourceActive(root: string): boolean { const s = sources.value.find((x) => x.root === root) return !!s && s.collectionIds.some((id) => store.activeCollections.has(id)) } /** Toggle a whole source (its docs + meetings collections) and re-merge. */ export async function toggleSource(root: string): Promise { const s = sources.value.find((x) => x.root === root) if (!s) return const allOn = s.collectionIds.every((id) => store.activeCollections.has(id)) for (const id of s.collectionIds) { if (allOn) store.activeCollections.delete(id) else store.activeCollections.add(id) } await loadTree() if (store.query) runSearch(store.query) } /** Scan a source folder (runs process_tree.py server-side) → new collection. */ export async function scanFolder(source: string): Promise { if (!source.trim()) return store.scanning = true store.scanError = '' try { const res = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: source.trim() }), }) const data = await res.json() if (!res.ok) { store.scanError = data.error ?? `HTTP ${res.status}`; return } await loadCollections() if (data.id) store.activeCollections.add(data.id) // show the new source await loadTree() } catch (e) { store.scanError = String(e) } finally { store.scanning = false } } /** Re-run the scan for an existing collection (its recorded source). */ export async function rescanCollection(id: string): Promise { store.scanning = true store.scanError = '' try { const res = await fetch('/api/rescan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) const data = await res.json() if (!res.ok) { store.scanError = data.error ?? `HTTP ${res.status}`; return } await loadCollections() await loadTree() if (store.query) runSearch(store.query) } catch (e) { store.scanError = String(e) } finally { store.scanning = false } } export async function select(path: string): Promise { store.selectedPath = path store.detailCollapsed = false // opening a file expands the viewer store.detail = null const res = await fetch(`/api/detail?path=${encodeURIComponent(path)}`) if (res.ok) store.detail = await res.json() } export function toggleSelect(path: string): void { if (store.selected.has(path)) store.selected.delete(path) else store.selected.add(path) } export const target = computed(() => TARGETS.find((t) => t.key === store.targetKey) ?? TARGETS[0]) /** Package estimate: one original per selected file (+ a small sidecar for extracted). */ export const packageEstimate = computed(() => { const chosen = store.files.filter((f) => store.selected.has(f.path)) let files = 0 let bytes = 0 for (const f of chosen) { files += 1 // the original bytes += f.bytes if (f.mode === 'extracted') { files += 1; bytes += (f.words ?? 0) * 6 + 512 } // content.md } const t = target.value return { docCount: chosen.length, files, bytes, overFiles: files > t.maxFiles, overBytes: bytes > t.maxBytes } }) export function humanBytes(n: number): string { if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` return `${(n / 1024 / 1024).toFixed(1)} MB` } export type ViewerKind = | 'pdf' | 'image' | 'html' | 'markdown' | 'code' | 'docx' | 'xlsx' | 'pptx' | 'video' | 'unhandled' /** Single source of truth for how a file is viewed — shared by the tree (to flag * unhandled types) and the viewer. 'unhandled' = no inline view; default action * is to open the original file (or the Drive link once we have it). */ export function kindFor(ext: string): ViewerKind { if (ext === 'pdf') return 'pdf' if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image' if (['html', 'htm'].includes(ext)) return 'html' if (['md', 'markdown'].includes(ext)) return 'markdown' if (['txt', 'json', 'yaml', 'yml', 'csv', 'log', 'ini', 'xml', 'toml'].includes(ext)) return 'code' if (ext === 'docx') return 'docx' if (ext === 'xlsx') return 'xlsx' if (ext === 'pptx') return 'pptx' if (['mp4', 'mkv', 'mov', 'm4v', 'webm', 'avi', 'wmv'].includes(ext)) return 'video' return 'unhandled' } export function isUnhandled(node: FileNode): boolean { return node.mode !== 'meeting' && kindFor(node.ext) === 'unhandled' }