162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
/**
|
|
* Vite dev middleware exposing the local doocus tree index to the app.
|
|
*
|
|
* 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/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 { ServerResponse } from 'node:http'
|
|
import fs from 'node:fs'
|
|
import fsp from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
interface Options {
|
|
outputDir: string
|
|
}
|
|
|
|
interface Node {
|
|
path: string
|
|
name: string
|
|
ext: string
|
|
family: string
|
|
mode: 'extracted' | 'meeting' | 'link'
|
|
bytes: number
|
|
modified: string
|
|
url: string | null
|
|
out?: string
|
|
}
|
|
|
|
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',
|
|
'.mp4': 'video/mp4',
|
|
}
|
|
|
|
export function doocusApi(opts: Options): Plugin {
|
|
const outputDir = path.resolve(opts.outputDir)
|
|
const indexPath = path.join(outputDir, 'index.json')
|
|
|
|
function readIndex(): Index | null {
|
|
try {
|
|
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 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(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() })
|
|
})
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if (parts[0] === 'detail') {
|
|
await getDetail(res, q.get('path') ?? '')
|
|
return true
|
|
}
|
|
|
|
if (parts[0] === 'original') {
|
|
await serveOriginal(res, q.get('path') ?? '')
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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: string | null = null // extracted search text (heavy formats)
|
|
let meta: unknown = null
|
|
let text: string | null = null // inline text for text-native originals
|
|
|
|
if (node.mode === 'extracted' && node.out) {
|
|
try { content = await fsp.readFile(path.join(outputDir, node.out, 'content.md'), 'utf-8') } catch { /* */ }
|
|
try { meta = JSON.parse(await fsp.readFile(path.join(outputDir, node.out, 'meta.json'), 'utf-8')) } catch { /* */ }
|
|
} else if (node.mode === 'link' && TEXT_EXTS.has(node.ext)) {
|
|
const abs = originalAbs(index, rel)
|
|
if (abs) { try { text = await fsp.readFile(abs, 'utf-8') } catch { /* */ } }
|
|
}
|
|
|
|
sendJson(res, 200, {
|
|
node,
|
|
content,
|
|
meta,
|
|
text,
|
|
mime: MIME[path.extname(node.name).toLowerCase()] ?? 'application/octet-stream',
|
|
originalUrl: `/api/original?path=${encodeURIComponent(rel)}`,
|
|
})
|
|
}
|
|
|
|
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(abs).pipe(res)
|
|
}
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
|
const body = JSON.stringify(obj)
|
|
res.statusCode = status
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.setHeader('Content-Length', Buffer.byteLength(body))
|
|
res.end(body)
|
|
}
|
|
|
|
function isFile(p: string): boolean {
|
|
try { return fs.statSync(p).isFile() } catch { return false }
|
|
}
|