407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
/**
|
|
* 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: "<collectionId>/<relpath>" (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<string, unknown> | 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<string>
|
|
files: FileNode[]
|
|
tree: TreeItem[]
|
|
counts: Record<string, number>
|
|
selectedPath: string | null
|
|
detail: Detail | null
|
|
loading: boolean
|
|
selected: Set<string>
|
|
collapsed: Set<string> // 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<string> | null // null = not searching; else the matched paths
|
|
searching: boolean
|
|
searchStats: { count: number; bytes: number }
|
|
|
|
scanning: boolean
|
|
scanError: string
|
|
}
|
|
|
|
export const store = reactive<State>({
|
|
collections: [],
|
|
activeCollections: new Set<string>(),
|
|
files: [],
|
|
tree: [],
|
|
counts: {},
|
|
selectedPath: null,
|
|
detail: null,
|
|
loading: false,
|
|
selected: new Set<string>(),
|
|
collapsed: new Set<string>(),
|
|
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<typeof setTimeout> | 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<string>(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<string, string> {
|
|
const roots = [...new Set(files.map((f) => f.root).filter(Boolean))] as string[]
|
|
const labels = new Map<string, string>()
|
|
const seen = new Set<string>()
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<SourceInfo[]>(() => {
|
|
const byRoot = new Map<string, SourceInfo>()
|
|
const seen = new Set<string>()
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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'
|
|
}
|