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

@@ -33,6 +33,11 @@ EXT ?=
PYTHON ?=
export PYTHON
# Optional: process only a newline list of RELATIVE paths (re-rooted at IN),
# e.g. the doocus meeting export. Used to run meetus over just the meetings:
# make batch IN="/mnt/drive" OUT=docs-output LIST=docs-output/meetings.txt
LIST ?=
# doocus (tree indexing) knobs. DOCS_OUT defaults to docs-output; the whole input
# tree is replicated into index.json under it. DOC_EXTRA forwards one-off flags to
# process_tree.py (e.g. --render for pdf page thumbnails, --ocr for scanned pdfs).
@@ -45,7 +50,7 @@ PY := $(if $(PYTHON),$(PYTHON),python3)
.PHONY: batch dry docs docs-dry help
batch:
@ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") -- \
@ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") $(if $(LIST),-l "$(LIST)") -- \
$(FLAGS) $(if $(AUDIO_LANG),--language $(AUDIO_LANG)) $(EXTRA)
dry:

View File

@@ -7,14 +7,19 @@
# the mirrored output subfolder.
#
# Usage:
# ctrl/batch.sh -i <input-dir> -o <output-dir> [-e "mkv mp4 ..."] [-n] \
# [-- <process_meeting.py flags>]
# ctrl/batch.sh -i <input-dir> -o <output-dir> [-e "mkv mp4 ..."] \
# [-l <list-file>] [-n] [-- <process_meeting.py flags>]
#
# Examples:
# # Everything under a mounted share, default extraction flags forwarded
# ctrl/batch.sh -i "/mnt/win/trainings" -o output/batch \
# -- --embed-images --scene-detection --diarize
#
# # Only the meetings doocus found (relative paths), re-rooted at a new mount,
# # output into the doocus folder for later packaging:
# ctrl/batch.sh -i "/mnt/drive" -o docs-output -l docs-output/meetings.txt \
# -- --embed-images --scene-detection --diarize
#
# # Dry run: just show which videos map to which output folders
# ctrl/batch.sh -i "/mnt/win/trainings" -o output/batch -n
#
@@ -42,6 +47,7 @@ INPUT=""
OUTPUT=""
EXTS="mkv mp4 mov avi m4v webm wmv ogg mp3 wav m4a opus flac aac"
DRY=false
LIST="" # optional newline list of RELATIVE paths (re-rooted at INPUT)
FORWARD=()
while [[ $# -gt 0 ]]; do
@@ -49,6 +55,7 @@ while [[ $# -gt 0 ]]; do
-i|--input) INPUT="$2"; shift 2 ;;
-o|--output) OUTPUT="$2"; shift 2 ;;
-e|--ext) EXTS="$2"; shift 2 ;;
-l|--list) LIST="$2"; shift 2 ;;
-n|--dry-run) DRY=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; FORWARD=("$@"); break ;;
@@ -59,6 +66,7 @@ done
[ -n "$INPUT" ] || { echo "ERROR: -i/--input is required" >&2; exit 1; }
[ -n "$OUTPUT" ] || { echo "ERROR: -o/--output is required" >&2; exit 1; }
[ -d "$INPUT" ] || { echo "ERROR: input dir not found: $INPUT" >&2; exit 1; }
[ -z "$LIST" ] || [ -f "$LIST" ] || { echo "ERROR: list file not found: $LIST" >&2; exit 1; }
# Absolute paths so relative-path math is stable regardless of where we cd'd.
INPUT="$(realpath "$INPUT")"
@@ -74,16 +82,35 @@ unset 'find_expr[${#find_expr[@]}-1]' # drop the trailing -o
echo "Input : $INPUT"
echo "Output: $OUTPUT"
echo "Exts : $EXTS"
[ -n "$LIST" ] && echo "List : $LIST (relative paths, re-rooted at input)" || echo "Exts : $EXTS"
[ "$DRY" = true ] && echo "(dry run — nothing will be processed)"
echo
# Producer: either the given list of relative paths (re-rooted at INPUT) or a
# recursive find by extension. Both emit null-delimited absolute paths.
producer() {
if [ -n "$LIST" ]; then
while IFS= read -r rel || [ -n "$rel" ]; do
[ -z "$rel" ] && continue
printf '%s\0' "$INPUT/$rel"
done < "$LIST"
else
find "$INPUT" -type f \( "${find_expr[@]}" \) -print0 | sort -z
fi
}
total=0 ok=0 fail=0
# Process substitution (not a pipe) so counters survive into the summary.
while IFS= read -r -d '' video; do
total=$((total + 1))
rel="${video#"$INPUT"/}" # path relative to the input root
if [ ! -f "$video" ]; then # list may name a path missing under this root
echo "[$total] $rel !! not found under input (skipped)" >&2
fail=$((fail + 1))
continue
fi
reldir="$(dirname "$rel")"
if [ "$reldir" = "." ]; then
outdir="$OUTPUT" # video sat directly in the input root
@@ -110,7 +137,7 @@ while IFS= read -r -d '' video; do
fail=$((fail + 1))
fi
echo
done < <(find "$INPUT" -type f \( "${find_expr[@]}" \) -print0 | sort -z)
done < <(producer)
echo "----------------------------------------"
if [ "$DRY" = true ]; then

