121 lines
3.2 KiB
TypeScript
121 lines
3.2 KiB
TypeScript
/**
|
|
* Reactive app store for the doocus browser.
|
|
*
|
|
* Reads the local docs-output tree via /api/docs (see server/doocusApi.ts).
|
|
* Holds the doc list, the currently open doc (meta + textified content), and the
|
|
* selection used by the package builder. Read-only over the pipeline output —
|
|
* the UI never mutates extracted artifacts.
|
|
*/
|
|
import { reactive, computed } from 'vue'
|
|
|
|
export interface DocSummary {
|
|
id: string
|
|
name: string
|
|
type: string | null
|
|
family: string | null
|
|
title: string | null
|
|
extractedAt: string | null
|
|
wordCount: number
|
|
bytes: number
|
|
hasThumb: boolean
|
|
warnings: number
|
|
}
|
|
|
|
export interface DocDetail {
|
|
id: string
|
|
meta: Record<string, unknown>
|
|
content: string
|
|
hasThumb: boolean
|
|
thumbUrl: string | null
|
|
hasOriginal: boolean
|
|
originalUrl: string | null
|
|
}
|
|
|
|
/** Package targets and their (soft) limits. Surfaced, not hard-enforced. */
|
|
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 {
|
|
docs: DocSummary[]
|
|
outputDir: string
|
|
currentId: string | null
|
|
current: DocDetail | null
|
|
loading: boolean
|
|
selected: Set<string>
|
|
targetKey: string
|
|
}
|
|
|
|
export const store = reactive<State>({
|
|
docs: [],
|
|
outputDir: '',
|
|
currentId: null,
|
|
current: null,
|
|
loading: false,
|
|
selected: new Set<string>(),
|
|
targetKey: TARGETS[0].key,
|
|
})
|
|
|
|
export async function loadDocs(): Promise<void> {
|
|
const res = await fetch('/api/docs')
|
|
const data = await res.json()
|
|
store.docs = data.docs ?? []
|
|
store.outputDir = data.outputDir ?? ''
|
|
if (!store.currentId && store.docs.length) {
|
|
await openDoc(store.docs[0].id)
|
|
}
|
|
}
|
|
|
|
export async function openDoc(id: string): Promise<void> {
|
|
store.loading = true
|
|
try {
|
|
const res = await fetch(`/api/docs/${encodeURIComponent(id)}`)
|
|
if (!res.ok) throw new Error(`open failed: ${res.status}`)
|
|
store.current = await res.json()
|
|
store.currentId = id
|
|
} finally {
|
|
store.loading = false
|
|
}
|
|
}
|
|
|
|
export function toggleSelect(id: string): void {
|
|
if (store.selected.has(id)) store.selected.delete(id)
|
|
else store.selected.add(id)
|
|
}
|
|
|
|
export function isSelected(id: string): boolean {
|
|
return store.selected.has(id)
|
|
}
|
|
|
|
export const target = computed(() => TARGETS.find((t) => t.key === store.targetKey) ?? TARGETS[0])
|
|
|
|
/** Rough package estimate: each doc contributes original + content.md + meta.json. */
|
|
export const packageEstimate = computed(() => {
|
|
const chosen = store.docs.filter((d) => store.selected.has(d.id))
|
|
const files = chosen.length * 3 // original + content.md + meta.json
|
|
// originals dominate size; content/meta are small — approximate with wordCount.
|
|
const bytes = chosen.reduce((sum, d) => sum + d.bytes + d.wordCount * 6 + 512, 0)
|
|
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`
|
|
}
|