From 300806b9362c8a5d74c192f7d5479e02b137da7d Mon Sep 17 00:00:00 2001 From: Mariano Gabriel Date: Sun, 5 Jul 2026 11:15:57 -0300 Subject: [PATCH] doocus update and meeting list export --- Makefile | 7 +- ctrl/batch.sh | 35 ++- doocus/README.md | 62 +++-- doocus/tree.py | 8 + process_tree.py | 3 + tests/test_doocus.py | 55 ++++ ui/doocus-app/package-lock.json | 13 + ui/doocus-app/package.json | 1 + ui/doocus-app/server/doocusApi.ts | 243 +++++++----------- ui/doocus-app/src/App.vue | 66 ++--- ui/doocus-app/src/components/DocDetail.vue | 142 ++++++++++ ui/doocus-app/src/components/DocPicker.vue | 85 ------ ui/doocus-app/src/components/DocViewer.vue | 76 ------ ui/doocus-app/src/components/ExportBar.vue | 83 ++---- ui/doocus-app/src/components/FileTree.vue | 73 ++++++ .../src/components/MetadataPanel.vue | 123 --------- ui/doocus-app/src/store.ts | 167 +++++++----- 17 files changed, 614 insertions(+), 628 deletions(-) create mode 100644 ui/doocus-app/src/components/DocDetail.vue delete mode 100644 ui/doocus-app/src/components/DocPicker.vue delete mode 100644 ui/doocus-app/src/components/DocViewer.vue create mode 100644 ui/doocus-app/src/components/FileTree.vue delete mode 100644 ui/doocus-app/src/components/MetadataPanel.vue diff --git a/Makefile b/Makefile index b6847df..2ba5d72 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/ctrl/batch.sh b/ctrl/batch.sh index 836d0f8..b4488c9 100755 --- a/ctrl/batch.sh +++ b/ctrl/batch.sh @@ -7,14 +7,19 @@ # the mirrored output subfolder. # # Usage: -# ctrl/batch.sh -i -o [-e "mkv mp4 ..."] [-n] \ -# [-- ] +# ctrl/batch.sh -i -o [-e "mkv mp4 ..."] \ +# [-l ] [-n] [-- ] # # 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 diff --git a/doocus/README.md b/doocus/README.md index 8e318f9..4d78fdb 100644 --- a/doocus/README.md +++ b/doocus/README.md @@ -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//` 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// -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// - 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) + /.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 ``` diff --git a/doocus/tree.py b/doocus/tree.py index a910895..d7c5f3f 100644 --- a/doocus/tree.py +++ b/doocus/tree.py @@ -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 diff --git a/process_tree.py b/process_tree.py index 52a8832..24286ab 100644 --- a/process_tree.py +++ b/process_tree.py @@ -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 -o {args.output} -l {args.output}/meetings.txt`") return 0 diff --git a/tests/test_doocus.py b/tests/test_doocus.py index 7b2fe92..296048c 100644 --- a/tests/test_doocus.py +++ b/tests/test_doocus.py @@ -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() diff --git a/ui/doocus-app/package-lock.json b/ui/doocus-app/package-lock.json index 3c793dd..fe141a6 100644 --- a/ui/doocus-app/package-lock.json +++ b/ui/doocus-app/package-lock.json @@ -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", diff --git a/ui/doocus-app/package.json b/ui/doocus-app/package.json index 85aecd3..2f66166 100644 --- a/ui/doocus-app/package.json +++ b/ui/doocus-app/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "jszip": "^3.10", + "marked": "^14", "vue": "^3.5" }, "devDependencies": { diff --git a/ui/doocus-app/server/doocusApi.ts b/ui/doocus-app/server/doocusApi.ts index 4126466..f3a0cf9 100644 --- a/ui/doocus-app/server/doocusApi.ts +++ b/ui/doocus-app/server/doocusApi.ts @@ -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 `.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= node + extracted content + meta + inline text + * GET /api/original?path= 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 = { - '.pdf': 'application/pdf', +interface Index { + root: string + generatedAt: string + counts: Record + 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 = { + '.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 { - const found: string[] = [] - const walk = async (dir: string, depth: number): Promise => { - 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(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 { - 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 { + 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 { - const dirs = await findDocDirs(outputDir) - const docs = [] - for (const dir of dirs) { - const meta = readJson>(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 { + 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 { - const meta = readJson>(path.join(dir, 'meta.json')) - if (!meta) { sendJson(res, 404, { error: 'meta missing' }); return } - const manifest = readJson(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 { + 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(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) } } diff --git a/ui/doocus-app/src/App.vue b/ui/doocus-app/src/App.vue index f743df7..6f0b791 100644 --- a/ui/doocus-app/src/App.vue +++ b/ui/doocus-app/src/App.vue @@ -1,40 +1,39 @@ diff --git a/ui/doocus-app/src/components/DocDetail.vue b/ui/doocus-app/src/components/DocDetail.vue new file mode 100644 index 0000000..86e317f --- /dev/null +++ b/ui/doocus-app/src/components/DocDetail.vue @@ -0,0 +1,142 @@ + + +