doocus improvemnts

This commit is contained in:
Mariano Gabriel
2026-07-05 20:35:44 -03:00
parent 7b276e8f3d
commit 8606520ef2
22 changed files with 1328 additions and 61 deletions

View File

@@ -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",

View File

@@ -10,6 +10,7 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"highlight.js": "^11.10",
"jszip": "^3.10",
"mammoth": "^1.8",
"marked": "^14",

View File

@@ -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<boolean> {
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise<boolean> {
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<void> {
async function serveOriginal(res: ServerResponse, rel: string, range?: 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')
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)
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, computed } from 'vue'
import Panel from '@framework/components/Panel.vue'
import SplitPane from '@framework/components/SplitPane.vue'
import FileTree from '@/components/FileTree.vue'
@@ -8,6 +8,10 @@ import ExportBar from '@/components/ExportBar.vue'
import { store, loadTree } from '@/store'
onMounted(loadTree)
const detailTitle = computed(() =>
store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File')
const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
</script>
<template>
@@ -24,18 +28,29 @@ onMounted(loadTree)
<main class="body">
<div v-if="store.error" class="err">{{ store.error }}</div>
<SplitPane v-else direction="vertical" :initial-size="0.9" :min="0.3" :max="3">
<!-- expanded: resizable tree / viewer split -->
<SplitPane v-else-if="!store.detailCollapsed" direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
<template #first>
<Panel title="Tree">
<FileTree :items="store.tree" />
</Panel>
<Panel title="Tree"><FileTree :items="store.tree" /></Panel>
</template>
<template #second>
<Panel title="File">
<Panel :title="detailTitle">
<template #actions>
<button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true"></button>
</template>
<DocDetail />
</Panel>
</template>
</SplitPane>
<!-- collapsed: tree fills, viewer is a bar docked at the bottom -->
<div v-else class="stack">
<Panel title="Tree" class="grow"><FileTree :items="store.tree" /></Panel>
<button class="expand-bar" title="Expand viewer" @click="store.detailCollapsed = false">
<span>{{ barLabel }}</span>
</button>
</div>
</main>
</div>
</template>
@@ -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); }
</style>

View File

@@ -33,7 +33,8 @@ const metaRows = computed(() => {
<template>
<div v-if="!node" class="empty">Select a file to view it.</div>
<div v-else class="detail">
<header class="head">
<!-- meetings get no selection header the viewer is the meeting itself -->
<header v-if="node.mode !== 'meeting'" 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>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { store, select, toggleSelect, toggleFolder, type TreeItem } from '@/store'
import { store, select, toggleSelect, toggleFolder, isUnhandled, type TreeItem } from '@/store'
defineProps<{ items: TreeItem[]; depth?: number }>()
@@ -36,8 +36,9 @@ const MODE_BADGE: Record<string, string> = {
/>
<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>
<span class="fname" :class="{ unhandled: it.node && isUnhandled(it.node) }">{{ it.node?.title || it.name }}</span>
<span v-if="it.node && isUnhandled(it.node)" class="attn" title="no viewer for this type — needs a handling decision"> needs attention</span>
<span v-else-if="it.node?.warnings?.length" class="warn" title="extraction warnings"></span>
</div>
</template>
</li>
@@ -69,5 +70,7 @@ const MODE_BADGE: Record<string, string> = {
.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; }
.fname.unhandled { color: var(--text-secondary); font-style: italic; }
.attn { margin-left: auto; font-size: 10px; color: var(--status-error, #e0a030); flex-shrink: 0; }
.warn { margin-left: auto; color: var(--status-error, #e0a030); }
</style>

View File

@@ -1,32 +1,47 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { marked } from 'marked'
import type { FileNode } from '@/store'
import hljs from 'highlight.js/lib/core'
import yaml from 'highlight.js/lib/languages/yaml'
import json from 'highlight.js/lib/languages/json'
import xml from 'highlight.js/lib/languages/xml'
import ini from 'highlight.js/lib/languages/ini'
import { kindFor, type FileNode } from '@/store'
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('json', json)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('ini', ini)
const props = defineProps<{
node: FileNode
originalUrl: string
content?: string | null // extracted text (pptx fallback render)
text?: string | null // inline text for text-native originals
text?: string | null // inline verbatim text for text-native originals
}>()
type Kind = 'pdf' | 'image' | 'html' | 'markdown' | 'text' | 'docx' | 'xlsx' | 'pptx' | 'video' | 'none'
const kind = computed(() => kindFor(props.node.ext))
const kind = computed<Kind>(() => {
const e = props.node.ext
if (e === 'pdf') return 'pdf'
if (['png', 'jpg', 'jpeg'].includes(e)) return 'image'
if (['html', 'htm'].includes(e)) return 'html'
if (['md', 'markdown'].includes(e)) return 'markdown'
if (['txt', 'json', 'yaml', 'yml', 'csv'].includes(e)) return 'text'
if (e === 'docx') return 'docx'
if (e === 'xlsx') return 'xlsx'
if (e === 'pptx') return 'pptx'
if (e === 'mp4') return 'video'
return 'none'
// Verbatim highlighting for code-ish originals (yaml/json/csv/txt/…).
const HLJS_LANG: Record<string, string> = {
yaml: 'yaml', yml: 'yaml', json: 'json', xml: 'xml', html: 'xml',
ini: 'ini', toml: 'ini', csv: 'plaintext', txt: 'plaintext', log: 'plaintext',
}
const highlighted = computed(() => {
if (kind.value !== 'code') return ''
const src = props.text ?? ''
const lang = HLJS_LANG[props.node.ext] ?? 'plaintext'
try {
return hljs.highlight(src, { language: lang }).value
} catch {
return src.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]!))
}
})
// Async-rendered HTML for docx (mammoth) and xlsx (SheetJS).
const mdHtml = computed(() => (props.text ? marked.parse(props.text) : ''))
const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : ''))
// docx / xlsx rendered client-side (lazy).
const rendered = ref('')
const loading = ref(false)
const loadError = ref('')
@@ -37,16 +52,15 @@ async function loadRich(): Promise<void> {
if (kind.value !== 'docx' && kind.value !== 'xlsx') return
loading.value = true
try {
const res = await fetch(props.originalUrl)
const buf = await res.arrayBuffer()
const buf = await (await fetch(props.originalUrl)).arrayBuffer()
if (kind.value === 'docx') {
const mammoth = await import('mammoth/mammoth.browser')
rendered.value = (await mammoth.convertToHtml({ arrayBuffer: buf })).value
} else {
const XLSX = await import('xlsx')
const wb = XLSX.read(new Uint8Array(buf), { type: 'array' })
rendered.value = wb.SheetNames.map((name) =>
`<h3>${name}</h3>` + XLSX.utils.sheet_to_html(wb.Sheets[name])).join('\n')
rendered.value = wb.SheetNames.map((n) =>
`<h3>${n}</h3>` + XLSX.utils.sheet_to_html(wb.Sheets[n])).join('\n')
}
} catch (e) {
loadError.value = String(e)
@@ -54,11 +68,11 @@ async function loadRich(): Promise<void> {
loading.value = false
}
}
watch(() => props.node.path, loadRich, { immediate: true })
const mdHtml = computed(() => (props.text ? marked.parse(props.text) : ''))
const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : ''))
function openOriginal(): void {
window.open(props.node.url ?? props.originalUrl, '_blank')
}
</script>
<template>
@@ -71,27 +85,31 @@ const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : '
<div v-else-if="kind === 'markdown'" class="doc md" v-html="mdHtml" />
<pre v-else-if="kind === 'text'" class="mono">{{ text }}</pre>
<pre v-else-if="kind === 'code'" class="hljs code"><code v-html="highlighted" /></pre>
<div v-else-if="kind === 'video'" class="center">
<video class="video" :src="originalUrl" controls />
<p class="dim">Meeting its transcript is produced by meetus, not doocus.</p>
<video class="video" :src="originalUrl" controls preload="metadata" />
</div>
<!-- docx / xlsx rendered client-side -->
<div v-else-if="kind === 'docx' || kind === 'xlsx'" class="doc rich">
<div v-if="loading" class="dim">Rendering</div>
<div v-else-if="loadError" class="dim">Preview failed: {{ loadError }}</div>
<div v-else v-html="rendered" />
</div>
<!-- pptx: no faithful client render; show the per-slide extracted text -->
<div v-else-if="kind === 'pptx'" class="doc md">
<div class="dim label">Slide text (no visual render)</div>
<div v-html="pptxHtml" />
</div>
<div v-else class="center dim">No preview for .{{ node.ext }}.</div>
<!-- unhandled type flagged for a handling decision -->
<div v-else class="center attention">
<div class="warn-icon"></div>
<p>No inline viewer for <b>.{{ node.ext }}</b> needs a handling decision.</p>
<button @click="openOriginal">
{{ node.url ? 'Open in Drive' : 'Open file' }}
</button>
</div>
</div>
</template>
@@ -100,13 +118,14 @@ const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : '
.frame { width: 100%; height: 100%; border: 0; }
.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-3); }
.center img { max-width: 100%; }
.video { max-width: 100%; max-height: 70vh; }
.video { max-width: 100%; max-height: 78vh; }
.doc { padding: var(--space-4); }
.doc.rich :deep(table) { border-collapse: collapse; }
.doc.rich :deep(td), .doc.rich :deep(th) { border: 1px solid var(--surface-3); padding: 2px 6px; }
.doc.rich :deep(h3) { margin: var(--space-3) 0 var(--space-1); }
.md :deep(img) { max-width: 100%; }
.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); }
.code { margin: 0; padding: var(--space-3); border-radius: 0; overflow: auto; font-size: var(--font-size-sm); line-height: 1.5; }
.attention .warn-icon { font-size: 28px; color: var(--status-error, #e0a030); }
.dim { color: var(--text-secondary); }
.label { font-size: 11px; margin-bottom: var(--space-2); }
</style>

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import '@framework/tokens.css'
import 'highlight.js/styles/atom-one-dark.css'
import './styles.css'
import App from './App.vue'

View File

@@ -62,6 +62,7 @@ interface State {
loading: boolean
selected: Set<string>
collapsed: Set<string> // collapsed folder paths (folders default open)
detailCollapsed: boolean // bottom viewer collapsed to a bar
targetKey: string
error: string
}
@@ -76,6 +77,7 @@ export const store = reactive<State>({
loading: false,
selected: new Set<string>(),
collapsed: new Set<string>(),
detailCollapsed: false,
targetKey: TARGETS[0].key,
error: '',
})
@@ -163,3 +165,26 @@ export function humanBytes(n: number): string {
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
export type ViewerKind =
| 'pdf' | 'image' | 'html' | 'markdown' | 'code' | 'docx' | 'xlsx' | 'pptx' | 'video' | 'unhandled'
/** Single source of truth for how a file is viewed — shared by the tree (to flag
* unhandled types) and the viewer. 'unhandled' = no inline view; default action
* is to open the original file (or the Drive link once we have it). */
export function kindFor(ext: string): ViewerKind {
if (ext === 'pdf') return 'pdf'
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image'
if (['html', 'htm'].includes(ext)) return 'html'
if (['md', 'markdown'].includes(ext)) return 'markdown'
if (['txt', 'json', 'yaml', 'yml', 'csv', 'log', 'ini', 'xml', 'toml'].includes(ext)) return 'code'
if (ext === 'docx') return 'docx'
if (ext === 'xlsx') return 'xlsx'
if (ext === 'pptx') return 'pptx'
if (['mp4', 'mkv', 'mov', 'm4v', 'webm', 'avi', 'wmv'].includes(ext)) return 'video'
return 'unhandled'
}
export function isUnhandled(node: FileNode): boolean {
return node.mode !== 'meeting' && kindFor(node.ext) === 'unhandled'
}