doocus update and meeting list export
This commit is contained in:
@@ -1,37 +1,45 @@
|
||||
/**
|
||||
* Reactive app store for the doocus browser.
|
||||
* Reactive store for the doocus tree 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.
|
||||
* 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 DocSummary {
|
||||
id: string
|
||||
export interface FileNode {
|
||||
path: string
|
||||
name: string
|
||||
type: string | null
|
||||
family: string | null
|
||||
title: string | null
|
||||
extractedAt: string | null
|
||||
wordCount: number
|
||||
ext: string
|
||||
family: string
|
||||
mode: 'extracted' | 'meeting' | 'link'
|
||||
bytes: number
|
||||
hasThumb: boolean
|
||||
warnings: number
|
||||
modified: string
|
||||
url: string | null
|
||||
out?: string
|
||||
words?: number
|
||||
title?: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface DocDetail {
|
||||
id: string
|
||||
meta: Record<string, unknown>
|
||||
content: string
|
||||
hasThumb: boolean
|
||||
thumbUrl: string | null
|
||||
hasOriginal: boolean
|
||||
originalUrl: string | null
|
||||
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
|
||||
}
|
||||
|
||||
/** Package targets and their (soft) limits. Surfaced, not hard-enforced. */
|
||||
export interface Target {
|
||||
key: string
|
||||
label: string
|
||||
@@ -45,72 +53,109 @@ export const TARGETS: Target[] = [
|
||||
]
|
||||
|
||||
interface State {
|
||||
docs: DocSummary[]
|
||||
outputDir: string
|
||||
currentId: string | null
|
||||
current: DocDetail | null
|
||||
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)
|
||||
targetKey: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export const store = reactive<State>({
|
||||
docs: [],
|
||||
outputDir: '',
|
||||
currentId: null,
|
||||
current: null,
|
||||
root: '',
|
||||
files: [],
|
||||
tree: [],
|
||||
counts: {},
|
||||
selectedPath: null,
|
||||
detail: null,
|
||||
loading: false,
|
||||
selected: new Set<string>(),
|
||||
collapsed: new Set<string>(),
|
||||
targetKey: TARGETS[0].key,
|
||||
error: '',
|
||||
})
|
||||
|
||||
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 function toggleFolder(path: string): void {
|
||||
if (store.collapsed.has(path)) store.collapsed.delete(path)
|
||||
else store.collapsed.add(path)
|
||||
}
|
||||
|
||||
export async function openDoc(id: string): Promise<void> {
|
||||
/** 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/docs/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`open failed: ${res.status}`)
|
||||
store.current = await res.json()
|
||||
store.currentId = id
|
||||
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 function toggleSelect(id: string): void {
|
||||
if (store.selected.has(id)) store.selected.delete(id)
|
||||
else store.selected.add(id)
|
||||
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 isSelected(id: string): boolean {
|
||||
return store.selected.has(id)
|
||||
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])
|
||||
|
||||
/** Rough package estimate: each doc contributes original + content.md + meta.json. */
|
||||
/** Package estimate: one original per selected file (+ a small sidecar for extracted). */
|
||||
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,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user