Files
meetus/ui/doocus-app/src/store.ts
Mariano Gabriel 8606520ef2 doocus improvemnts
2026-07-05 20:35:44 -03:00

191 lines
5.8 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
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 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 {
root: 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)
detailCollapsed: boolean // bottom viewer collapsed to a bar
targetKey: string
error: string
}
export const store = reactive<State>({
root: '',
files: [],
tree: [],
counts: {},
selectedPath: null,
detail: null,
loading: false,
selected: new Set<string>(),
collapsed: new Set<string>(),
detailCollapsed: false,
targetKey: TARGETS[0].key,
error: '',
})
export function toggleFolder(path: string): void {
if (store.collapsed.has(path)) store.collapsed.delete(path)
else store.collapsed.add(path)
}
/** Build a nested folder tree from flat posix paths, folders before files. */
function buildTree(files: FileNode[]): TreeItem[] {
const root: TreeItem = { name: '', path: '', dir: true, children: [] }
for (const f of files) {
const parts = f.path.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: f.path, 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!)
}
export async function loadTree(): Promise<void> {
store.loading = true
store.error = ''
try {
const res = await fetch('/api/tree')
if (!res.ok) { store.error = (await res.json()).error ?? `HTTP ${res.status}`; return }
const index = await res.json()
store.root = index.root
store.files = index.files
store.counts = index.counts
store.tree = buildTree(index.files)
} catch (e) {
store.error = String(e)
} finally {
store.loading = false
}
}
export async function select(path: string): Promise<void> {
store.selectedPath = path
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'
}