doocus update and meeting list export

This commit is contained in:
Mariano Gabriel
2026-07-05 11:15:57 -03:00
parent fa3a0e3d1a
commit 300806b936
17 changed files with 614 additions and 628 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"jszip": "^3.10",
"marked": "^14",
"vue": "^3.5"
},
"devDependencies": {
@@ -1253,6 +1254,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "14.1.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
"integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"jszip": "^3.10",
"marked": "^14",
"vue": "^3.5"
},
"devDependencies": {

View File

@@ -1,19 +1,18 @@
/**
* Vite dev middleware exposing the local doocus docs-output tree to the app.
* Vite dev middleware exposing the local doocus tree index to the app.
*
* Runs in the dev server process (Node), so it can reach the original source
* file by the absolute path stored in each doc's manifest.json — needed for the
* package builder, which bundles the original alongside content.md + meta.json.
* All reads are local; nothing leaves the machine.
* Reads `docs-output/index.json` (produced by process_tree.py) — a replica of
* the whole local drive tree — plus the extracted `<file>.doocus/content.md`
* sidecars, and streams the ORIGINAL files by relative path (resolved against
* the index's recorded root). All reads are local; nothing leaves the machine.
*
* Routes:
* GET /api/docs list extracted docs
* GET /api/docs/:id meta.json + content.md + thumb/original flags
* GET /api/docs/:id/thumb stream thumb.jpg
* GET /api/docs/:id/original stream the original source file
* GET /api/tree the index.json tree
* GET /api/detail?path=<rel> node + extracted content + meta + inline text
* GET /api/original?path=<rel> stream the original file (native viewer / packaging)
*/
import type { Plugin } from 'vite'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ServerResponse } from 'node:http'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
@@ -22,196 +21,130 @@ interface Options {
outputDir: string
}
interface Manifest {
source?: { name?: string; path?: string; sha256?: string }
processed_at?: string
outputs?: { content?: string; meta?: string; thumb?: string | null }
interface Node {
path: string
name: string
ext: string
family: string
mode: 'extracted' | 'meeting' | 'link'
bytes: number
modified: string
url: string | null
out?: string
}
const ORIGINAL_MIME: Record<string, string> = {
'.pdf': 'application/pdf',
interface Index {
root: string
generatedAt: string
counts: Record<string, number>
files: Node[]
}
// Text-native files: readable as-is, so the "original" doubles as its own text.
const TEXT_EXTS = new Set(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm'])
const MIME: Record<string, string> = {
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.html': 'text/html', '.htm': 'text/html', '.md': 'text/markdown', '.txt': 'text/plain',
'.csv': 'text/csv', '.json': 'application/json', '.yaml': 'text/yaml', '.yml': 'text/yaml',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.csv': 'text/csv',
'.html': 'text/html',
'.htm': 'text/html',
'.json': 'application/json',
'.md': 'text/markdown',
'.txt': 'text/plain',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.mp4': 'video/mp4',
}
export function doocusApi(opts: Options): Plugin {
const outputDir = path.resolve(opts.outputDir)
const toPosix = (p: string) => p.split(path.sep).join('/')
const indexPath = path.join(outputDir, 'index.json')
/** Resolve a doc id (possibly nested) to its directory, rejecting traversal. */
function docDir(id: string): string | null {
const dir = path.resolve(outputDir, id)
if (dir !== outputDir && !dir.startsWith(outputDir + path.sep)) return null
function readIndex(): Index | null {
try {
if (fs.statSync(dir).isDirectory()) return dir
} catch { /* missing */ }
return null
}
/** Find every doc run directory (one containing meta.json) under `base`. */
async function findDocDirs(base: string, maxDepth = 8): Promise<string[]> {
const found: string[] = []
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > maxDepth) return
let ents
try {
ents = await fsp.readdir(dir, { withFileTypes: true })
} catch { return }
if (ents.some((e) => e.isFile() && e.name === 'meta.json')) {
found.push(dir)
return // a doc dir; don't descend into assets/
}
for (const e of ents) {
if (!e.isDirectory()) continue
if (e.name === 'assets' || e.name === 'node_modules' || e.name.startsWith('.')) continue
await walk(path.join(dir, e.name), depth + 1)
}
}
await walk(base, 0)
return found
}
function readJson<T>(file: string): T | null {
try {
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'))
} catch {
return null
}
}
/** Resolve a node's original file, rejecting traversal outside the index root. */
function originalAbs(index: Index, rel: string): string | null {
const root = path.resolve(index.root)
const abs = path.resolve(root, rel)
if (abs !== root && !abs.startsWith(root + path.sep)) return null
return abs
}
return {
name: 'doocus-api',
configureServer(server) {
server.config.logger.info(`[doocus-api] serving docs from: ${outputDir}`)
server.config.logger.info(`[doocus-api] serving tree from: ${indexPath}`)
server.middlewares.use('/api', (req, res, next) => {
const url = new URL(req.url ?? '/', 'http://localhost')
const parts = url.pathname.split('/').filter(Boolean)
handle(req, res, parts).catch((err) => {
handle(res, parts, url.searchParams).catch((err) => {
server.config.logger.error(`[doocus-api] ${String(err)}`)
sendJson(res, 500, { error: String(err) })
}).then((handled) => {
if (!handled) next()
})
}).then((handled) => { if (!handled) next() })
})
},
}
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<boolean> {
if (parts[0] !== 'docs') return false
// GET /api/docs
if (parts.length === 1 && req.method === 'GET') {
await listDocs(res)
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams): Promise<boolean> {
if (parts[0] === 'tree') {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true }
sendJson(res, 200, index)
return true
}
const id = parts[1] ? decodeURIComponent(parts[1]) : ''
const dir = id ? docDir(id) : null
if (!dir) { sendJson(res, 404, { error: 'doc not found' }); return true }
// GET /api/docs/:id
if (parts.length === 2 && req.method === 'GET') {
await getDoc(res, id, dir)
return true
}
// GET /api/docs/:id/thumb
if (parts.length === 3 && parts[2] === 'thumb' && req.method === 'GET') {
serveThumb(res, dir)
return true
}
// GET /api/docs/:id/original
if (parts.length === 3 && parts[2] === 'original' && req.method === 'GET') {
serveOriginal(res, dir)
if (parts[0] === 'detail') {
await getDetail(res, q.get('path') ?? '')
return true
}
sendJson(res, 404, { error: 'not found' })
return true
if (parts[0] === 'original') {
await serveOriginal(res, q.get('path') ?? '')
return true
}
return false
}
async function listDocs(res: ServerResponse): Promise<void> {
const dirs = await findDocDirs(outputDir)
const docs = []
for (const dir of dirs) {
const meta = readJson<Record<string, unknown>>(path.join(dir, 'meta.json'))
if (!meta) continue
const id = toPosix(path.relative(outputDir, dir))
const source = (meta.source ?? {}) as { name?: string; bytes?: number }
docs.push({
id,
name: source.name ?? id,
type: meta.type ?? null,
family: meta.family ?? null,
title: meta.title ?? null,
extractedAt: meta.extracted_at ?? null,
wordCount: meta.word_count ?? 0,
bytes: source.bytes ?? 0,
hasThumb: fs.existsSync(path.join(dir, 'thumb.jpg')),
warnings: Array.isArray(meta.warnings) ? meta.warnings.length : 0,
})
async function getDetail(res: ServerResponse, rel: string): Promise<void> {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
const node = index.files.find((f) => f.path === rel)
if (!node) { sendJson(res, 404, { error: 'node not found' }); return }
let content: string | null = null // extracted search text (heavy formats)
let meta: unknown = null
let text: string | null = null // inline text for text-native originals
if (node.mode === 'extracted' && node.out) {
try { content = await fsp.readFile(path.join(outputDir, node.out, 'content.md'), 'utf-8') } catch { /* */ }
try { meta = JSON.parse(await fsp.readFile(path.join(outputDir, node.out, 'meta.json'), 'utf-8')) } catch { /* */ }
} else if (node.mode === 'link' && TEXT_EXTS.has(node.ext)) {
const abs = originalAbs(index, rel)
if (abs) { try { text = await fsp.readFile(abs, 'utf-8') } catch { /* */ } }
}
docs.sort((a, b) =>
String(b.extractedAt ?? '').localeCompare(String(a.extractedAt ?? '')) || a.id.localeCompare(b.id))
sendJson(res, 200, { docs, outputDir })
}
async function getDoc(res: ServerResponse, id: string, dir: string): Promise<void> {
const meta = readJson<Record<string, unknown>>(path.join(dir, 'meta.json'))
if (!meta) { sendJson(res, 404, { error: 'meta missing' }); return }
const manifest = readJson<Manifest>(path.join(dir, 'manifest.json'))
let content = ''
try {
content = await fsp.readFile(path.join(dir, 'content.md'), 'utf-8')
} catch { /* none */ }
const hasThumb = fs.existsSync(path.join(dir, 'thumb.jpg'))
const originalPath = manifest?.source?.path
const hasOriginal = !!originalPath && isFile(originalPath)
sendJson(res, 200, {
id,
meta,
node,
content,
hasThumb,
thumbUrl: hasThumb ? `/api/docs/${encodeURIComponent(id)}/thumb` : null,
hasOriginal,
originalUrl: hasOriginal ? `/api/docs/${encodeURIComponent(id)}/original` : null,
meta,
text,
mime: MIME[path.extname(node.name).toLowerCase()] ?? 'application/octet-stream',
originalUrl: `/api/original?path=${encodeURIComponent(rel)}`,
})
}
function serveThumb(res: ServerResponse, dir: string): void {
const full = path.join(dir, 'thumb.jpg')
if (!fs.existsSync(full)) { sendJson(res, 404, { error: 'no thumb' }); return }
res.setHeader('Content-Type', 'image/jpeg')
async function serveOriginal(res: ServerResponse, rel: string): Promise<void> {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
const abs = originalAbs(index, rel)
if (!abs || !isFile(abs)) { sendJson(res, 404, { error: 'original not reachable' }); return }
res.setHeader('Content-Type', MIME[path.extname(abs).toLowerCase()] ?? 'application/octet-stream')
res.setHeader('Cache-Control', 'no-cache')
fs.createReadStream(full).pipe(res)
}
function serveOriginal(res: ServerResponse, dir: string): void {
const manifest = readJson<Manifest>(path.join(dir, 'manifest.json'))
const original = manifest?.source?.path
if (!original || !isFile(original)) {
sendJson(res, 404, { error: 'original source not reachable on this machine' })
return
}
const mime = ORIGINAL_MIME[path.extname(original).toLowerCase()] ?? 'application/octet-stream'
res.setHeader('Content-Type', mime)
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(original)}"`)
fs.createReadStream(original).pipe(res)
fs.createReadStream(abs).pipe(res)
}
}

View File

@@ -1,40 +1,39 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import Panel from '@framework/components/Panel.vue'
import SplitPane from '@framework/components/SplitPane.vue'
import DocPicker from '@/components/DocPicker.vue'
import DocViewer from '@/components/DocViewer.vue'
import MetadataPanel from '@/components/MetadataPanel.vue'
import FileTree from '@/components/FileTree.vue'
import DocDetail from '@/components/DocDetail.vue'
import ExportBar from '@/components/ExportBar.vue'
import { store, loadTree } from '@/store'
onMounted(loadTree)
</script>
<template>
<div class="app">
<header class="topbar">
<span class="brand">doocus</span>
<span v-if="store.counts.total" class="counts">
{{ store.counts.total }} files · {{ store.counts.extracted }} extracted ·
{{ store.counts.link }} linked · {{ store.counts.meeting }} meetings
</span>
<span class="spacer" />
<ExportBar />
</header>
<main class="body">
<SplitPane :initial-size="0.9" :min="0.5" :max="2">
<div v-if="store.error" class="err">{{ store.error }}</div>
<SplitPane v-else direction="vertical" :initial-size="0.9" :min="0.3" :max="3">
<template #first>
<Panel title="Documents">
<DocPicker />
<Panel title="Tree">
<FileTree :items="store.tree" />
</Panel>
</template>
<template #second>
<SplitPane :initial-size="2.2" :min="1" :max="5">
<template #first>
<Panel title="Content">
<DocViewer />
</Panel>
</template>
<template #second>
<Panel title="Metadata">
<MetadataPanel />
</Panel>
</template>
</SplitPane>
<Panel title="File">
<DocDetail />
</Panel>
</template>
</SplitPane>
</main>
@@ -42,30 +41,15 @@ import ExportBar from '@/components/ExportBar.vue'
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.app { display: flex; flex-direction: column; height: 100%; }
.topbar {
display: flex;
align-items: center;
gap: var(--space-3);
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--surface-1);
border-bottom: var(--panel-border);
flex-shrink: 0;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.spacer {
flex: 1;
}
.body {
flex: 1;
min-height: 0;
padding: var(--space-2);
background: var(--surface-1); border-bottom: var(--panel-border); flex-shrink: 0;
}
.brand { font-weight: 700; letter-spacing: 0.02em; }
.counts { color: var(--text-secondary); font-size: var(--font-size-sm); }
.spacer { flex: 1; }
.body { flex: 1; min-height: 0; padding: var(--space-2); }
.err { color: var(--status-error, #e0a030); padding: var(--space-4); }
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import SplitPane from '@framework/components/SplitPane.vue'
import { store, humanBytes } from '@/store'
const d = computed(() => store.detail)
const node = computed(() => d.value?.node)
type ViewerKind = 'pdf' | 'image' | 'html' | 'markdown' | 'text' | 'office' | 'video' | 'download'
const viewerKind = computed<ViewerKind>(() => {
const ext = node.value?.ext ?? ''
if (ext === 'pdf') return 'pdf'
if (['png', 'jpg', 'jpeg'].includes(ext)) return 'image'
if (['html', 'htm'].includes(ext)) return 'html'
if (['md', 'markdown'].includes(ext)) return 'markdown'
if (['txt', 'json', 'yaml', 'yml', 'csv'].includes(ext)) return 'text'
if (['docx', 'pptx', 'xlsx'].includes(ext)) return 'office'
if (ext === 'mp4') return 'video'
return 'download'
})
// Left pane (extracted search text) only exists for extracted heavy formats.
const hasExtracted = computed(() => node.value?.mode === 'extracted' && d.value?.content != null)
const extractedHtml = computed(() =>
d.value?.content ? marked.parse(d.value.content) : '')
// Right pane text (for text-native originals rendered inline).
const inlineText = computed(() => d.value?.text ?? '')
const inlineMarkdown = computed(() =>
viewerKind.value === 'markdown' && inlineText.value ? marked.parse(inlineText.value) : '')
const metaRows = computed(() => {
const m = d.value?.meta as Record<string, any> | null
if (!m) return []
const out: Array<[string, string]> = []
const push = (k: string, v: unknown) => {
if (v == null || v === '') return
out.push([k, Array.isArray(v) ? v.join(', ') : typeof v === 'object' ? JSON.stringify(v) : String(v)])
}
push('title', m.title); push('author', m.author); push('created', m.created)
push('pages', m.pages); push('slides', m.slides); push('sheets', m.sheets)
push('words', m.word_count); push('extractor', m.extractor)
return out
})
</script>
<template>
<div v-if="!node" class="empty">Select a file to view it.</div>
<div v-else class="detail">
<header class="head">
<span class="badge" :data-mode="node.mode">{{ node.mode }}</span>
<span class="name">{{ node.path }}</span>
<span class="dim">{{ node.ext }} · {{ humanBytes(node.bytes) }}</span>
<a class="dl" :href="d?.originalUrl" :download="node.name"> original</a>
</header>
<!-- extracted: search text (left, secondary) | native original (right) -->
<SplitPane v-if="hasExtracted" class="body" :initial-size="1" :min="0.3" :max="3">
<template #first>
<div class="pane">
<div class="pane-label">Extracted text · search index (not the document)</div>
<div class="md" v-html="extractedHtml" />
</div>
</template>
<template #second>
<div class="pane viewer">
<template v-if="viewerKind === 'pdf'">
<iframe class="frame" :src="d?.originalUrl" title="pdf" />
</template>
<div v-else class="office">
<p>No inline preview for <b>.{{ node.ext }}</b>.</p>
<a class="btn" :href="d?.originalUrl" :download="node.name">Download original</a>
<p class="dim">The extracted text on the left is a search aid, not a faithful render.</p>
</div>
</div>
</template>
</SplitPane>
<!-- non-extracted: single native viewer of the original -->
<div v-else class="body single">
<div v-if="viewerKind === 'image'" class="viewer center">
<img :src="d?.originalUrl" :alt="node.name" />
</div>
<iframe v-else-if="viewerKind === 'html'" class="frame" :src="d?.originalUrl" sandbox="" title="html" />
<div v-else-if="viewerKind === 'markdown'" class="md pad" v-html="inlineMarkdown" />
<pre v-else-if="viewerKind === 'text'" class="mono">{{ inlineText }}</pre>
<div v-else-if="viewerKind === 'video'" class="viewer center meeting">
<video class="video" :src="d?.originalUrl" controls />
<p class="dim">Meeting its transcript is produced by meetus, not doocus.</p>
</div>
<div v-else class="office">
<p>No inline preview for <b>.{{ node.ext }}</b>.</p>
<a class="btn" :href="d?.originalUrl" :download="node.name">Download original</a>
</div>
</div>
<footer v-if="metaRows.length" class="meta">
<span v-for="[k, v] in metaRows" :key="k" class="chip"><b>{{ k }}</b> {{ v }}</span>
</footer>
</div>
</template>
<style scoped>
.empty { padding: var(--space-4); color: var(--text-secondary); }
.detail { display: flex; flex-direction: column; height: 100%; }
.head {
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-2) var(--space-3); border-bottom: var(--panel-border); flex-shrink: 0;
}
.head .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.head .dim, .dim { color: var(--text-secondary); font-size: var(--font-size-sm); }
.head .dl { margin-left: auto; }
.badge {
font-size: 10px; text-transform: uppercase; padding: 1px 5px; border-radius: 3px;
background: var(--surface-3); color: var(--text-secondary);
}
.badge[data-mode='extracted'] { background: var(--status-live, #2a6); color: #fff; }
.badge[data-mode='meeting'] { background: var(--status-processing, #a60); color: #fff; }
.body { flex: 1; min-height: 0; }
.body.single { overflow: auto; }
.pane { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
.pane-label {
font-size: 11px; color: var(--text-secondary); padding: var(--space-1) var(--space-3);
border-bottom: 1px solid var(--surface-2); flex-shrink: 0;
}
.md { padding: var(--space-3); overflow: auto; }
.md.pad { padding: var(--space-4); }
.viewer { height: 100%; overflow: auto; }
.viewer.center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-3); }
.frame { width: 100%; height: 100%; border: 0; }
.viewer img { max-width: 100%; }
.video { max-width: 100%; max-height: 70vh; }
.mono { margin: 0; padding: var(--space-4); white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono, monospace); font-size: var(--font-size-sm); }
.office { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; }
.btn { padding: var(--space-1) var(--space-3); background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius); }
.meta { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-top: var(--panel-border); flex-shrink: 0; }
.chip { font-size: var(--font-size-sm); color: var(--text-secondary); }
.chip b { color: var(--text-primary); }
</style>

View File

@@ -1,85 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { store, loadDocs, openDoc, toggleSelect, isSelected } from '@/store'
onMounted(loadDocs)
</script>
<template>
<div class="doc-picker">
<div class="picker-head">
<strong>{{ store.docs.length }}</strong> document{{ store.docs.length === 1 ? '' : 's' }}
</div>
<ul class="doc-list">
<li
v-for="d in store.docs"
:key="d.id"
:class="{ active: d.id === store.currentId }"
@click="openDoc(d.id)"
>
<input
type="checkbox"
:checked="isSelected(d.id)"
title="Add to package"
@click.stop="toggleSelect(d.id)"
/>
<span class="type-badge" :data-type="d.type ?? '?'">{{ d.type ?? '?' }}</span>
<span class="doc-name">{{ d.title || d.name }}</span>
<span v-if="d.warnings" class="warn" :title="`${d.warnings} warning(s)`"></span>
</li>
</ul>
</div>
</template>
<style scoped>
.doc-picker {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.picker-head {
padding: var(--space-2) var(--space-3);
color: var(--text-secondary);
font-size: var(--font-size-sm);
border-bottom: var(--panel-border);
}
.doc-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
}
.doc-list li {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
cursor: pointer;
border-bottom: 1px solid var(--surface-2);
}
.doc-list li:hover {
background: var(--surface-2);
}
.doc-list li.active {
background: var(--surface-3);
}
.type-badge {
font-size: 10px;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 3px;
background: var(--surface-3);
color: var(--text-secondary);
flex-shrink: 0;
}
.doc-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.warn {
margin-left: auto;
color: var(--status-error, #e0a030);
}
</style>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import VideoPlayer from '@framework/components/VideoPlayer.vue'
import { store } from '@/store'
// Choose a viewer by file type. Images and video render the original directly;
// everything else renders the textified content.md (monospace for structured
// text, prose for documents).
const kind = computed<'image' | 'video' | 'mono' | 'prose'>(() => {
const t = (store.current?.meta as any)?.type as string | undefined
if (t === 'jpg' || t === 'jpeg' || t === 'png') return 'image'
if (t === 'mp4') return 'video'
if (t === 'json' || t === 'yaml' || t === 'yml' || t === 'csv') return 'mono'
return 'prose'
})
const content = computed(() => store.current?.content ?? '')
</script>
<template>
<div class="viewer">
<div v-if="!store.current" class="empty">Select a document to view its extracted content.</div>
<div v-else-if="kind === 'image'" class="image-wrap">
<img :src="store.current.originalUrl ?? store.current.thumbUrl ?? ''" alt="document" />
</div>
<div v-else-if="kind === 'video'" class="video-wrap">
<VideoPlayer v-if="store.current.originalUrl" :src="store.current.originalUrl" />
<p class="note">
Video preview. The transcript is produced by the meetus pipeline, not doocus.
</p>
</div>
<pre v-else-if="kind === 'mono'" class="mono">{{ content }}</pre>
<div v-else class="prose">{{ content }}</div>
</div>
</template>
<style scoped>
.viewer {
height: 100%;
overflow: auto;
padding: var(--space-4);
}
.empty {
color: var(--text-secondary);
}
.image-wrap img {
max-width: 100%;
border-radius: var(--panel-radius);
border: var(--panel-border);
}
.video-wrap {
max-width: 900px;
}
.note {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.mono {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: var(--font-mono, monospace);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.prose {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-width: 820px;
line-height: 1.6;
}
</style>

View File

@@ -1,36 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import JSZip from 'jszip'
import {
store, TARGETS, target, packageEstimate, humanBytes,
} from '@/store'
import { store, TARGETS, target, packageEstimate, humanBytes } from '@/store'
const building = ref(false)
const error = ref('')
// Build a zip: per selected doc, a folder holding the original source file plus
// the textified content.md and meta.json. Originals are included because the
// permitted services (Gemini web / NotebookLM) receive them directly; content.md
// keeps each doc greppable and small.
// Build a zip of the selected files: each ORIGINAL (what Gemini/NotebookLM
// actually consume) plus, for extracted formats, the content.md search text.
// Folder structure is preserved. Later this can emit Drive links instead of
// bytes once nodes carry a `url`.
async function buildPackage(): Promise<void> {
if (!store.selected.size) return
building.value = true
error.value = ''
try {
const zip = new JSZip()
for (const id of store.selected) {
const summary = store.docs.find((d) => d.id === id)
const res = await fetch(`/api/docs/${encodeURIComponent(id)}`)
if (!res.ok) throw new Error(`fetch ${id} failed`)
const detail = await res.json()
const folder = zip.folder(id.replace(/[\\/]+/g, '__')) ?? zip
folder.file('content.md', detail.content ?? '')
folder.file('meta.json', JSON.stringify(detail.meta ?? {}, null, 2))
if (detail.originalUrl) {
const bin = await fetch(detail.originalUrl)
if (bin.ok) {
const name = (summary?.name) || 'original'
folder.file(name, await bin.blob())
for (const f of store.files.filter((x) => store.selected.has(x.path))) {
const dir = f.path.includes('/') ? f.path.slice(0, f.path.lastIndexOf('/')) : ''
const folder = dir ? (zip.folder(dir) ?? zip) : zip
const bin = await fetch(`/api/original?path=${encodeURIComponent(f.path)}`)
if (bin.ok) folder.file(f.name, await bin.blob())
if (f.mode === 'extracted') {
const detail = await fetch(`/api/detail?path=${encodeURIComponent(f.path)}`)
if (detail.ok) {
const d = await detail.json()
if (d.content) folder.file(`${f.name}.txt`, d.content)
}
}
}
@@ -47,10 +42,6 @@ async function buildPackage(): Promise<void> {
building.value = false
}
}
function clearSelection(): void {
store.selected.clear()
}
</script>
<template>
@@ -63,17 +54,12 @@ function clearSelection(): void {
</label>
<div class="estimate" :class="{ over: packageEstimate.overFiles || packageEstimate.overBytes }">
{{ packageEstimate.docCount }} docs ·
<span :class="{ bad: packageEstimate.overFiles }">
{{ packageEstimate.files }}/{{ target.maxFiles }} files
</span>
·
<span :class="{ bad: packageEstimate.overBytes }">
{{ humanBytes(packageEstimate.bytes) }} / {{ humanBytes(target.maxBytes) }}
</span>
{{ packageEstimate.docCount }} sel ·
<span :class="{ bad: packageEstimate.overFiles }">{{ packageEstimate.files }}/{{ target.maxFiles }} files</span> ·
<span :class="{ bad: packageEstimate.overBytes }">{{ humanBytes(packageEstimate.bytes) }} / {{ humanBytes(target.maxBytes) }}</span>
</div>
<button :disabled="!store.selected.size" @click="clearSelection">Clear</button>
<button :disabled="!store.selected.size" @click="store.selected.clear()">Clear</button>
<button :disabled="!store.selected.size || building" @click="buildPackage">
{{ building ? 'Building' : 'Download package' }}
</button>
@@ -82,29 +68,10 @@ function clearSelection(): void {
</template>
<style scoped>
.export-bar {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-size-sm);
}
.target {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--text-secondary);
}
.estimate {
color: var(--text-secondary);
}
.estimate.over {
color: var(--status-error, #e0a030);
}
.estimate .bad {
color: var(--status-error, #e0a030);
font-weight: 600;
}
.err {
color: var(--status-error, #e0a030);
}
.export-bar { display: flex; align-items: center; gap: var(--space-3); font-size: var(--font-size-sm); }
.target { display: flex; align-items: center; gap: var(--space-1); color: var(--text-secondary); }
.estimate { color: var(--text-secondary); }
.estimate.over { color: var(--status-error, #e0a030); }
.estimate .bad { color: var(--status-error, #e0a030); font-weight: 600; }
.err { color: var(--status-error, #e0a030); }
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { store, select, toggleSelect, toggleFolder, type TreeItem } from '@/store'
defineProps<{ items: TreeItem[]; depth?: number }>()
const MODE_BADGE: Record<string, string> = {
extracted: 'ext', meeting: 'mtg', link: 'link',
}
</script>
<template>
<ul class="tree" :class="{ root: !depth }">
<li v-for="it in items" :key="it.path">
<!-- folder -->
<template v-if="it.dir">
<div class="row folder" :style="{ paddingLeft: (depth ?? 0) * 14 + 8 + 'px' }" @click="toggleFolder(it.path)">
<span class="twisty">{{ store.collapsed.has(it.path) ? '▸' : '▾' }}</span>
<span class="fname">{{ it.name }}</span>
</div>
<FileTree v-show="!store.collapsed.has(it.path)" :items="it.children!" :depth="(depth ?? 0) + 1" />
</template>
<!-- file -->
<template v-else>
<div
class="row file"
:class="{ active: it.path === store.selectedPath, meeting: it.node?.mode === 'meeting' }"
:style="{ paddingLeft: (depth ?? 0) * 14 + 8 + 'px' }"
@click="select(it.path)"
>
<input
type="checkbox"
:checked="store.selected.has(it.path)"
title="Add to package"
@click.stop="toggleSelect(it.path)"
/>
<span class="badge" :data-mode="it.node?.mode">{{ MODE_BADGE[it.node?.mode ?? ''] ?? it.node?.mode }}</span>
<span class="ext">{{ it.node?.ext }}</span>
<span class="fname">{{ it.node?.title || it.name }}</span>
<span v-if="it.node?.warnings?.length" class="warn" title="extraction warnings"></span>
</div>
</template>
</li>
</ul>
</template>
<style scoped>
.tree { list-style: none; margin: 0; padding: 0; }
.tree.root { overflow-y: auto; }
.row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 3px var(--space-2);
cursor: pointer;
white-space: nowrap;
font-size: var(--font-size-sm);
}
.row:hover { background: var(--surface-2); }
.row.active { background: var(--surface-3); }
.folder { color: var(--text-secondary); font-weight: 600; }
.twisty { width: 12px; display: inline-block; }
.fname { overflow: hidden; text-overflow: ellipsis; }
.file.meeting .fname { color: var(--text-secondary); }
.badge {
font-size: 9px; text-transform: uppercase; padding: 1px 4px; border-radius: 3px;
background: var(--surface-3); color: var(--text-secondary); flex-shrink: 0;
}
.badge[data-mode='extracted'] { background: var(--status-live, #2a6); color: #fff; }
.badge[data-mode='meeting'] { background: var(--status-processing, #a60); color: #fff; }
.ext { color: var(--text-secondary); font-size: 10px; width: 34px; flex-shrink: 0; }
.warn { margin-left: auto; color: var(--status-error, #e0a030); }
</style>

View File

@@ -1,123 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { store, humanBytes } from '@/store'
// Flatten meta.json into label/value rows, promoting the common fields to the
// top and rendering nested objects/arrays compactly.
const rows = computed(() => {
const m = store.current?.meta as Record<string, any> | undefined
if (!m) return []
const out: Array<{ label: string; value: string }> = []
const push = (label: string, value: unknown) => {
if (value === null || value === undefined || value === '') return
let s: string
if (Array.isArray(value)) s = value.join(', ')
else if (typeof value === 'object') s = JSON.stringify(value)
else s = String(value)
out.push({ label, value: s })
}
const src = (m.source ?? {}) as Record<string, unknown>
push('File', src.name)
push('Type', m.type)
push('Family', m.family)
push('Size', typeof src.bytes === 'number' ? humanBytes(src.bytes) : undefined)
push('Modified', src.modified)
push('Title', m.title)
push('Author', m.author)
push('Created', m.created)
push('Doc modified', m.modified_doc)
push('Pages', m.pages)
push('Slides', m.slides)
push('Sheets', m.sheets)
push('Rows', m.rows)
push('Columns', m.columns)
push('Words', m.word_count)
push('Captured', m.captured)
push('Duration (s)', m.duration_seconds)
push('Delegate', m.delegate)
push('Extractor', m.extractor)
push('sha256', src.sha256)
// Any remaining structured extras not already shown.
if (m.structure) push('Structure', m.structure)
if (m.headings) push('Headings', m.headings)
return out
})
const warnings = computed(() => {
const w = (store.current?.meta as any)?.warnings
return Array.isArray(w) ? w : []
})
</script>
<template>
<div class="meta-panel">
<div v-if="!store.current" class="empty">No document selected.</div>
<template v-else>
<img
v-if="store.current.thumbUrl"
class="thumb"
:src="store.current.thumbUrl"
alt="preview"
/>
<dl class="rows">
<template v-for="r in rows" :key="r.label">
<dt>{{ r.label }}</dt>
<dd>{{ r.value }}</dd>
</template>
</dl>
<div v-if="warnings.length" class="warnings">
<div class="warn-title">Warnings</div>
<ul>
<li v-for="(w, i) in warnings" :key="i">{{ w }}</li>
</ul>
</div>
</template>
</div>
</template>
<style scoped>
.meta-panel {
height: 100%;
overflow-y: auto;
padding: var(--space-3);
}
.empty {
color: var(--text-secondary);
}
.thumb {
width: 100%;
border-radius: var(--panel-radius);
border: var(--panel-border);
margin-bottom: var(--space-3);
}
.rows {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-3);
margin: 0;
font-size: var(--font-size-sm);
}
.rows dt {
color: var(--text-secondary);
white-space: nowrap;
}
.rows dd {
margin: 0;
overflow-wrap: anywhere;
}
.warnings {
margin-top: var(--space-4);
color: var(--status-error, #e0a030);
font-size: var(--font-size-sm);
}
.warn-title {
font-weight: 600;
margin-bottom: var(--space-1);
}
.warnings ul {
margin: 0;
padding-left: var(--space-4);
}
</style>

View File

@@ -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 {