From 8606520ef2ae086fde61988d3eee922655ad4b07 Mon Sep 17 00:00:00 2001 From: Mariano Gabriel Date: Sun, 5 Jul 2026 20:35:44 -0300 Subject: [PATCH] doocus improvemnts --- .gitignore | 1 + INDEX.md | 13 +- Makefile | 39 +- doocus/README.md | 29 +- doocus/tree.py | 12 + meetus/output_manager.py | 48 +- meetus/workflow.py | 7 +- process_meeting.py | 7 + tests/test_config.py | 4 + tests/test_doocus.py | 14 +- tests/test_makefile.py | 9 + tests/test_output_manager.py | 43 + ui/doocus-app/package-lock.json | 10 + ui/doocus-app/package.json | 1 + ui/doocus-app/server/doocusApi.ts | 33 +- ui/doocus-app/src/App.vue | 37 +- ui/doocus-app/src/components/DocDetail.vue | 3 +- ui/doocus-app/src/components/FileTree.vue | 9 +- ui/doocus-app/src/components/NativeViewer.vue | 81 +- ui/doocus-app/src/main.ts | 1 + ui/doocus-app/src/store.ts | 25 + uv.lock | 963 ++++++++++++++++++ 22 files changed, 1328 insertions(+), 61 deletions(-) create mode 100644 tests/test_output_manager.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index adeb428..2581b31 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ __pycache__ .venv/ local-run.sh +meetings.txt diff --git a/INDEX.md b/INDEX.md index 9c2b539..06a58f7 100644 --- a/INDEX.md +++ b/INDEX.md @@ -51,9 +51,16 @@ docs-output/ whole tree replicated, no flatt Node modes: **extracted** (docx/pdf/pptx/xlsx → text sidecar), **meeting** (mp4/... → belongs to meetus), **link** (everything else → original is the -artifact, not duplicated). Each node has a `url` slot for the eventual Drive link. -`make docs` (→ `process_tree.py`) indexes a whole tree; `process_doc.py` still does -one-off single-file extraction. Full doocus docs: [`doocus/README.md`](doocus/README.md). +artifact, not duplicated). Each node has a `url` slot for the eventual Drive link +and a `created` date. `make docs` (→ `process_tree.py`) indexes a whole tree; +`process_doc.py` still does one-off single-file extraction. + +Meetings stay coherent with docs: `docs-output/meetings.txt` lists the meeting +paths, and `make batch … LIST=… OUT_FORMAT="{name}.meetus"` runs meetus into a +`.meetus/` sidecar beside the docs — the exact path the indexer already +pointed each `meeting` node at (`out`/`transcript`). meetus's `--out-format` +(tokens `{date} {run} {stem} {name}`) makes the run-folder name configurable; +default is unchanged. Full doocus docs: [`doocus/README.md`](doocus/README.md). Cross-search (docs frame + meetings frame, over cached text) is a later phase. 🚧 ## Status legend diff --git a/Makefile b/Makefile index 2ba5d72..07a6339 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,13 @@ # make batch IN="..." EXT="mp4 mov" # limit extensions # make batch IN="..." FLAGS="--embed-images" # replace the base flags # +# Meetings flow (process only the videos doocus found, coherent with the docs): +# 1. uv run make docs IN="/mnt/drive" # → docs-output/{index.json, meetings.txt} +# 2. make batch IN="/mnt/drive" OUT=docs-output \ # meetus over just the meetings… +# LIST=docs-output/meetings.txt \ # …only the listed relative paths +# OUT_FORMAT="{name}.meetus" # …into .meetus/ sidecars +# (matches doocus's index pointer, so meetings sit beside the .doocus/ docs.) +# # IN is required. OUT defaults to IN (write in place); otherwise the input # folder structure is mirrored under OUT. @@ -38,6 +45,11 @@ export PYTHON # make batch IN="/mnt/drive" OUT=docs-output LIST=docs-output/meetings.txt LIST ?= +# Optional: run-folder name template forwarded to process_meeting.py --out-format. +# Tokens: {date} {run} {stem} {name}. Default (empty) → "{date}-{run:03d}-{stem}". +# Use "{name}.meetus" so meetings land in a docs-coherent .meetus/ sidecar. +OUT_FORMAT ?= + # 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). @@ -47,14 +59,23 @@ DOC_EXTRA ?= # Python interpreter for doocus (override PYTHON, else python3 — use `uv run make`). PY := $(if $(PYTHON),$(PYTHON),python3) -.PHONY: batch dry docs docs-dry help +# Merge a separate output tree into another (e.g. a meetings run done in its own +# folder → docs-output). Both mirror the same source-relative structure, so this +# just unions the sidecars beside the docs. Always dry-run first. +# make merge-dry SRC=meetings-output # preview +# make merge SRC=meetings-output # apply (DST defaults to docs-output) +MERGE_SRC ?= +MERGE_DST ?= docs-output + +.PHONY: batch dry docs docs-dry merge merge-dry help batch: @ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") $(if $(LIST),-l "$(LIST)") -- \ - $(FLAGS) $(if $(AUDIO_LANG),--language $(AUDIO_LANG)) $(EXTRA) + $(FLAGS) $(if $(AUDIO_LANG),--language $(AUDIO_LANG)) \ + $(if $(OUT_FORMAT),--out-format "$(OUT_FORMAT)") $(EXTRA) dry: - @ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") -n + @ctrl/batch.sh -i "$(IN)" -o "$(OUT)" $(if $(EXT),-e "$(EXT)") $(if $(LIST),-l "$(LIST)") -n # Document indexing (doocus): replicate the whole IN tree into DOCS_OUT/index.json, # extracting text sidecars only for docx/pdf/pptx/xlsx. @@ -67,5 +88,17 @@ docs: docs-dry: @$(PY) process_tree.py "$(IN)" --output "$(DOCS_OUT)" --dry-run +# index.json/meetings.txt are excluded so a merge of meeting dumps can never +# clobber the canonical index that lives only in docs-output. +MERGE_EXCLUDES = --exclude index.json --exclude meetings.txt + +merge: + @test -n "$(MERGE_SRC)" || { echo "ERROR: MERGE_SRC is required" >&2; exit 1; } + @rsync -a $(MERGE_EXCLUDES) "$(MERGE_SRC)/" "$(MERGE_DST)/" && echo "merged $(MERGE_SRC) → $(MERGE_DST)" + +merge-dry: + @test -n "$(MERGE_SRC)" || { echo "ERROR: MERGE_SRC is required" >&2; exit 1; } + @rsync -avn $(MERGE_EXCLUDES) "$(MERGE_SRC)/" "$(MERGE_DST)/" + help: @sed -n '3,14p' Makefile diff --git a/doocus/README.md b/doocus/README.md index 4d78fdb..844da93 100644 --- a/doocus/README.md +++ b/doocus/README.md @@ -40,7 +40,34 @@ docs-output/ 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). +is the artifact, nothing duplicated). Every node also carries `created` (birth +time, falling back to mtime) so docs and meetings sort by creation date coherently. + +## Meetings + +Videos are `meeting` nodes — doocus does **not** transcribe them; meetus does. To +keep everything in one coherent, searchable tree, run meetus over **only** the +meetings doocus found and write each into a `.meetus/` sidecar right next to +its `.doocus/`-style neighbours: + +```bash +# 1. index the drive → docs-output/{index.json, meetings.txt} +uv run make docs IN="/mnt/drive" + +# 2. meetus over ONLY the listed meetings, re-rooted at the mount, into +# .meetus/ sidecars (matches the pointer doocus already wrote): +make batch IN="/mnt/drive" OUT=docs-output \ + LIST=docs-output/meetings.txt \ + OUT_FORMAT="{name}.meetus" +``` + +- `meetings.txt` holds **relative** paths only — mount the drive anywhere and the + `-l/--list` re-roots them under `-i`, so this machine reads only the meetings. +- `OUT_FORMAT="{name}.meetus"` is meetus's new `--out-format` (tokens `{date} + {run} {stem} {name}`; default keeps `YYYYMMDD-NNN-`). Omitting `{run}` + makes the folder deterministic, so it matches the `out`/`transcript` pointer the + indexer set on each `meeting` node — no relink step. +- Frames land in `.meetus/frames/` for now (unchanged). ## Supported types diff --git a/doocus/tree.py b/doocus/tree.py index d7c5f3f..f9e7a4f 100644 --- a/doocus/tree.py +++ b/doocus/tree.py @@ -31,6 +31,11 @@ EXTRACT_EXTS = {"docx", "pdf", "pptx", "xlsx"} # Videos are meetings → meetus territory. MEETING_EXTS = {"mp4", "mkv", "mov", "m4v", "webm", "avi", "wmv"} +# Sidecar suffix meetus writes a meeting's output into. Must match the batch +# invocation `make batch ... OUT_FORMAT="{name}.meetus"` so the index pointer set +# here (deterministically, at index time) resolves once meetus has run. +MEETING_SIDECAR_SUFFIX = ".meetus" + def classify(ext: str) -> str: if ext in EXTRACT_EXTS: @@ -60,6 +65,7 @@ def build_index(source_root, output_dir, options=None, dry_run=False) -> dict: mode = classify(ext) counts[mode] += 1 stat = p.stat() + birth = getattr(stat, "st_birthtime", None) # not on every filesystem node = { "path": rel, "name": p.name, @@ -68,11 +74,17 @@ def build_index(source_root, output_dir, options=None, dry_run=False) -> dict: "mode": mode, "bytes": stat.st_size, "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "created": datetime.fromtimestamp(birth or stat.st_mtime).isoformat(), "url": None, # Drive link — filled later from a URL source } if mode == "extracted" and not dry_run: _extract_sidecar(p, rel, out, options, node, counts) + elif mode == "meeting": + # Deterministic pointer to meetus's output sidecar (produced later by + # `make batch ... OUT_FORMAT="{name}.meetus"`). No relink needed. + node["out"] = rel + MEETING_SIDECAR_SUFFIX + node["transcript"] = f"{rel}{MEETING_SIDECAR_SUFFIX}/{p.stem}_enhanced.txt" files.append(node) diff --git a/meetus/output_manager.py b/meetus/output_manager.py index 0946d65..41faa6b 100644 --- a/meetus/output_manager.py +++ b/meetus/output_manager.py @@ -11,10 +11,16 @@ from typing import Dict, Any, Optional logger = logging.getLogger(__name__) +# Default run-folder name format. Tokens: {date} (YYYYMMDD), {run} (per-day +# counter), {stem} (filename w/o ext), {name} (full filename incl. ext). +DEFAULT_FOLDER_FORMAT = "{date}-{run:03d}-{stem}" + + class OutputManager: """Manage output directories and manifest files for video processing.""" - def __init__(self, video_path: Path, base_output_dir: str = "output", use_cache: bool = True): + def __init__(self, video_path: Path, base_output_dir: str = "output", + use_cache: bool = True, folder_format: str = DEFAULT_FOLDER_FORMAT): """ Initialize output manager. @@ -22,10 +28,15 @@ class OutputManager: video_path: Path to the video file being processed base_output_dir: Base directory for all outputs use_cache: Whether to use existing directories if found + folder_format: Run-folder name template. Tokens: {date}, {run}, + {stem}, {name}. When {run} is absent the counter is skipped and + the expanded name is used directly (reuse-by-name = caching), which + is how meetings land in a stable `.meetus/` sidecar. """ self.video_path = video_path self.base_output_dir = Path(base_output_dir) self.use_cache = use_cache + self.folder_format = folder_format or DEFAULT_FOLDER_FORMAT # Find or create output directory self.output_dir = self._get_or_create_output_dir() @@ -34,16 +45,45 @@ class OutputManager: logger.info(f"Output directory: {self.output_dir}") + def _has_run(self) -> bool: + """Whether the folder format uses the per-day {run} counter.""" + return "{run" in self.folder_format + + def _expand(self, run: int = 1) -> str: + """Expand the folder-format template to a directory name.""" + return self.folder_format.format( + date=datetime.now().strftime("%Y%m%d"), + run=run, + stem=self.video_path.stem, + name=self.video_path.name, + ) + def _get_or_create_output_dir(self) -> Path: """ - Get existing output directory or create a new one with incremental number. + Get existing output directory or create one per the folder format. + + With a {run} counter (the default) this reuses the most recent run for + this video when caching, else creates the next-numbered folder. Without + {run} (e.g. "{name}.meetus") the name is deterministic — reuse it when it + exists (caching), else create it. Returns: Path to output directory """ video_name = self.video_path.stem - # Look for existing directories if caching is enabled + # Deterministic (no {run}) — stable name, reuse-by-name gives caching. + if not self._has_run(): + dir_name = self._expand() + output_dir = self.base_output_dir / dir_name + if self.use_cache and output_dir.exists(): + logger.info(f"Found existing output: {dir_name}") + else: + output_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Created new output directory: {dir_name}") + return output_dir + + # {run} counter (default): reuse most recent for this video, else next NNN. if self.use_cache and self.base_output_dir.exists(): existing_dirs = sorted([ d for d in self.base_output_dir.iterdir() @@ -76,7 +116,7 @@ class OutputManager: else: next_run = 1 - dir_name = f"{date_str}-{next_run:03d}-{video_name}" + dir_name = self._expand(run=next_run) output_dir = self.base_output_dir / dir_name output_dir.mkdir(parents=True, exist_ok=True) logger.info(f"Created new output directory: {dir_name}") diff --git a/meetus/workflow.py b/meetus/workflow.py index 31bff00..c0e52a7 100644 --- a/meetus/workflow.py +++ b/meetus/workflow.py @@ -9,7 +9,7 @@ import subprocess import shutil from typing import Dict, Any, Optional -from .output_manager import OutputManager +from .output_manager import OutputManager, DEFAULT_FOLDER_FORMAT from .cache_manager import CacheManager from .frame_extractor import FrameExtractor from .transcript_merger import TranscriptMerger @@ -31,6 +31,8 @@ class WorkflowConfig: self.transcript_path = kwargs.get('transcript') self.output_dir = kwargs.get('output_dir', 'output') self.custom_output = kwargs.get('output') + # Run-folder name template (see meetus/output_manager.py). None → default. + self.out_format = kwargs.get('out_format') # Whisper options self.run_whisper = kwargs.get('run_whisper', False) @@ -124,7 +126,8 @@ class ProcessingWorkflow: self.output_mgr = OutputManager( config.video_path, config.output_dir, - use_cache=not config.no_cache + use_cache=not config.no_cache, + folder_format=config.out_format or DEFAULT_FOLDER_FORMAT, ) self.cache_mgr = CacheManager( self.output_mgr.output_dir, diff --git a/process_meeting.py b/process_meeting.py index 3f3ea59..8fd64f8 100644 --- a/process_meeting.py +++ b/process_meeting.py @@ -97,6 +97,13 @@ Examples: help='Base directory for outputs (default: output/)', default='output' ) + parser.add_argument( + '--out-format', + help='Run-folder name template. Tokens: {date} {run} {stem} {name}. ' + 'Default: "{date}-{run:03d}-{stem}". Omit {run} for a stable folder ' + '(e.g. "{name}.meetus" → a docs-coherent sidecar for doocus).', + default=None + ) # Frame extraction options parser.add_argument( diff --git a/tests/test_config.py b/tests/test_config.py index bc8e956..3026e8a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -51,6 +51,10 @@ class TestDefaults(unittest.TestCase): def test_to_dict_analysis_is_embed_images(self): self.assertEqual(cfg().to_dict()["analysis"]["method"], "embed-images") + def test_out_format_defaults_none_and_passes_through(self): + self.assertIsNone(cfg().out_format) + self.assertEqual(cfg(out_format="{name}.meetus").out_format, "{name}.meetus") + def test_to_dict_whisper_has_diarize_and_formats(self): d = cfg(diarize=True, transcript_formats="srt").to_dict() self.assertTrue(d["whisper"]["diarize"]) diff --git a/tests/test_doocus.py b/tests/test_doocus.py index 296048c..c1ef221 100644 --- a/tests/test_doocus.py +++ b/tests/test_doocus.py @@ -167,10 +167,22 @@ class TestTreeIndex(unittest.TestCase): 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. + # Every node carries the Drive-url slot and a created date. self.assertTrue(all("url" in f for f in index["files"])) + self.assertTrue(all(f.get("created") for f in index["files"])) self.assertTrue((out / "index.json").exists()) + def test_meeting_node_points_at_meetus_sidecar(self): + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "drive" + (src / "team a").mkdir(parents=True) + (src / "team a" / "standup.mp4").write_bytes(b"\x00\x00") + index = build_index(src, Path(tmp) / "out") + node = next(f for f in index["files"] if f["path"] == "team a/standup.mp4") + # Deterministic pointer that `make batch ... OUT_FORMAT="{name}.meetus"` fills. + self.assertEqual(node["out"], "team a/standup.mp4.meetus") + self.assertEqual(node["transcript"], "team a/standup.mp4.meetus/standup_enhanced.txt") + @unittest.skipUnless(has("docx"), "python-docx not installed") def test_build_index_extracts_sidecar(self): import docx diff --git a/tests/test_makefile.py b/tests/test_makefile.py index 2b6dcde..6eb4914 100644 --- a/tests/test_makefile.py +++ b/tests/test_makefile.py @@ -50,6 +50,15 @@ class TestMakefile(unittest.TestCase): self.assertEqual(r.returncode, 0, r.stderr) self.assertIn("--language es", r.stdout) + def test_out_format_forwarded(self): + with tempfile.TemporaryDirectory() as tmp: + ind = Path(tmp) / "in" + ind.mkdir() + (ind / "x.mp4").touch() + r = make("batch", IN=str(ind), PYTHON="echo", OUT_FORMAT="{name}.meetus") + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("--out-format {name}.meetus", r.stdout) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_output_manager.py b/tests/test_output_manager.py new file mode 100644 index 0000000..66bc215 --- /dev/null +++ b/tests/test_output_manager.py @@ -0,0 +1,43 @@ +"""OutputManager run-folder naming: the default {run} counter and the new +configurable folder_format (deterministic sidecars for the doocus meeting flow).""" +import tempfile +import unittest +from pathlib import Path + +from tests import REPO_ROOT # noqa: F401 +from meetus.output_manager import OutputManager, DEFAULT_FOLDER_FORMAT + + +class TestFolderFormat(unittest.TestCase): + def test_default_uses_date_run_stem(self): + with tempfile.TemporaryDirectory() as d: + om = OutputManager(Path("/x/meeting.mkv"), d) + name = om.output_dir.name + self.assertTrue(name.endswith("-meeting")) + # YYYYMMDD-NNN-meeting → middle part is the zero-padded run number + self.assertRegex(name, r"^\d{8}-\d{3}-meeting$") + + def test_deterministic_sidecar_no_counter(self): + with tempfile.TemporaryDirectory() as d: + om = OutputManager(Path("/x/team a/standup.mp4"), d, folder_format="{name}.meetus") + self.assertEqual(om.output_dir.name, "standup.mp4.meetus") + + def test_sidecar_reused_by_name(self): + with tempfile.TemporaryDirectory() as d: + a = OutputManager(Path("/x/standup.mp4"), d, folder_format="{name}.meetus") + b = OutputManager(Path("/x/standup.mp4"), d, folder_format="{name}.meetus") + self.assertEqual(a.output_dir, b.output_dir) # caching by stable name + + def test_stem_token(self): + with tempfile.TemporaryDirectory() as d: + om = OutputManager(Path("/x/standup.mp4"), d, folder_format="{stem}") + self.assertEqual(om.output_dir.name, "standup") + + def test_empty_format_falls_back_to_default(self): + with tempfile.TemporaryDirectory() as d: + om = OutputManager(Path("/x/m.mkv"), d, folder_format="") + self.assertEqual(om.folder_format, DEFAULT_FOLDER_FORMAT) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/doocus-app/package-lock.json b/ui/doocus-app/package-lock.json index f50f132..ee86086 100644 --- a/ui/doocus-app/package-lock.json +++ b/ui/doocus-app/package-lock.json @@ -8,6 +8,7 @@ "name": "doocus-browser-ui", "version": "0.1.0", "dependencies": { + "highlight.js": "^11.10", "jszip": "^3.10", "mammoth": "^1.8", "marked": "^14", @@ -1319,6 +1320,15 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", diff --git a/ui/doocus-app/package.json b/ui/doocus-app/package.json index 81daccf..9cd3d2b 100644 --- a/ui/doocus-app/package.json +++ b/ui/doocus-app/package.json @@ -10,6 +10,7 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { + "highlight.js": "^11.10", "jszip": "^3.10", "mammoth": "^1.8", "marked": "^14", diff --git a/ui/doocus-app/server/doocusApi.ts b/ui/doocus-app/server/doocusApi.ts index f3a0cf9..c182b20 100644 --- a/ui/doocus-app/server/doocusApi.ts +++ b/ui/doocus-app/server/doocusApi.ts @@ -80,7 +80,7 @@ export function doocusApi(opts: Options): Plugin { server.middlewares.use('/api', (req, res, next) => { const url = new URL(req.url ?? '/', 'http://localhost') const parts = url.pathname.split('/').filter(Boolean) - handle(res, parts, url.searchParams).catch((err) => { + handle(res, parts, url.searchParams, req.headers.range).catch((err) => { server.config.logger.error(`[doocus-api] ${String(err)}`) sendJson(res, 500, { error: String(err) }) }).then((handled) => { if (!handled) next() }) @@ -88,7 +88,7 @@ export function doocusApi(opts: Options): Plugin { }, } - async function handle(res: ServerResponse, parts: string[], q: URLSearchParams): Promise { + async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise { if (parts[0] === 'tree') { const index = readIndex() if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true } @@ -102,7 +102,7 @@ export function doocusApi(opts: Options): Plugin { } if (parts[0] === 'original') { - await serveOriginal(res, q.get('path') ?? '') + await serveOriginal(res, q.get('path') ?? '', range) return true } @@ -137,13 +137,34 @@ export function doocusApi(opts: Options): Plugin { }) } - async function serveOriginal(res: ServerResponse, rel: string): Promise { + async function serveOriginal(res: ServerResponse, rel: string, range?: 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') + const mime = MIME[path.extname(abs).toLowerCase()] ?? 'application/octet-stream' + const size = fs.statSync(abs).size + res.setHeader('Content-Type', mime) + res.setHeader('Accept-Ranges', 'bytes') + + // Range support — needed so large meeting videos can seek/stream. + const m = range?.match(/bytes=(\d*)-(\d*)/) + if (m) { + const start = m[1] ? parseInt(m[1], 10) : 0 + const end = m[2] ? parseInt(m[2], 10) : size - 1 + if (start >= size || end >= size || start > end) { + res.statusCode = 416 + res.setHeader('Content-Range', `bytes */${size}`) + res.end() + return + } + res.statusCode = 206 + res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`) + res.setHeader('Content-Length', String(end - start + 1)) + fs.createReadStream(abs, { start, end }).pipe(res) + return + } + res.setHeader('Content-Length', String(size)) fs.createReadStream(abs).pipe(res) } } diff --git a/ui/doocus-app/src/App.vue b/ui/doocus-app/src/App.vue index 6f0b791..fbdb2c4 100644 --- a/ui/doocus-app/src/App.vue +++ b/ui/doocus-app/src/App.vue @@ -1,5 +1,5 @@ @@ -52,4 +67,14 @@ onMounted(loadTree) .spacer { flex: 1; } .body { flex: 1; min-height: 0; padding: var(--space-2); } .err { color: var(--status-error, #e0a030); padding: var(--space-4); } +.stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); } +.grow { flex: 1; min-height: 0; } +.expand-bar { + flex-shrink: 0; display: flex; align-items: center; gap: var(--space-2); + justify-content: flex-start; text-align: left; width: 100%; + background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius); + padding: var(--space-1) var(--space-3); color: var(--text-secondary); +} +.expand-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chev { padding: 0 var(--space-2); background: transparent; border: 0; color: var(--text-secondary); } diff --git a/ui/doocus-app/src/components/DocDetail.vue b/ui/doocus-app/src/components/DocDetail.vue index 07174a5..4345d4e 100644 --- a/ui/doocus-app/src/components/DocDetail.vue +++ b/ui/doocus-app/src/components/DocDetail.vue @@ -33,7 +33,8 @@ const metaRows = computed(() => {