View File

@@ -1,37 +1,47 @@
# doocus — local document extraction
# doocus — local drive tree indexer
The document twin of meetus. Turns locally-downloaded files (Drive exports,
PDFs, images, ...) into a shareable **textified** product (`content.md`) plus
structured `meta.json`, mirroring the meetus `output/<run>/` contract.
The document twin of meetus. Replicates a **whole local drive tree** into a
single `index.json`, preserving the folder hierarchy (no flattening). Every file
is a node; the **original file is the artifact** (what a QA/PM opens, what a
package links to). doocus only *extracts text* for the heavy formats
(docx/pdf/pptx/xlsx) — and that text is a **search index only, never a
replacement** for the document.
Extraction is **deterministic and offline** — no cloud AI touches the raw source.
The textified output is what feeds permitted services (Gemini web, NotebookLM)
and the local search index; the raw source stays on disk.
The raw source stays on disk; permitted services (Gemini web, NotebookLM) get the
original (as a link once we have Drive URLs, or re-uploaded).
## Quick start
```bash
# one document
python process_doc.py notes.docx # → docs-output/<YYYYMMDD-NNN-notes>/
python process_doc.py report.pdf --render # also render page 1 → thumb.jpg
python process_doc.py scan.png --ocr # OCR the image into content.md
# index a whole downloaded drive tree → docs-output/index.json
uv run make docs IN="/mnt/win/drive"
uv run make docs IN="..." DOC_EXTRA="--ocr" # OCR scanned pdfs
make docs-dry IN="..." # counts only, no writes
# a whole downloaded folder (mirrors the input tree)
make docs IN="/mnt/win/drive" # → docs-output/...
make docs IN="..." DOC_EXTRA="--render --ocr"
make docs-dry IN="..." # list what would run
# or directly
uv run process_tree.py /mnt/win/drive --output docs-output
# one-off single file (older per-file model, still handy)
uv run process_doc.py notes.docx
```
## Output contract
```
docs-output/<YYYYMMDD-NNN-stem>/
content.md textified document — the shareable product
meta.json source info, sha256, dates, author, title, pages/sheets/slides, word_count, warnings
thumb.jpg optional single preview (image / rendered page / video frame)
manifest.json run config + outputs inventory + source pointer (path, not a copy)
docs-output/
index.json whole tree; every file a node:
{ path, name, ext, family, mode, bytes,
modified, url:null } ◀ url = Drive link (later)
<mirrored path>/<file>.doocus/ sidecar — ONLY for extracted formats:
├── content.md extracted text — SEARCH INDEX ONLY
└── meta.json source/sha/dates/author/pages/... metadata
```
Node **modes**: `extracted` (docx/pdf/pptx/xlsx → text sidecar) · `meeting`
(mp4/mkv/... → belongs to meetus, not a doc) · `link` (everything else → original
is the artifact, nothing duplicated).
## Supported types
| Family | Extensions | Notes |
@@ -65,12 +75,16 @@ uv sync --group doocus --group ocr # + pytesseract (needs system `tesseract`
## Browser UI
`ui/doocus-app/` (sibling of `ui/meetus-app/`, shares `ui/framework/`) browses the
extracted docs with per-type viewers, a metadata panel, and a package builder
that zips the **original file + content.md + meta.json** per doc for a chosen
target (Gemini web / NotebookLM):
`ui/doocus-app/` (sibling of `ui/meetus-app/`, shares `ui/framework/`) reads
`index.json` and shows the **full tree** (folders preserved). Select a file →
detail view: for extracted docs, a split of the markdown-rendered **extracted
text** (clearly labelled "search index, not the document") beside the **native
view of the original** (pdf inline; docx/xlsx → download); for linked files the
original renders directly (image / html / text / markdown). A package builder
zips selected **originals** (+ the `content.md` for extracted ones) for a target
(Gemini web / NotebookLM), and will emit Drive **links** once nodes carry a `url`.
```bash
cd ui/doocus-app && npm install
DOOCUS_OUTPUT=/path/to/docs-output npm run dev
DOOCUS_OUTPUT=/abs/path/to/docs-output npm run dev
```

View File

