client.js (32625B)
1 2 (function () { 3 const PLUGIN_ID = "library"; 4 const PDF_CHUNK_BYTES = 256 * 1024; 5 const TEXT_FILE_MAX_BYTES = 512 * 1024; 6 const TAG_SUGGESTIONS = ["fic", "fix-it-fic", "journal", "fiction", "fantasy", "sci-fi", "lore", "poetry", "notes", "history"]; 7 8 function escapeHtml(text) { 9 return String(text || "") 10 .replace(/&/g, "&") 11 .replace(/</g, "<") 12 .replace(/>/g, ">") 13 .replace(/\"/g, """) 14 .replace(/'/g, "'"); 15 } 16 17 function formatBytes(n) { 18 const v = Number(n || 0); 19 if (!Number.isFinite(v) || v <= 0) return "0 B"; 20 const units = ["B", "KB", "MB", "GB"]; 21 let idx = 0; 22 let x = v; 23 while (x >= 1024 && idx < units.length - 1) { 24 x /= 1024; 25 idx += 1; 26 } 27 return `${x.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; 28 } 29 30 function bytesToBase64(bytes) { 31 let bin = ""; 32 const chunk = 0x8000; 33 for (let i = 0; i < bytes.length; i += chunk) { 34 // eslint-disable-next-line prefer-spread 35 bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); 36 } 37 return btoa(bin); 38 } 39 40 function sleep(ms) { 41 return new Promise((r) => setTimeout(r, ms)); 42 } 43 44 function normalizeTag(raw) { 45 return String(raw || "") 46 .trim() 47 .toLowerCase() 48 .replace(/[^a-z0-9 _-]+/g, "") 49 .replace(/\s+/g, "-") 50 .replace(/-+/g, "-") 51 .replace(/^[-_]+|[-_]+$/g, "") 52 .slice(0, 32); 53 } 54 55 function parseTags(raw) { 56 const parts = String(raw || "") 57 .split(",") 58 .map((x) => normalizeTag(x)) 59 .filter(Boolean); 60 return [...new Set(parts)].slice(0, 16); 61 } 62 63 function paginateText(text, opts = {}) { 64 const maxLines = Number(opts.maxLines || 40); 65 const maxChars = Number(opts.maxChars || 2400); 66 const lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); 67 const pages = []; 68 let buf = ""; 69 let lineCount = 0; 70 const flush = () => { 71 pages.push(buf); 72 buf = ""; 73 lineCount = 0; 74 }; 75 for (let i = 0; i < lines.length; i++) { 76 const lineWithNl = i === lines.length - 1 ? lines[i] : `${lines[i]}\n`; 77 if (lineWithNl.length > maxChars) { 78 let r = lineWithNl; 79 while (r.length) { 80 const take = r.slice(0, maxChars); 81 r = r.slice(maxChars); 82 if (buf && (buf.length + take.length > maxChars || lineCount >= maxLines)) flush(); 83 buf += take; 84 lineCount += 1; 85 if (buf.length >= maxChars || lineCount >= maxLines) flush(); 86 } 87 continue; 88 } 89 if (buf && (buf.length + lineWithNl.length > maxChars || lineCount + 1 > maxLines)) flush(); 90 buf += lineWithNl; 91 lineCount += 1; 92 } 93 if (buf || !pages.length) pages.push(buf); 94 return pages; 95 } 96 97 function ensureStyles() { 98 if (document.getElementById("bzlLibraryPanelsStyle")) return; 99 const el = document.createElement("style"); 100 el.id = "bzlLibraryPanelsStyle"; 101 el.textContent = ` 102 .lib3 { color: var(--text, #f3f7ff); display:flex; flex-direction:column; gap:10px; min-height:0; height:100%; flex:1 1 auto; } 103 .lib3Row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } 104 .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; } 105 .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; } 106 .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; } 107 .lib3Scroll { overflow:auto; min-height:0; flex:1 1 auto; } 108 .lib3ReaderViewport { display:flex; flex:1 1 auto; min-height:0; } 109 .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; } 110 .lib3Meta { opacity:.82; font-size:12px; margin-top:3px; } 111 .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; } 112 .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; } 113 .lib3Hint { opacity:.78; font-size:12px; } 114 .lib3Iframe { width:100%; height:100%; border:1px solid rgba(255,255,255,0.12); border-radius:10px; } 115 `; 116 document.head.appendChild(el); 117 } 118 119 window.BzlPluginHost?.register(PLUGIN_ID, (ctx) => { 120 ensureStyles(); 121 122 let wsAttachedTo = null; 123 let snapshot = { me: "", myShelfId: "", shelves: [] }; 124 let activeShelfId = ""; 125 let readerBookId = ""; 126 let readerPage = 1; 127 let searchQuery = ""; 128 let searchTag = ""; 129 let statusLine = ""; 130 let uploadInProgress = false; 131 132 const textByBookId = new Map(); 133 const mounts = { library: null, shelf: null, reader: null }; 134 135 function setStatus(msg) { 136 statusLine = String(msg || ""); 137 renderAll(); 138 } 139 140 function ownedShelves() { 141 return (snapshot.shelves || []).filter((s) => Boolean(s?.isOwner)); 142 } 143 144 function activeShelf() { 145 return (snapshot.shelves || []).find((s) => String(s?.id || "") === String(activeShelfId || "")) || null; 146 } 147 148 function defaultShelfId() { 149 if (snapshot.myShelfId && ownedShelves().some((s) => s.id === snapshot.myShelfId)) return snapshot.myShelfId; 150 return ownedShelves()[0]?.id || ""; 151 } 152 153 function allShelfEntries() { 154 const out = []; 155 for (const shelf of snapshot.shelves || []) { 156 for (const entry of shelf.items || []) out.push({ shelf, entry, book: entry?.book || null }); 157 } 158 return out; 159 } 160 161 function getBookById(bookId) { 162 const id = String(bookId || ""); 163 if (!id) return null; 164 for (const x of allShelfEntries()) { 165 if (String(x.book?.id || "") === id) return x.book; 166 } 167 return null; 168 } 169 170 function activeReaderBook() { 171 return getBookById(readerBookId); 172 } 173 174 function requestList() { 175 ctx.send("list", {}); 176 } 177 178 function requestText(bookId) { 179 ctx.send("textGet", { id: bookId }); 180 } 181 182 function attachWsListener() { 183 const ws = window.__bzlWs; 184 if (!ws || ws.readyState !== WebSocket.OPEN) return; 185 if (wsAttachedTo === ws) return; 186 try { 187 if (wsAttachedTo) wsAttachedTo.removeEventListener("message", onWsMsg); 188 } catch {} 189 wsAttachedTo = ws; 190 ws.addEventListener("message", onWsMsg); 191 } 192 193 function openInReader(bookId) { 194 const id = String(bookId || ""); 195 if (!id) return; 196 readerBookId = id; 197 readerPage = 1; 198 const book = getBookById(id); 199 if (book && String(book.kind || "") === "text" && !textByBookId.has(id)) requestText(id); 200 renderReader(); 201 } 202 async function uploadPdfToShelf(file, meta, shelfId) { 203 if (uploadInProgress) return; 204 uploadInProgress = true; 205 window.__bzlLibraryUploadId = ""; 206 ctx.send("uploadStart", { 207 filename: file.name, 208 mime: String(file.type || "").trim().toLowerCase(), 209 size: file.size, 210 title: meta.title, 211 author: meta.author, 212 isOriginal: meta.isOriginal, 213 tags: meta.tags, 214 shelfId, 215 }); 216 217 const t0 = Date.now(); 218 while (!window.__bzlLibraryUploadId && Date.now() - t0 < 3000) { 219 // eslint-disable-next-line no-await-in-loop 220 await sleep(35); 221 } 222 const uploadId = String(window.__bzlLibraryUploadId || ""); 223 if (!uploadId) { 224 uploadInProgress = false; 225 setStatus("Upload failed to start."); 226 return; 227 } 228 229 let sent = 0; 230 for (let off = 0; off < file.size; off += PDF_CHUNK_BYTES) { 231 const slice = file.slice(off, Math.min(file.size, off + PDF_CHUNK_BYTES)); 232 // eslint-disable-next-line no-await-in-loop 233 const buf = await slice.arrayBuffer(); 234 const bytes = new Uint8Array(buf); 235 const b64 = bytesToBase64(bytes); 236 ctx.send("uploadChunk", { uploadId, data: b64 }); 237 sent += bytes.length; 238 setStatus(`Uploading PDF... ${formatBytes(sent)} / ${formatBytes(file.size)}`); 239 // eslint-disable-next-line no-await-in-loop 240 await sleep(0); 241 } 242 ctx.send("uploadFinish", { uploadId }); 243 } 244 245 async function createBookFromFile(file, payload) { 246 const name = String(file?.name || "").toLowerCase(); 247 const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""; 248 if (ext === ".pdf" || String(file.type || "").toLowerCase() === "application/pdf") { 249 await uploadPdfToShelf(file, payload, payload.shelfId); 250 return; 251 } 252 if (![".txt", ".md", ".rtf"].includes(ext)) { 253 setStatus("Supported upload types: PDF, TXT, MD, RTF."); 254 return; 255 } 256 if (file.size > TEXT_FILE_MAX_BYTES) { 257 setStatus(`Text/RTF too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`); 258 return; 259 } 260 const text = await file.text(); 261 ctx.send("textCreate", { 262 shelfId: payload.shelfId, 263 title: payload.title, 264 author: payload.author, 265 isOriginal: payload.isOriginal, 266 tags: payload.tags, 267 format: ext === ".rtf" ? "rtf" : "text", 268 text, 269 }); 270 setStatus("Book created."); 271 } 272 273 function filteredBrowseRows() { 274 const q = String(searchQuery || "").trim().toLowerCase(); 275 const tag = String(searchTag || "").trim().toLowerCase(); 276 return allShelfEntries().filter((x) => { 277 const book = x.book || {}; 278 if (!book.id) return false; 279 if (q) { 280 const hay = `${book.title || ""} ${book.author || ""} ${(book.tags || []).join(" ")} ${x.shelf?.name || ""}`.toLowerCase(); 281 if (!hay.includes(q)) return false; 282 } 283 if (tag) { 284 const tags = Array.isArray(book.tags) ? book.tags.map((t) => String(t || "").toLowerCase()) : []; 285 if (!tags.includes(tag)) return false; 286 } 287 return true; 288 }); 289 } 290 291 function uniqueTags() { 292 const out = new Set(); 293 for (const x of allShelfEntries()) { 294 for (const t of x?.book?.tags || []) out.add(String(t || "").toLowerCase()); 295 } 296 return [...out].filter(Boolean).sort(); 297 } 298 299 function renderLibrary() { 300 const mount = mounts.library; 301 if (!(mount instanceof HTMLElement)) return; 302 const rows = filteredBrowseRows(); 303 const tags = uniqueTags(); 304 const targetShelfId = defaultShelfId(); 305 const shelfOpt = ownedShelves().map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === targetShelfId ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join(""); 306 307 mount.innerHTML = ` 308 <div class="lib3"> 309 <div class="lib3Row"> 310 <input id="lib3Search" type="text" placeholder="Search title, author, tags" value="${escapeHtml(searchQuery)}" style="flex:1 1 220px;" /> 311 <select id="lib3TagFilter"><option value="">All tags</option>${tags.map((t) => `<option value="${escapeHtml(t)}" ${t === searchTag ? "selected" : ""}>${escapeHtml(t)}</option>`).join("")}</select> 312 <button type="button" class="lib3Btn" id="lib3Refresh">Refresh</button> 313 </div> 314 <div class="lib3Row"><span class="lib3Hint">Browse shelves and discover books. Choose one of your shelves for pin/check out:</span> 315 <select id="lib3LibraryTargetShelf">${shelfOpt || "<option value=''>No owned shelf</option>"}</select> 316 </div> 317 <div class="lib3Scroll"> 318 ${rows.map((x) => { 319 const b = x.book || {}; 320 const s = x.shelf || {}; 321 const tagsHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join(""); 322 return `<div class="lib3Card"> 323 <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.entry?.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div> 324 <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> 325 <div>${tagsHtml}</div> 326 <div class="lib3Row" style="margin-top:6px;"> 327 <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button> 328 <button type="button" class="lib3Btn" data-pin-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Pin</button> 329 <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Check out</button> 330 <button type="button" class="lib3Btn" data-wander-shelf="${escapeHtml(s.id || "")}">Wander shelf</button> 331 </div> 332 </div>`; 333 }).join("") || "<div class='lib3Hint'>No matches.</div>"} 334 </div> 335 <div class="lib3Hint">Wander shelves and open a book in Reader.</div> 336 </div> 337 `; 338 339 mount.querySelector("#lib3Search")?.addEventListener("input", (e) => { 340 searchQuery = String(e?.target?.value || ""); 341 renderLibrary(); 342 }); 343 mount.querySelector("#lib3TagFilter")?.addEventListener("change", (e) => { 344 searchTag = String(e?.target?.value || "").toLowerCase(); 345 renderLibrary(); 346 }); 347 mount.querySelector("#lib3Refresh")?.addEventListener("click", () => requestList()); 348 mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || ""))); 349 mount.querySelectorAll("[data-wander-shelf]").forEach((btn) => { 350 btn.addEventListener("click", () => { 351 activeShelfId = String(btn.getAttribute("data-wander-shelf") || ""); 352 renderShelf(); 353 }); 354 }); 355 mount.querySelectorAll("[data-pin-book]").forEach((btn) => { 356 btn.addEventListener("click", () => { 357 const shelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || ""); 358 const bookId = String(btn.getAttribute("data-pin-book") || ""); 359 const sourceShelfId = String(btn.getAttribute("data-source-shelf") || ""); 360 if (!shelfId || !bookId) return setStatus("Pick one of your shelves first."); 361 ctx.send("pinBook", { shelfId, bookId, sourceShelfId }); 362 setStatus("Pinned."); 363 }); 364 }); 365 mount.querySelectorAll("[data-checkout-book]").forEach((btn) => { 366 btn.addEventListener("click", () => { 367 const targetShelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || ""); 368 const sourceBookId = String(btn.getAttribute("data-checkout-book") || ""); 369 const sourceShelfId = String(btn.getAttribute("data-source-shelf") || ""); 370 if (!targetShelfId || !sourceBookId) return setStatus("Pick one of your shelves first."); 371 ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId }); 372 setStatus("Checked out."); 373 }); 374 }); 375 } 376 function renderShelf() { 377 const mount = mounts.shelf; 378 if (!(mount instanceof HTMLElement)) return; 379 const mine = ownedShelves(); 380 if (!activeShelfId || !(snapshot.shelves || []).some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves?.[0]?.id || ""; 381 const shelf = activeShelf(); 382 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(""); 383 const mineDefault = defaultShelfId(); 384 const mineList = mine.map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === mineDefault ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join(""); 385 386 mount.innerHTML = ` 387 <div class="lib3"> 388 <div class="lib3Row"> 389 <select id="lib3ShelfPick" style="min-width:220px;">${shelfList || "<option>No shelves</option>"}</select> 390 ${shelf && !shelf.isOwner ? `<button type="button" class="lib3Btn" id="lib3SubBtn">${shelf.isSubscribed ? "Unsubscribe" : "Subscribe"}</button>` : ""} 391 <button type="button" class="lib3Btn" id="lib3RefreshShelf">Refresh</button> 392 </div> 393 <div class="lib3Card"> 394 <div class="lib3Row"> 395 <input id="lib3NewShelfName" type="text" placeholder="New shelf name" style="flex:1 1 180px;" /> 396 <input id="lib3NewShelfDesc" type="text" placeholder="Shelf description" style="flex:2 1 220px;" /> 397 <button type="button" class="lib3Btn" id="lib3CreateShelf">Create shelf</button> 398 </div> 399 </div> 400 <div class="lib3Card"> 401 <div class="lib3Row"><b>Add Book</b> <span class="lib3Hint">(PDF, TXT, MD, RTF)</span></div> 402 <div class="lib3Row"> 403 <select id="lib3CreateTargetShelf">${mineList || "<option value=''>No owned shelves</option>"}</select> 404 <input id="lib3BookTitle" type="text" placeholder="Book name" style="flex:1 1 180px;" /> 405 <input id="lib3BookAuthor" type="text" placeholder="Author" style="flex:1 1 160px;" value="${escapeHtml(snapshot.me || "")}" /> 406 <label class="lib3Hint"><input id="lib3BookOriginal" type="checkbox" checked/> original</label> 407 </div> 408 <div class="lib3Row"> 409 <input id="lib3BookTags" type="text" placeholder="tags: fic, fantasy, journal" style="flex:1 1 260px;" /> 410 <select id="lib3TagQuick"><option value="">Tag quick-add</option>${TAG_SUGGESTIONS.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join("")}</select> 411 </div> 412 <div class="lib3Row"> 413 <input id="lib3BookFile" type="file" accept=".pdf,.txt,.md,.rtf,application/pdf,text/plain,application/rtf,text/rtf" /> 414 <button type="button" class="lib3Btn primary" id="lib3UploadBook">Upload File</button> 415 </div> 416 <div class="lib3Row"> 417 <select id="lib3TextFormat"><option value="text">Text</option><option value="rtf">Rich text (RTF)</option></select> 418 <textarea id="lib3BookText" rows="5" placeholder="Or create a book directly" style="width:100%; box-sizing:border-box;"></textarea> 419 <button type="button" class="lib3Btn" id="lib3CreateText">Create Text Book</button> 420 </div> 421 </div> 422 <div class="lib3Scroll"> 423 ${(shelf?.items || []).map((x) => { 424 const b = x.book || {}; 425 const tHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join(""); 426 return `<div class="lib3Card"> 427 <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div> 428 <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | ${String(b.kind || "").toUpperCase()} | ${formatBytes(b.bytes)} | original: ${b.isOriginal !== false ? "yes" : "no"}</div> 429 <div>${tHtml}</div> 430 <div class="lib3Row" style="margin-top:6px;"> 431 <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button> 432 ${x.canReturn ? `<button type="button" class="lib3Btn" data-return-item="${escapeHtml(x.id)}">Return</button>` : ""} 433 ${x.canRemoveItem ? `<button type="button" class="lib3Btn" data-remove-item="${escapeHtml(x.id)}">Remove</button>` : ""} 434 ${b.canDeleteBook ? `<button type="button" class="lib3Btn" data-delete-book="${escapeHtml(b.id)}">Delete book</button>` : ""} 435 <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(shelf?.id || "")}">Check out</button> 436 </div> 437 </div>`; 438 }).join("") || "<div class='lib3Hint'>No books on this shelf.</div>"} 439 </div> 440 <div class="lib3Hint">${escapeHtml(statusLine)}</div> 441 </div> 442 `; 443 444 mount.querySelector("#lib3ShelfPick")?.addEventListener("change", (e) => { 445 activeShelfId = String(e?.target?.value || ""); 446 renderShelf(); 447 }); 448 mount.querySelector("#lib3RefreshShelf")?.addEventListener("click", () => requestList()); 449 mount.querySelector("#lib3SubBtn")?.addEventListener("click", () => { 450 if (!shelf || shelf.isOwner) return; 451 ctx.send("shelfSubscribe", { shelfId: shelf.id, subscribe: !shelf.isSubscribed }); 452 }); 453 mount.querySelector("#lib3CreateShelf")?.addEventListener("click", () => { 454 const name = String(mount.querySelector("#lib3NewShelfName")?.value || "").trim(); 455 const description = String(mount.querySelector("#lib3NewShelfDesc")?.value || "").trim(); 456 if (!name) return setStatus("Shelf name required."); 457 ctx.send("shelfCreate", { name, description, isPublic: true }); 458 setStatus("Shelf created."); 459 }); 460 mount.querySelector("#lib3TagQuick")?.addEventListener("change", (e) => { 461 const v = String(e?.target?.value || "").trim(); 462 if (!v) return; 463 const inp = mount.querySelector("#lib3BookTags"); 464 const next = parseTags(`${String(inp?.value || "")}, ${v}`); 465 if (inp) inp.value = next.join(", "); 466 e.target.value = ""; 467 }); 468 mount.querySelector("#lib3UploadBook")?.addEventListener("click", async () => { 469 const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || ""); 470 const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim(); 471 const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim(); 472 const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked); 473 const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || "")); 474 const file = mount.querySelector("#lib3BookFile")?.files?.[0]; 475 if (!shelfId) return setStatus("Pick one of your shelves."); 476 if (!file) return setStatus("Choose a file first."); 477 try { 478 await createBookFromFile(file, { shelfId, title: title || file.name, author: author || snapshot.me || "Unknown", isOriginal, tags }); 479 } catch { 480 setStatus("Failed to create book from file."); 481 } 482 }); 483 mount.querySelector("#lib3CreateText")?.addEventListener("click", () => { 484 const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || ""); 485 const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim(); 486 const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim(); 487 const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked); 488 const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || "")); 489 const format = String(mount.querySelector("#lib3TextFormat")?.value || "text").toLowerCase() === "rtf" ? "rtf" : "text"; 490 const text = String(mount.querySelector("#lib3BookText")?.value || ""); 491 if (!shelfId) return setStatus("Pick one of your shelves."); 492 if (!title) return setStatus("Book name required."); 493 ctx.send("textCreate", { shelfId, title, author: author || snapshot.me || "Unknown", isOriginal, tags, format, text }); 494 setStatus("Book created."); 495 }); 496 mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || ""))); 497 mount.querySelectorAll("[data-checkout-book]").forEach((btn) => { 498 btn.addEventListener("click", () => { 499 const targetShelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || ""); 500 const sourceBookId = String(btn.getAttribute("data-checkout-book") || ""); 501 const sourceShelfId = String(btn.getAttribute("data-source-shelf") || ""); 502 if (!targetShelfId || !sourceBookId) return; 503 ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId }); 504 setStatus("Checked out."); 505 }); 506 }); 507 mount.querySelectorAll("[data-remove-item]").forEach((btn) => { 508 btn.addEventListener("click", () => { 509 if (!shelf) return; 510 const shelfItemId = String(btn.getAttribute("data-remove-item") || ""); 511 if (!shelfItemId) return; 512 ctx.send("shelfItemRemove", { shelfId: shelf.id, shelfItemId }); 513 }); 514 }); 515 mount.querySelectorAll("[data-return-item]").forEach((btn) => { 516 btn.addEventListener("click", () => { 517 if (!shelf) return; 518 const shelfItemId = String(btn.getAttribute("data-return-item") || ""); 519 if (!shelfItemId) return; 520 ctx.send("returnBook", { shelfId: shelf.id, shelfItemId }); 521 }); 522 }); 523 mount.querySelectorAll("[data-delete-book]").forEach((btn) => { 524 btn.addEventListener("click", () => { 525 const bookId = String(btn.getAttribute("data-delete-book") || ""); 526 if (!bookId) return; 527 ctx.send("delete", { id: bookId }); 528 }); 529 }); 530 } 531 532 function renderReader() { 533 const mount = mounts.reader; 534 if (!(mount instanceof HTMLElement)) return; 535 const book = activeReaderBook(); 536 if (!book) { 537 mount.innerHTML = `<div class="lib3"><div class="lib3Hint">Select a book from Shelf or Library to start reading.</div></div>`; 538 return; 539 } 540 541 const isText = String(book.kind || "") === "text"; 542 const fullText = textByBookId.get(String(book.id || "")) || ""; 543 const pages = isText ? paginateText(fullText) : []; 544 const totalPages = isText ? Math.max(1, pages.length) : 1; 545 readerPage = Math.max(1, Math.min(isText ? totalPages : 9999, Number(readerPage || 1))); 546 547 const tagsHtml = (book.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join(""); 548 mount.innerHTML = ` 549 <div class="lib3"> 550 <div class="lib3Row" style="justify-content:space-between;"> 551 <div> 552 <div><b>${escapeHtml(book.title || "Untitled")}</b> ${book.checkedOutBy ? "<span title='Checked out'>↩</span>" : ""}</div> 553 <div class="lib3Meta">by ${escapeHtml(book.author || book.createdBy || "Unknown")} | ${String(book.kind || "").toUpperCase()} | ${formatBytes(book.bytes)}</div> 554 <div>${tagsHtml}</div> 555 </div> 556 ${ 557 isText 558 ? `<div class="lib3Row"> 559 <button type="button" class="lib3Btn" id="lib3ReadPrev"><</button> 560 <input id="lib3ReadPage" type="number" min="1" value="${readerPage}" style="width:84px;" /> 561 <button type="button" class="lib3Btn" id="lib3ReadGo">Go</button> 562 <button type="button" class="lib3Btn" id="lib3ReadNext">></button> 563 </div>` 564 : `<div class="lib3Hint">Use the PDF toolbar to navigate pages.</div>` 565 } 566 </div> 567 <div class="lib3ReaderViewport"> 568 ${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>`} 569 </div> 570 <div class="lib3Hint">Page ${readerPage}${isText ? ` / ${totalPages}` : ""}</div> 571 </div> 572 `; 573 574 if (isText && !textByBookId.has(String(book.id || ""))) requestText(book.id); 575 if (isText) { 576 mount.querySelector("#lib3ReadPrev")?.addEventListener("click", () => { 577 readerPage -= 1; 578 renderReader(); 579 }); 580 mount.querySelector("#lib3ReadNext")?.addEventListener("click", () => { 581 readerPage += 1; 582 renderReader(); 583 }); 584 mount.querySelector("#lib3ReadGo")?.addEventListener("click", () => { 585 readerPage = Number(mount.querySelector("#lib3ReadPage")?.value || 1); 586 renderReader(); 587 }); 588 } 589 } 590 function renderAll() { 591 renderLibrary(); 592 renderShelf(); 593 renderReader(); 594 } 595 596 function onWsMsg(ev) { 597 try { 598 const msg = JSON.parse(String(ev?.data || "")); 599 const type = String(msg?.type || ""); 600 if (!type.startsWith("plugin:library:")) return; 601 602 if (type === "plugin:library:list") { 603 snapshot = { me: String(msg.me || ""), myShelfId: String(msg.myShelfId || ""), shelves: Array.isArray(msg.shelves) ? msg.shelves : [] }; 604 if (!activeShelfId || !snapshot.shelves.some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves[0]?.id || ""; 605 renderAll(); 606 return; 607 } 608 if (type === "plugin:library:changed") { 609 requestList(); 610 return; 611 } 612 if (type === "plugin:library:text") { 613 const it = msg.item || null; 614 if (!it || !it.id) return; 615 textByBookId.set(String(it.id), String(it.text || "")); 616 renderReader(); 617 return; 618 } 619 if (type === "plugin:library:uploadStarted") { 620 window.__bzlLibraryUploadId = String(msg.uploadId || ""); 621 return; 622 } 623 if (type === "plugin:library:uploadProgress") { 624 setStatus(`Uploading PDF... ${formatBytes(msg.received)} / ${formatBytes(msg.expected)}`); 625 return; 626 } 627 if (type === "plugin:library:uploadFinished") { 628 uploadInProgress = false; 629 window.__bzlLibraryUploadId = ""; 630 setStatus("Upload complete."); 631 requestList(); 632 return; 633 } 634 if (type === "plugin:library:error") { 635 uploadInProgress = false; 636 setStatus(String(msg.message || "Error.")); 637 ctx.toast("Library", String(msg.message || "Error.")); 638 return; 639 } 640 if ( 641 type === "plugin:library:textCreated" || 642 type === "plugin:library:textUpdated" || 643 type === "plugin:library:shelfCreated" || 644 type === "plugin:library:shelfUpdated" || 645 type === "plugin:library:shelfSubscribed" || 646 type === "plugin:library:pinned" || 647 type === "plugin:library:checkedOut" || 648 type === "plugin:library:shelfItemRemoved" || 649 type === "plugin:library:returned" || 650 type === "plugin:library:deleted" 651 ) { 652 requestList(); 653 } 654 } catch { 655 // ignore 656 } 657 } 658 659 function mountPanel(kind, mount) { 660 if (!(mount instanceof HTMLElement)) return; 661 mount.style.height = "100%"; 662 mount.style.minHeight = "0"; 663 mount.style.display = "flex"; 664 mounts[kind] = mount; 665 renderAll(); 666 } 667 668 const panelOpts = { defaultRack: "main", role: "primary" }; 669 if (ctx?.ui?.registerPanel) { 670 ctx.ui.registerPanel({ 671 id: "library-reader", 672 title: "Reader", 673 icon: "Read", 674 ...panelOpts, 675 render(mount) { 676 mountPanel("reader", mount); 677 return () => { 678 if (mounts.reader === mount) mounts.reader = null; 679 }; 680 }, 681 }); 682 ctx.ui.registerPanel({ 683 id: "library-shelf", 684 title: "Shelf", 685 icon: "Shelf", 686 ...panelOpts, 687 render(mount) { 688 mountPanel("shelf", mount); 689 return () => { 690 if (mounts.shelf === mount) mounts.shelf = null; 691 }; 692 }, 693 }); 694 ctx.ui.registerPanel({ 695 id: "library-browser", 696 title: "Library", 697 icon: "Books", 698 ...panelOpts, 699 render(mount) { 700 mountPanel("library", mount); 701 return () => { 702 if (mounts.library === mount) mounts.library = null; 703 }; 704 }, 705 }); 706 } 707 708 setInterval(attachWsListener, 1000); 709 attachWsListener(); 710 requestList(); 711 ctx.devLog("info", "library:init", { ok: true, panels: ["library-reader", "library-shelf", "library-browser"] }); 712 }); 713 })();