pagepiper/web/src/views/LibraryView.vue
pyr0ball d39cfbd87a feat: add XLSX, ODS, and Apple Numbers spreadsheet support
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.
2026-07-10 15:06:16 -07:00

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>