@@ -87,6 +87,14 @@ def build_index(source_root, output_dir, options=None, dry_run=False) -> dict:
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
)
logger.info("Wrote index: %s (%d files)", out / "index.json", len(files))
# Export ONLY the meeting paths (relative), so a meetus run elsewhere reads
# just what it needs: mount the drive at a new root and feed this list to
# `ctrl/batch.sh -l`. Relative paths re-root cleanly under the new mount.
meetings = [f["path"] for f in files if f["mode"] == "meeting"]
(out / "meetings.txt").write_text(
"".join(m + "\n" for m in meetings), encoding="utf-8"
)
logger.info("Wrote meetings list: %s (%d meetings)", out / "meetings.txt", len(meetings))
return index

View File

@@ -51,6 +51,9 @@ def main() -> int:
f"{c['meeting']} meeting, {c['link']} linked"
+ (f", {c['warnings']} with warnings" if c.get("warnings") else "")
+ (" (dry run — nothing written)" if args.dry_run else ""))
if not args.dry_run and c["meeting"]:
print(f"{args.output}/meetings.txt lists the {c['meeting']} meeting path(s) "
f"for meetus: `ctrl/batch.sh -i <mount> -o {args.output} -l {args.output}/meetings.txt`")
return 0

View File

@@ -13,6 +13,7 @@ from pathlib import Path
from tests import REPO_ROOT # noqa: F401 (puts repo root on sys.path)
from doocus import registry
from doocus.workflow import DocConfig, DocWorkflow
from doocus.tree import build_index, classify
def has(mod: str) -> bool:
@@ -136,5 +137,59 @@ class TestExtractors(unittest.TestCase):
self.assertEqual(meta["title"], "PDF Doc")
class TestTreeIndex(unittest.TestCase):
def test_classify(self):
self.assertEqual(classify("docx"), "extracted")
self.assertEqual(classify("pdf"), "extracted")
self.assertEqual(classify("mp4"), "meeting")
self.assertEqual(classify("mkv"), "meeting")
self.assertEqual(classify("md"), "link")
self.assertEqual(classify("csv"), "link")
self.assertEqual(classify("bin"), "link")
def test_build_index_replicates_tree(self):
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "drive"
(src / "team a" / "sub").mkdir(parents=True)
(src / "team a" / "notes.md").write_text("# Hi\n\nbody\n")
(src / "team a" / "sub" / "t.csv").write_text("a,b\n1,2\n")
(src / "team a" / "clip.mp4").write_bytes(b"\x00\x00") # video → meeting
(src / "misc.bin").write_bytes(b"\x00") # unknown → link
out = Path(tmp) / "out"
index = build_index(src, out)
# Whole tree replicated (no flattening), paths preserved.
paths = {f["path"] for f in index["files"]}
self.assertEqual(paths, {
"team a/notes.md", "team a/sub/t.csv", "team a/clip.mp4", "misc.bin"})
modes = {f["path"]: f["mode"] for f in index["files"]}
self.assertEqual(modes["team a/clip.mp4"], "meeting")
self.assertEqual(modes["team a/notes.md"], "link")
self.assertEqual(modes["misc.bin"], "link")
# Every node carries the Drive-url slot.
self.assertTrue(all("url" in f for f in index["files"]))
self.assertTrue((out / "index.json").exists())
@unittest.skipUnless(has("docx"), "python-docx not installed")
def test_build_index_extracts_sidecar(self):
import docx
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "drive"
src.mkdir()
doc = docx.Document()
doc.add_paragraph("hello world")
doc.save(src / "report.docx")
out = Path(tmp) / "out"
index = build_index(src, out)
node = next(f for f in index["files"] if f["path"] == "report.docx")
self.assertEqual(node["mode"], "extracted")
sidecar = out / node["out"]
self.assertTrue((sidecar / "content.md").exists())
self.assertTrue((sidecar / "meta.json").exists())
self.assertIn("hello", (sidecar / "content.md").read_text())
if __name__ == "__main__":
unittest.main()

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' })
if (parts[0] === 'original') {
await serveOriginal(res, q.get('path') ?? '')
return true
}
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,
})
}
docs.sort((a, b) =>
String(b.extractedAt ?? '').localeCompare(String(a.extractedAt ?? '')) || a.id.localeCompare(b.id))
sendJson(res, 200, { docs, outputDir })
return false
}
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'))
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 = ''
try {
content = await fsp.readFile(path.join(dir, 'content.md'), 'utf-8')
} catch { /* none */ }
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
const hasThumb = fs.existsSync(path.join(dir, 'thumb.jpg'))
const originalPath = manifest?.source?.path
const hasOriginal = !!originalPath && isFile(originalPath)
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 { /* */ } }
}
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,71 +1,55 @@
<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 title="File">
<DocDetail />
</Panel>
</template>
<template #second>
<Panel title="Metadata">
<MetadataPanel />
</Panel>
</template>
</SplitPane>
</template>
</SplitPane>
</main>
</div>
</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 {