doocus update and meeting list export
This commit is contained in:
7
Makefile
7
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
13
ui/doocus-app/package-lock.json
generated
13
ui/doocus-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10",
|
||||
"marked": "^14",
|
||||
"vue": "^3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
142
ui/doocus-app/src/components/DocDetail.vue
Normal file
142
ui/doocus-app/src/components/DocDetail.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
73
ui/doocus-app/src/components/FileTree.vue
Normal file
73
ui/doocus-app/src/components/FileTree.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,37 +1,45 @@
|
||||
/**
|
||||
* Reactive app store for the doocus browser.
|
||||
* Reactive store for the doocus tree browser.
|
||||
*
|
||||
* Reads the local docs-output tree via /api/docs (see server/doocusApi.ts).
|
||||
* Holds the doc list, the currently open doc (meta + textified content), and the
|
||||
* selection used by the package builder. Read-only over the pipeline output —
|
||||
* the UI never mutates extracted artifacts.
|
||||
* Loads the whole-drive tree from /api/tree (index.json), builds a folder
|
||||
* hierarchy from the flat file list, lazily fetches per-file detail on select,
|
||||
* and tracks the packaging selection. Read-only over the pipeline output.
|
||||
*/
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
export interface DocSummary {
|
||||
id: string
|
||||
export interface FileNode {
|
||||
path: string
|
||||
name: string
|
||||
type: string | null
|
||||
family: string | null
|
||||
title: string | null
|
||||
extractedAt: string | null
|
||||
wordCount: number
|
||||
ext: string
|
||||
family: string
|
||||
mode: 'extracted' | 'meeting' | 'link'
|
||||
bytes: number
|
||||
hasThumb: boolean
|
||||
warnings: number
|
||||
modified: string
|
||||
url: string | null
|
||||
out?: string
|
||||
words?: number
|
||||
title?: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface DocDetail {
|
||||
id: string
|
||||
meta: Record<string, unknown>
|
||||
content: string
|
||||
hasThumb: boolean
|
||||
thumbUrl: string | null
|
||||
hasOriginal: boolean
|
||||
originalUrl: string | null
|
||||
export interface Detail {
|
||||
node: FileNode
|
||||
content: string | null // extracted search text (heavy formats), markdown
|
||||
meta: Record<string, unknown> | null
|
||||
text: string | null // inline text for text-native originals
|
||||
mime: string
|
||||
originalUrl: string
|
||||
}
|
||||
|
||||
/** A folder or file in the display tree. */
|
||||
export interface TreeItem {
|
||||
name: string
|
||||
path: string
|
||||
dir: boolean
|
||||
children?: TreeItem[]
|
||||
node?: FileNode
|
||||
}
|
||||
|
||||
/** Package targets and their (soft) limits. Surfaced, not hard-enforced. */
|
||||
export interface Target {
|
||||
key: string
|
||||
label: string
|
||||
@@ -45,72 +53,109 @@ export const TARGETS: Target[] = [
|
||||
]
|
||||
|
||||
interface State {
|
||||
docs: DocSummary[]
|
||||
outputDir: string
|
||||
currentId: string | null
|
||||
current: DocDetail | null
|
||||
root: string
|
||||
files: FileNode[]
|
||||
tree: TreeItem[]
|
||||
counts: Record<string, number>
|
||||
selectedPath: string | null
|
||||
detail: Detail | null
|
||||
loading: boolean
|
||||
selected: Set<string>
|
||||
collapsed: Set<string> // collapsed folder paths (folders default open)
|
||||
targetKey: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export const store = reactive<State>({
|
||||
docs: [],
|
||||
outputDir: '',
|
||||
currentId: null,
|
||||
current: null,
|
||||
root: '',
|
||||
files: [],
|
||||
tree: [],
|
||||
counts: {},
|
||||
selectedPath: null,
|
||||
detail: null,
|
||||
loading: false,
|
||||
selected: new Set<string>(),
|
||||
collapsed: new Set<string>(),
|
||||
targetKey: TARGETS[0].key,
|
||||
error: '',
|
||||
})
|
||||
|
||||
export async function loadDocs(): Promise<void> {
|
||||
const res = await fetch('/api/docs')
|
||||
const data = await res.json()
|
||||
store.docs = data.docs ?? []
|
||||
store.outputDir = data.outputDir ?? ''
|
||||
if (!store.currentId && store.docs.length) {
|
||||
await openDoc(store.docs[0].id)
|
||||
}
|
||||
export function toggleFolder(path: string): void {
|
||||
if (store.collapsed.has(path)) store.collapsed.delete(path)
|
||||
else store.collapsed.add(path)
|
||||
}
|
||||
|
||||
export async function openDoc(id: string): Promise<void> {
|
||||
/** Build a nested folder tree from flat posix paths, folders before files. */
|
||||
function buildTree(files: FileNode[]): TreeItem[] {
|
||||
const root: TreeItem = { name: '', path: '', dir: true, children: [] }
|
||||
for (const f of files) {
|
||||
const parts = f.path.split('/')
|
||||
let cur = root
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const seg = parts[i]
|
||||
const p = parts.slice(0, i + 1).join('/')
|
||||
let next = cur.children!.find((c) => c.dir && c.name === seg)
|
||||
if (!next) {
|
||||
next = { name: seg, path: p, dir: true, children: [] }
|
||||
cur.children!.push(next)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
cur.children!.push({ name: f.name, path: f.path, dir: false, node: f })
|
||||
}
|
||||
const sort = (items: TreeItem[]): TreeItem[] => {
|
||||
items.sort((a, b) =>
|
||||
a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1)
|
||||
for (const it of items) if (it.dir) sort(it.children!)
|
||||
return items
|
||||
}
|
||||
return sort(root.children!)
|
||||
}
|
||||
|
||||
export async function loadTree(): Promise<void> {
|
||||
store.loading = true
|
||||
store.error = ''
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`open failed: ${res.status}`)
|
||||
store.current = await res.json()
|
||||
store.currentId = id
|
||||
const res = await fetch('/api/tree')
|
||||
if (!res.ok) { store.error = (await res.json()).error ?? `HTTP ${res.status}`; return }
|
||||
const index = await res.json()
|
||||
store.root = index.root
|
||||
store.files = index.files
|
||||
store.counts = index.counts
|
||||
store.tree = buildTree(index.files)
|
||||
} catch (e) {
|
||||
store.error = String(e)
|
||||
} finally {
|
||||
store.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleSelect(id: string): void {
|
||||
if (store.selected.has(id)) store.selected.delete(id)
|
||||
else store.selected.add(id)
|
||||
export async function select(path: string): Promise<void> {
|
||||
store.selectedPath = path
|
||||
store.detail = null
|
||||
const res = await fetch(`/api/detail?path=${encodeURIComponent(path)}`)
|
||||
if (res.ok) store.detail = await res.json()
|
||||
}
|
||||
|
||||
export function isSelected(id: string): boolean {
|
||||
return store.selected.has(id)
|
||||
export function toggleSelect(path: string): void {
|
||||
if (store.selected.has(path)) store.selected.delete(path)
|
||||
else store.selected.add(path)
|
||||
}
|
||||
|
||||
export const target = computed(() => TARGETS.find((t) => t.key === store.targetKey) ?? TARGETS[0])
|
||||
|
||||
/** Rough package estimate: each doc contributes original + content.md + meta.json. */
|
||||
/** Package estimate: one original per selected file (+ a small sidecar for extracted). */
|
||||
export const packageEstimate = computed(() => {
|
||||
const chosen = store.docs.filter((d) => store.selected.has(d.id))
|
||||
const files = chosen.length * 3 // original + content.md + meta.json
|
||||
// originals dominate size; content/meta are small — approximate with wordCount.
|
||||
const bytes = chosen.reduce((sum, d) => sum + d.bytes + d.wordCount * 6 + 512, 0)
|
||||
const t = target.value
|
||||
return {
|
||||
docCount: chosen.length,
|
||||
files,
|
||||
bytes,
|
||||
overFiles: files > t.maxFiles,
|
||||
overBytes: bytes > t.maxBytes,
|
||||
const chosen = store.files.filter((f) => store.selected.has(f.path))
|
||||
let files = 0
|
||||
let bytes = 0
|
||||
for (const f of chosen) {
|
||||
files += 1 // the original
|
||||
bytes += f.bytes
|
||||
if (f.mode === 'extracted') { files += 1; bytes += (f.words ?? 0) * 6 + 512 } // content.md
|
||||
}
|
||||
const t = target.value
|
||||
return { docCount: chosen.length, files, bytes, overFiles: files > t.maxFiles, overBytes: bytes > t.maxBytes }
|
||||
})
|
||||
|
||||
export function humanBytes(n: number): string {
|
||||
|
||||
Reference in New Issue
Block a user