Extends the shelve pipeline to cover spreadsheets, closing the Excel gap called out in the PR's original "known gaps" list — Windchill/DocPortal corpora commonly include parts lists and spec sheets as spreadsheets, not just prose documents. - scripts/shelve_xlsx.py — openpyxl, chunked by sheet with row-window splitting for large sheets (header row repeated in every window so each chunk stays self-describing for retrieval). - scripts/shelve_ods.py — same chunking strategy via odfpy (already a dependency from ODT support), OpenDocumentSpreadsheet's Table/TableRow/ TableCell. - scripts/shelve_numbers.py — converts via headless LibreOffice to XLSX and delegates to shelve_xlsx, mirroring shelve_pages.py's pattern for .pages. Adds libreoffice-calc to the Docker image alongside the existing libreoffice-writer. - Upload button text changed from an ever-growing format list to "Upload Document or Spreadsheet" — the Supported Formats table in README/docs is now the source of truth for the full list. - 13 new tests (XLSX, ODS, Numbers); full suite (85 tests) passing. Manually verified via Playwright against an isolated test instance: XLSX and ODS both upload, shelve to "ready", and store correctly row-serialized, header-repeated chunks (confirmed via sample-chunks). BM25 search against a 2-chunk toy corpus returned no hits for terms split 1-vs-1 across the two chunks — traced to Okapi BM25's IDF formula giving an exact 0 for terms in exactly half a tiny corpus (log((N-n+0.5)/(n+0.5)) = log(1.0) = 0, filtered by `score <= 0`), not a defect in the new shelvers. The earlier DOCX/ODT/PDF Playwright pass (5 chunks total) diluted this enough to return real results.
144 lines
4.6 KiB
Vue
144 lines
4.6 KiB
Vue
<template>
|
|
<main class="library">
|
|
<header class="library-header">
|
|
<h1>Library</h1>
|
|
<div class="header-actions">
|
|
<button class="btn-secondary" @click="triggerUpload" :disabled="uploading">
|
|
{{ uploading ? "Uploading..." : "Upload Document or Spreadsheet" }}
|
|
</button>
|
|
<input ref="fileInput" type="file" accept=".pdf,.epub,.docx,.odt,.pages,.xlsx,.ods,.numbers" style="display:none" @change="handleUpload">
|
|
<button class="btn-primary" @click="scan" :disabled="scanning">
|
|
{{ scanning ? "Scanning..." : "Scan for PDFs" }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<p class="error-msg" v-if="error">{{ error }}</p>
|
|
|
|
<p class="empty-state" v-if="!loading && docs.length === 0">
|
|
No documents indexed yet.<br>
|
|
<strong>Upload a PDF</strong> using the button above, or mount a directory and click
|
|
<strong>Scan for PDFs</strong> to index an entire collection.
|
|
</p>
|
|
|
|
<div class="doc-grid" v-else>
|
|
<DocumentCard
|
|
v-for="doc in docs"
|
|
:key="doc.id"
|
|
:doc="doc"
|
|
@reshelve="reshelve"
|
|
@delete="remove"
|
|
@refresh="load"
|
|
/>
|
|
</div>
|
|
|
|
<p class="scan-result" v-if="scanResult">
|
|
Found {{ scanResult.discovered }} PDFs, queued {{ scanResult.queued }} for indexing.
|
|
</p>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, ref } from "vue"
|
|
import { api, type Document } from "@/api"
|
|
import DocumentCard from "@/components/DocumentCard.vue"
|
|
|
|
const docs = ref<Document[]>([])
|
|
const loading = ref(true)
|
|
const scanning = ref(false)
|
|
const uploading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const scanResult = ref<{ discovered: number; queued: number } | null>(null)
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
docs.value = await api.getLibrary()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : "Failed to load library"
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function scan() {
|
|
scanning.value = true
|
|
error.value = null
|
|
try {
|
|
scanResult.value = await api.scanLibrary()
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : "Scan failed"
|
|
} finally {
|
|
scanning.value = false
|
|
}
|
|
}
|
|
|
|
async function reshelve(id: string) {
|
|
error.value = null
|
|
try {
|
|
await api.reshelveDocument(id)
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : "Re-index failed"
|
|
}
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
if (!confirm("Remove this book from the library? The PDF file is not deleted.")) return
|
|
error.value = null
|
|
try {
|
|
await api.deleteDocument(id)
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : "Remove failed"
|
|
}
|
|
}
|
|
|
|
function triggerUpload() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function handleUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
uploading.value = true
|
|
error.value = null
|
|
try {
|
|
await api.uploadDocument(file)
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : "Upload failed"
|
|
} finally {
|
|
uploading.value = false
|
|
input.value = ""
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.library { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
|
.library-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; }
|
|
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
h1 { font-size: 1.5rem; }
|
|
.btn-primary {
|
|
background: var(--color-accent); color: #fff; border: none; padding: 0.6rem 1.2rem;
|
|
border-radius: var(--radius-sm); cursor: pointer; font-size: 0.95rem;
|
|
}
|
|
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
|
.btn-secondary {
|
|
background: transparent; color: var(--color-accent); border: 1px solid var(--color-accent);
|
|
padding: 0.6rem 1.2rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.95rem;
|
|
}
|
|
.btn-secondary:disabled { opacity: 0.5; cursor: default; }
|
|
.doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
|
.empty-state { color: var(--color-text-muted); line-height: 1.8; }
|
|
.empty-state code { font-family: var(--font-mono); background: var(--color-surface-alt); padding: 2px 6px; border-radius: 3px; }
|
|
.scan-result { margin-top: 1rem; color: var(--color-text-muted); font-size: 0.9rem; }
|
|
.error-msg { color: var(--color-error); margin-bottom: 1rem; font-size: 0.9rem; }
|
|
</style>
|