commit 40e1aaaba2acc433cee8102291687cbff1fbb640
parent 879a6d6a95a9ecd824bc3662432671a7fbdf1979
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Fri, 20 Feb 2026 02:40:49 -0700
library plugin overhaul
for nerrrrrrrrrrrds
Diffstat:
8 files changed, 2472 insertions(+), 1601 deletions(-)
diff --git a/CLEAN_INSTALL/plugins_dev/library/client.js b/CLEAN_INSTALL/plugins_dev/library/client.js
@@ -1,8 +1,9 @@
+
(function () {
const PLUGIN_ID = "library";
-
const PDF_CHUNK_BYTES = 256 * 1024;
- const TEXT_FILE_MAX_BYTES = 512 * 1024; // mirrors server default
+ const TEXT_FILE_MAX_BYTES = 512 * 1024;
+ const TAG_SUGGESTIONS = ["fic", "fix-it-fic", "journal", "fiction", "fantasy", "sci-fi", "lore", "poetry", "notes", "history"];
function escapeHtml(text) {
return String(text || "")
@@ -40,41 +41,44 @@
return new Promise((r) => setTimeout(r, ms));
}
- function sanitizeFilenameBase(name) {
- return String(name || "book")
+ function normalizeTag(raw) {
+ return String(raw || "")
.trim()
.toLowerCase()
- .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/[^a-z0-9 _-]+/g, "")
+ .replace(/\s+/g, "-")
.replace(/-+/g, "-")
- .replace(/^[-.]+|[-.]+$/g, "")
- .slice(0, 80);
+ .replace(/^[-_]+|[-_]+$/g, "")
+ .slice(0, 32);
}
- function paginateText(text, opts = {}) {
- const maxLines = Number(opts.maxLines || 42);
- const maxChars = Number(opts.maxChars || 2200);
- const raw = String(text || "").replace(/\r\n/g, "\n");
- const lines = raw.split("\n");
+ function parseTags(raw) {
+ const parts = String(raw || "")
+ .split(",")
+ .map((x) => normalizeTag(x))
+ .filter(Boolean);
+ return [...new Set(parts)].slice(0, 16);
+ }
+ function paginateText(text, opts = {}) {
+ const maxLines = Number(opts.maxLines || 40);
+ const maxChars = Number(opts.maxChars || 2400);
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
const pages = [];
let buf = "";
let lineCount = 0;
-
const flush = () => {
pages.push(buf);
buf = "";
lineCount = 0;
};
-
for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const lineWithNl = i === lines.length - 1 ? line : `${line}\n`;
-
+ const lineWithNl = i === lines.length - 1 ? lines[i] : `${lines[i]}\n`;
if (lineWithNl.length > maxChars) {
- let remaining = lineWithNl;
- while (remaining.length) {
- const take = remaining.slice(0, maxChars);
- remaining = remaining.slice(maxChars);
+ let r = lineWithNl;
+ while (r.length) {
+ const take = r.slice(0, maxChars);
+ r = r.slice(maxChars);
if (buf && (buf.length + take.length > maxChars || lineCount >= maxLines)) flush();
buf += take;
lineCount += 1;
@@ -82,223 +86,97 @@
}
continue;
}
-
if (buf && (buf.length + lineWithNl.length > maxChars || lineCount + 1 > maxLines)) flush();
buf += lineWithNl;
lineCount += 1;
}
-
if (buf || !pages.length) pages.push(buf);
return pages;
}
function ensureStyles() {
- if (document.getElementById("bzlLibraryStyle")) return;
+ if (document.getElementById("bzlLibraryPanelsStyle")) return;
const el = document.createElement("style");
- el.id = "bzlLibraryStyle";
+ el.id = "bzlLibraryPanelsStyle";
el.textContent = `
- .bzlLibraryToggle {
- position: fixed; right: 18px; bottom: 18px; z-index: 9998;
- padding: 10px 14px; border-radius: 999px;
- background: linear-gradient(180deg, rgba(255,140,0,0.95), rgba(255,80,160,0.95));
- color: #1b0a12; border: 0; cursor: pointer; font-weight: 700;
- box-shadow: 0 10px 30px rgba(0,0,0,0.35);
- }
- .bzlLibraryPanel {
- position: fixed; z-index: 9999;
- left: 18px; top: 18px;
- width: min(560px, calc(100vw - 36px));
- height: min(74vh, 760px);
- max-width: calc(100vw - 36px);
- max-height: calc(100vh - 36px);
- overflow: hidden;
- border-radius: 16px;
- background: rgba(20, 12, 18, 0.92);
- border: 1px solid rgba(255,255,255,0.12);
- box-shadow: 0 22px 70px rgba(0,0,0,0.55);
- backdrop-filter: blur(10px);
- display: flex;
- flex-direction: column;
- }
- .bzlLibraryHeader {
- display: flex; align-items: center; justify-content: space-between;
- gap: 10px; padding: 12px 12px 10px 12px;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- }
- .bzlLibraryTitle {
- font-weight: 800;
- cursor: move;
- user-select: none;
- -webkit-user-select: none;
- touch-action: none;
- }
- .bzlLibraryBody { padding: 12px; overflow: auto; flex: 1 1 auto; min-height: 0; }
- .bzlLibraryRow { display:flex; gap: 10px; align-items:center; flex-wrap: wrap; margin-bottom: 10px; }
- .bzlLibraryRow input[type="text"], .bzlLibraryRow input[type="number"], .bzlLibraryRow textarea {
- background: rgba(255,255,255,0.08); color: #f6e8f0;
- border: 1px solid rgba(255,255,255,0.12);
- border-radius: 10px; padding: 8px 10px;
- }
- .bzlLibraryBtn {
- border-radius: 999px; padding: 8px 12px; border: 1px solid rgba(255,255,255,0.12);
- background: rgba(255,255,255,0.06); color: #f6e8f0; cursor: pointer;
- }
- .bzlLibraryBtn.primary {
- background: linear-gradient(180deg, rgba(255,140,0,0.95), rgba(255,80,160,0.95));
- color: #1b0a12; border: 0; font-weight: 800;
- }
- .bzlLibraryTabs { display:flex; gap: 8px; align-items:center; }
- .bzlLibraryList { display:flex; flex-direction: column; gap: 10px; }
- .bzlLibraryItem {
- border: 1px solid rgba(255,255,255,0.10);
- background: rgba(255,255,255,0.04);
- border-radius: 12px; padding: 10px;
- display:flex; align-items:flex-start; justify-content: space-between; gap: 10px;
- }
- .bzlLibraryMeta { opacity: 0.8; font-size: 12px; margin-top: 4px; }
- .bzlLibraryViewer { display:flex; flex-direction: column; gap: 10px; height: 100%; }
- .bzlLibraryDocWrap { flex: 1 1 auto; min-height: 240px; min-width: 0; }
- .bzlLibraryFrame {
- width: 100%;
- height: 100%;
- border: 1px solid rgba(255,255,255,0.12);
- border-radius: 12px;
- background: rgba(0,0,0,0.25);
- }
- .bzlLibraryTextPage {
- width: 100%;
- height: 100%;
- overflow: auto;
- border: 1px solid rgba(255,255,255,0.12);
- border-radius: 12px;
- background: rgba(0,0,0,0.18);
- padding: 10px;
- color: #f6e8f0;
- white-space: pre-wrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;
- font-size: 13px;
- line-height: 1.35;
- }
- .bzlLibraryResize {
- position: absolute;
- right: 10px;
- bottom: 10px;
- width: 16px;
- height: 16px;
- border-radius: 5px;
- border: 1px solid rgba(255,255,255,0.18);
- background: rgba(255,255,255,0.10);
- cursor: se-resize;
- opacity: 0.85;
- }
- .bzlLibraryResize:hover { opacity: 1; }
- .bzlLibraryHint { opacity: 0.8; font-size: 12px; margin-top: 6px; }
- .hidden { display: none !important; }
+ .lib3 { color: var(--text, #f3f7ff); display:flex; flex-direction:column; gap:10px; min-height:0; height:100%; flex:1 1 auto; }
+ .lib3Row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
+ .lib3 input[type="text"], .lib3 input[type="number"], .lib3 textarea, .lib3 select { background: rgba(255,255,255,0.08); color:#f6e8f0; border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 7px 9px; }
+ .lib3Btn { border-radius: 999px; padding: 7px 11px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08); color:#f6e8f0; cursor:pointer; }
+ .lib3Btn.primary { border:0; background: linear-gradient(180deg, rgba(255,145,50,0.95), rgba(255,90,160,0.95)); color:#1b0a12; font-weight:700; }
+ .lib3Scroll { overflow:auto; min-height:0; flex:1 1 auto; }
+ .lib3ReaderViewport { display:flex; flex:1 1 auto; min-height:0; }
+ .lib3Card { border: 1px solid rgba(255,255,255,0.13); border-radius: 12px; padding: 9px; background: rgba(255,255,255,0.04); margin-bottom:8px; }
+ .lib3Meta { opacity:.82; font-size:12px; margin-top:3px; }
+ .lib3Tag { display:inline-block; font-size:11px; border:1px solid rgba(255,255,255,0.18); border-radius:999px; padding:2px 7px; margin-right:4px; margin-top:4px; }
+ .lib3ReaderPage { white-space:pre-wrap; border:1px solid rgba(255,255,255,0.15); border-radius:10px; padding:10px; overflow:auto; height:100%; background: rgba(0,0,0,0.18); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:13px; }
+ .lib3Hint { opacity:.78; font-size:12px; }
+ .lib3Iframe { width:100%; height:100%; border:1px solid rgba(255,255,255,0.12); border-radius:10px; }
`;
document.head.appendChild(el);
}
- function whenBodyReady(fn) {
- if (document.body) return fn();
- const run = () => {
- try {
- document.removeEventListener("DOMContentLoaded", run);
- } catch {
- // ignore
- }
- fn();
- };
- document.addEventListener("DOMContentLoaded", run, { once: true });
- }
-
window.BzlPluginHost?.register(PLUGIN_ID, (ctx) => {
ensureStyles();
- const PANEL_RECT_KEY = "bzlLibraryPanelRect";
- const PANEL_MIN_W = 420;
- const PANEL_MIN_H = 320;
-
- let panelOpen = false;
- let viewerOpen = false;
- let items = [];
- let filterKind = "all"; // all | pdf | text
-
- let activeItem = null; // {id, kind, ...}
- let activePage = 1;
- let totalPages = 1;
- let textPages = [""];
- let activeText = "";
- let editorOpen = false;
- let editorText = "";
- let editorTitle = "";
-
- let uploadingPdf = false;
let wsAttachedTo = null;
+ let snapshot = { me: "", myShelfId: "", shelves: [] };
+ let activeShelfId = "";
+ let readerBookId = "";
+ let readerPage = 1;
+ let searchQuery = "";
+ let searchTag = "";
+ let statusLine = "";
+ let uploadInProgress = false;
+
+ const textByBookId = new Map();
+ const mounts = { library: null, shelf: null, reader: null };
- function readPanelRect() {
- try {
- const raw = localStorage.getItem(PANEL_RECT_KEY);
- if (!raw) return null;
- const json = JSON.parse(raw);
- const left = Number(json?.left);
- const top = Number(json?.top);
- const width = Number(json?.width);
- const height = Number(json?.height);
- if (![left, top, width, height].every((n) => Number.isFinite(n))) return null;
- return { left, top, width, height };
- } catch {
- return null;
- }
+ function setStatus(msg) {
+ statusLine = String(msg || "");
+ renderAll();
}
- function defaultPanelRect() {
- const width = Math.min(560, Math.max(PANEL_MIN_W, window.innerWidth - 36));
- const height = Math.min(Math.floor(window.innerHeight * 0.74), 760);
- const left = Math.max(18, Math.floor(window.innerWidth - width - 18));
- const top = Math.max(18, Math.floor(window.innerHeight - height - 70));
- return { left, top, width, height };
+ function ownedShelves() {
+ return (snapshot.shelves || []).filter((s) => Boolean(s?.isOwner));
}
- function clampPanelRect(rect) {
- const maxW = Math.max(PANEL_MIN_W, window.innerWidth - 36);
- const maxH = Math.max(PANEL_MIN_H, window.innerHeight - 36);
- const width = Math.min(maxW, Math.max(PANEL_MIN_W, Math.floor(rect.width)));
- const height = Math.min(maxH, Math.max(PANEL_MIN_H, Math.floor(rect.height)));
- const left = Math.min(window.innerWidth - 18 - width, Math.max(18, Math.floor(rect.left)));
- const top = Math.min(window.innerHeight - 18 - height, Math.max(18, Math.floor(rect.top)));
- return { left, top, width, height };
+ function activeShelf() {
+ return (snapshot.shelves || []).find((s) => String(s?.id || "") === String(activeShelfId || "")) || null;
}
- function applyPanelRect(panel, rect) {
- const r = clampPanelRect(rect);
- panel.style.left = `${r.left}px`;
- panel.style.top = `${r.top}px`;
- panel.style.right = "";
- panel.style.bottom = "";
- panel.style.width = `${r.width}px`;
- panel.style.height = `${r.height}px`;
+ function defaultShelfId() {
+ if (snapshot.myShelfId && ownedShelves().some((s) => s.id === snapshot.myShelfId)) return snapshot.myShelfId;
+ return ownedShelves()[0]?.id || "";
}
- function savePanelRect(rect) {
- try {
- localStorage.setItem(PANEL_RECT_KEY, JSON.stringify(rect));
- } catch {
- // ignore
+ function allShelfEntries() {
+ const out = [];
+ for (const shelf of snapshot.shelves || []) {
+ for (const entry of shelf.items || []) out.push({ shelf, entry, book: entry?.book || null });
}
+ return out;
}
- function savePanelRectFromEl(panel) {
- const left = Number.parseFloat(panel.style.left || "0");
- const top = Number.parseFloat(panel.style.top || "0");
- const width = Number.parseFloat(panel.style.width || "0");
- const height = Number.parseFloat(panel.style.height || "0");
- if (![left, top, width, height].every((n) => Number.isFinite(n) && n > 0)) return;
- savePanelRect(clampPanelRect({ left, top, width, height }));
+ function getBookById(bookId) {
+ const id = String(bookId || "");
+ if (!id) return null;
+ for (const x of allShelfEntries()) {
+ if (String(x.book?.id || "") === id) return x.book;
+ }
+ return null;
}
- function setStatus(msg) {
- const el = document.getElementById("bzlLibraryStatus");
- if (el) el.textContent = String(msg || "");
+ function activeReaderBook() {
+ return getBookById(readerBookId);
+ }
+
+ function requestList() {
+ ctx.send("list", {});
+ }
+
+ function requestText(bookId) {
+ ctx.send("textGet", { id: bookId });
}
function attachWsListener() {
@@ -307,129 +185,46 @@
if (wsAttachedTo === ws) return;
try {
if (wsAttachedTo) wsAttachedTo.removeEventListener("message", onWsMsg);
- } catch {
- // ignore
- }
+ } catch {}
wsAttachedTo = ws;
ws.addEventListener("message", onWsMsg);
}
- function requestList() {
- ctx.send("list", {});
+ function openInReader(bookId) {
+ const id = String(bookId || "");
+ if (!id) return;
+ readerBookId = id;
+ readerPage = 1;
+ const book = getBookById(id);
+ if (book && String(book.kind || "") === "text" && !textByBookId.has(id)) requestText(id);
+ renderReader();
}
-
- function requestText(id) {
- ctx.send("textGet", { id });
- }
-
- function isAuthor(it) {
- const me = String(ctx.getUser() || "");
- return Boolean(me) && String(it?.createdBy || "") === me;
- }
-
- function canDelete(it) {
- const role = String(ctx.getRole() || "");
- return role === "owner" || isAuthor(it);
- }
-
- function downloadTextFile(filename, text) {
- try {
- const blob = new Blob([String(text || "")], { type: "text/plain;charset=utf-8" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(url), 2000);
- } catch {
- // ignore
- }
- }
-
- function openItem(it) {
- activeItem = it;
- activePage = 1;
- viewerOpen = true;
- editorOpen = false;
- editorText = "";
- editorTitle = "";
- activeText = "";
- textPages = [""];
- totalPages = 1;
- render();
- if (String(it.kind || "pdf") === "text") {
- requestText(it.id);
- } else {
- setPage(1);
- }
- }
-
- function closeViewer() {
- viewerOpen = false;
- activeItem = null;
- activePage = 1;
- totalPages = 1;
- textPages = [""];
- activeText = "";
- editorOpen = false;
- editorText = "";
- editorTitle = "";
- render();
- }
-
- function setPage(n) {
- if (!viewerOpen || !activeItem) return;
- const kind = String(activeItem.kind || "pdf");
- if (kind === "text") {
- const next = Math.max(1, Math.min(totalPages, Number(n || 1)));
- activePage = next;
- const pageLabel = document.getElementById("bzlLibraryPageLabel");
- if (pageLabel) pageLabel.textContent = `Page ${activePage} / ${totalPages}`;
- const inp = document.getElementById("bzlLibraryPage");
- if (inp) inp.value = String(activePage);
- const textEl = document.getElementById("bzlLibraryTextPage");
- if (textEl) textEl.textContent = textPages[activePage - 1] || "";
- return;
- }
- const next = Math.max(1, Math.min(9999, Number(n || 1)));
- activePage = next;
- const iframe = document.getElementById("bzlLibraryFrame");
- if (iframe) iframe.src = `${activeItem.url}#page=${activePage}`;
- const inp = document.getElementById("bzlLibraryPage");
- if (inp) inp.value = String(activePage);
- const pageLabel = document.getElementById("bzlLibraryPageLabel");
- if (pageLabel) pageLabel.textContent = `Page ${activePage}`;
- }
-
- async function uploadSelectedPdf() {
- if (uploadingPdf) return;
- attachWsListener();
- const fileEl = document.getElementById("bzlLibraryPdfFile");
- const titleEl = document.getElementById("bzlLibraryPdfTitle");
- const file = fileEl?.files?.[0];
- if (!file) return setStatus("Choose a PDF first.");
- const mime = String(file.type || "").trim().toLowerCase();
- const isPdf = /\\.pdf$/i.test(file.name || "") || mime === "application/pdf";
- if (!isPdf) return setStatus("Only PDF files are supported.");
-
- uploadingPdf = true;
- setStatus("Starting PDF upload...");
+ async function uploadPdfToShelf(file, meta, shelfId) {
+ if (uploadInProgress) return;
+ uploadInProgress = true;
window.__bzlLibraryUploadId = "";
- ctx.send("uploadStart", { filename: file.name, mime, size: file.size, title: String(titleEl?.value || "").trim() });
+ ctx.send("uploadStart", {
+ filename: file.name,
+ mime: String(file.type || "").trim().toLowerCase(),
+ size: file.size,
+ title: meta.title,
+ author: meta.author,
+ isOriginal: meta.isOriginal,
+ tags: meta.tags,
+ shelfId,
+ });
const t0 = Date.now();
while (!window.__bzlLibraryUploadId && Date.now() - t0 < 3000) {
// eslint-disable-next-line no-await-in-loop
- await sleep(30);
+ await sleep(35);
}
const uploadId = String(window.__bzlLibraryUploadId || "");
if (!uploadId) {
- uploadingPdf = false;
- return setStatus("Upload failed to start.");
+ uploadInProgress = false;
+ setStatus("Upload failed to start.");
+ return;
}
- ctx.devLog("info", "library:uploadId", { uploadId, size: file.size });
let sent = 0;
for (let off = 0; off < file.size; off += PDF_CHUNK_BYTES) {
@@ -444,353 +239,359 @@
// eslint-disable-next-line no-await-in-loop
await sleep(0);
}
-
ctx.send("uploadFinish", { uploadId });
- setStatus("Finalizing PDF...");
}
- async function importTextFromFile() {
- const fileEl = document.getElementById("bzlLibraryTextFile");
- const titleEl = document.getElementById("bzlLibraryTextTitle");
- const file = fileEl?.files?.[0];
- if (!file) return setStatus("Choose a text file first.");
-
- const name = String(file.name || "").toLowerCase();
- const okExt = name.endsWith(".txt") || name.endsWith(".md");
- if (!okExt && String(file.type || "").toLowerCase() !== "text/plain") {
- return setStatus("Supported: .txt, .md, or text/plain.");
- }
- if (file.size > TEXT_FILE_MAX_BYTES) {
- return setStatus(`Text file too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`);
+ async function createBookFromFile(file, payload) {
+ const name = String(file?.name || "").toLowerCase();
+ const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : "";
+ if (ext === ".pdf" || String(file.type || "").toLowerCase() === "application/pdf") {
+ await uploadPdfToShelf(file, payload, payload.shelfId);
+ return;
}
-
- let text = "";
- try {
- text = await file.text();
- } catch {
- return setStatus("Failed to read file.");
+ if (![".txt", ".md", ".rtf"].includes(ext)) {
+ setStatus("Supported upload types: PDF, TXT, MD, RTF.");
+ return;
}
- const title = String(titleEl?.value || "").trim() || String(file.name || "").replace(/\\.(txt|md)$/i, "");
- ctx.send("textCreate", { title, text });
- setStatus("Importing text...");
- }
-
- function createBlankTextBook() {
- const titleEl = document.getElementById("bzlLibraryTextNewTitle");
- const title = String(titleEl?.value || "").trim() || "Untitled text";
- ctx.send("textCreate", { title, text: "" });
- setStatus("Creating blank text...");
- }
-
- function openEditor() {
- if (!activeItem || String(activeItem.kind || "") !== "text") return;
- if (!isAuthor(activeItem)) {
- ctx.toast("Library", "Only the author can edit/export this text.");
+ if (file.size > TEXT_FILE_MAX_BYTES) {
+ setStatus(`Text/RTF too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`);
return;
}
- editorOpen = true;
- editorText = String(activeText || "");
- editorTitle = String(activeItem.title || "");
- render();
- }
-
- function closeEditor() {
- editorOpen = false;
- editorText = "";
- editorTitle = "";
- render();
+ const text = await file.text();
+ ctx.send("textCreate", {
+ shelfId: payload.shelfId,
+ title: payload.title,
+ author: payload.author,
+ isOriginal: payload.isOriginal,
+ tags: payload.tags,
+ format: ext === ".rtf" ? "rtf" : "text",
+ text,
+ });
+ setStatus("Book created.");
}
- function saveEditor() {
- if (!activeItem || String(activeItem.kind || "") !== "text") return;
- if (!isAuthor(activeItem)) return;
- ctx.send("textUpdate", { id: activeItem.id, title: editorTitle, text: editorText });
- setStatus("Saving...");
+ function filteredBrowseRows() {
+ const q = String(searchQuery || "").trim().toLowerCase();
+ const tag = String(searchTag || "").trim().toLowerCase();
+ return allShelfEntries().filter((x) => {
+ const book = x.book || {};
+ if (!book.id) return false;
+ if (q) {
+ const hay = `${book.title || ""} ${book.author || ""} ${(book.tags || []).join(" ")} ${x.shelf?.name || ""}`.toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ if (tag) {
+ const tags = Array.isArray(book.tags) ? book.tags.map((t) => String(t || "").toLowerCase()) : [];
+ if (!tags.includes(tag)) return false;
+ }
+ return true;
+ });
}
- function exportActiveText() {
- if (!activeItem || String(activeItem.kind || "") !== "text") return;
- if (!isAuthor(activeItem)) return;
- const base = sanitizeFilenameBase(activeItem.title || "book") || "book";
- downloadTextFile(`${base}.txt`, activeText);
+ function uniqueTags() {
+ const out = new Set();
+ for (const x of allShelfEntries()) {
+ for (const t of x?.book?.tags || []) out.add(String(t || "").toLowerCase());
+ }
+ return [...out].filter(Boolean).sort();
}
- function render() {
- ensureDom();
- const panel = document.getElementById("bzlLibraryPanel");
- if (!panel) return;
- panel.classList.toggle("hidden", !panelOpen);
- if (!panelOpen) return;
-
- const viewList = !viewerOpen;
- const list = items
- .filter((it) => {
- const k = String(it?.kind || "pdf");
- if (filterKind === "all") return true;
- return k === filterKind;
- })
- .map((it) => {
- const kind = String(it.kind || "pdf");
- const title = escapeHtml(it.title || it.filename || (kind === "text" ? "Text" : "PDF"));
- const when = new Date(Number(it.createdAt || 0) || 0).toLocaleString();
- const who = escapeHtml(String(it.createdBy || ""));
- const meta = `${kind.toUpperCase()} | ${who} | ${when} | ${formatBytes(it.bytes)}`;
- const delBtn = canDelete(it) ? `<button type="button" class="bzlLibraryBtn" data-libdel="${escapeHtml(it.id)}">Delete</button>` : "";
- const openBtn = `<button type="button" class="bzlLibraryBtn primary" data-libopen="${escapeHtml(it.id)}">Open</button>`;
- const newTab =
- kind === "pdf" && it.url
- ? `<a class="bzlLibraryBtn" href="${escapeHtml(it.url)}" target="_blank" rel="noreferrer">New tab</a>`
- : "";
- return `
- <div class="bzlLibraryItem">
- <div>
- <div><b>${title}</b></div>
- <div class="bzlLibraryMeta">${escapeHtml(meta)}</div>
- </div>
- <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end;">
- ${openBtn}
- ${newTab}
- ${delBtn}
- </div>
- </div>
- `;
- })
- .join("");
-
- const kindTabs = `
- <div class="bzlLibraryTabs">
- <button type="button" class="bzlLibraryBtn ${filterKind === "all" ? "primary" : ""}" data-libkind="all">All</button>
- <button type="button" class="bzlLibraryBtn ${filterKind === "pdf" ? "primary" : ""}" data-libkind="pdf">PDFs</button>
- <button type="button" class="bzlLibraryBtn ${filterKind === "text" ? "primary" : ""}" data-libkind="text">Texts</button>
+ function renderLibrary() {
+ const mount = mounts.library;
+ if (!(mount instanceof HTMLElement)) return;
+ const rows = filteredBrowseRows();
+ const tags = uniqueTags();
+ const targetShelfId = defaultShelfId();
+ const shelfOpt = ownedShelves().map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === targetShelfId ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join("");
+
+ mount.innerHTML = `
+ <div class="lib3">
+ <div class="lib3Row">
+ <input id="lib3Search" type="text" placeholder="Search title, author, tags" value="${escapeHtml(searchQuery)}" style="flex:1 1 220px;" />
+ <select id="lib3TagFilter"><option value="">All tags</option>${tags.map((t) => `<option value="${escapeHtml(t)}" ${t === searchTag ? "selected" : ""}>${escapeHtml(t)}</option>`).join("")}</select>
+ <button type="button" class="lib3Btn" id="lib3Refresh">Refresh</button>
+ </div>
+ <div class="lib3Row"><span class="lib3Hint">Browse shelves and discover books. Choose one of your shelves for pin/check out:</span>
+ <select id="lib3LibraryTargetShelf">${shelfOpt || "<option value=''>No owned shelf</option>"}</select>
+ </div>
+ <div class="lib3Scroll">
+ ${rows.map((x) => {
+ const b = x.book || {};
+ const s = x.shelf || {};
+ const tagsHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
+ return `<div class="lib3Card">
+ <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.entry?.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div>
+ <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | shelf ${escapeHtml(s.name || "Shelf")}${s.owner ? ` · @${escapeHtml(s.owner)}` : ""} | ${formatBytes(b.bytes)} | pins ${Number(b?.popularity?.pins || 0)} | checkouts ${Number(b?.popularity?.checkouts || 0)}</div>
+ <div>${tagsHtml}</div>
+ <div class="lib3Row" style="margin-top:6px;">
+ <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button>
+ <button type="button" class="lib3Btn" data-pin-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Pin</button>
+ <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Check out</button>
+ <button type="button" class="lib3Btn" data-wander-shelf="${escapeHtml(s.id || "")}">Wander shelf</button>
+ </div>
+ </div>`;
+ }).join("") || "<div class='lib3Hint'>No matches.</div>"}
+ </div>
+ <div class="lib3Hint">Wander shelves and open a book in Reader.</div>
</div>
`;
- const isText = viewerOpen && activeItem && String(activeItem.kind || "pdf") === "text";
- const canEdit = isText && isAuthor(activeItem);
- const pageLabelText = isText ? `Page ${activePage} / ${totalPages}` : `Page ${activePage}`;
-
- panel.innerHTML = `
- <div class="bzlLibraryHeader">
- <div class="bzlLibraryTitle" id="bzlLibraryDrag" title="Drag to move">Library</div>
- <div style="display:flex; gap:8px; align-items:center;">
- ${kindTabs}
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryRefresh">Refresh</button>
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryReset" title="Reset panel size/position">Reset</button>
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryClose">Close</button>
+ mount.querySelector("#lib3Search")?.addEventListener("input", (e) => {
+ searchQuery = String(e?.target?.value || "");
+ renderLibrary();
+ });
+ mount.querySelector("#lib3TagFilter")?.addEventListener("change", (e) => {
+ searchTag = String(e?.target?.value || "").toLowerCase();
+ renderLibrary();
+ });
+ mount.querySelector("#lib3Refresh")?.addEventListener("click", () => requestList());
+ mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || "")));
+ mount.querySelectorAll("[data-wander-shelf]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ activeShelfId = String(btn.getAttribute("data-wander-shelf") || "");
+ renderShelf();
+ });
+ });
+ mount.querySelectorAll("[data-pin-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const shelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || "");
+ const bookId = String(btn.getAttribute("data-pin-book") || "");
+ const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
+ if (!shelfId || !bookId) return setStatus("Pick one of your shelves first.");
+ ctx.send("pinBook", { shelfId, bookId, sourceShelfId });
+ setStatus("Pinned.");
+ });
+ });
+ mount.querySelectorAll("[data-checkout-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const targetShelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || "");
+ const sourceBookId = String(btn.getAttribute("data-checkout-book") || "");
+ const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
+ if (!targetShelfId || !sourceBookId) return setStatus("Pick one of your shelves first.");
+ ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId });
+ setStatus("Checked out.");
+ });
+ });
+ }
+ function renderShelf() {
+ const mount = mounts.shelf;
+ if (!(mount instanceof HTMLElement)) return;
+ const mine = ownedShelves();
+ if (!activeShelfId || !(snapshot.shelves || []).some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves?.[0]?.id || "";
+ const shelf = activeShelf();
+ const shelfList = (snapshot.shelves || []).map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === activeShelfId ? "selected" : ""}>${escapeHtml(s.name)}${s.owner ? ` (@${escapeHtml(s.owner)})` : ""}</option>`).join("");
+ const mineDefault = defaultShelfId();
+ const mineList = mine.map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === mineDefault ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join("");
+
+ mount.innerHTML = `
+ <div class="lib3">
+ <div class="lib3Row">
+ <select id="lib3ShelfPick" style="min-width:220px;">${shelfList || "<option>No shelves</option>"}</select>
+ ${shelf && !shelf.isOwner ? `<button type="button" class="lib3Btn" id="lib3SubBtn">${shelf.isSubscribed ? "Unsubscribe" : "Subscribe"}</button>` : ""}
+ <button type="button" class="lib3Btn" id="lib3RefreshShelf">Refresh</button>
</div>
- </div>
-
- <div class="bzlLibraryBody">
- <div class="${viewList ? "" : "hidden"}" id="bzlLibraryListView">
- <div class="bzlLibraryRow" style="align-items:flex-end;">
- <div style="flex: 1 1 260px;">
- <div class="bzlLibraryHint"><b>Upload PDF</b></div>
- <div class="bzlLibraryRow" style="margin: 6px 0 0 0;">
- <input id="bzlLibraryPdfTitle" type="text" placeholder="PDF title (optional)" style="flex: 1 1 200px;" />
- <input id="bzlLibraryPdfFile" type="file" accept="application/pdf,.pdf" />
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryPdfUpload">Upload PDF</button>
- </div>
- </div>
- </div>
-
- <div class="bzlLibraryRow" style="align-items:flex-end;">
- <div style="flex: 1 1 260px;">
- <div class="bzlLibraryHint"><b>Text books</b> (for lore, notes, character bios, poetry, etc)</div>
- <div class="bzlLibraryRow" style="margin: 6px 0 0 0;">
- <input id="bzlLibraryTextNewTitle" type="text" placeholder="New text title" style="flex: 1 1 200px;" />
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryTextNew">New blank</button>
- </div>
- <div class="bzlLibraryRow" style="margin: 6px 0 0 0;">
- <input id="bzlLibraryTextTitle" type="text" placeholder="Import title (optional)" style="flex: 1 1 200px;" />
- <input id="bzlLibraryTextFile" type="file" accept="text/plain,.txt,.md" />
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryTextImport">Import</button>
- </div>
- </div>
- </div>
-
- <div id="bzlLibraryStatus" class="bzlLibraryHint"></div>
- <div class="bzlLibraryHint">Open to read here. Left/Right arrows change pages. Text books can be edited and exported by the author.</div>
- <div style="height: 10px;"></div>
- <div class="bzlLibraryList">
- ${list || `<div class="bzlLibraryHint">No library items yet.</div>`}
+ <div class="lib3Card">
+ <div class="lib3Row">
+ <input id="lib3NewShelfName" type="text" placeholder="New shelf name" style="flex:1 1 180px;" />
+ <input id="lib3NewShelfDesc" type="text" placeholder="Shelf description" style="flex:2 1 220px;" />
+ <button type="button" class="lib3Btn" id="lib3CreateShelf">Create shelf</button>
</div>
</div>
-
- <div class="${viewList ? "hidden" : ""} bzlLibraryViewer" id="bzlLibraryViewer">
- <div class="bzlLibraryRow" style="justify-content: space-between; align-items:center;">
- <div style="display:flex; gap:8px; align-items:center;">
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryBack">Back</button>
- <div id="bzlLibraryPageLabel" class="bzlLibraryHint">${escapeHtml(pageLabelText)}</div>
- </div>
- <div style="display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap;">
- ${canEdit ? `<button type="button" class="bzlLibraryBtn" id="bzlLibraryEdit">Edit</button>` : ""}
- ${canEdit ? `<button type="button" class="bzlLibraryBtn" id="bzlLibraryExport">Export .txt</button>` : ""}
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryPrev"><</button>
- <input id="bzlLibraryPage" type="number" min="1" step="1" value="${activePage}" style="width: 92px;" />
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryGo">Go</button>
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryNext">></button>
- </div>
+ <div class="lib3Card">
+ <div class="lib3Row"><b>Add Book</b> <span class="lib3Hint">(PDF, TXT, MD, RTF)</span></div>
+ <div class="lib3Row">
+ <select id="lib3CreateTargetShelf">${mineList || "<option value=''>No owned shelves</option>"}</select>
+ <input id="lib3BookTitle" type="text" placeholder="Book name" style="flex:1 1 180px;" />
+ <input id="lib3BookAuthor" type="text" placeholder="Author" style="flex:1 1 160px;" value="${escapeHtml(snapshot.me || "")}" />
+ <label class="lib3Hint"><input id="lib3BookOriginal" type="checkbox" checked/> original</label>
</div>
-
- <div class="bzlLibraryDocWrap">
- <div class="${isText ? "hidden" : ""}" style="height:100%;">
- <iframe id="bzlLibraryFrame" class="bzlLibraryFrame" title="PDF viewer"></iframe>
- </div>
- <div class="${isText ? "" : "hidden"}" style="height:100%;">
- <div id="bzlLibraryTextPage" class="bzlLibraryTextPage"></div>
- </div>
+ <div class="lib3Row">
+ <input id="lib3BookTags" type="text" placeholder="tags: fic, fantasy, journal" style="flex:1 1 260px;" />
+ <select id="lib3TagQuick"><option value="">Tag quick-add</option>${TAG_SUGGESTIONS.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join("")}</select>
</div>
-
- <div class="bzlLibraryHint">${activeItem ? escapeHtml(activeItem.title || activeItem.filename || "") : ""}</div>
-
- <div class="${editorOpen ? "" : "hidden"}" id="bzlLibraryEditorWrap">
- <div style="height:10px;"></div>
- <div class="bzlLibraryHint"><b>Edit text book</b> (author only)</div>
- <div class="bzlLibraryRow" style="margin-top:6px;">
- <input id="bzlLibraryEditorTitle" type="text" placeholder="Title" value="${escapeHtml(editorTitle)}" style="flex: 1 1 240px;" />
- </div>
- <div class="bzlLibraryRow" style="margin-top:6px;">
- <textarea id="bzlLibraryEditorText" rows="12" style="width:100%; box-sizing:border-box;">${escapeHtml(
- editorText
- )}</textarea>
- </div>
- <div class="bzlLibraryRow" style="justify-content:flex-end;">
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryEditorCancel">Cancel</button>
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryEditorSave">Save</button>
- </div>
- <div class="bzlLibraryHint">Note: pages are auto-generated from the text.</div>
+ <div class="lib3Row">
+ <input id="lib3BookFile" type="file" accept=".pdf,.txt,.md,.rtf,application/pdf,text/plain,application/rtf,text/rtf" />
+ <button type="button" class="lib3Btn primary" id="lib3UploadBook">Upload File</button>
+ </div>
+ <div class="lib3Row">
+ <select id="lib3TextFormat"><option value="text">Text</option><option value="rtf">Rich text (RTF)</option></select>
+ <textarea id="lib3BookText" rows="5" placeholder="Or create a book directly" style="width:100%; box-sizing:border-box;"></textarea>
+ <button type="button" class="lib3Btn" id="lib3CreateText">Create Text Book</button>
</div>
</div>
+ <div class="lib3Scroll">
+ ${(shelf?.items || []).map((x) => {
+ const b = x.book || {};
+ const tHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
+ return `<div class="lib3Card">
+ <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div>
+ <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | ${String(b.kind || "").toUpperCase()} | ${formatBytes(b.bytes)} | original: ${b.isOriginal !== false ? "yes" : "no"}</div>
+ <div>${tHtml}</div>
+ <div class="lib3Row" style="margin-top:6px;">
+ <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button>
+ ${x.canReturn ? `<button type="button" class="lib3Btn" data-return-item="${escapeHtml(x.id)}">Return</button>` : ""}
+ ${x.canRemoveItem ? `<button type="button" class="lib3Btn" data-remove-item="${escapeHtml(x.id)}">Remove</button>` : ""}
+ ${b.canDeleteBook ? `<button type="button" class="lib3Btn" data-delete-book="${escapeHtml(b.id)}">Delete book</button>` : ""}
+ <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(shelf?.id || "")}">Check out</button>
+ </div>
+ </div>`;
+ }).join("") || "<div class='lib3Hint'>No books on this shelf.</div>"}
+ </div>
+ <div class="lib3Hint">${escapeHtml(statusLine)}</div>
</div>
-
- <div class="bzlLibraryResize" id="bzlLibraryResize" title="Resize"></div>
`;
- document.getElementById("bzlLibraryClose")?.addEventListener("click", () => {
- panelOpen = false;
- closeViewer();
- render();
+ mount.querySelector("#lib3ShelfPick")?.addEventListener("change", (e) => {
+ activeShelfId = String(e?.target?.value || "");
+ renderShelf();
+ });
+ mount.querySelector("#lib3RefreshShelf")?.addEventListener("click", () => requestList());
+ mount.querySelector("#lib3SubBtn")?.addEventListener("click", () => {
+ if (!shelf || shelf.isOwner) return;
+ ctx.send("shelfSubscribe", { shelfId: shelf.id, subscribe: !shelf.isSubscribed });
});
- document.getElementById("bzlLibraryRefresh")?.addEventListener("click", () => requestList());
- document.getElementById("bzlLibraryReset")?.addEventListener("click", () => {
+ mount.querySelector("#lib3CreateShelf")?.addEventListener("click", () => {
+ const name = String(mount.querySelector("#lib3NewShelfName")?.value || "").trim();
+ const description = String(mount.querySelector("#lib3NewShelfDesc")?.value || "").trim();
+ if (!name) return setStatus("Shelf name required.");
+ ctx.send("shelfCreate", { name, description, isPublic: true });
+ setStatus("Shelf created.");
+ });
+ mount.querySelector("#lib3TagQuick")?.addEventListener("change", (e) => {
+ const v = String(e?.target?.value || "").trim();
+ if (!v) return;
+ const inp = mount.querySelector("#lib3BookTags");
+ const next = parseTags(`${String(inp?.value || "")}, ${v}`);
+ if (inp) inp.value = next.join(", ");
+ e.target.value = "";
+ });
+ mount.querySelector("#lib3UploadBook")?.addEventListener("click", async () => {
+ const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
+ const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim();
+ const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim();
+ const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked);
+ const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || ""));
+ const file = mount.querySelector("#lib3BookFile")?.files?.[0];
+ if (!shelfId) return setStatus("Pick one of your shelves.");
+ if (!file) return setStatus("Choose a file first.");
try {
- localStorage.removeItem(PANEL_RECT_KEY);
+ await createBookFromFile(file, { shelfId, title: title || file.name, author: author || snapshot.me || "Unknown", isOriginal, tags });
} catch {
- // ignore
+ setStatus("Failed to create book from file.");
}
- applyPanelRect(panel, defaultPanelRect());
- savePanelRectFromEl(panel);
});
- panel.querySelectorAll("[data-libkind]").forEach((b) => {
- b.addEventListener("click", () => {
- filterKind = String(b.getAttribute("data-libkind") || "all");
- render();
- });
+ mount.querySelector("#lib3CreateText")?.addEventListener("click", () => {
+ const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
+ const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim();
+ const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim();
+ const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked);
+ const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || ""));
+ const format = String(mount.querySelector("#lib3TextFormat")?.value || "text").toLowerCase() === "rtf" ? "rtf" : "text";
+ const text = String(mount.querySelector("#lib3BookText")?.value || "");
+ if (!shelfId) return setStatus("Pick one of your shelves.");
+ if (!title) return setStatus("Book name required.");
+ ctx.send("textCreate", { shelfId, title, author: author || snapshot.me || "Unknown", isOriginal, tags, format, text });
+ setStatus("Book created.");
});
-
- const drag = document.getElementById("bzlLibraryDrag");
- const resize = document.getElementById("bzlLibraryResize");
-
- if (drag) {
- drag.addEventListener("pointerdown", (e) => {
- if (e.button !== 0) return;
- e.preventDefault();
- const start = { x: e.clientX, y: e.clientY };
- const rect = panel.getBoundingClientRect();
- const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
-
- const onMove = (ev) => {
- const dx = ev.clientX - start.x;
- const dy = ev.clientY - start.y;
- applyPanelRect(panel, { ...startRect, left: startRect.left + dx, top: startRect.top + dy });
- };
- const onUp = () => {
- window.removeEventListener("pointermove", onMove, true);
- window.removeEventListener("pointerup", onUp, true);
- window.removeEventListener("pointercancel", onUp, true);
- savePanelRectFromEl(panel);
- };
-
- window.addEventListener("pointermove", onMove, true);
- window.addEventListener("pointerup", onUp, true);
- window.addEventListener("pointercancel", onUp, true);
+ mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || "")));
+ mount.querySelectorAll("[data-checkout-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const targetShelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
+ const sourceBookId = String(btn.getAttribute("data-checkout-book") || "");
+ const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
+ if (!targetShelfId || !sourceBookId) return;
+ ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId });
+ setStatus("Checked out.");
});
- }
-
- if (resize) {
- resize.addEventListener("pointerdown", (e) => {
- if (e.button !== 0) return;
- e.preventDefault();
- const start = { x: e.clientX, y: e.clientY };
- const rect = panel.getBoundingClientRect();
- const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
-
- const onMove = (ev) => {
- const dx = ev.clientX - start.x;
- const dy = ev.clientY - start.y;
- applyPanelRect(panel, { ...startRect, width: startRect.width + dx, height: startRect.height + dy });
- };
- const onUp = () => {
- window.removeEventListener("pointermove", onMove, true);
- window.removeEventListener("pointerup", onUp, true);
- window.removeEventListener("pointercancel", onUp, true);
- savePanelRectFromEl(panel);
- };
-
- window.addEventListener("pointermove", onMove, true);
- window.addEventListener("pointerup", onUp, true);
- window.addEventListener("pointercancel", onUp, true);
+ });
+ mount.querySelectorAll("[data-remove-item]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ if (!shelf) return;
+ const shelfItemId = String(btn.getAttribute("data-remove-item") || "");
+ if (!shelfItemId) return;
+ ctx.send("shelfItemRemove", { shelfId: shelf.id, shelfItemId });
});
- }
-
- document.getElementById("bzlLibraryPdfUpload")?.addEventListener("click", () => uploadSelectedPdf());
- document.getElementById("bzlLibraryTextImport")?.addEventListener("click", () => importTextFromFile());
- document.getElementById("bzlLibraryTextNew")?.addEventListener("click", () => createBlankTextBook());
-
- panel.querySelectorAll("[data-libopen]").forEach((b) => {
- b.addEventListener("click", () => {
- const id = String(b.getAttribute("data-libopen") || "");
- const it = items.find((x) => String(x.id || "") === id);
- if (it) openItem(it);
+ });
+ mount.querySelectorAll("[data-return-item]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ if (!shelf) return;
+ const shelfItemId = String(btn.getAttribute("data-return-item") || "");
+ if (!shelfItemId) return;
+ ctx.send("returnBook", { shelfId: shelf.id, shelfItemId });
});
});
- panel.querySelectorAll("[data-libdel]").forEach((b) => {
- b.addEventListener("click", () => {
- const id = String(b.getAttribute("data-libdel") || "");
- if (!id) return;
- if (!confirm("Delete this item from the library?")) return;
- ctx.send("delete", { id });
+ mount.querySelectorAll("[data-delete-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const bookId = String(btn.getAttribute("data-delete-book") || "");
+ if (!bookId) return;
+ ctx.send("delete", { id: bookId });
});
});
+ }
- document.getElementById("bzlLibraryBack")?.addEventListener("click", () => closeViewer());
- document.getElementById("bzlLibraryPrev")?.addEventListener("click", () => setPage(activePage - 1));
- document.getElementById("bzlLibraryNext")?.addEventListener("click", () => setPage(activePage + 1));
- document.getElementById("bzlLibraryGo")?.addEventListener("click", () => {
- const inp = document.getElementById("bzlLibraryPage");
- setPage(Number(inp?.value || 1));
- });
- document.getElementById("bzlLibraryEdit")?.addEventListener("click", () => openEditor());
- document.getElementById("bzlLibraryExport")?.addEventListener("click", () => exportActiveText());
- document.getElementById("bzlLibraryEditorCancel")?.addEventListener("click", () => closeEditor());
- document.getElementById("bzlLibraryEditorSave")?.addEventListener("click", () => {
- const titleEl = document.getElementById("bzlLibraryEditorTitle");
- const textEl = document.getElementById("bzlLibraryEditorText");
- editorTitle = String(titleEl?.value || "").trim();
- editorText = String(textEl?.value || "");
- saveEditor();
- closeEditor();
- });
+ function renderReader() {
+ const mount = mounts.reader;
+ if (!(mount instanceof HTMLElement)) return;
+ const book = activeReaderBook();
+ if (!book) {
+ mount.innerHTML = `<div class="lib3"><div class="lib3Hint">Select a book from Shelf or Library to start reading.</div></div>`;
+ return;
+ }
- if (viewerOpen && activeItem) {
- if (String(activeItem.kind || "pdf") === "pdf") {
- const iframe = document.getElementById("bzlLibraryFrame");
- if (iframe && !iframe.src) iframe.src = `${activeItem.url}#page=${activePage}`;
- } else {
- setPage(activePage);
- }
+ const isText = String(book.kind || "") === "text";
+ const fullText = textByBookId.get(String(book.id || "")) || "";
+ const pages = isText ? paginateText(fullText) : [];
+ const totalPages = isText ? Math.max(1, pages.length) : 1;
+ readerPage = Math.max(1, Math.min(isText ? totalPages : 9999, Number(readerPage || 1)));
+
+ const tagsHtml = (book.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
+ mount.innerHTML = `
+ <div class="lib3">
+ <div class="lib3Row" style="justify-content:space-between;">
+ <div>
+ <div><b>${escapeHtml(book.title || "Untitled")}</b> ${book.checkedOutBy ? "<span title='Checked out'>↩</span>" : ""}</div>
+ <div class="lib3Meta">by ${escapeHtml(book.author || book.createdBy || "Unknown")} | ${String(book.kind || "").toUpperCase()} | ${formatBytes(book.bytes)}</div>
+ <div>${tagsHtml}</div>
+ </div>
+ ${
+ isText
+ ? `<div class="lib3Row">
+ <button type="button" class="lib3Btn" id="lib3ReadPrev"><</button>
+ <input id="lib3ReadPage" type="number" min="1" value="${readerPage}" style="width:84px;" />
+ <button type="button" class="lib3Btn" id="lib3ReadGo">Go</button>
+ <button type="button" class="lib3Btn" id="lib3ReadNext">></button>
+ </div>`
+ : `<div class="lib3Hint">Use the PDF toolbar to navigate pages.</div>`
+ }
+ </div>
+ <div class="lib3ReaderViewport">
+ ${isText ? `<div id="lib3ReaderText" class="lib3ReaderPage">${escapeHtml(pages[readerPage - 1] || (fullText ? "" : "Loading text..."))}</div>` : `<iframe class="lib3Iframe" src="${escapeHtml(`${book.url || ""}#page=${readerPage}`)}" title="Reader"></iframe>`}
+ </div>
+ <div class="lib3Hint">Page ${readerPage}${isText ? ` / ${totalPages}` : ""}</div>
+ </div>
+ `;
+
+ if (isText && !textByBookId.has(String(book.id || ""))) requestText(book.id);
+ if (isText) {
+ mount.querySelector("#lib3ReadPrev")?.addEventListener("click", () => {
+ readerPage -= 1;
+ renderReader();
+ });
+ mount.querySelector("#lib3ReadNext")?.addEventListener("click", () => {
+ readerPage += 1;
+ renderReader();
+ });
+ mount.querySelector("#lib3ReadGo")?.addEventListener("click", () => {
+ readerPage = Number(mount.querySelector("#lib3ReadPage")?.value || 1);
+ renderReader();
+ });
}
}
+ function renderAll() {
+ renderLibrary();
+ renderShelf();
+ renderReader();
+ }
function onWsMsg(ev) {
try {
@@ -799,17 +600,24 @@
if (!type.startsWith("plugin:library:")) return;
if (type === "plugin:library:list") {
- items = Array.isArray(msg.items) ? msg.items : [];
- render();
+ snapshot = { me: String(msg.me || ""), myShelfId: String(msg.myShelfId || ""), shelves: Array.isArray(msg.shelves) ? msg.shelves : [] };
+ if (!activeShelfId || !snapshot.shelves.some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves[0]?.id || "";
+ renderAll();
return;
}
if (type === "plugin:library:changed") {
- if (panelOpen) requestList();
+ requestList();
+ return;
+ }
+ if (type === "plugin:library:text") {
+ const it = msg.item || null;
+ if (!it || !it.id) return;
+ textByBookId.set(String(it.id), String(it.text || ""));
+ renderReader();
return;
}
if (type === "plugin:library:uploadStarted") {
- const uploadId = String(msg.uploadId || "");
- if (uploadId) window.__bzlLibraryUploadId = uploadId;
+ window.__bzlLibraryUploadId = String(msg.uploadId || "");
return;
}
if (type === "plugin:library:uploadProgress") {
@@ -817,104 +625,89 @@
return;
}
if (type === "plugin:library:uploadFinished") {
- uploadingPdf = false;
+ uploadInProgress = false;
window.__bzlLibraryUploadId = "";
setStatus("Upload complete.");
requestList();
- render();
- return;
- }
- if (type === "plugin:library:text") {
- const it = msg.item;
- if (!it || !activeItem || String(activeItem.id || "") !== String(it.id || "")) return;
- activeText = String(it.text || "");
- textPages = paginateText(activeText);
- totalPages = Math.max(1, textPages.length);
- activeItem = { ...activeItem, title: it.title, createdBy: it.createdBy, updatedAt: it.updatedAt, bytes: it.bytes };
- editorTitle = String(activeItem.title || "");
- setStatus("");
- render();
- setPage(1);
- return;
- }
- if (type === "plugin:library:textCreated") {
- requestList();
- setStatus("Created.");
- return;
- }
- if (type === "plugin:library:textUpdated") {
- requestList();
- setStatus("Saved.");
- if (activeItem && String(activeItem.kind || "") === "text" && msg.id && String(msg.id) === String(activeItem.id)) {
- requestText(activeItem.id);
- }
- return;
- }
- if (type === "plugin:library:deleted") {
- requestList();
- if (activeItem && msg.id && String(msg.id) === String(activeItem.id)) closeViewer();
return;
}
if (type === "plugin:library:error") {
- uploadingPdf = false;
+ uploadInProgress = false;
setStatus(String(msg.message || "Error."));
ctx.toast("Library", String(msg.message || "Error."));
+ return;
+ }
+ if (
+ type === "plugin:library:textCreated" ||
+ type === "plugin:library:textUpdated" ||
+ type === "plugin:library:shelfCreated" ||
+ type === "plugin:library:shelfUpdated" ||
+ type === "plugin:library:shelfSubscribed" ||
+ type === "plugin:library:pinned" ||
+ type === "plugin:library:checkedOut" ||
+ type === "plugin:library:shelfItemRemoved" ||
+ type === "plugin:library:returned" ||
+ type === "plugin:library:deleted"
+ ) {
+ requestList();
}
} catch {
// ignore
}
}
- function ensureDom() {
- if (document.getElementById("bzlLibraryToggle")) return;
- if (!document.body) {
- ctx.devLog("warn", "library:bodyMissing", {});
- return;
- }
- const btn = document.createElement("button");
- btn.id = "bzlLibraryToggle";
- btn.className = "bzlLibraryToggle";
- btn.type = "button";
- btn.textContent = "Library";
- btn.addEventListener("click", () => {
- panelOpen = !panelOpen;
- if (panelOpen) {
- const panel = document.getElementById("bzlLibraryPanel");
- if (panel) applyPanelRect(panel, readPanelRect() || defaultPanelRect());
- requestList();
- }
- render();
+ function mountPanel(kind, mount) {
+ if (!(mount instanceof HTMLElement)) return;
+ mount.style.height = "100%";
+ mount.style.minHeight = "0";
+ mount.style.display = "flex";
+ mounts[kind] = mount;
+ renderAll();
+ }
+
+ const panelOpts = { defaultRack: "main", role: "primary" };
+ if (ctx?.ui?.registerPanel) {
+ ctx.ui.registerPanel({
+ id: "library-reader",
+ title: "Reader",
+ icon: "Read",
+ ...panelOpts,
+ render(mount) {
+ mountPanel("reader", mount);
+ return () => {
+ if (mounts.reader === mount) mounts.reader = null;
+ };
+ },
});
- document.body.appendChild(btn);
- ctx.devLog("info", "library:toggleMounted", {});
-
- const panel = document.createElement("div");
- panel.id = "bzlLibraryPanel";
- panel.className = "bzlLibraryPanel hidden";
- applyPanelRect(panel, readPanelRect() || defaultPanelRect());
- document.body.appendChild(panel);
-
- window.addEventListener("resize", () => {
- const p = document.getElementById("bzlLibraryPanel");
- if (!p) return;
- applyPanelRect(p, readPanelRect() || defaultPanelRect());
+ ctx.ui.registerPanel({
+ id: "library-shelf",
+ title: "Shelf",
+ icon: "Shelf",
+ ...panelOpts,
+ render(mount) {
+ mountPanel("shelf", mount);
+ return () => {
+ if (mounts.shelf === mount) mounts.shelf = null;
+ };
+ },
});
-
- window.addEventListener("keydown", (e) => {
- if (!panelOpen || !viewerOpen) return;
- if (e.key === "ArrowLeft") {
- e.preventDefault();
- setPage(activePage - 1);
- } else if (e.key === "ArrowRight") {
- e.preventDefault();
- setPage(activePage + 1);
- }
+ ctx.ui.registerPanel({
+ id: "library-browser",
+ title: "Library",
+ icon: "Books",
+ ...panelOpts,
+ render(mount) {
+ mountPanel("library", mount);
+ return () => {
+ if (mounts.library === mount) mounts.library = null;
+ };
+ },
});
}
setInterval(attachWsListener, 1000);
attachWsListener();
- whenBodyReady(ensureDom);
- ctx.devLog("info", "library:init", { ok: true });
+ requestList();
+ ctx.devLog("info", "library:init", { ok: true, panels: ["library-reader", "library-shelf", "library-browser"] });
});
})();
diff --git a/CLEAN_INSTALL/plugins_dev/library/plugin.json b/CLEAN_INSTALL/plugins_dev/library/plugin.json
@@ -1,8 +1,8 @@
{
"id": "library",
"name": "Library",
- "version": "0.2.5",
- "description": "Upload PDFs as library posts and read them in-app.",
+ "version": "0.4.0",
+ "description": "Panel-based social library with Reader, Shelf, and Library browsing plus checkout/return and metadata tags.",
"entryClient": "client.js",
"entryServer": "server.js",
"permissions": ["ui", "ws"]
diff --git a/CLEAN_INSTALL/plugins_dev/library/server.js b/CLEAN_INSTALL/plugins_dev/library/server.js
@@ -53,6 +53,7 @@ function normalizeTextBody(text) {
let t = typeof text === "string" ? text : "";
t = t.replace(/\r\n/g, "\n");
if (Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) {
+ // Trim to max bytes (best-effort by characters).
while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 4096));
while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 256));
while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 16));
@@ -60,6 +61,33 @@ function normalizeTextBody(text) {
}
return t;
}
+
+function normalizeAuthor(name) {
+ const a = String(name || "").trim().slice(0, 80);
+ return a || "Unknown";
+}
+
+function normalizeTags(tags) {
+ if (!Array.isArray(tags)) return [];
+ const out = [];
+ const seen = new Set();
+ for (const raw of tags) {
+ const t = String(raw || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9 _-]+/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^[-_]+|[-_]+$/g, "")
+ .slice(0, 32);
+ if (!t || seen.has(t)) continue;
+ seen.add(t);
+ out.push(t);
+ if (out.length >= 16) break;
+ }
+ return out;
+}
+
function sanitizeFilename(name) {
const base = String(name || "")
.trim()
@@ -136,6 +164,10 @@ module.exports = function init(api) {
createdAt: Number(meta.createdAt || t),
createdBy: String(meta.createdBy || ""),
title: String(meta.title || ""),
+ author: normalizeAuthor(meta.author || meta.createdBy || ""),
+ isOriginal: meta?.isOriginal !== false,
+ tags: normalizeTags(meta?.tags),
+ shelfId: normalizeId(meta.shelfId || ""),
lastSeenAt: t
};
inFlight.set(uploadId, rec);
@@ -143,31 +175,324 @@ module.exports = function init(api) {
return rec;
}
- function loadItems() {
+ function normalizeShelfName(name) {
+ const s = String(name || "").trim().slice(0, 80);
+ return s || "Untitled shelf";
+ }
+
+ function normalizeShelfDescription(description) {
+ return String(description || "").trim().slice(0, 240);
+ }
+
+ function createShelfId() {
+ return `shelf-${crypto.randomBytes(8).toString("hex")}`;
+ }
+
+ function createShelfItemId() {
+ return `si-${crypto.randomBytes(8).toString("hex")}`;
+ }
+
+ function normalizeBook(raw) {
+ const kind = String(raw?.kind || "pdf") === "text" ? "text" : "pdf";
+ const id = normalizeId(raw?.id);
+ if (!id) return null;
+ const createdAt = Number(raw?.createdAt || 0) || nowMs();
+ const out = {
+ id,
+ kind,
+ title: kind === "text" ? normalizeTextTitle(raw?.title) : normalizeTitle(raw?.title),
+ author: normalizeAuthor(raw?.author || raw?.createdBy || ""),
+ isOriginal: raw?.isOriginal !== false,
+ tags: normalizeTags(raw?.tags),
+ format: kind === "text" ? (String(raw?.format || "text").toLowerCase() === "rtf" ? "rtf" : "text") : "pdf",
+ bytes: Number(raw?.bytes || 0),
+ createdAt,
+ createdBy: String(raw?.createdBy || ""),
+ updatedAt: Number(raw?.updatedAt || createdAt) || createdAt,
+ sourceBookId: normalizeId(raw?.sourceBookId || ""),
+ sourceShelfId: normalizeId(raw?.sourceShelfId || ""),
+ checkedOutBy: String(raw?.checkedOutBy || ""),
+ };
+ if (kind === "text") {
+ out.text = typeof raw?.text === "string" ? normalizeTextBody(raw.text) : "";
+ out.bytes = Number(out.bytes || Buffer.byteLength(String(out.text || ""), "utf8"));
+ return out;
+ }
+ out.url = String(raw?.url || "");
+ out.filename = String(raw?.filename || "");
+ if (!out.url || !out.filename) return null;
+ return out;
+ }
+
+ function normalizeShelfItem(raw) {
+ const id = normalizeId(raw?.id);
+ const bookId = normalizeId(raw?.bookId);
+ if (!id || !bookId) return null;
+ const kind = ["own", "pin", "checkout"].includes(String(raw?.kind || "")) ? String(raw.kind) : "own";
+ return {
+ id,
+ bookId,
+ kind,
+ addedBy: String(raw?.addedBy || ""),
+ addedAt: Number(raw?.addedAt || 0) || nowMs(),
+ note: String(raw?.note || "").slice(0, 180),
+ sourceBookId: normalizeId(raw?.sourceBookId || ""),
+ sourceShelfId: normalizeId(raw?.sourceShelfId || ""),
+ };
+ }
+
+ function normalizeShelf(raw) {
+ const id = normalizeId(raw?.id);
+ if (!id) return null;
+ const createdAt = Number(raw?.createdAt || 0) || nowMs();
+ const subscribers = Array.isArray(raw?.subscribers) ? raw.subscribers.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean) : [];
+ const uniqSubs = [...new Set(subscribers)];
+ const items = Array.isArray(raw?.items) ? raw.items.map(normalizeShelfItem).filter(Boolean) : [];
+ return {
+ id,
+ name: normalizeShelfName(raw?.name),
+ description: normalizeShelfDescription(raw?.description),
+ owner: String(raw?.owner || "").trim().toLowerCase(),
+ isPublic: raw?.isPublic !== false,
+ createdAt,
+ updatedAt: Number(raw?.updatedAt || createdAt) || createdAt,
+ subscribers: uniqSubs,
+ items,
+ };
+ }
+
+ function userPrimaryShelfId(user) {
+ return normalizeId(`shelf-user-${String(user || "").trim().toLowerCase()}`);
+ }
+
+ function ensureUserPrimaryShelf(store, user) {
+ const u = String(user || "").trim().toLowerCase();
+ if (!u) return { changed: false, shelf: null };
+ const id = userPrimaryShelfId(u);
+ const existing = store.shelves.find((s) => s.id === id);
+ if (existing) return { changed: false, shelf: existing };
+ const t = nowMs();
+ const shelf = {
+ id,
+ name: `${u}'s shelf`,
+ description: "My personal shelf",
+ owner: u,
+ isPublic: true,
+ createdAt: t,
+ updatedAt: t,
+ subscribers: [],
+ items: [],
+ };
+ store.shelves.push(shelf);
+ return { changed: true, shelf };
+ }
+
+ function migrateLegacy(parsed) {
+ const legacyItems = Array.isArray(parsed?.items) ? parsed.items : [];
+ const books = [];
+ const communityItems = [];
+ for (const it of legacyItems) {
+ const book = normalizeBook(it);
+ if (!book) continue;
+ books.push(book);
+ communityItems.push({
+ id: createShelfItemId(),
+ bookId: book.id,
+ kind: "own",
+ addedBy: String(book.createdBy || ""),
+ addedAt: Number(book.createdAt || nowMs()),
+ note: "",
+ sourceBookId: "",
+ sourceShelfId: "",
+ });
+ }
+ const t = nowMs();
+ return {
+ version: 2,
+ books,
+ shelves: [
+ {
+ id: "shelf-community",
+ name: "Community shelf",
+ description: "Shared shelf migrated from the classic library.",
+ owner: "",
+ isPublic: true,
+ createdAt: t,
+ updatedAt: t,
+ subscribers: [],
+ items: communityItems,
+ },
+ ],
+ };
+ }
+
+ function loadStore() {
const parsed = readJsonOrNull(dataFile);
- const items = Array.isArray(parsed?.items) ? parsed.items : [];
- return items
- .map((it) => ({
- id: normalizeId(it?.id),
- kind: String(it?.kind || "pdf") === "text" ? "text" : "pdf",
- title: String(it?.kind || "pdf") === "text" ? normalizeTextTitle(it?.title) : normalizeTitle(it?.title),
- url: String(it?.url || ""),
- filename: String(it?.filename || ""),
- bytes: Number(it?.bytes || 0),
- createdAt: Number(it?.createdAt || 0),
- createdBy: String(it?.createdBy || ""),
- updatedAt: Number(it?.updatedAt || 0),
- text: typeof it?.text === "string" ? it.text : "",
- }))
- .filter((it) => {
- if (!it.id) return false;
- if (it.kind === "pdf") return Boolean(it.url && it.filename);
- return true;
+ if (!parsed || typeof parsed !== "object") {
+ return { version: 2, books: [], shelves: [] };
+ }
+ if (Number(parsed.version || 0) !== 2) return migrateLegacy(parsed);
+ const books = Array.isArray(parsed.books) ? parsed.books.map(normalizeBook).filter(Boolean) : [];
+ const byBook = new Map(books.map((b) => [b.id, b]));
+ const shelves = Array.isArray(parsed.shelves) ? parsed.shelves.map(normalizeShelf).filter(Boolean) : [];
+ for (const shelf of shelves) {
+ shelf.items = shelf.items.filter((si) => byBook.has(si.bookId));
+ }
+ return { version: 2, books, shelves };
+ }
+
+ function saveStore(store) {
+ writeFileAtomic(
+ dataFile,
+ JSON.stringify(
+ {
+ version: 2,
+ books: Array.isArray(store?.books) ? store.books : [],
+ shelves: Array.isArray(store?.shelves) ? store.shelves : [],
+ },
+ null,
+ 2
+ ) + "\n"
+ );
+ }
+
+ function findShelf(store, shelfId) {
+ const id = normalizeId(shelfId);
+ if (!id) return null;
+ return store.shelves.find((s) => s.id === id) || null;
+ }
+
+ function findBook(store, bookId) {
+ const id = normalizeId(bookId);
+ if (!id) return null;
+ return store.books.find((b) => b.id === id) || null;
+ }
+
+ function removeBookIfOrphan(store, bookId) {
+ const id = normalizeId(bookId);
+ if (!id) return null;
+ const stillReferenced = store.shelves.some((shelf) => shelf.items.some((si) => si.bookId === id));
+ if (stillReferenced) return null;
+ const idx = store.books.findIndex((b) => b.id === id);
+ if (idx < 0) return null;
+ const book = store.books[idx];
+ store.books.splice(idx, 1);
+ return book;
+ }
+
+ function isShelfOwner(user, shelf) {
+ return Boolean(user) && String(shelf?.owner || "") === String(user || "");
+ }
+
+ function canViewShelf(user, shelf) {
+ if (!shelf) return false;
+ if (shelf.isPublic) return true;
+ return isShelfOwner(user, shelf);
+ }
+
+ function canAccessBook(user, store, bookId) {
+ const id = normalizeId(bookId);
+ if (!id) return false;
+ return store.shelves.some((shelf) => canViewShelf(user, shelf) && shelf.items.some((si) => si.bookId === id));
+ }
+
+ function popularityByBook(store) {
+ const byId = new Map();
+ const add = (id, field) => {
+ if (!id) return;
+ if (!byId.has(id)) byId.set(id, { pins: 0, checkouts: 0 });
+ byId.get(id)[field] += 1;
+ };
+ for (const shelf of store.shelves) {
+ for (const si of shelf.items) {
+ if (si.kind === "pin") add(si.bookId, "pins");
+ }
+ }
+ for (const book of store.books) {
+ if (book.sourceBookId) add(book.sourceBookId, "checkouts");
+ }
+ return byId;
+ }
+
+ function makeSnapshot(ws) {
+ const u = username(ws);
+ const role = userRole(ws);
+ const isOwner = role === "owner";
+ const store = loadStore();
+ const ensured = ensureUserPrimaryShelf(store, u);
+ if (ensured.changed) saveStore(store);
+
+ const pop = popularityByBook(store);
+ const booksById = new Map(store.books.map((b) => [b.id, b]));
+ const shelves = store.shelves
+ .filter((shelf) => canViewShelf(u, shelf))
+ .sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
+ .map((shelf) => {
+ const shelfOwner = isShelfOwner(u, shelf);
+ const items = shelf.items
+ .slice()
+ .sort((a, b) => Number(b.addedAt || 0) - Number(a.addedAt || 0))
+ .map((si) => {
+ const book = booksById.get(si.bookId);
+ if (!book) return null;
+ const score = pop.get(book.id) || { pins: 0, checkouts: 0 };
+ return {
+ id: si.id,
+ kind: si.kind,
+ addedAt: si.addedAt,
+ addedBy: si.addedBy,
+ note: si.note || "",
+ sourceBookId: si.sourceBookId || "",
+ sourceShelfId: si.sourceShelfId || "",
+ canRemoveItem: shelfOwner,
+ canReturn: shelfOwner && si.kind === "checkout",
+ book: {
+ id: book.id,
+ kind: book.kind,
+ title: book.title,
+ author: book.author || normalizeAuthor(book.createdBy || ""),
+ isOriginal: book.isOriginal !== false,
+ tags: Array.isArray(book.tags) ? book.tags : [],
+ format: String(book.format || (book.kind === "text" ? "text" : "pdf")),
+ url: book.kind === "pdf" ? book.url : "",
+ filename: book.kind === "pdf" ? book.filename : "",
+ bytes: book.bytes,
+ createdAt: book.createdAt,
+ createdBy: book.createdBy,
+ updatedAt: book.updatedAt || book.createdAt,
+ sourceBookId: book.sourceBookId || "",
+ sourceShelfId: book.sourceShelfId || "",
+ checkedOutBy: book.checkedOutBy || "",
+ popularity: { pins: Number(score.pins || 0), checkouts: Number(score.checkouts || 0) },
+ canDeleteBook: isOwner || String(book.createdBy || "") === u,
+ },
+ };
+ })
+ .filter(Boolean);
+ return {
+ id: shelf.id,
+ name: shelf.name,
+ description: shelf.description || "",
+ owner: shelf.owner || "",
+ isPublic: shelf.isPublic !== false,
+ createdAt: shelf.createdAt,
+ updatedAt: shelf.updatedAt,
+ isOwner: shelfOwner,
+ isSubscribed: Boolean(u && shelf.subscribers.includes(u)),
+ subscriberCount: Number((shelf.subscribers || []).length || 0),
+ items,
+ };
});
+
+ return {
+ me: u,
+ myShelfId: ensured.shelf?.id || userPrimaryShelfId(u),
+ shelves,
+ };
}
- function saveItems(items) {
- writeFileAtomic(dataFile, JSON.stringify({ version: 1, items }, null, 2) + "\n");
+ function broadcastChanged() {
+ api.broadcast({ type: "plugin:library:changed" });
}
function send(ws, msg) {
@@ -183,34 +508,6 @@ module.exports = function init(api) {
send(ws, { type: "plugin:library:error", message: String(message || "Error."), data: data || null });
}
- function listForClient() {
- const items = loadItems();
- items.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
- return items.map((it) => {
- if (it.kind === "text") {
- return {
- id: it.id,
- kind: "text",
- title: it.title,
- bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")),
- createdAt: it.createdAt,
- createdBy: it.createdBy,
- updatedAt: it.updatedAt || it.createdAt,
- };
- }
- return {
- id: it.id,
- kind: "pdf",
- title: it.title,
- url: it.url,
- filename: it.filename,
- bytes: it.bytes,
- createdAt: it.createdAt,
- createdBy: it.createdBy,
- };
- });
- }
-
// Important: do not delete inflight uploads on WS close. Reconnects are common, and
// the client may continue the upload on a new socket. Instead, time out abandoned uploads.
setInterval(() => {
@@ -239,7 +536,226 @@ module.exports = function init(api) {
}, 60_000).unref?.();
api.registerWs("list", (ws) => {
- send(ws, { type: "plugin:library:list", items: listForClient() });
+ send(ws, { type: "plugin:library:list", ...makeSnapshot(ws) });
+ });
+
+ api.registerWs("shelfCreate", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const name = normalizeShelfName(msg?.name);
+ const description = normalizeShelfDescription(msg?.description);
+ const isPublic = msg?.isPublic !== false;
+ const t = nowMs();
+ const store = loadStore();
+ const shelf = {
+ id: createShelfId(),
+ name,
+ description,
+ owner: u,
+ isPublic,
+ createdAt: t,
+ updatedAt: t,
+ subscribers: [],
+ items: [],
+ };
+ store.shelves.push(shelf);
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfCreated", ok: true, shelfId: shelf.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("shelfUpdate", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can edit it.");
+ shelf.name = normalizeShelfName(msg?.name ?? shelf.name);
+ shelf.description = normalizeShelfDescription(msg?.description ?? shelf.description);
+ if (typeof msg?.isPublic === "boolean") shelf.isPublic = msg.isPublic;
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfUpdated", ok: true, shelfId: shelf.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("shelfSubscribe", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const subscribe = msg?.subscribe !== false;
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf || !canViewShelf(u, shelf)) return sendError(ws, "Shelf not found.");
+ const set = new Set(Array.isArray(shelf.subscribers) ? shelf.subscribers : []);
+ if (subscribe) set.add(u);
+ else set.delete(u);
+ shelf.subscribers = [...set];
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfSubscribed", ok: true, shelfId: shelf.id, subscribed: subscribe });
+ broadcastChanged();
+ });
+
+ api.registerWs("pinBook", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const bookId = normalizeId(msg?.bookId);
+ if (!shelfId || !bookId) return sendError(ws, "Missing shelf/book id.");
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only pin into your own shelves.");
+ const book = findBook(store, bookId);
+ if (!book) return sendError(ws, "Book not found.");
+ if (!canAccessBook(u, store, book.id)) return sendError(ws, "Book not found.");
+ if (shelf.items.some((si) => si.bookId === book.id && si.kind === "pin")) {
+ return sendError(ws, "Already pinned on this shelf.");
+ }
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: book.id,
+ kind: "pin",
+ addedBy: u,
+ addedAt: nowMs(),
+ note: "",
+ sourceBookId: book.id,
+ sourceShelfId: normalizeId(msg?.sourceShelfId || ""),
+ });
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:pinned", ok: true, shelfId: shelf.id, bookId: book.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("checkoutBook", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const targetShelfId = normalizeId(msg?.targetShelfId);
+ const sourceBookId = normalizeId(msg?.sourceBookId);
+ const sourceShelfId = normalizeId(msg?.sourceShelfId);
+ if (!targetShelfId || !sourceBookId) return sendError(ws, "Missing ids.");
+ const store = loadStore();
+ const shelf = findShelf(store, targetShelfId);
+ if (!shelf) return sendError(ws, "Target shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only check out to your own shelf.");
+ const source = findBook(store, sourceBookId);
+ if (!source) return sendError(ws, "Source book not found.");
+ if (!canAccessBook(u, store, source.id)) return sendError(ws, "Source book not found.");
+ const t = nowMs();
+ const copyId = crypto.randomBytes(10).toString("hex");
+ const cloned = source.kind === "text"
+ ? {
+ id: copyId,
+ kind: "text",
+ title: source.title,
+ author: source.author || source.createdBy || u,
+ isOriginal: false,
+ tags: normalizeTags(source.tags),
+ format: String(source.format || "text").toLowerCase() === "rtf" ? "rtf" : "text",
+ text: String(source.text || ""),
+ bytes: Number(source.bytes || Buffer.byteLength(String(source.text || ""), "utf8")),
+ createdAt: t,
+ updatedAt: t,
+ createdBy: u,
+ sourceBookId: source.id,
+ sourceShelfId,
+ checkedOutBy: u,
+ }
+ : {
+ id: copyId,
+ kind: "pdf",
+ title: source.title,
+ author: source.author || source.createdBy || u,
+ isOriginal: false,
+ tags: normalizeTags(source.tags),
+ format: "pdf",
+ filename: source.filename,
+ url: source.url,
+ bytes: source.bytes,
+ createdAt: t,
+ updatedAt: t,
+ createdBy: u,
+ sourceBookId: source.id,
+ sourceShelfId,
+ checkedOutBy: u,
+ };
+ store.books.push(cloned);
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: cloned.id,
+ kind: "checkout",
+ addedBy: u,
+ addedAt: t,
+ note: "",
+ sourceBookId: source.id,
+ sourceShelfId,
+ });
+ shelf.updatedAt = t;
+ saveStore(store);
+ send(ws, { type: "plugin:library:checkedOut", ok: true, targetShelfId: shelf.id, newBookId: cloned.id, sourceBookId: source.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("shelfItemRemove", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const shelfItemId = normalizeId(msg?.shelfItemId);
+ if (!shelfId || !shelfItemId) return sendError(ws, "Missing ids.");
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can remove items.");
+ const row = shelf.items.find((si) => si.id === shelfItemId);
+ if (!row) return sendError(ws, "Item not found.");
+ shelf.items = shelf.items.filter((si) => si.id !== shelfItemId);
+ const maybeRemovedBook = removeBookIfOrphan(store, row.bookId);
+ if (maybeRemovedBook?.kind === "pdf" && maybeRemovedBook.filename) {
+ const filePath = path.join(uploadsDir, maybeRemovedBook.filename);
+ try {
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {
+ // ignore
+ }
+ }
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfItemRemoved", ok: true, shelfId, shelfItemId });
+ broadcastChanged();
+ });
+
+ api.registerWs("returnBook", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const shelfItemId = normalizeId(msg?.shelfItemId);
+ if (!shelfId || !shelfItemId) return sendError(ws, "Missing ids.");
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can return books.");
+ const row = shelf.items.find((si) => si.id === shelfItemId);
+ if (!row) return sendError(ws, "Item not found.");
+ if (row.kind !== "checkout") return sendError(ws, "Only checked-out books can be returned.");
+
+ shelf.items = shelf.items.filter((si) => si.id !== shelfItemId);
+ const removed = removeBookIfOrphan(store, row.bookId);
+ if (removed?.kind === "pdf" && removed.filename) {
+ const filePath = path.join(uploadsDir, removed.filename);
+ try {
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {
+ // ignore
+ }
+ }
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:returned", ok: true, shelfId, shelfItemId, bookId: row.bookId });
+ broadcastChanged();
});
api.registerWs("textGet", (ws, msg) => {
@@ -247,8 +763,9 @@ module.exports = function init(api) {
if (!u) return sendError(ws, "Sign in required.");
const id = normalizeId(msg?.id);
if (!id) return sendError(ws, "Missing id.");
- const items = loadItems();
- const it = items.find((x) => x.id === id);
+ const store = loadStore();
+ if (!canAccessBook(u, store, id)) return sendError(ws, "Not found.");
+ const it = findBook(store, id);
if (!it || it.kind !== "text") return sendError(ws, "Not found.");
send(ws, {
type: "plugin:library:text",
@@ -256,6 +773,10 @@ module.exports = function init(api) {
id: it.id,
kind: "text",
title: it.title,
+ author: it.author || normalizeAuthor(it.createdBy || ""),
+ isOriginal: it.isOriginal !== false,
+ tags: Array.isArray(it.tags) ? it.tags : [],
+ format: String(it.format || "text"),
text: String(it.text || ""),
bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")),
createdAt: it.createdAt,
@@ -268,28 +789,52 @@ module.exports = function init(api) {
api.registerWs("textCreate", (ws, msg) => {
const u = username(ws);
if (!u) return sendError(ws, "Sign in required.");
+ const store = loadStore();
+ const ensured = ensureUserPrimaryShelf(store, u);
+ const shelfId = normalizeId(msg?.shelfId || ensured.shelf?.id);
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only add books to your own shelf.");
const title = normalizeTextTitle(msg?.title);
+ const author = normalizeAuthor(msg?.author || u);
+ const isOriginal = msg?.isOriginal !== false;
+ const tags = normalizeTags(msg?.tags);
+ const format = String(msg?.format || "text").toLowerCase() === "rtf" ? "rtf" : "text";
const text = normalizeTextBody(msg?.text);
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`);
- const items = loadItems();
const id = crypto.randomBytes(10).toString("hex");
const t = nowMs();
- items.push({
+ store.books.push({
id,
kind: "text",
title,
+ author,
+ isOriginal,
+ tags,
+ format,
text,
bytes,
createdAt: t,
updatedAt: t,
createdBy: u,
});
- saveItems(items);
- api.log("info", "library:textCreate", { id, bytes, user: u });
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: id,
+ kind: "own",
+ addedBy: u,
+ addedAt: t,
+ note: "",
+ sourceBookId: "",
+ sourceShelfId: "",
+ });
+ shelf.updatedAt = t;
+ saveStore(store);
+ api.log("info", "library:textCreate", { id, bytes, user: u, shelfId: shelf.id });
send(ws, { type: "plugin:library:textCreated", ok: true, id });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
api.registerWs("textUpdate", (ws, msg) => {
@@ -298,29 +843,40 @@ module.exports = function init(api) {
const id = normalizeId(msg?.id);
if (!id) return sendError(ws, "Missing id.");
- const items = loadItems();
- const idx = items.findIndex((x) => x.id === id);
+ const store = loadStore();
+ const idx = store.books.findIndex((x) => x.id === id);
if (idx < 0) return sendError(ws, "Not found.");
- const it = items[idx];
+ const it = store.books[idx];
if (it.kind !== "text") return sendError(ws, "Not found.");
if (String(it.createdBy || "") !== u) return sendError(ws, "Only the author can edit this text.");
const title = normalizeTextTitle(msg?.title ?? it.title);
+ const author = normalizeAuthor(msg?.author ?? it.author ?? u);
+ const isOriginal = typeof msg?.isOriginal === "boolean" ? msg.isOriginal : it.isOriginal !== false;
+ const tags = Array.isArray(msg?.tags) ? normalizeTags(msg.tags) : Array.isArray(it.tags) ? normalizeTags(it.tags) : [];
+ const format = String(msg?.format || it.format || "text").toLowerCase() === "rtf" ? "rtf" : "text";
const text = normalizeTextBody(msg?.text);
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`);
const t = nowMs();
- items[idx] = { ...it, title, text, bytes, updatedAt: t };
- saveItems(items);
+ store.books[idx] = { ...it, title, author, isOriginal, tags, format, text, bytes, updatedAt: t };
+ saveStore(store);
api.log("info", "library:textUpdate", { id, bytes, user: u });
send(ws, { type: "plugin:library:textUpdated", ok: true, id, updatedAt: t });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
api.registerWs("uploadStart", (ws, msg) => {
const u = username(ws);
if (!u) return sendError(ws, "Sign in required.");
+ const store = loadStore();
+ const ensured = ensureUserPrimaryShelf(store, u);
+ const shelfId = normalizeId(msg?.shelfId || ensured.shelf?.id);
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only upload to your own shelf.");
+ if (ensured.changed) saveStore(store);
const size = Number(msg?.size || 0);
if (!Number.isFinite(size) || size <= 0) return sendError(ws, "Invalid file size.");
@@ -332,6 +888,9 @@ module.exports = function init(api) {
if (!isPdf) return sendError(ws, "Only PDF files are supported.");
const title = normalizeTitle(msg?.title || original.replace(/\\.pdf$/i, ""));
+ const author = normalizeAuthor(msg?.author || u);
+ const isOriginal = msg?.isOriginal !== false;
+ const tags = normalizeTags(msg?.tags);
const uploadId = crypto.randomBytes(12).toString("hex");
fs.mkdirSync(tmpDir, { recursive: true });
@@ -346,7 +905,20 @@ module.exports = function init(api) {
const t = nowMs();
try {
- writeMeta(metaPath, { version: 1, uploadId, tmpPath, expected: size, received: 0, createdAt: t, createdBy: u, title });
+ writeMeta(metaPath, {
+ version: 1,
+ uploadId,
+ tmpPath,
+ expected: size,
+ received: 0,
+ createdAt: t,
+ createdBy: u,
+ title,
+ author,
+ isOriginal,
+ tags,
+ shelfId: shelf.id,
+ });
} catch (e) {
try {
fs.closeSync(fd);
@@ -361,8 +933,22 @@ module.exports = function init(api) {
return sendError(ws, "Failed to start upload.", { error: e?.message || String(e) });
}
- inFlight.set(uploadId, { fd, tmpPath, metaPath, expected: size, received: 0, createdAt: t, createdBy: u, title, lastSeenAt: t });
- api.log("info", "library:uploadStart", { uploadId, size, user: u });
+ inFlight.set(uploadId, {
+ fd,
+ tmpPath,
+ metaPath,
+ expected: size,
+ received: 0,
+ createdAt: t,
+ createdBy: u,
+ title,
+ author,
+ isOriginal,
+ tags,
+ shelfId: shelf.id,
+ lastSeenAt: t,
+ });
+ api.log("info", "library:uploadStart", { uploadId, size, user: u, shelfId: shelf.id });
send(ws, { type: "plugin:library:uploadStarted", uploadId, maxBytes: MAX_PDF_BYTES });
});
@@ -407,6 +993,10 @@ module.exports = function init(api) {
createdAt: rec.createdAt,
createdBy: rec.createdBy,
title: rec.title,
+ author: rec.author || rec.createdBy || "",
+ isOriginal: rec.isOriginal !== false,
+ tags: normalizeTags(rec.tags),
+ shelfId: rec.shelfId || "",
});
} catch {
// ignore
@@ -452,20 +1042,45 @@ module.exports = function init(api) {
return sendError(ws, "Failed to finalize upload.", { error: e?.message || String(e) });
}
- const items = loadItems();
+ const store = loadStore();
+ const shelf = findShelf(store, rec.shelfId || userPrimaryShelfId(u));
+ if (!shelf || !isShelfOwner(u, shelf)) {
+ try {
+ if (fs.existsSync(finalPath)) fs.unlinkSync(finalPath);
+ } catch {
+ // ignore
+ }
+ inFlight.delete(uploadId);
+ return sendError(ws, "Target shelf not found.");
+ }
const itemId = crypto.randomBytes(10).toString("hex");
const item = {
id: itemId,
kind: "pdf",
title: rec.title,
+ author: normalizeAuthor(rec.author || u),
+ isOriginal: rec.isOriginal !== false,
+ tags: normalizeTags(rec.tags),
+ format: "pdf",
filename: finalName,
url: `/uploads/library/${finalName}`,
bytes: rec.expected,
createdAt: rec.createdAt,
createdBy: u,
};
- items.push(item);
- saveItems(items);
+ store.books.push(item);
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: itemId,
+ kind: "own",
+ addedBy: u,
+ addedAt: rec.createdAt,
+ note: "",
+ sourceBookId: "",
+ sourceShelfId: "",
+ });
+ shelf.updatedAt = nowMs();
+ saveStore(store);
inFlight.delete(uploadId);
try {
if (rec.metaPath && fs.existsSync(rec.metaPath)) fs.unlinkSync(rec.metaPath);
@@ -475,7 +1090,7 @@ module.exports = function init(api) {
api.log("info", "library:uploadFinish", { id: itemId, bytes: rec.expected, user: u });
send(ws, { type: "plugin:library:uploadFinished", ok: true, item });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
api.registerWs("delete", (ws, msg) => {
@@ -487,26 +1102,34 @@ module.exports = function init(api) {
const role = userRole(ws);
const isOwner = role === "owner";
- const items = loadItems();
- const idx = items.findIndex((it) => it.id === id);
+ const store = loadStore();
+ const idx = store.books.findIndex((it) => it.id === id);
if (idx < 0) return sendError(ws, "Not found.");
- const item = items[idx];
+ const item = store.books[idx];
if (!isOwner && item.createdBy !== u) return sendError(ws, "Not allowed.");
- items.splice(idx, 1);
- saveItems(items);
+ store.books.splice(idx, 1);
+ for (const shelf of store.shelves) {
+ const before = shelf.items.length;
+ shelf.items = shelf.items.filter((si) => si.bookId !== id);
+ if (shelf.items.length !== before) shelf.updatedAt = nowMs();
+ }
+ saveStore(store);
- if (item.kind === "pdf") {
- const filePath = path.join(uploadsDir, item.filename);
- try {
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
- } catch {
- // ignore
+ if (item.kind === "pdf" && item.filename) {
+ const stillUsed = store.books.some((b) => b.kind === "pdf" && String(b.filename || "") === String(item.filename || ""));
+ if (!stillUsed) {
+ const filePath = path.join(uploadsDir, item.filename);
+ try {
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {
+ // ignore
+ }
}
}
api.log("info", "library:delete", { id, user: u });
send(ws, { type: "plugin:library:deleted", ok: true, id });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
};
diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js
@@ -897,7 +897,7 @@ const PRESET_DEFS = {
sideOrder: ["chat", "profile", "composer"],
sideCollapsed: false,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "maps", "library"],
+ dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
},
social: {
presetId: "social",
@@ -908,7 +908,7 @@ const PRESET_DEFS = {
sideOrder: ["profile", "composer"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "maps", "library"],
+ dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
},
chatFocus: {
presetId: "chatFocus",
@@ -920,7 +920,7 @@ const PRESET_DEFS = {
sideOrder: ["profile"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "hives", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
browse: {
presetId: "browse",
@@ -932,7 +932,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["pluginRack", "people", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "people", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
creator: {
presetId: "creator",
@@ -944,7 +944,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["pluginRack", "chat", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "maps", "library-browser", "library-shelf", "library-reader"],
},
mapsSession: {
presetId: "mapsSession",
@@ -955,7 +955,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "library-browser", "library-shelf", "library-reader"],
},
quiet: {
presetId: "quiet",
@@ -967,7 +967,29 @@ const PRESET_DEFS = {
sideCollapsed: true,
rightOrder: [],
rightCollapsed: true,
- dockBottom: ["pluginRack", "chat", "people", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "library-reader"],
+ },
+ readingNook: {
+ presetId: "readingNook",
+ label: "Reading Nook",
+ group: "user",
+ workspaceLeftOrder: ["library-reader"],
+ workspaceRightOrder: ["library-shelf"],
+ sideOrder: ["profile"],
+ sideCollapsed: true,
+ rightOrder: ["people"],
+ dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-browser"],
+ },
+ libraryCurator: {
+ presetId: "libraryCurator",
+ label: "Library Curator",
+ group: "user",
+ workspaceLeftOrder: ["library-browser"],
+ workspaceRightOrder: ["library-shelf"],
+ sideOrder: ["profile"],
+ sideCollapsed: true,
+ rightOrder: ["people"],
+ dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-reader"],
},
ops: {
presetId: "ops",
@@ -979,7 +1001,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
reportsFocus: {
presetId: "reportsFocus",
@@ -992,7 +1014,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["chat"],
- dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
communityWatch: {
presetId: "communityWatch",
@@ -1004,7 +1026,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
serverAdmin: {
presetId: "serverAdmin",
@@ -1016,7 +1038,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
};
@@ -1030,6 +1052,8 @@ const PRESET_ALIASES = {
focus: "quiet",
clean: "social",
moderation: "ops",
+ reading: "readingNook",
+ library: "libraryCurator",
};
function resolvePresetKey(presetId) {
diff --git a/plugins_dev/library/client.js b/plugins_dev/library/client.js
@@ -1,8 +1,9 @@
+
(function () {
const PLUGIN_ID = "library";
-
const PDF_CHUNK_BYTES = 256 * 1024;
- const TEXT_FILE_MAX_BYTES = 512 * 1024; // mirrors server default
+ const TEXT_FILE_MAX_BYTES = 512 * 1024;
+ const TAG_SUGGESTIONS = ["fic", "fix-it-fic", "journal", "fiction", "fantasy", "sci-fi", "lore", "poetry", "notes", "history"];
function escapeHtml(text) {
return String(text || "")
@@ -40,42 +41,44 @@
return new Promise((r) => setTimeout(r, ms));
}
- function sanitizeFilenameBase(name) {
- return String(name || "book")
+ function normalizeTag(raw) {
+ return String(raw || "")
.trim()
.toLowerCase()
- .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/[^a-z0-9 _-]+/g, "")
+ .replace(/\s+/g, "-")
.replace(/-+/g, "-")
- .replace(/^[-.]+|[-.]+$/g, "")
- .slice(0, 80);
+ .replace(/^[-_]+|[-_]+$/g, "")
+ .slice(0, 32);
}
- function paginateText(text, opts = {}) {
- const maxLines = Number(opts.maxLines || 42);
- const maxChars = Number(opts.maxChars || 2200);
- const raw = String(text || "").replace(/\r\n/g, "\n");
- const lines = raw.split("\n");
+ function parseTags(raw) {
+ const parts = String(raw || "")
+ .split(",")
+ .map((x) => normalizeTag(x))
+ .filter(Boolean);
+ return [...new Set(parts)].slice(0, 16);
+ }
+ function paginateText(text, opts = {}) {
+ const maxLines = Number(opts.maxLines || 40);
+ const maxChars = Number(opts.maxChars || 2400);
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
const pages = [];
let buf = "";
let lineCount = 0;
-
const flush = () => {
pages.push(buf);
buf = "";
lineCount = 0;
};
-
for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const lineWithNl = i === lines.length - 1 ? line : `${line}\n`;
-
- // If a single line is huge, split it to fit.
+ const lineWithNl = i === lines.length - 1 ? lines[i] : `${lines[i]}\n`;
if (lineWithNl.length > maxChars) {
- let remaining = lineWithNl;
- while (remaining.length) {
- const take = remaining.slice(0, maxChars);
- remaining = remaining.slice(maxChars);
+ let r = lineWithNl;
+ while (r.length) {
+ const take = r.slice(0, maxChars);
+ r = r.slice(maxChars);
if (buf && (buf.length + take.length > maxChars || lineCount >= maxLines)) flush();
buf += take;
lineCount += 1;
@@ -83,223 +86,97 @@
}
continue;
}
-
if (buf && (buf.length + lineWithNl.length > maxChars || lineCount + 1 > maxLines)) flush();
buf += lineWithNl;
lineCount += 1;
}
-
if (buf || !pages.length) pages.push(buf);
return pages;
}
function ensureStyles() {
- if (document.getElementById("bzlLibraryStyle")) return;
+ if (document.getElementById("bzlLibraryPanelsStyle")) return;
const el = document.createElement("style");
- el.id = "bzlLibraryStyle";
+ el.id = "bzlLibraryPanelsStyle";
el.textContent = `
- .bzlLibraryToggle {
- position: fixed; right: 18px; bottom: 18px; z-index: 9998;
- padding: 10px 14px; border-radius: 999px;
- background: linear-gradient(180deg, rgba(255,140,0,0.95), rgba(255,80,160,0.95));
- color: #1b0a12; border: 0; cursor: pointer; font-weight: 700;
- box-shadow: 0 10px 30px rgba(0,0,0,0.35);
- }
- .bzlLibraryPanel {
- position: fixed; z-index: 9999;
- left: 18px; top: 18px;
- width: min(560px, calc(100vw - 36px));
- height: min(74vh, 760px);
- max-width: calc(100vw - 36px);
- max-height: calc(100vh - 36px);
- overflow: hidden;
- border-radius: 16px;
- background: rgba(20, 12, 18, 0.92);
- border: 1px solid rgba(255,255,255,0.12);
- box-shadow: 0 22px 70px rgba(0,0,0,0.55);
- backdrop-filter: blur(10px);
- display: flex;
- flex-direction: column;
- }
- .bzlLibraryHeader {
- display: flex; align-items: center; justify-content: space-between;
- gap: 10px; padding: 12px 12px 10px 12px;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- }
- .bzlLibraryTitle {
- font-weight: 800;
- cursor: move;
- user-select: none;
- -webkit-user-select: none;
- touch-action: none;
- }
- .bzlLibraryBody { padding: 12px; overflow: auto; flex: 1 1 auto; min-height: 0; }
- .bzlLibraryRow { display:flex; gap: 10px; align-items:center; flex-wrap: wrap; margin-bottom: 10px; }
- .bzlLibraryRow input[type="text"], .bzlLibraryRow input[type="number"], .bzlLibraryRow textarea {
- background: rgba(255,255,255,0.08); color: #f6e8f0;
- border: 1px solid rgba(255,255,255,0.12);
- border-radius: 10px; padding: 8px 10px;
- }
- .bzlLibraryBtn {
- border-radius: 999px; padding: 8px 12px; border: 1px solid rgba(255,255,255,0.12);
- background: rgba(255,255,255,0.06); color: #f6e8f0; cursor: pointer;
- }
- .bzlLibraryBtn.primary {
- background: linear-gradient(180deg, rgba(255,140,0,0.95), rgba(255,80,160,0.95));
- color: #1b0a12; border: 0; font-weight: 800;
- }
- .bzlLibraryTabs { display:flex; gap: 8px; align-items:center; }
- .bzlLibraryList { display:flex; flex-direction: column; gap: 10px; }
- .bzlLibraryItem {
- border: 1px solid rgba(255,255,255,0.10);
- background: rgba(255,255,255,0.04);
- border-radius: 12px; padding: 10px;
- display:flex; align-items:flex-start; justify-content: space-between; gap: 10px;
- }
- .bzlLibraryMeta { opacity: 0.8; font-size: 12px; margin-top: 4px; }
- .bzlLibraryViewer { display:flex; flex-direction: column; gap: 10px; height: 100%; }
- .bzlLibraryDocWrap { flex: 1 1 auto; min-height: 240px; min-width: 0; }
- .bzlLibraryFrame {
- width: 100%;
- height: 100%;
- border: 1px solid rgba(255,255,255,0.12);
- border-radius: 12px;
- background: rgba(0,0,0,0.25);
- }
- .bzlLibraryTextPage {
- width: 100%;
- height: 100%;
- overflow: auto;
- border: 1px solid rgba(255,255,255,0.12);
- border-radius: 12px;
- background: rgba(0,0,0,0.18);
- padding: 10px;
- color: #f6e8f0;
- white-space: pre-wrap;
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;
- font-size: 13px;
- line-height: 1.35;
- }
- .bzlLibraryResize {
- position: absolute;
- right: 10px;
- bottom: 10px;
- width: 16px;
- height: 16px;
- border-radius: 5px;
- border: 1px solid rgba(255,255,255,0.18);
- background: rgba(255,255,255,0.10);
- cursor: se-resize;
- opacity: 0.85;
- }
- .bzlLibraryResize:hover { opacity: 1; }
- .bzlLibraryHint { opacity: 0.8; font-size: 12px; margin-top: 6px; }
- .hidden { display: none !important; }
+ .lib3 { color: var(--text, #f3f7ff); display:flex; flex-direction:column; gap:10px; min-height:0; height:100%; flex:1 1 auto; }
+ .lib3Row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
+ .lib3 input[type="text"], .lib3 input[type="number"], .lib3 textarea, .lib3 select { background: rgba(255,255,255,0.08); color:#f6e8f0; border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 7px 9px; }
+ .lib3Btn { border-radius: 999px; padding: 7px 11px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08); color:#f6e8f0; cursor:pointer; }
+ .lib3Btn.primary { border:0; background: linear-gradient(180deg, rgba(255,145,50,0.95), rgba(255,90,160,0.95)); color:#1b0a12; font-weight:700; }
+ .lib3Scroll { overflow:auto; min-height:0; flex:1 1 auto; }
+ .lib3ReaderViewport { display:flex; flex:1 1 auto; min-height:0; }
+ .lib3Card { border: 1px solid rgba(255,255,255,0.13); border-radius: 12px; padding: 9px; background: rgba(255,255,255,0.04); margin-bottom:8px; }
+ .lib3Meta { opacity:.82; font-size:12px; margin-top:3px; }
+ .lib3Tag { display:inline-block; font-size:11px; border:1px solid rgba(255,255,255,0.18); border-radius:999px; padding:2px 7px; margin-right:4px; margin-top:4px; }
+ .lib3ReaderPage { white-space:pre-wrap; border:1px solid rgba(255,255,255,0.15); border-radius:10px; padding:10px; overflow:auto; height:100%; background: rgba(0,0,0,0.18); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:13px; }
+ .lib3Hint { opacity:.78; font-size:12px; }
+ .lib3Iframe { width:100%; height:100%; border:1px solid rgba(255,255,255,0.12); border-radius:10px; }
`;
document.head.appendChild(el);
}
- function whenBodyReady(fn) {
- if (document.body) return fn();
- const run = () => {
- try {
- document.removeEventListener("DOMContentLoaded", run);
- } catch {
- // ignore
- }
- fn();
- };
- document.addEventListener("DOMContentLoaded", run, { once: true });
- }
-
window.BzlPluginHost?.register(PLUGIN_ID, (ctx) => {
ensureStyles();
- const PANEL_RECT_KEY = "bzlLibraryPanelRect";
- const PANEL_MIN_W = 420;
- const PANEL_MIN_H = 320;
-
- let panelOpen = false;
- let viewerOpen = false;
- let items = [];
- let filterKind = "all"; // all | pdf | text
-
- let activeItem = null; // {id, kind, ...}
- let activePage = 1;
- let totalPages = 1;
- let textPages = [""];
- let activeText = "";
- let editorOpen = false;
- let editorText = "";
- let editorTitle = "";
-
- let uploadingPdf = false;
let wsAttachedTo = null;
+ let snapshot = { me: "", myShelfId: "", shelves: [] };
+ let activeShelfId = "";
+ let readerBookId = "";
+ let readerPage = 1;
+ let searchQuery = "";
+ let searchTag = "";
+ let statusLine = "";
+ let uploadInProgress = false;
+
+ const textByBookId = new Map();
+ const mounts = { library: null, shelf: null, reader: null };
- function readPanelRect() {
- try {
- const raw = localStorage.getItem(PANEL_RECT_KEY);
- if (!raw) return null;
- const json = JSON.parse(raw);
- const left = Number(json?.left);
- const top = Number(json?.top);
- const width = Number(json?.width);
- const height = Number(json?.height);
- if (![left, top, width, height].every((n) => Number.isFinite(n))) return null;
- return { left, top, width, height };
- } catch {
- return null;
- }
+ function setStatus(msg) {
+ statusLine = String(msg || "");
+ renderAll();
}
- function defaultPanelRect() {
- const width = Math.min(560, Math.max(PANEL_MIN_W, window.innerWidth - 36));
- const height = Math.min(Math.floor(window.innerHeight * 0.74), 760);
- const left = Math.max(18, Math.floor(window.innerWidth - width - 18));
- const top = Math.max(18, Math.floor(window.innerHeight - height - 70));
- return { left, top, width, height };
+ function ownedShelves() {
+ return (snapshot.shelves || []).filter((s) => Boolean(s?.isOwner));
}
- function clampPanelRect(rect) {
- const maxW = Math.max(PANEL_MIN_W, window.innerWidth - 36);
- const maxH = Math.max(PANEL_MIN_H, window.innerHeight - 36);
- const width = Math.min(maxW, Math.max(PANEL_MIN_W, Math.floor(rect.width)));
- const height = Math.min(maxH, Math.max(PANEL_MIN_H, Math.floor(rect.height)));
- const left = Math.min(window.innerWidth - 18 - width, Math.max(18, Math.floor(rect.left)));
- const top = Math.min(window.innerHeight - 18 - height, Math.max(18, Math.floor(rect.top)));
- return { left, top, width, height };
+ function activeShelf() {
+ return (snapshot.shelves || []).find((s) => String(s?.id || "") === String(activeShelfId || "")) || null;
}
- function applyPanelRect(panel, rect) {
- const r = clampPanelRect(rect);
- panel.style.left = `${r.left}px`;
- panel.style.top = `${r.top}px`;
- panel.style.right = "";
- panel.style.bottom = "";
- panel.style.width = `${r.width}px`;
- panel.style.height = `${r.height}px`;
+ function defaultShelfId() {
+ if (snapshot.myShelfId && ownedShelves().some((s) => s.id === snapshot.myShelfId)) return snapshot.myShelfId;
+ return ownedShelves()[0]?.id || "";
}
- function savePanelRect(rect) {
- try {
- localStorage.setItem(PANEL_RECT_KEY, JSON.stringify(rect));
- } catch {
- // ignore
+ function allShelfEntries() {
+ const out = [];
+ for (const shelf of snapshot.shelves || []) {
+ for (const entry of shelf.items || []) out.push({ shelf, entry, book: entry?.book || null });
}
+ return out;
}
- function savePanelRectFromEl(panel) {
- const left = Number.parseFloat(panel.style.left || "0");
- const top = Number.parseFloat(panel.style.top || "0");
- const width = Number.parseFloat(panel.style.width || "0");
- const height = Number.parseFloat(panel.style.height || "0");
- if (![left, top, width, height].every((n) => Number.isFinite(n) && n > 0)) return;
- savePanelRect(clampPanelRect({ left, top, width, height }));
+ function getBookById(bookId) {
+ const id = String(bookId || "");
+ if (!id) return null;
+ for (const x of allShelfEntries()) {
+ if (String(x.book?.id || "") === id) return x.book;
+ }
+ return null;
}
- function setStatus(msg) {
- const el = document.getElementById("bzlLibraryStatus");
- if (el) el.textContent = String(msg || "");
+ function activeReaderBook() {
+ return getBookById(readerBookId);
+ }
+
+ function requestList() {
+ ctx.send("list", {});
+ }
+
+ function requestText(bookId) {
+ ctx.send("textGet", { id: bookId });
}
function attachWsListener() {
@@ -308,129 +185,46 @@
if (wsAttachedTo === ws) return;
try {
if (wsAttachedTo) wsAttachedTo.removeEventListener("message", onWsMsg);
- } catch {
- // ignore
- }
+ } catch {}
wsAttachedTo = ws;
ws.addEventListener("message", onWsMsg);
}
- function requestList() {
- ctx.send("list", {});
+ function openInReader(bookId) {
+ const id = String(bookId || "");
+ if (!id) return;
+ readerBookId = id;
+ readerPage = 1;
+ const book = getBookById(id);
+ if (book && String(book.kind || "") === "text" && !textByBookId.has(id)) requestText(id);
+ renderReader();
}
-
- function requestText(id) {
- ctx.send("textGet", { id });
- }
-
- function isAuthor(it) {
- const me = String(ctx.getUser() || "");
- return Boolean(me) && String(it?.createdBy || "") === me;
- }
-
- function canDelete(it) {
- const role = String(ctx.getRole() || "");
- return role === "owner" || isAuthor(it);
- }
-
- function downloadTextFile(filename, text) {
- try {
- const blob = new Blob([String(text || "")], { type: "text/plain;charset=utf-8" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(url), 2000);
- } catch {
- // ignore
- }
- }
-
- function openItem(it) {
- activeItem = it;
- activePage = 1;
- viewerOpen = true;
- editorOpen = false;
- editorText = "";
- editorTitle = "";
- activeText = "";
- textPages = [""];
- totalPages = 1;
- render();
- if (String(it.kind || "pdf") === "text") {
- requestText(it.id);
- } else {
- setPage(1);
- }
- }
-
- function closeViewer() {
- viewerOpen = false;
- activeItem = null;
- activePage = 1;
- totalPages = 1;
- textPages = [""];
- activeText = "";
- editorOpen = false;
- editorText = "";
- editorTitle = "";
- render();
- }
-
- function setPage(n) {
- if (!viewerOpen || !activeItem) return;
- const kind = String(activeItem.kind || "pdf");
- if (kind === "text") {
- const next = Math.max(1, Math.min(totalPages, Number(n || 1)));
- activePage = next;
- const pageLabel = document.getElementById("bzlLibraryPageLabel");
- if (pageLabel) pageLabel.textContent = `Page ${activePage} / ${totalPages}`;
- const inp = document.getElementById("bzlLibraryPage");
- if (inp) inp.value = String(activePage);
- const textEl = document.getElementById("bzlLibraryTextPage");
- if (textEl) textEl.textContent = textPages[activePage - 1] || "";
- return;
- }
- const next = Math.max(1, Math.min(9999, Number(n || 1)));
- activePage = next;
- const iframe = document.getElementById("bzlLibraryFrame");
- if (iframe) iframe.src = `${activeItem.url}#page=${activePage}`;
- const inp = document.getElementById("bzlLibraryPage");
- if (inp) inp.value = String(activePage);
- const pageLabel = document.getElementById("bzlLibraryPageLabel");
- if (pageLabel) pageLabel.textContent = `Page ${activePage}`;
- }
-
- async function uploadSelectedPdf() {
- if (uploadingPdf) return;
- attachWsListener();
- const fileEl = document.getElementById("bzlLibraryPdfFile");
- const titleEl = document.getElementById("bzlLibraryPdfTitle");
- const file = fileEl?.files?.[0];
- if (!file) return setStatus("Choose a PDF first.");
- const mime = String(file.type || "").trim().toLowerCase();
- const isPdf = /\.pdf$/i.test(file.name || "") || mime === "application/pdf";
- if (!isPdf) return setStatus("Only PDF files are supported.");
-
- uploadingPdf = true;
- setStatus("Starting PDF upload...");
+ async function uploadPdfToShelf(file, meta, shelfId) {
+ if (uploadInProgress) return;
+ uploadInProgress = true;
window.__bzlLibraryUploadId = "";
- ctx.send("uploadStart", { filename: file.name, mime, size: file.size, title: String(titleEl?.value || "").trim() });
+ ctx.send("uploadStart", {
+ filename: file.name,
+ mime: String(file.type || "").trim().toLowerCase(),
+ size: file.size,
+ title: meta.title,
+ author: meta.author,
+ isOriginal: meta.isOriginal,
+ tags: meta.tags,
+ shelfId,
+ });
const t0 = Date.now();
while (!window.__bzlLibraryUploadId && Date.now() - t0 < 3000) {
// eslint-disable-next-line no-await-in-loop
- await sleep(30);
+ await sleep(35);
}
const uploadId = String(window.__bzlLibraryUploadId || "");
if (!uploadId) {
- uploadingPdf = false;
- return setStatus("Upload failed to start.");
+ uploadInProgress = false;
+ setStatus("Upload failed to start.");
+ return;
}
- ctx.devLog("info", "library:uploadId", { uploadId, size: file.size });
let sent = 0;
for (let off = 0; off < file.size; off += PDF_CHUNK_BYTES) {
@@ -445,357 +239,359 @@
// eslint-disable-next-line no-await-in-loop
await sleep(0);
}
-
ctx.send("uploadFinish", { uploadId });
- setStatus("Finalizing PDF...");
}
- async function importTextFromFile() {
- const fileEl = document.getElementById("bzlLibraryTextFile");
- const titleEl = document.getElementById("bzlLibraryTextTitle");
- const file = fileEl?.files?.[0];
- if (!file) return setStatus("Choose a text file first.");
-
- const name = String(file.name || "").toLowerCase();
- const okExt = name.endsWith(".txt") || name.endsWith(".md");
- if (!okExt && String(file.type || "").toLowerCase() !== "text/plain") {
- return setStatus("Supported: .txt, .md, or text/plain.");
- }
- if (file.size > TEXT_FILE_MAX_BYTES) {
- return setStatus(`Text file too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`);
+ async function createBookFromFile(file, payload) {
+ const name = String(file?.name || "").toLowerCase();
+ const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : "";
+ if (ext === ".pdf" || String(file.type || "").toLowerCase() === "application/pdf") {
+ await uploadPdfToShelf(file, payload, payload.shelfId);
+ return;
}
-
- let text = "";
- try {
- text = await file.text();
- } catch {
- return setStatus("Failed to read file.");
+ if (![".txt", ".md", ".rtf"].includes(ext)) {
+ setStatus("Supported upload types: PDF, TXT, MD, RTF.");
+ return;
}
- const title = String(titleEl?.value || "").trim() || String(file.name || "").replace(/\.(txt|md)$/i, "");
- ctx.send("textCreate", { title, text });
- setStatus("Importing text...");
- }
-
- function createBlankTextBook() {
- const titleEl = document.getElementById("bzlLibraryTextNewTitle");
- const title = String(titleEl?.value || "").trim() || "Untitled text";
- ctx.send("textCreate", { title, text: "" });
- setStatus("Creating blank text...");
- }
-
- function openEditor() {
- if (!activeItem || String(activeItem.kind || "") !== "text") return;
- if (!isAuthor(activeItem)) {
- ctx.toast("Library", "Only the author can edit/export this text.");
+ if (file.size > TEXT_FILE_MAX_BYTES) {
+ setStatus(`Text/RTF too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`);
return;
}
- editorOpen = true;
- editorText = String(activeText || "");
- editorTitle = String(activeItem.title || "");
- render();
- }
-
- function closeEditor() {
- editorOpen = false;
- editorText = "";
- editorTitle = "";
- render();
+ const text = await file.text();
+ ctx.send("textCreate", {
+ shelfId: payload.shelfId,
+ title: payload.title,
+ author: payload.author,
+ isOriginal: payload.isOriginal,
+ tags: payload.tags,
+ format: ext === ".rtf" ? "rtf" : "text",
+ text,
+ });
+ setStatus("Book created.");
}
- function saveEditor() {
- if (!activeItem || String(activeItem.kind || "") !== "text") return;
- if (!isAuthor(activeItem)) return;
- ctx.send("textUpdate", { id: activeItem.id, title: editorTitle, text: editorText });
- setStatus("Saving...");
+ function filteredBrowseRows() {
+ const q = String(searchQuery || "").trim().toLowerCase();
+ const tag = String(searchTag || "").trim().toLowerCase();
+ return allShelfEntries().filter((x) => {
+ const book = x.book || {};
+ if (!book.id) return false;
+ if (q) {
+ const hay = `${book.title || ""} ${book.author || ""} ${(book.tags || []).join(" ")} ${x.shelf?.name || ""}`.toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ if (tag) {
+ const tags = Array.isArray(book.tags) ? book.tags.map((t) => String(t || "").toLowerCase()) : [];
+ if (!tags.includes(tag)) return false;
+ }
+ return true;
+ });
}
- function exportActiveText() {
- if (!activeItem || String(activeItem.kind || "") !== "text") return;
- if (!isAuthor(activeItem)) return;
- const base = sanitizeFilenameBase(activeItem.title || "book") || "book";
- downloadTextFile(`${base}.txt`, activeText);
+ function uniqueTags() {
+ const out = new Set();
+ for (const x of allShelfEntries()) {
+ for (const t of x?.book?.tags || []) out.add(String(t || "").toLowerCase());
+ }
+ return [...out].filter(Boolean).sort();
}
- function render() {
- ensureDom();
- const panel = document.getElementById("bzlLibraryPanel");
- if (!panel) return;
- panel.classList.toggle("hidden", !panelOpen);
- if (!panelOpen) return;
-
- const viewList = !viewerOpen;
- const list = items
- .filter((it) => {
- const k = String(it?.kind || "pdf");
- if (filterKind === "all") return true;
- return k === filterKind;
- })
- .map((it) => {
- const kind = String(it.kind || "pdf");
- const title = escapeHtml(it.title || it.filename || (kind === "text" ? "Text" : "PDF"));
- const when = new Date(Number(it.createdAt || 0) || 0).toLocaleString();
- const who = escapeHtml(String(it.createdBy || ""));
- const meta = `${kind.toUpperCase()} | ${who} | ${when} | ${formatBytes(it.bytes)}`;
- const delBtn = canDelete(it) ? `<button type="button" class="bzlLibraryBtn" data-libdel="${escapeHtml(it.id)}">Delete</button>` : "";
- const openBtn = `<button type="button" class="bzlLibraryBtn primary" data-libopen="${escapeHtml(it.id)}">Open</button>`;
- const newTab =
- kind === "pdf" && it.url
- ? `<a class="bzlLibraryBtn" href="${escapeHtml(it.url)}" target="_blank" rel="noreferrer">New tab</a>`
- : "";
- return `
- <div class="bzlLibraryItem">
- <div>
- <div><b>${title}</b></div>
- <div class="bzlLibraryMeta">${escapeHtml(meta)}</div>
- </div>
- <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end;">
- ${openBtn}
- ${newTab}
- ${delBtn}
- </div>
- </div>
- `;
- })
- .join("");
-
- const kindTabs = `
- <div class="bzlLibraryTabs">
- <button type="button" class="bzlLibraryBtn ${filterKind === "all" ? "primary" : ""}" data-libkind="all">All</button>
- <button type="button" class="bzlLibraryBtn ${filterKind === "pdf" ? "primary" : ""}" data-libkind="pdf">PDFs</button>
- <button type="button" class="bzlLibraryBtn ${filterKind === "text" ? "primary" : ""}" data-libkind="text">Texts</button>
+ function renderLibrary() {
+ const mount = mounts.library;
+ if (!(mount instanceof HTMLElement)) return;
+ const rows = filteredBrowseRows();
+ const tags = uniqueTags();
+ const targetShelfId = defaultShelfId();
+ const shelfOpt = ownedShelves().map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === targetShelfId ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join("");
+
+ mount.innerHTML = `
+ <div class="lib3">
+ <div class="lib3Row">
+ <input id="lib3Search" type="text" placeholder="Search title, author, tags" value="${escapeHtml(searchQuery)}" style="flex:1 1 220px;" />
+ <select id="lib3TagFilter"><option value="">All tags</option>${tags.map((t) => `<option value="${escapeHtml(t)}" ${t === searchTag ? "selected" : ""}>${escapeHtml(t)}</option>`).join("")}</select>
+ <button type="button" class="lib3Btn" id="lib3Refresh">Refresh</button>
+ </div>
+ <div class="lib3Row"><span class="lib3Hint">Browse shelves and discover books. Choose one of your shelves for pin/check out:</span>
+ <select id="lib3LibraryTargetShelf">${shelfOpt || "<option value=''>No owned shelf</option>"}</select>
+ </div>
+ <div class="lib3Scroll">
+ ${rows.map((x) => {
+ const b = x.book || {};
+ const s = x.shelf || {};
+ const tagsHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
+ return `<div class="lib3Card">
+ <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.entry?.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div>
+ <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | shelf ${escapeHtml(s.name || "Shelf")}${s.owner ? ` · @${escapeHtml(s.owner)}` : ""} | ${formatBytes(b.bytes)} | pins ${Number(b?.popularity?.pins || 0)} | checkouts ${Number(b?.popularity?.checkouts || 0)}</div>
+ <div>${tagsHtml}</div>
+ <div class="lib3Row" style="margin-top:6px;">
+ <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button>
+ <button type="button" class="lib3Btn" data-pin-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Pin</button>
+ <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Check out</button>
+ <button type="button" class="lib3Btn" data-wander-shelf="${escapeHtml(s.id || "")}">Wander shelf</button>
+ </div>
+ </div>`;
+ }).join("") || "<div class='lib3Hint'>No matches.</div>"}
+ </div>
+ <div class="lib3Hint">Wander shelves and open a book in Reader.</div>
</div>
`;
- const isText = viewerOpen && activeItem && String(activeItem.kind || "pdf") === "text";
- const canEdit = isText && isAuthor(activeItem);
- const pageLabelText = isText ? `Page ${activePage} / ${totalPages}` : `Page ${activePage}`;
-
- panel.innerHTML = `
- <div class="bzlLibraryHeader">
- <div class="bzlLibraryTitle" id="bzlLibraryDrag" title="Drag to move">Library</div>
- <div style="display:flex; gap:8px; align-items:center;">
- ${kindTabs}
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryRefresh">Refresh</button>
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryReset" title="Reset panel size/position">Reset</button>
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryClose">Close</button>
+ mount.querySelector("#lib3Search")?.addEventListener("input", (e) => {
+ searchQuery = String(e?.target?.value || "");
+ renderLibrary();
+ });
+ mount.querySelector("#lib3TagFilter")?.addEventListener("change", (e) => {
+ searchTag = String(e?.target?.value || "").toLowerCase();
+ renderLibrary();
+ });
+ mount.querySelector("#lib3Refresh")?.addEventListener("click", () => requestList());
+ mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || "")));
+ mount.querySelectorAll("[data-wander-shelf]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ activeShelfId = String(btn.getAttribute("data-wander-shelf") || "");
+ renderShelf();
+ });
+ });
+ mount.querySelectorAll("[data-pin-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const shelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || "");
+ const bookId = String(btn.getAttribute("data-pin-book") || "");
+ const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
+ if (!shelfId || !bookId) return setStatus("Pick one of your shelves first.");
+ ctx.send("pinBook", { shelfId, bookId, sourceShelfId });
+ setStatus("Pinned.");
+ });
+ });
+ mount.querySelectorAll("[data-checkout-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const targetShelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || "");
+ const sourceBookId = String(btn.getAttribute("data-checkout-book") || "");
+ const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
+ if (!targetShelfId || !sourceBookId) return setStatus("Pick one of your shelves first.");
+ ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId });
+ setStatus("Checked out.");
+ });
+ });
+ }
+ function renderShelf() {
+ const mount = mounts.shelf;
+ if (!(mount instanceof HTMLElement)) return;
+ const mine = ownedShelves();
+ if (!activeShelfId || !(snapshot.shelves || []).some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves?.[0]?.id || "";
+ const shelf = activeShelf();
+ const shelfList = (snapshot.shelves || []).map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === activeShelfId ? "selected" : ""}>${escapeHtml(s.name)}${s.owner ? ` (@${escapeHtml(s.owner)})` : ""}</option>`).join("");
+ const mineDefault = defaultShelfId();
+ const mineList = mine.map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === mineDefault ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join("");
+
+ mount.innerHTML = `
+ <div class="lib3">
+ <div class="lib3Row">
+ <select id="lib3ShelfPick" style="min-width:220px;">${shelfList || "<option>No shelves</option>"}</select>
+ ${shelf && !shelf.isOwner ? `<button type="button" class="lib3Btn" id="lib3SubBtn">${shelf.isSubscribed ? "Unsubscribe" : "Subscribe"}</button>` : ""}
+ <button type="button" class="lib3Btn" id="lib3RefreshShelf">Refresh</button>
</div>
- </div>
-
- <div class="bzlLibraryBody">
- <div class="${viewList ? "" : "hidden"}" id="bzlLibraryListView">
- <div class="bzlLibraryRow" style="align-items:flex-end;">
- <div style="flex: 1 1 260px;">
- <div class="bzlLibraryHint"><b>Upload PDF</b></div>
- <div class="bzlLibraryRow" style="margin: 6px 0 0 0;">
- <input id="bzlLibraryPdfTitle" type="text" placeholder="PDF title (optional)" style="flex: 1 1 200px;" />
- <input id="bzlLibraryPdfFile" type="file" accept="application/pdf,.pdf" />
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryPdfUpload">Upload PDF</button>
- </div>
- </div>
- </div>
-
- <div class="bzlLibraryRow" style="align-items:flex-end;">
- <div style="flex: 1 1 260px;">
- <div class="bzlLibraryHint"><b>Text books</b> (for lore, notes, character bios, poetry, etc)</div>
- <div class="bzlLibraryRow" style="margin: 6px 0 0 0;">
- <input id="bzlLibraryTextNewTitle" type="text" placeholder="New text title" style="flex: 1 1 200px;" />
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryTextNew">New blank</button>
- </div>
- <div class="bzlLibraryRow" style="margin: 6px 0 0 0;">
- <input id="bzlLibraryTextTitle" type="text" placeholder="Import title (optional)" style="flex: 1 1 200px;" />
- <input id="bzlLibraryTextFile" type="file" accept="text/plain,.txt,.md" />
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryTextImport">Import</button>
- </div>
- </div>
- </div>
-
- <div id="bzlLibraryStatus" class="bzlLibraryHint"></div>
- <div class="bzlLibraryHint">Open to read here. Left/Right arrows change pages. Text books can be edited and exported by the author.</div>
- <div style="height: 10px;"></div>
- <div class="bzlLibraryList">
- ${list || `<div class="bzlLibraryHint">No library items yet.</div>`}
+ <div class="lib3Card">
+ <div class="lib3Row">
+ <input id="lib3NewShelfName" type="text" placeholder="New shelf name" style="flex:1 1 180px;" />
+ <input id="lib3NewShelfDesc" type="text" placeholder="Shelf description" style="flex:2 1 220px;" />
+ <button type="button" class="lib3Btn" id="lib3CreateShelf">Create shelf</button>
</div>
</div>
-
- <div class="${viewList ? "hidden" : ""} bzlLibraryViewer" id="bzlLibraryViewer">
- <div class="bzlLibraryRow" style="justify-content: space-between; align-items:center;">
- <div style="display:flex; gap:8px; align-items:center;">
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryBack">Back</button>
- <div id="bzlLibraryPageLabel" class="bzlLibraryHint">${escapeHtml(pageLabelText)}</div>
- </div>
- <div style="display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap;">
- ${canEdit ? `<button type="button" class="bzlLibraryBtn" id="bzlLibraryEdit">Edit</button>` : ""}
- ${canEdit ? `<button type="button" class="bzlLibraryBtn" id="bzlLibraryExport">Export .txt</button>` : ""}
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryPrev"><</button>
- <input id="bzlLibraryPage" type="number" min="1" step="1" value="${activePage}" style="width: 92px;" />
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryGo">Go</button>
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryNext">></button>
- </div>
+ <div class="lib3Card">
+ <div class="lib3Row"><b>Add Book</b> <span class="lib3Hint">(PDF, TXT, MD, RTF)</span></div>
+ <div class="lib3Row">
+ <select id="lib3CreateTargetShelf">${mineList || "<option value=''>No owned shelves</option>"}</select>
+ <input id="lib3BookTitle" type="text" placeholder="Book name" style="flex:1 1 180px;" />
+ <input id="lib3BookAuthor" type="text" placeholder="Author" style="flex:1 1 160px;" value="${escapeHtml(snapshot.me || "")}" />
+ <label class="lib3Hint"><input id="lib3BookOriginal" type="checkbox" checked/> original</label>
</div>
-
- <div class="bzlLibraryDocWrap">
- <div class="${isText ? "hidden" : ""}" style="height:100%;">
- <iframe id="bzlLibraryFrame" class="bzlLibraryFrame" title="PDF viewer"></iframe>
- </div>
- <div class="${isText ? "" : "hidden"}" style="height:100%;">
- <div id="bzlLibraryTextPage" class="bzlLibraryTextPage"></div>
- </div>
+ <div class="lib3Row">
+ <input id="lib3BookTags" type="text" placeholder="tags: fic, fantasy, journal" style="flex:1 1 260px;" />
+ <select id="lib3TagQuick"><option value="">Tag quick-add</option>${TAG_SUGGESTIONS.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join("")}</select>
</div>
-
- <div class="bzlLibraryHint">${activeItem ? escapeHtml(activeItem.title || activeItem.filename || "") : ""}</div>
-
- <div class="${editorOpen ? "" : "hidden"}" id="bzlLibraryEditorWrap">
- <div style="height:10px;"></div>
- <div class="bzlLibraryHint"><b>Edit text book</b> (author only)</div>
- <div class="bzlLibraryRow" style="margin-top:6px;">
- <input id="bzlLibraryEditorTitle" type="text" placeholder="Title" value="${escapeHtml(editorTitle)}" style="flex: 1 1 240px;" />
- </div>
- <div class="bzlLibraryRow" style="margin-top:6px;">
- <textarea id="bzlLibraryEditorText" rows="12" style="width:100%; box-sizing:border-box;">${escapeHtml(
- editorText
- )}</textarea>
- </div>
- <div class="bzlLibraryRow" style="justify-content:flex-end;">
- <button type="button" class="bzlLibraryBtn" id="bzlLibraryEditorCancel">Cancel</button>
- <button type="button" class="bzlLibraryBtn primary" id="bzlLibraryEditorSave">Save</button>
- </div>
- <div class="bzlLibraryHint">Note: pages are auto-generated from the text.</div>
+ <div class="lib3Row">
+ <input id="lib3BookFile" type="file" accept=".pdf,.txt,.md,.rtf,application/pdf,text/plain,application/rtf,text/rtf" />
+ <button type="button" class="lib3Btn primary" id="lib3UploadBook">Upload File</button>
+ </div>
+ <div class="lib3Row">
+ <select id="lib3TextFormat"><option value="text">Text</option><option value="rtf">Rich text (RTF)</option></select>
+ <textarea id="lib3BookText" rows="5" placeholder="Or create a book directly" style="width:100%; box-sizing:border-box;"></textarea>
+ <button type="button" class="lib3Btn" id="lib3CreateText">Create Text Book</button>
</div>
</div>
+ <div class="lib3Scroll">
+ ${(shelf?.items || []).map((x) => {
+ const b = x.book || {};
+ const tHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
+ return `<div class="lib3Card">
+ <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div>
+ <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | ${String(b.kind || "").toUpperCase()} | ${formatBytes(b.bytes)} | original: ${b.isOriginal !== false ? "yes" : "no"}</div>
+ <div>${tHtml}</div>
+ <div class="lib3Row" style="margin-top:6px;">
+ <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button>
+ ${x.canReturn ? `<button type="button" class="lib3Btn" data-return-item="${escapeHtml(x.id)}">Return</button>` : ""}
+ ${x.canRemoveItem ? `<button type="button" class="lib3Btn" data-remove-item="${escapeHtml(x.id)}">Remove</button>` : ""}
+ ${b.canDeleteBook ? `<button type="button" class="lib3Btn" data-delete-book="${escapeHtml(b.id)}">Delete book</button>` : ""}
+ <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(shelf?.id || "")}">Check out</button>
+ </div>
+ </div>`;
+ }).join("") || "<div class='lib3Hint'>No books on this shelf.</div>"}
+ </div>
+ <div class="lib3Hint">${escapeHtml(statusLine)}</div>
</div>
-
- <div class="bzlLibraryResize" id="bzlLibraryResize" title="Resize"></div>
`;
- // header buttons
- document.getElementById("bzlLibraryClose")?.addEventListener("click", () => {
- panelOpen = false;
- closeViewer();
- render();
+ mount.querySelector("#lib3ShelfPick")?.addEventListener("change", (e) => {
+ activeShelfId = String(e?.target?.value || "");
+ renderShelf();
+ });
+ mount.querySelector("#lib3RefreshShelf")?.addEventListener("click", () => requestList());
+ mount.querySelector("#lib3SubBtn")?.addEventListener("click", () => {
+ if (!shelf || shelf.isOwner) return;
+ ctx.send("shelfSubscribe", { shelfId: shelf.id, subscribe: !shelf.isSubscribed });
});
- document.getElementById("bzlLibraryRefresh")?.addEventListener("click", () => requestList());
- document.getElementById("bzlLibraryReset")?.addEventListener("click", () => {
+ mount.querySelector("#lib3CreateShelf")?.addEventListener("click", () => {
+ const name = String(mount.querySelector("#lib3NewShelfName")?.value || "").trim();
+ const description = String(mount.querySelector("#lib3NewShelfDesc")?.value || "").trim();
+ if (!name) return setStatus("Shelf name required.");
+ ctx.send("shelfCreate", { name, description, isPublic: true });
+ setStatus("Shelf created.");
+ });
+ mount.querySelector("#lib3TagQuick")?.addEventListener("change", (e) => {
+ const v = String(e?.target?.value || "").trim();
+ if (!v) return;
+ const inp = mount.querySelector("#lib3BookTags");
+ const next = parseTags(`${String(inp?.value || "")}, ${v}`);
+ if (inp) inp.value = next.join(", ");
+ e.target.value = "";
+ });
+ mount.querySelector("#lib3UploadBook")?.addEventListener("click", async () => {
+ const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
+ const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim();
+ const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim();
+ const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked);
+ const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || ""));
+ const file = mount.querySelector("#lib3BookFile")?.files?.[0];
+ if (!shelfId) return setStatus("Pick one of your shelves.");
+ if (!file) return setStatus("Choose a file first.");
try {
- localStorage.removeItem(PANEL_RECT_KEY);
+ await createBookFromFile(file, { shelfId, title: title || file.name, author: author || snapshot.me || "Unknown", isOriginal, tags });
} catch {
- // ignore
+ setStatus("Failed to create book from file.");
}
- applyPanelRect(panel, defaultPanelRect());
- savePanelRectFromEl(panel);
});
- panel.querySelectorAll("[data-libkind]").forEach((b) => {
- b.addEventListener("click", () => {
- filterKind = String(b.getAttribute("data-libkind") || "all");
- render();
- });
+ mount.querySelector("#lib3CreateText")?.addEventListener("click", () => {
+ const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
+ const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim();
+ const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim();
+ const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked);
+ const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || ""));
+ const format = String(mount.querySelector("#lib3TextFormat")?.value || "text").toLowerCase() === "rtf" ? "rtf" : "text";
+ const text = String(mount.querySelector("#lib3BookText")?.value || "");
+ if (!shelfId) return setStatus("Pick one of your shelves.");
+ if (!title) return setStatus("Book name required.");
+ ctx.send("textCreate", { shelfId, title, author: author || snapshot.me || "Unknown", isOriginal, tags, format, text });
+ setStatus("Book created.");
});
-
- const drag = document.getElementById("bzlLibraryDrag");
- const resize = document.getElementById("bzlLibraryResize");
-
- if (drag) {
- drag.addEventListener("pointerdown", (e) => {
- if (e.button !== 0) return;
- e.preventDefault();
- const start = { x: e.clientX, y: e.clientY };
- const rect = panel.getBoundingClientRect();
- const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
-
- const onMove = (ev) => {
- const dx = ev.clientX - start.x;
- const dy = ev.clientY - start.y;
- applyPanelRect(panel, { ...startRect, left: startRect.left + dx, top: startRect.top + dy });
- };
- const onUp = () => {
- window.removeEventListener("pointermove", onMove, true);
- window.removeEventListener("pointerup", onUp, true);
- window.removeEventListener("pointercancel", onUp, true);
- savePanelRectFromEl(panel);
- };
-
- window.addEventListener("pointermove", onMove, true);
- window.addEventListener("pointerup", onUp, true);
- window.addEventListener("pointercancel", onUp, true);
+ mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || "")));
+ mount.querySelectorAll("[data-checkout-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const targetShelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
+ const sourceBookId = String(btn.getAttribute("data-checkout-book") || "");
+ const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
+ if (!targetShelfId || !sourceBookId) return;
+ ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId });
+ setStatus("Checked out.");
});
- }
-
- if (resize) {
- resize.addEventListener("pointerdown", (e) => {
- if (e.button !== 0) return;
- e.preventDefault();
- const start = { x: e.clientX, y: e.clientY };
- const rect = panel.getBoundingClientRect();
- const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
-
- const onMove = (ev) => {
- const dx = ev.clientX - start.x;
- const dy = ev.clientY - start.y;
- applyPanelRect(panel, { ...startRect, width: startRect.width + dx, height: startRect.height + dy });
- };
- const onUp = () => {
- window.removeEventListener("pointermove", onMove, true);
- window.removeEventListener("pointerup", onUp, true);
- window.removeEventListener("pointercancel", onUp, true);
- savePanelRectFromEl(panel);
- };
-
- window.addEventListener("pointermove", onMove, true);
- window.addEventListener("pointerup", onUp, true);
- window.addEventListener("pointercancel", onUp, true);
+ });
+ mount.querySelectorAll("[data-remove-item]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ if (!shelf) return;
+ const shelfItemId = String(btn.getAttribute("data-remove-item") || "");
+ if (!shelfItemId) return;
+ ctx.send("shelfItemRemove", { shelfId: shelf.id, shelfItemId });
});
- }
-
- // list actions
- document.getElementById("bzlLibraryPdfUpload")?.addEventListener("click", () => uploadSelectedPdf());
- document.getElementById("bzlLibraryTextImport")?.addEventListener("click", () => importTextFromFile());
- document.getElementById("bzlLibraryTextNew")?.addEventListener("click", () => createBlankTextBook());
-
- panel.querySelectorAll("[data-libopen]").forEach((b) => {
- b.addEventListener("click", () => {
- const id = String(b.getAttribute("data-libopen") || "");
- const it = items.find((x) => String(x.id || "") === id);
- if (it) openItem(it);
+ });
+ mount.querySelectorAll("[data-return-item]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ if (!shelf) return;
+ const shelfItemId = String(btn.getAttribute("data-return-item") || "");
+ if (!shelfItemId) return;
+ ctx.send("returnBook", { shelfId: shelf.id, shelfItemId });
});
});
- panel.querySelectorAll("[data-libdel]").forEach((b) => {
- b.addEventListener("click", () => {
- const id = String(b.getAttribute("data-libdel") || "");
- if (!id) return;
- if (!confirm("Delete this item from the library?")) return;
- ctx.send("delete", { id });
+ mount.querySelectorAll("[data-delete-book]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const bookId = String(btn.getAttribute("data-delete-book") || "");
+ if (!bookId) return;
+ ctx.send("delete", { id: bookId });
});
});
+ }
- // viewer actions
- document.getElementById("bzlLibraryBack")?.addEventListener("click", () => closeViewer());
- document.getElementById("bzlLibraryPrev")?.addEventListener("click", () => setPage(activePage - 1));
- document.getElementById("bzlLibraryNext")?.addEventListener("click", () => setPage(activePage + 1));
- document.getElementById("bzlLibraryGo")?.addEventListener("click", () => {
- const inp = document.getElementById("bzlLibraryPage");
- setPage(Number(inp?.value || 1));
- });
- document.getElementById("bzlLibraryEdit")?.addEventListener("click", () => openEditor());
- document.getElementById("bzlLibraryExport")?.addEventListener("click", () => exportActiveText());
- document.getElementById("bzlLibraryEditorCancel")?.addEventListener("click", () => closeEditor());
- document.getElementById("bzlLibraryEditorSave")?.addEventListener("click", () => {
- const titleEl = document.getElementById("bzlLibraryEditorTitle");
- const textEl = document.getElementById("bzlLibraryEditorText");
- editorTitle = String(titleEl?.value || "").trim();
- editorText = String(textEl?.value || "");
- saveEditor();
- closeEditor();
- });
+ function renderReader() {
+ const mount = mounts.reader;
+ if (!(mount instanceof HTMLElement)) return;
+ const book = activeReaderBook();
+ if (!book) {
+ mount.innerHTML = `<div class="lib3"><div class="lib3Hint">Select a book from Shelf or Library to start reading.</div></div>`;
+ return;
+ }
- // post-render sync for viewer
- if (viewerOpen && activeItem) {
- if (String(activeItem.kind || "pdf") === "pdf") {
- const iframe = document.getElementById("bzlLibraryFrame");
- if (iframe && !iframe.src) iframe.src = `${activeItem.url}#page=${activePage}`;
- } else {
- setPage(activePage);
- }
+ const isText = String(book.kind || "") === "text";
+ const fullText = textByBookId.get(String(book.id || "")) || "";
+ const pages = isText ? paginateText(fullText) : [];
+ const totalPages = isText ? Math.max(1, pages.length) : 1;
+ readerPage = Math.max(1, Math.min(isText ? totalPages : 9999, Number(readerPage || 1)));
+
+ const tagsHtml = (book.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
+ mount.innerHTML = `
+ <div class="lib3">
+ <div class="lib3Row" style="justify-content:space-between;">
+ <div>
+ <div><b>${escapeHtml(book.title || "Untitled")}</b> ${book.checkedOutBy ? "<span title='Checked out'>↩</span>" : ""}</div>
+ <div class="lib3Meta">by ${escapeHtml(book.author || book.createdBy || "Unknown")} | ${String(book.kind || "").toUpperCase()} | ${formatBytes(book.bytes)}</div>
+ <div>${tagsHtml}</div>
+ </div>
+ ${
+ isText
+ ? `<div class="lib3Row">
+ <button type="button" class="lib3Btn" id="lib3ReadPrev"><</button>
+ <input id="lib3ReadPage" type="number" min="1" value="${readerPage}" style="width:84px;" />
+ <button type="button" class="lib3Btn" id="lib3ReadGo">Go</button>
+ <button type="button" class="lib3Btn" id="lib3ReadNext">></button>
+ </div>`
+ : `<div class="lib3Hint">Use the PDF toolbar to navigate pages.</div>`
+ }
+ </div>
+ <div class="lib3ReaderViewport">
+ ${isText ? `<div id="lib3ReaderText" class="lib3ReaderPage">${escapeHtml(pages[readerPage - 1] || (fullText ? "" : "Loading text..."))}</div>` : `<iframe class="lib3Iframe" src="${escapeHtml(`${book.url || ""}#page=${readerPage}`)}" title="Reader"></iframe>`}
+ </div>
+ <div class="lib3Hint">Page ${readerPage}${isText ? ` / ${totalPages}` : ""}</div>
+ </div>
+ `;
+
+ if (isText && !textByBookId.has(String(book.id || ""))) requestText(book.id);
+ if (isText) {
+ mount.querySelector("#lib3ReadPrev")?.addEventListener("click", () => {
+ readerPage -= 1;
+ renderReader();
+ });
+ mount.querySelector("#lib3ReadNext")?.addEventListener("click", () => {
+ readerPage += 1;
+ renderReader();
+ });
+ mount.querySelector("#lib3ReadGo")?.addEventListener("click", () => {
+ readerPage = Number(mount.querySelector("#lib3ReadPage")?.value || 1);
+ renderReader();
+ });
}
}
+ function renderAll() {
+ renderLibrary();
+ renderShelf();
+ renderReader();
+ }
function onWsMsg(ev) {
try {
@@ -804,17 +600,24 @@
if (!type.startsWith("plugin:library:")) return;
if (type === "plugin:library:list") {
- items = Array.isArray(msg.items) ? msg.items : [];
- render();
+ snapshot = { me: String(msg.me || ""), myShelfId: String(msg.myShelfId || ""), shelves: Array.isArray(msg.shelves) ? msg.shelves : [] };
+ if (!activeShelfId || !snapshot.shelves.some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves[0]?.id || "";
+ renderAll();
return;
}
if (type === "plugin:library:changed") {
- if (panelOpen) requestList();
+ requestList();
+ return;
+ }
+ if (type === "plugin:library:text") {
+ const it = msg.item || null;
+ if (!it || !it.id) return;
+ textByBookId.set(String(it.id), String(it.text || ""));
+ renderReader();
return;
}
if (type === "plugin:library:uploadStarted") {
- const uploadId = String(msg.uploadId || "");
- if (uploadId) window.__bzlLibraryUploadId = uploadId;
+ window.__bzlLibraryUploadId = String(msg.uploadId || "");
return;
}
if (type === "plugin:library:uploadProgress") {
@@ -822,106 +625,89 @@
return;
}
if (type === "plugin:library:uploadFinished") {
- uploadingPdf = false;
+ uploadInProgress = false;
window.__bzlLibraryUploadId = "";
setStatus("Upload complete.");
requestList();
- render();
- return;
- }
- if (type === "plugin:library:text") {
- const it = msg.item;
- if (!it || !activeItem || String(activeItem.id || "") !== String(it.id || "")) return;
- activeText = String(it.text || "");
- textPages = paginateText(activeText);
- totalPages = Math.max(1, textPages.length);
- // update title in activeItem
- activeItem = { ...activeItem, title: it.title, createdBy: it.createdBy, updatedAt: it.updatedAt, bytes: it.bytes };
- editorTitle = String(activeItem.title || "");
- setStatus("");
- render();
- setPage(1);
- return;
- }
- if (type === "plugin:library:textCreated") {
- requestList();
- setStatus("Created.");
- return;
- }
- if (type === "plugin:library:textUpdated") {
- requestList();
- setStatus("Saved.");
- if (activeItem && String(activeItem.kind || "") === "text" && msg.id && String(msg.id) === String(activeItem.id)) {
- requestText(activeItem.id);
- }
- return;
- }
- if (type === "plugin:library:deleted") {
- requestList();
- if (activeItem && msg.id && String(msg.id) === String(activeItem.id)) closeViewer();
return;
}
if (type === "plugin:library:error") {
- uploadingPdf = false;
+ uploadInProgress = false;
setStatus(String(msg.message || "Error."));
ctx.toast("Library", String(msg.message || "Error."));
+ return;
+ }
+ if (
+ type === "plugin:library:textCreated" ||
+ type === "plugin:library:textUpdated" ||
+ type === "plugin:library:shelfCreated" ||
+ type === "plugin:library:shelfUpdated" ||
+ type === "plugin:library:shelfSubscribed" ||
+ type === "plugin:library:pinned" ||
+ type === "plugin:library:checkedOut" ||
+ type === "plugin:library:shelfItemRemoved" ||
+ type === "plugin:library:returned" ||
+ type === "plugin:library:deleted"
+ ) {
+ requestList();
}
} catch {
// ignore
}
}
- function ensureDom() {
- if (document.getElementById("bzlLibraryToggle")) return;
- if (!document.body) {
- ctx.devLog("warn", "library:bodyMissing", {});
- return;
- }
- const btn = document.createElement("button");
- btn.id = "bzlLibraryToggle";
- btn.className = "bzlLibraryToggle";
- btn.type = "button";
- btn.textContent = "Library";
- btn.addEventListener("click", () => {
- panelOpen = !panelOpen;
- if (panelOpen) {
- const panel = document.getElementById("bzlLibraryPanel");
- if (panel) applyPanelRect(panel, readPanelRect() || defaultPanelRect());
- requestList();
- }
- render();
+ function mountPanel(kind, mount) {
+ if (!(mount instanceof HTMLElement)) return;
+ mount.style.height = "100%";
+ mount.style.minHeight = "0";
+ mount.style.display = "flex";
+ mounts[kind] = mount;
+ renderAll();
+ }
+
+ const panelOpts = { defaultRack: "main", role: "primary" };
+ if (ctx?.ui?.registerPanel) {
+ ctx.ui.registerPanel({
+ id: "library-reader",
+ title: "Reader",
+ icon: "Read",
+ ...panelOpts,
+ render(mount) {
+ mountPanel("reader", mount);
+ return () => {
+ if (mounts.reader === mount) mounts.reader = null;
+ };
+ },
});
- document.body.appendChild(btn);
- ctx.devLog("info", "library:toggleMounted", {});
-
- const panel = document.createElement("div");
- panel.id = "bzlLibraryPanel";
- panel.className = "bzlLibraryPanel hidden";
- applyPanelRect(panel, readPanelRect() || defaultPanelRect());
- document.body.appendChild(panel);
-
- window.addEventListener("resize", () => {
- const p = document.getElementById("bzlLibraryPanel");
- if (!p) return;
- applyPanelRect(p, readPanelRect() || defaultPanelRect());
+ ctx.ui.registerPanel({
+ id: "library-shelf",
+ title: "Shelf",
+ icon: "Shelf",
+ ...panelOpts,
+ render(mount) {
+ mountPanel("shelf", mount);
+ return () => {
+ if (mounts.shelf === mount) mounts.shelf = null;
+ };
+ },
});
-
- window.addEventListener("keydown", (e) => {
- if (!panelOpen || !viewerOpen) return;
- if (e.key === "ArrowLeft") {
- e.preventDefault();
- setPage(activePage - 1);
- } else if (e.key === "ArrowRight") {
- e.preventDefault();
- setPage(activePage + 1);
- }
+ ctx.ui.registerPanel({
+ id: "library-browser",
+ title: "Library",
+ icon: "Books",
+ ...panelOpts,
+ render(mount) {
+ mountPanel("library", mount);
+ return () => {
+ if (mounts.library === mount) mounts.library = null;
+ };
+ },
});
}
- // kick off
setInterval(attachWsListener, 1000);
attachWsListener();
- whenBodyReady(ensureDom);
- ctx.devLog("info", "library:init", { ok: true });
+ requestList();
+ ctx.devLog("info", "library:init", { ok: true, panels: ["library-reader", "library-shelf", "library-browser"] });
});
})();
diff --git a/plugins_dev/library/plugin.json b/plugins_dev/library/plugin.json
@@ -1,8 +1,8 @@
{
"id": "library",
"name": "Library",
- "version": "0.2.5",
- "description": "Upload PDFs as library posts and read them in-app.",
+ "version": "0.4.0",
+ "description": "Panel-based social library with Reader, Shelf, and Library browsing plus checkout/return and metadata tags.",
"entryClient": "client.js",
"entryServer": "server.js",
"permissions": ["ui", "ws"]
diff --git a/plugins_dev/library/server.js b/plugins_dev/library/server.js
@@ -62,6 +62,32 @@ function normalizeTextBody(text) {
return t;
}
+function normalizeAuthor(name) {
+ const a = String(name || "").trim().slice(0, 80);
+ return a || "Unknown";
+}
+
+function normalizeTags(tags) {
+ if (!Array.isArray(tags)) return [];
+ const out = [];
+ const seen = new Set();
+ for (const raw of tags) {
+ const t = String(raw || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9 _-]+/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^[-_]+|[-_]+$/g, "")
+ .slice(0, 32);
+ if (!t || seen.has(t)) continue;
+ seen.add(t);
+ out.push(t);
+ if (out.length >= 16) break;
+ }
+ return out;
+}
+
function sanitizeFilename(name) {
const base = String(name || "")
.trim()
@@ -138,6 +164,10 @@ module.exports = function init(api) {
createdAt: Number(meta.createdAt || t),
createdBy: String(meta.createdBy || ""),
title: String(meta.title || ""),
+ author: normalizeAuthor(meta.author || meta.createdBy || ""),
+ isOriginal: meta?.isOriginal !== false,
+ tags: normalizeTags(meta?.tags),
+ shelfId: normalizeId(meta.shelfId || ""),
lastSeenAt: t
};
inFlight.set(uploadId, rec);
@@ -145,31 +175,324 @@ module.exports = function init(api) {
return rec;
}
- function loadItems() {
+ function normalizeShelfName(name) {
+ const s = String(name || "").trim().slice(0, 80);
+ return s || "Untitled shelf";
+ }
+
+ function normalizeShelfDescription(description) {
+ return String(description || "").trim().slice(0, 240);
+ }
+
+ function createShelfId() {
+ return `shelf-${crypto.randomBytes(8).toString("hex")}`;
+ }
+
+ function createShelfItemId() {
+ return `si-${crypto.randomBytes(8).toString("hex")}`;
+ }
+
+ function normalizeBook(raw) {
+ const kind = String(raw?.kind || "pdf") === "text" ? "text" : "pdf";
+ const id = normalizeId(raw?.id);
+ if (!id) return null;
+ const createdAt = Number(raw?.createdAt || 0) || nowMs();
+ const out = {
+ id,
+ kind,
+ title: kind === "text" ? normalizeTextTitle(raw?.title) : normalizeTitle(raw?.title),
+ author: normalizeAuthor(raw?.author || raw?.createdBy || ""),
+ isOriginal: raw?.isOriginal !== false,
+ tags: normalizeTags(raw?.tags),
+ format: kind === "text" ? (String(raw?.format || "text").toLowerCase() === "rtf" ? "rtf" : "text") : "pdf",
+ bytes: Number(raw?.bytes || 0),
+ createdAt,
+ createdBy: String(raw?.createdBy || ""),
+ updatedAt: Number(raw?.updatedAt || createdAt) || createdAt,
+ sourceBookId: normalizeId(raw?.sourceBookId || ""),
+ sourceShelfId: normalizeId(raw?.sourceShelfId || ""),
+ checkedOutBy: String(raw?.checkedOutBy || ""),
+ };
+ if (kind === "text") {
+ out.text = typeof raw?.text === "string" ? normalizeTextBody(raw.text) : "";
+ out.bytes = Number(out.bytes || Buffer.byteLength(String(out.text || ""), "utf8"));
+ return out;
+ }
+ out.url = String(raw?.url || "");
+ out.filename = String(raw?.filename || "");
+ if (!out.url || !out.filename) return null;
+ return out;
+ }
+
+ function normalizeShelfItem(raw) {
+ const id = normalizeId(raw?.id);
+ const bookId = normalizeId(raw?.bookId);
+ if (!id || !bookId) return null;
+ const kind = ["own", "pin", "checkout"].includes(String(raw?.kind || "")) ? String(raw.kind) : "own";
+ return {
+ id,
+ bookId,
+ kind,
+ addedBy: String(raw?.addedBy || ""),
+ addedAt: Number(raw?.addedAt || 0) || nowMs(),
+ note: String(raw?.note || "").slice(0, 180),
+ sourceBookId: normalizeId(raw?.sourceBookId || ""),
+ sourceShelfId: normalizeId(raw?.sourceShelfId || ""),
+ };
+ }
+
+ function normalizeShelf(raw) {
+ const id = normalizeId(raw?.id);
+ if (!id) return null;
+ const createdAt = Number(raw?.createdAt || 0) || nowMs();
+ const subscribers = Array.isArray(raw?.subscribers) ? raw.subscribers.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean) : [];
+ const uniqSubs = [...new Set(subscribers)];
+ const items = Array.isArray(raw?.items) ? raw.items.map(normalizeShelfItem).filter(Boolean) : [];
+ return {
+ id,
+ name: normalizeShelfName(raw?.name),
+ description: normalizeShelfDescription(raw?.description),
+ owner: String(raw?.owner || "").trim().toLowerCase(),
+ isPublic: raw?.isPublic !== false,
+ createdAt,
+ updatedAt: Number(raw?.updatedAt || createdAt) || createdAt,
+ subscribers: uniqSubs,
+ items,
+ };
+ }
+
+ function userPrimaryShelfId(user) {
+ return normalizeId(`shelf-user-${String(user || "").trim().toLowerCase()}`);
+ }
+
+ function ensureUserPrimaryShelf(store, user) {
+ const u = String(user || "").trim().toLowerCase();
+ if (!u) return { changed: false, shelf: null };
+ const id = userPrimaryShelfId(u);
+ const existing = store.shelves.find((s) => s.id === id);
+ if (existing) return { changed: false, shelf: existing };
+ const t = nowMs();
+ const shelf = {
+ id,
+ name: `${u}'s shelf`,
+ description: "My personal shelf",
+ owner: u,
+ isPublic: true,
+ createdAt: t,
+ updatedAt: t,
+ subscribers: [],
+ items: [],
+ };
+ store.shelves.push(shelf);
+ return { changed: true, shelf };
+ }
+
+ function migrateLegacy(parsed) {
+ const legacyItems = Array.isArray(parsed?.items) ? parsed.items : [];
+ const books = [];
+ const communityItems = [];
+ for (const it of legacyItems) {
+ const book = normalizeBook(it);
+ if (!book) continue;
+ books.push(book);
+ communityItems.push({
+ id: createShelfItemId(),
+ bookId: book.id,
+ kind: "own",
+ addedBy: String(book.createdBy || ""),
+ addedAt: Number(book.createdAt || nowMs()),
+ note: "",
+ sourceBookId: "",
+ sourceShelfId: "",
+ });
+ }
+ const t = nowMs();
+ return {
+ version: 2,
+ books,
+ shelves: [
+ {
+ id: "shelf-community",
+ name: "Community shelf",
+ description: "Shared shelf migrated from the classic library.",
+ owner: "",
+ isPublic: true,
+ createdAt: t,
+ updatedAt: t,
+ subscribers: [],
+ items: communityItems,
+ },
+ ],
+ };
+ }
+
+ function loadStore() {
const parsed = readJsonOrNull(dataFile);
- const items = Array.isArray(parsed?.items) ? parsed.items : [];
- return items
- .map((it) => ({
- id: normalizeId(it?.id),
- kind: String(it?.kind || "pdf") === "text" ? "text" : "pdf",
- title: String(it?.kind || "pdf") === "text" ? normalizeTextTitle(it?.title) : normalizeTitle(it?.title),
- url: String(it?.url || ""),
- filename: String(it?.filename || ""),
- bytes: Number(it?.bytes || 0),
- createdAt: Number(it?.createdAt || 0),
- createdBy: String(it?.createdBy || ""),
- updatedAt: Number(it?.updatedAt || 0),
- text: typeof it?.text === "string" ? it.text : "",
- }))
- .filter((it) => {
- if (!it.id) return false;
- if (it.kind === "pdf") return Boolean(it.url && it.filename);
- return true;
+ if (!parsed || typeof parsed !== "object") {
+ return { version: 2, books: [], shelves: [] };
+ }
+ if (Number(parsed.version || 0) !== 2) return migrateLegacy(parsed);
+ const books = Array.isArray(parsed.books) ? parsed.books.map(normalizeBook).filter(Boolean) : [];
+ const byBook = new Map(books.map((b) => [b.id, b]));
+ const shelves = Array.isArray(parsed.shelves) ? parsed.shelves.map(normalizeShelf).filter(Boolean) : [];
+ for (const shelf of shelves) {
+ shelf.items = shelf.items.filter((si) => byBook.has(si.bookId));
+ }
+ return { version: 2, books, shelves };
+ }
+
+ function saveStore(store) {
+ writeFileAtomic(
+ dataFile,
+ JSON.stringify(
+ {
+ version: 2,
+ books: Array.isArray(store?.books) ? store.books : [],
+ shelves: Array.isArray(store?.shelves) ? store.shelves : [],
+ },
+ null,
+ 2
+ ) + "\n"
+ );
+ }
+
+ function findShelf(store, shelfId) {
+ const id = normalizeId(shelfId);
+ if (!id) return null;
+ return store.shelves.find((s) => s.id === id) || null;
+ }
+
+ function findBook(store, bookId) {
+ const id = normalizeId(bookId);
+ if (!id) return null;
+ return store.books.find((b) => b.id === id) || null;
+ }
+
+ function removeBookIfOrphan(store, bookId) {
+ const id = normalizeId(bookId);
+ if (!id) return null;
+ const stillReferenced = store.shelves.some((shelf) => shelf.items.some((si) => si.bookId === id));
+ if (stillReferenced) return null;
+ const idx = store.books.findIndex((b) => b.id === id);
+ if (idx < 0) return null;
+ const book = store.books[idx];
+ store.books.splice(idx, 1);
+ return book;
+ }
+
+ function isShelfOwner(user, shelf) {
+ return Boolean(user) && String(shelf?.owner || "") === String(user || "");
+ }
+
+ function canViewShelf(user, shelf) {
+ if (!shelf) return false;
+ if (shelf.isPublic) return true;
+ return isShelfOwner(user, shelf);
+ }
+
+ function canAccessBook(user, store, bookId) {
+ const id = normalizeId(bookId);
+ if (!id) return false;
+ return store.shelves.some((shelf) => canViewShelf(user, shelf) && shelf.items.some((si) => si.bookId === id));
+ }
+
+ function popularityByBook(store) {
+ const byId = new Map();
+ const add = (id, field) => {
+ if (!id) return;
+ if (!byId.has(id)) byId.set(id, { pins: 0, checkouts: 0 });
+ byId.get(id)[field] += 1;
+ };
+ for (const shelf of store.shelves) {
+ for (const si of shelf.items) {
+ if (si.kind === "pin") add(si.bookId, "pins");
+ }
+ }
+ for (const book of store.books) {
+ if (book.sourceBookId) add(book.sourceBookId, "checkouts");
+ }
+ return byId;
+ }
+
+ function makeSnapshot(ws) {
+ const u = username(ws);
+ const role = userRole(ws);
+ const isOwner = role === "owner";
+ const store = loadStore();
+ const ensured = ensureUserPrimaryShelf(store, u);
+ if (ensured.changed) saveStore(store);
+
+ const pop = popularityByBook(store);
+ const booksById = new Map(store.books.map((b) => [b.id, b]));
+ const shelves = store.shelves
+ .filter((shelf) => canViewShelf(u, shelf))
+ .sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
+ .map((shelf) => {
+ const shelfOwner = isShelfOwner(u, shelf);
+ const items = shelf.items
+ .slice()
+ .sort((a, b) => Number(b.addedAt || 0) - Number(a.addedAt || 0))
+ .map((si) => {
+ const book = booksById.get(si.bookId);
+ if (!book) return null;
+ const score = pop.get(book.id) || { pins: 0, checkouts: 0 };
+ return {
+ id: si.id,
+ kind: si.kind,
+ addedAt: si.addedAt,
+ addedBy: si.addedBy,
+ note: si.note || "",
+ sourceBookId: si.sourceBookId || "",
+ sourceShelfId: si.sourceShelfId || "",
+ canRemoveItem: shelfOwner,
+ canReturn: shelfOwner && si.kind === "checkout",
+ book: {
+ id: book.id,
+ kind: book.kind,
+ title: book.title,
+ author: book.author || normalizeAuthor(book.createdBy || ""),
+ isOriginal: book.isOriginal !== false,
+ tags: Array.isArray(book.tags) ? book.tags : [],
+ format: String(book.format || (book.kind === "text" ? "text" : "pdf")),
+ url: book.kind === "pdf" ? book.url : "",
+ filename: book.kind === "pdf" ? book.filename : "",
+ bytes: book.bytes,
+ createdAt: book.createdAt,
+ createdBy: book.createdBy,
+ updatedAt: book.updatedAt || book.createdAt,
+ sourceBookId: book.sourceBookId || "",
+ sourceShelfId: book.sourceShelfId || "",
+ checkedOutBy: book.checkedOutBy || "",
+ popularity: { pins: Number(score.pins || 0), checkouts: Number(score.checkouts || 0) },
+ canDeleteBook: isOwner || String(book.createdBy || "") === u,
+ },
+ };
+ })
+ .filter(Boolean);
+ return {
+ id: shelf.id,
+ name: shelf.name,
+ description: shelf.description || "",
+ owner: shelf.owner || "",
+ isPublic: shelf.isPublic !== false,
+ createdAt: shelf.createdAt,
+ updatedAt: shelf.updatedAt,
+ isOwner: shelfOwner,
+ isSubscribed: Boolean(u && shelf.subscribers.includes(u)),
+ subscriberCount: Number((shelf.subscribers || []).length || 0),
+ items,
+ };
});
+
+ return {
+ me: u,
+ myShelfId: ensured.shelf?.id || userPrimaryShelfId(u),
+ shelves,
+ };
}
- function saveItems(items) {
- writeFileAtomic(dataFile, JSON.stringify({ version: 1, items }, null, 2) + "\n");
+ function broadcastChanged() {
+ api.broadcast({ type: "plugin:library:changed" });
}
function send(ws, msg) {
@@ -185,34 +508,6 @@ module.exports = function init(api) {
send(ws, { type: "plugin:library:error", message: String(message || "Error."), data: data || null });
}
- function listForClient() {
- const items = loadItems();
- items.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
- return items.map((it) => {
- if (it.kind === "text") {
- return {
- id: it.id,
- kind: "text",
- title: it.title,
- bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")),
- createdAt: it.createdAt,
- createdBy: it.createdBy,
- updatedAt: it.updatedAt || it.createdAt,
- };
- }
- return {
- id: it.id,
- kind: "pdf",
- title: it.title,
- url: it.url,
- filename: it.filename,
- bytes: it.bytes,
- createdAt: it.createdAt,
- createdBy: it.createdBy,
- };
- });
- }
-
// Important: do not delete inflight uploads on WS close. Reconnects are common, and
// the client may continue the upload on a new socket. Instead, time out abandoned uploads.
setInterval(() => {
@@ -241,7 +536,226 @@ module.exports = function init(api) {
}, 60_000).unref?.();
api.registerWs("list", (ws) => {
- send(ws, { type: "plugin:library:list", items: listForClient() });
+ send(ws, { type: "plugin:library:list", ...makeSnapshot(ws) });
+ });
+
+ api.registerWs("shelfCreate", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const name = normalizeShelfName(msg?.name);
+ const description = normalizeShelfDescription(msg?.description);
+ const isPublic = msg?.isPublic !== false;
+ const t = nowMs();
+ const store = loadStore();
+ const shelf = {
+ id: createShelfId(),
+ name,
+ description,
+ owner: u,
+ isPublic,
+ createdAt: t,
+ updatedAt: t,
+ subscribers: [],
+ items: [],
+ };
+ store.shelves.push(shelf);
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfCreated", ok: true, shelfId: shelf.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("shelfUpdate", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can edit it.");
+ shelf.name = normalizeShelfName(msg?.name ?? shelf.name);
+ shelf.description = normalizeShelfDescription(msg?.description ?? shelf.description);
+ if (typeof msg?.isPublic === "boolean") shelf.isPublic = msg.isPublic;
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfUpdated", ok: true, shelfId: shelf.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("shelfSubscribe", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const subscribe = msg?.subscribe !== false;
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf || !canViewShelf(u, shelf)) return sendError(ws, "Shelf not found.");
+ const set = new Set(Array.isArray(shelf.subscribers) ? shelf.subscribers : []);
+ if (subscribe) set.add(u);
+ else set.delete(u);
+ shelf.subscribers = [...set];
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfSubscribed", ok: true, shelfId: shelf.id, subscribed: subscribe });
+ broadcastChanged();
+ });
+
+ api.registerWs("pinBook", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const bookId = normalizeId(msg?.bookId);
+ if (!shelfId || !bookId) return sendError(ws, "Missing shelf/book id.");
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only pin into your own shelves.");
+ const book = findBook(store, bookId);
+ if (!book) return sendError(ws, "Book not found.");
+ if (!canAccessBook(u, store, book.id)) return sendError(ws, "Book not found.");
+ if (shelf.items.some((si) => si.bookId === book.id && si.kind === "pin")) {
+ return sendError(ws, "Already pinned on this shelf.");
+ }
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: book.id,
+ kind: "pin",
+ addedBy: u,
+ addedAt: nowMs(),
+ note: "",
+ sourceBookId: book.id,
+ sourceShelfId: normalizeId(msg?.sourceShelfId || ""),
+ });
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:pinned", ok: true, shelfId: shelf.id, bookId: book.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("checkoutBook", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const targetShelfId = normalizeId(msg?.targetShelfId);
+ const sourceBookId = normalizeId(msg?.sourceBookId);
+ const sourceShelfId = normalizeId(msg?.sourceShelfId);
+ if (!targetShelfId || !sourceBookId) return sendError(ws, "Missing ids.");
+ const store = loadStore();
+ const shelf = findShelf(store, targetShelfId);
+ if (!shelf) return sendError(ws, "Target shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only check out to your own shelf.");
+ const source = findBook(store, sourceBookId);
+ if (!source) return sendError(ws, "Source book not found.");
+ if (!canAccessBook(u, store, source.id)) return sendError(ws, "Source book not found.");
+ const t = nowMs();
+ const copyId = crypto.randomBytes(10).toString("hex");
+ const cloned = source.kind === "text"
+ ? {
+ id: copyId,
+ kind: "text",
+ title: source.title,
+ author: source.author || source.createdBy || u,
+ isOriginal: false,
+ tags: normalizeTags(source.tags),
+ format: String(source.format || "text").toLowerCase() === "rtf" ? "rtf" : "text",
+ text: String(source.text || ""),
+ bytes: Number(source.bytes || Buffer.byteLength(String(source.text || ""), "utf8")),
+ createdAt: t,
+ updatedAt: t,
+ createdBy: u,
+ sourceBookId: source.id,
+ sourceShelfId,
+ checkedOutBy: u,
+ }
+ : {
+ id: copyId,
+ kind: "pdf",
+ title: source.title,
+ author: source.author || source.createdBy || u,
+ isOriginal: false,
+ tags: normalizeTags(source.tags),
+ format: "pdf",
+ filename: source.filename,
+ url: source.url,
+ bytes: source.bytes,
+ createdAt: t,
+ updatedAt: t,
+ createdBy: u,
+ sourceBookId: source.id,
+ sourceShelfId,
+ checkedOutBy: u,
+ };
+ store.books.push(cloned);
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: cloned.id,
+ kind: "checkout",
+ addedBy: u,
+ addedAt: t,
+ note: "",
+ sourceBookId: source.id,
+ sourceShelfId,
+ });
+ shelf.updatedAt = t;
+ saveStore(store);
+ send(ws, { type: "plugin:library:checkedOut", ok: true, targetShelfId: shelf.id, newBookId: cloned.id, sourceBookId: source.id });
+ broadcastChanged();
+ });
+
+ api.registerWs("shelfItemRemove", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const shelfItemId = normalizeId(msg?.shelfItemId);
+ if (!shelfId || !shelfItemId) return sendError(ws, "Missing ids.");
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can remove items.");
+ const row = shelf.items.find((si) => si.id === shelfItemId);
+ if (!row) return sendError(ws, "Item not found.");
+ shelf.items = shelf.items.filter((si) => si.id !== shelfItemId);
+ const maybeRemovedBook = removeBookIfOrphan(store, row.bookId);
+ if (maybeRemovedBook?.kind === "pdf" && maybeRemovedBook.filename) {
+ const filePath = path.join(uploadsDir, maybeRemovedBook.filename);
+ try {
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {
+ // ignore
+ }
+ }
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:shelfItemRemoved", ok: true, shelfId, shelfItemId });
+ broadcastChanged();
+ });
+
+ api.registerWs("returnBook", (ws, msg) => {
+ const u = username(ws);
+ if (!u) return sendError(ws, "Sign in required.");
+ const shelfId = normalizeId(msg?.shelfId);
+ const shelfItemId = normalizeId(msg?.shelfItemId);
+ if (!shelfId || !shelfItemId) return sendError(ws, "Missing ids.");
+ const store = loadStore();
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can return books.");
+ const row = shelf.items.find((si) => si.id === shelfItemId);
+ if (!row) return sendError(ws, "Item not found.");
+ if (row.kind !== "checkout") return sendError(ws, "Only checked-out books can be returned.");
+
+ shelf.items = shelf.items.filter((si) => si.id !== shelfItemId);
+ const removed = removeBookIfOrphan(store, row.bookId);
+ if (removed?.kind === "pdf" && removed.filename) {
+ const filePath = path.join(uploadsDir, removed.filename);
+ try {
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {
+ // ignore
+ }
+ }
+ shelf.updatedAt = nowMs();
+ saveStore(store);
+ send(ws, { type: "plugin:library:returned", ok: true, shelfId, shelfItemId, bookId: row.bookId });
+ broadcastChanged();
});
api.registerWs("textGet", (ws, msg) => {
@@ -249,8 +763,9 @@ module.exports = function init(api) {
if (!u) return sendError(ws, "Sign in required.");
const id = normalizeId(msg?.id);
if (!id) return sendError(ws, "Missing id.");
- const items = loadItems();
- const it = items.find((x) => x.id === id);
+ const store = loadStore();
+ if (!canAccessBook(u, store, id)) return sendError(ws, "Not found.");
+ const it = findBook(store, id);
if (!it || it.kind !== "text") return sendError(ws, "Not found.");
send(ws, {
type: "plugin:library:text",
@@ -258,6 +773,10 @@ module.exports = function init(api) {
id: it.id,
kind: "text",
title: it.title,
+ author: it.author || normalizeAuthor(it.createdBy || ""),
+ isOriginal: it.isOriginal !== false,
+ tags: Array.isArray(it.tags) ? it.tags : [],
+ format: String(it.format || "text"),
text: String(it.text || ""),
bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")),
createdAt: it.createdAt,
@@ -270,28 +789,52 @@ module.exports = function init(api) {
api.registerWs("textCreate", (ws, msg) => {
const u = username(ws);
if (!u) return sendError(ws, "Sign in required.");
+ const store = loadStore();
+ const ensured = ensureUserPrimaryShelf(store, u);
+ const shelfId = normalizeId(msg?.shelfId || ensured.shelf?.id);
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only add books to your own shelf.");
const title = normalizeTextTitle(msg?.title);
+ const author = normalizeAuthor(msg?.author || u);
+ const isOriginal = msg?.isOriginal !== false;
+ const tags = normalizeTags(msg?.tags);
+ const format = String(msg?.format || "text").toLowerCase() === "rtf" ? "rtf" : "text";
const text = normalizeTextBody(msg?.text);
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`);
- const items = loadItems();
const id = crypto.randomBytes(10).toString("hex");
const t = nowMs();
- items.push({
+ store.books.push({
id,
kind: "text",
title,
+ author,
+ isOriginal,
+ tags,
+ format,
text,
bytes,
createdAt: t,
updatedAt: t,
createdBy: u,
});
- saveItems(items);
- api.log("info", "library:textCreate", { id, bytes, user: u });
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: id,
+ kind: "own",
+ addedBy: u,
+ addedAt: t,
+ note: "",
+ sourceBookId: "",
+ sourceShelfId: "",
+ });
+ shelf.updatedAt = t;
+ saveStore(store);
+ api.log("info", "library:textCreate", { id, bytes, user: u, shelfId: shelf.id });
send(ws, { type: "plugin:library:textCreated", ok: true, id });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
api.registerWs("textUpdate", (ws, msg) => {
@@ -300,29 +843,40 @@ module.exports = function init(api) {
const id = normalizeId(msg?.id);
if (!id) return sendError(ws, "Missing id.");
- const items = loadItems();
- const idx = items.findIndex((x) => x.id === id);
+ const store = loadStore();
+ const idx = store.books.findIndex((x) => x.id === id);
if (idx < 0) return sendError(ws, "Not found.");
- const it = items[idx];
+ const it = store.books[idx];
if (it.kind !== "text") return sendError(ws, "Not found.");
if (String(it.createdBy || "") !== u) return sendError(ws, "Only the author can edit this text.");
const title = normalizeTextTitle(msg?.title ?? it.title);
+ const author = normalizeAuthor(msg?.author ?? it.author ?? u);
+ const isOriginal = typeof msg?.isOriginal === "boolean" ? msg.isOriginal : it.isOriginal !== false;
+ const tags = Array.isArray(msg?.tags) ? normalizeTags(msg.tags) : Array.isArray(it.tags) ? normalizeTags(it.tags) : [];
+ const format = String(msg?.format || it.format || "text").toLowerCase() === "rtf" ? "rtf" : "text";
const text = normalizeTextBody(msg?.text);
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`);
const t = nowMs();
- items[idx] = { ...it, title, text, bytes, updatedAt: t };
- saveItems(items);
+ store.books[idx] = { ...it, title, author, isOriginal, tags, format, text, bytes, updatedAt: t };
+ saveStore(store);
api.log("info", "library:textUpdate", { id, bytes, user: u });
send(ws, { type: "plugin:library:textUpdated", ok: true, id, updatedAt: t });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
api.registerWs("uploadStart", (ws, msg) => {
const u = username(ws);
if (!u) return sendError(ws, "Sign in required.");
+ const store = loadStore();
+ const ensured = ensureUserPrimaryShelf(store, u);
+ const shelfId = normalizeId(msg?.shelfId || ensured.shelf?.id);
+ const shelf = findShelf(store, shelfId);
+ if (!shelf) return sendError(ws, "Shelf not found.");
+ if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only upload to your own shelf.");
+ if (ensured.changed) saveStore(store);
const size = Number(msg?.size || 0);
if (!Number.isFinite(size) || size <= 0) return sendError(ws, "Invalid file size.");
@@ -334,6 +888,9 @@ module.exports = function init(api) {
if (!isPdf) return sendError(ws, "Only PDF files are supported.");
const title = normalizeTitle(msg?.title || original.replace(/\\.pdf$/i, ""));
+ const author = normalizeAuthor(msg?.author || u);
+ const isOriginal = msg?.isOriginal !== false;
+ const tags = normalizeTags(msg?.tags);
const uploadId = crypto.randomBytes(12).toString("hex");
fs.mkdirSync(tmpDir, { recursive: true });
@@ -348,7 +905,20 @@ module.exports = function init(api) {
const t = nowMs();
try {
- writeMeta(metaPath, { version: 1, uploadId, tmpPath, expected: size, received: 0, createdAt: t, createdBy: u, title });
+ writeMeta(metaPath, {
+ version: 1,
+ uploadId,
+ tmpPath,
+ expected: size,
+ received: 0,
+ createdAt: t,
+ createdBy: u,
+ title,
+ author,
+ isOriginal,
+ tags,
+ shelfId: shelf.id,
+ });
} catch (e) {
try {
fs.closeSync(fd);
@@ -363,8 +933,22 @@ module.exports = function init(api) {
return sendError(ws, "Failed to start upload.", { error: e?.message || String(e) });
}
- inFlight.set(uploadId, { fd, tmpPath, metaPath, expected: size, received: 0, createdAt: t, createdBy: u, title, lastSeenAt: t });
- api.log("info", "library:uploadStart", { uploadId, size, user: u });
+ inFlight.set(uploadId, {
+ fd,
+ tmpPath,
+ metaPath,
+ expected: size,
+ received: 0,
+ createdAt: t,
+ createdBy: u,
+ title,
+ author,
+ isOriginal,
+ tags,
+ shelfId: shelf.id,
+ lastSeenAt: t,
+ });
+ api.log("info", "library:uploadStart", { uploadId, size, user: u, shelfId: shelf.id });
send(ws, { type: "plugin:library:uploadStarted", uploadId, maxBytes: MAX_PDF_BYTES });
});
@@ -409,6 +993,10 @@ module.exports = function init(api) {
createdAt: rec.createdAt,
createdBy: rec.createdBy,
title: rec.title,
+ author: rec.author || rec.createdBy || "",
+ isOriginal: rec.isOriginal !== false,
+ tags: normalizeTags(rec.tags),
+ shelfId: rec.shelfId || "",
});
} catch {
// ignore
@@ -454,20 +1042,45 @@ module.exports = function init(api) {
return sendError(ws, "Failed to finalize upload.", { error: e?.message || String(e) });
}
- const items = loadItems();
+ const store = loadStore();
+ const shelf = findShelf(store, rec.shelfId || userPrimaryShelfId(u));
+ if (!shelf || !isShelfOwner(u, shelf)) {
+ try {
+ if (fs.existsSync(finalPath)) fs.unlinkSync(finalPath);
+ } catch {
+ // ignore
+ }
+ inFlight.delete(uploadId);
+ return sendError(ws, "Target shelf not found.");
+ }
const itemId = crypto.randomBytes(10).toString("hex");
const item = {
id: itemId,
kind: "pdf",
title: rec.title,
+ author: normalizeAuthor(rec.author || u),
+ isOriginal: rec.isOriginal !== false,
+ tags: normalizeTags(rec.tags),
+ format: "pdf",
filename: finalName,
url: `/uploads/library/${finalName}`,
bytes: rec.expected,
createdAt: rec.createdAt,
createdBy: u,
};
- items.push(item);
- saveItems(items);
+ store.books.push(item);
+ shelf.items.push({
+ id: createShelfItemId(),
+ bookId: itemId,
+ kind: "own",
+ addedBy: u,
+ addedAt: rec.createdAt,
+ note: "",
+ sourceBookId: "",
+ sourceShelfId: "",
+ });
+ shelf.updatedAt = nowMs();
+ saveStore(store);
inFlight.delete(uploadId);
try {
if (rec.metaPath && fs.existsSync(rec.metaPath)) fs.unlinkSync(rec.metaPath);
@@ -477,7 +1090,7 @@ module.exports = function init(api) {
api.log("info", "library:uploadFinish", { id: itemId, bytes: rec.expected, user: u });
send(ws, { type: "plugin:library:uploadFinished", ok: true, item });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
api.registerWs("delete", (ws, msg) => {
@@ -489,26 +1102,34 @@ module.exports = function init(api) {
const role = userRole(ws);
const isOwner = role === "owner";
- const items = loadItems();
- const idx = items.findIndex((it) => it.id === id);
+ const store = loadStore();
+ const idx = store.books.findIndex((it) => it.id === id);
if (idx < 0) return sendError(ws, "Not found.");
- const item = items[idx];
+ const item = store.books[idx];
if (!isOwner && item.createdBy !== u) return sendError(ws, "Not allowed.");
- items.splice(idx, 1);
- saveItems(items);
+ store.books.splice(idx, 1);
+ for (const shelf of store.shelves) {
+ const before = shelf.items.length;
+ shelf.items = shelf.items.filter((si) => si.bookId !== id);
+ if (shelf.items.length !== before) shelf.updatedAt = nowMs();
+ }
+ saveStore(store);
- if (item.kind === "pdf") {
- const filePath = path.join(uploadsDir, item.filename);
- try {
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
- } catch {
- // ignore
+ if (item.kind === "pdf" && item.filename) {
+ const stillUsed = store.books.some((b) => b.kind === "pdf" && String(b.filename || "") === String(item.filename || ""));
+ if (!stillUsed) {
+ const filePath = path.join(uploadsDir, item.filename);
+ try {
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {
+ // ignore
+ }
}
}
api.log("info", "library:delete", { id, user: u });
send(ws, { type: "plugin:library:deleted", ok: true, id });
- api.broadcast({ type: "plugin:library:changed" });
+ broadcastChanged();
});
};
diff --git a/public/app.js b/public/app.js
@@ -897,7 +897,7 @@ const PRESET_DEFS = {
sideOrder: ["chat", "profile", "composer"],
sideCollapsed: false,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "maps", "library"],
+ dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
},
social: {
presetId: "social",
@@ -908,7 +908,7 @@ const PRESET_DEFS = {
sideOrder: ["profile", "composer"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "maps", "library"],
+ dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"],
},
chatFocus: {
presetId: "chatFocus",
@@ -920,7 +920,7 @@ const PRESET_DEFS = {
sideOrder: ["profile"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "hives", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
browse: {
presetId: "browse",
@@ -932,7 +932,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["pluginRack", "people", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "people", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
creator: {
presetId: "creator",
@@ -944,7 +944,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["pluginRack", "chat", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "maps", "library-browser", "library-shelf", "library-reader"],
},
mapsSession: {
presetId: "mapsSession",
@@ -955,7 +955,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "library-browser", "library-shelf", "library-reader"],
},
quiet: {
presetId: "quiet",
@@ -967,7 +967,29 @@ const PRESET_DEFS = {
sideCollapsed: true,
rightOrder: [],
rightCollapsed: true,
- dockBottom: ["pluginRack", "chat", "people", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "library-reader"],
+ },
+ readingNook: {
+ presetId: "readingNook",
+ label: "Reading Nook",
+ group: "user",
+ workspaceLeftOrder: ["library-reader"],
+ workspaceRightOrder: ["library-shelf"],
+ sideOrder: ["profile"],
+ sideCollapsed: true,
+ rightOrder: ["people"],
+ dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-browser"],
+ },
+ libraryCurator: {
+ presetId: "libraryCurator",
+ label: "Library Curator",
+ group: "user",
+ workspaceLeftOrder: ["library-browser"],
+ workspaceRightOrder: ["library-shelf"],
+ sideOrder: ["profile"],
+ sideCollapsed: true,
+ rightOrder: ["people"],
+ dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-reader"],
},
ops: {
presetId: "ops",
@@ -979,7 +1001,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
reportsFocus: {
presetId: "reportsFocus",
@@ -992,7 +1014,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["chat"],
- dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
communityWatch: {
presetId: "communityWatch",
@@ -1004,7 +1026,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
serverAdmin: {
presetId: "serverAdmin",
@@ -1016,7 +1038,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"],
},
};
@@ -1030,6 +1052,8 @@ const PRESET_ALIASES = {
focus: "quiet",
clean: "social",
moderation: "ops",
+ reading: "readingNook",
+ library: "libraryCurator",
};
function resolvePresetKey(presetId) {