1import crypto from "crypto";
2import Book from "./model.js";
3import { collectSubtreeNodeIds, nodeMatchesStatus } from "../../seed/tree/notes.js";
4
5// Models wired from init() via setModels()
6let Node = null;
7let Note = null;
8export function setModels(models) { Node = models.Node; Note = models.Note; }
9
10function hashBookSettings(settings) {
11 return crypto
12 .createHash("sha256")
13 .update(JSON.stringify(settings))
14 .digest("hex");
15}
16
17function normalizeBookSettings(raw = {}) {
18 return {
19 latestVersionOnly: !!raw.latestVersionOnly,
20 lastNoteOnly: !!raw.lastNoteOnly,
21 leafNotesOnly: !!raw.leafNotesOnly,
22 filesOnly: !!raw.filesOnly,
23 textOnly: !!raw.textOnly,
24
25 active: !!raw.active,
26 completed: !!raw.completed,
27 true: !!raw["true"],
28
29 toc: !!raw.toc,
30 tocDepth: parseInt(raw.tocDepth) || 0,
31 };
32}
33
34function getNoteVersion(n) {
35 const meta = n.metadata instanceof Map ? n.metadata.get("version") : n.metadata?.version;
36 return Number(meta) || 0;
37}
38
39function applyNoteFilters(notes, node, flags) {
40 let result = notes;
41 if (flags.latestVersionOnly && result.length > 0) {
42 const maxVersion = Math.max(
43 ...result.map((n) => getNoteVersion(n)).filter((v) => !Number.isNaN(v)),
44 );
45 result = result.filter((n) => getNoteVersion(n) === maxVersion);
46 }
47 if (flags.filesOnly) result = result.filter((n) => n.contentType === "file");
48 if (flags.textOnly) result = result.filter((n) => n.contentType === "text");
49 if (flags.lastNoteOnly) result = result.length ? [result[result.length - 1]] : [];
50 return result;
51}
52
53function buildBookTree(node, nodeMap, notesByNode, flags = {}) {
54 const nodeId = node._id.toString();
55 const filteredChildren = [];
56
57 for (const childId of node.children || []) {
58 const child = nodeMap.get(childId.toString());
59 if (!child) continue;
60 const childTree = buildBookTree(child, nodeMap, notesByNode, flags);
61 if (childTree) filteredChildren.push(childTree);
62 }
63
64 const nodePassesStatus = nodeMatchesStatus(node, flags.statusFilters);
65 if (!nodePassesStatus && filteredChildren.length === 0) return null;
66
67 const rawNotes = notesByNode.get(nodeId) || [];
68 const filteredNotes = applyNoteFilters(rawNotes, node, flags).map((n) => ({
69 noteId: n._id.toString(),
70 version: getNoteVersion(n),
71 userId: n.userId?.toString(),
72 content: n.content,
73 type: n.contentType,
74 }));
75
76 const isLeaf = filteredChildren.length === 0;
77 const notes = flags.leafNotesOnly && !isLeaf ? [] : filteredNotes;
78
79 return { nodeId, nodeName: node.name, notes, children: filteredChildren };
80}
81
82export async function getBook({ nodeId, options = {} }) {
83 if (!nodeId) throw new Error("Missing nodeId");
84
85 const flags = {
86 latestVersionOnly: false,
87 lastNoteOnly: false,
88 leafNotesOnly: false,
89 filesOnly: false,
90 textOnly: false,
91 statusFilters: null,
92 ...options,
93 };
94
95 if (flags.filesOnly && flags.textOnly) {
96 flags.filesOnly = false;
97 flags.textOnly = false;
98 }
99
100 const subtreeIds = await collectSubtreeNodeIds(nodeId);
101 const [nodes, notes] = await Promise.all([
102 Node.find({ _id: { $in: subtreeIds } }).lean(),
103 Note.find({ nodeId: { $in: subtreeIds } }).lean(),
104 ]);
105
106 const nodeMap = new Map(nodes.map((n) => [n._id.toString(), n]));
107 const notesByNode = new Map();
108 for (const n of notes) {
109 const key = n.nodeId.toString();
110 if (!notesByNode.has(key)) notesByNode.set(key, []);
111 notesByNode.get(key).push(n);
112 }
113
114 const book = buildBookTree(nodeMap.get(nodeId.toString()), nodeMap, notesByNode, flags);
115 return { message: "Book generated successfully", book };
116}
117
118export async function generateBook({ nodeId, settings, userId }) {
119 if (!nodeId) throw new Error("Missing nodeId");
120
121 const normalizedSettings = normalizeBookSettings(settings);
122 const settingsHash = hashBookSettings(normalizedSettings);
123
124 let book = await Book.findOne({ nodeId, settingsHash });
125 if (book) return { shareId: book.shareId };
126
127 const shareId = crypto.randomBytes(8).toString("hex");
128 book = await Book.create({
129 nodeId,
130 settings: normalizedSettings,
131 settingsHash,
132 shareId,
133 createdBy: userId,
134 });
135
136 return { reused: false, shareId };
137}
138
1import { setModels } from "./core.js";
2import { getExtension } from "../loader.js";
3
4export async function init(core) {
5 setModels(core.models);
6 const { default: router, resolveHtmlAuth } = await import("./routes.js");
7 resolveHtmlAuth();
8
9 try {
10 const treeos = getExtension("treeos-base");
11 treeos?.exports?.registerSlot?.("tree-quick-links", "book", ({ rootId, queryString }) =>
12 `<a href="/api/v1/root/${rootId}/book${queryString}" class="back-link">Book</a>`,
13 { priority: 25 }
14 );
15 } catch {}
16
17 return { router };
18}
19
1export default {
2 name: "book",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Trees store knowledge as scattered notes across dozens or hundreds of nodes. Reading " +
7 "a tree means navigating branch by branch, node by node. That works for the AI and " +
8 "for the person who built it. It does not work when you need to hand someone a single " +
9 "document that contains everything from a branch. Book compiles a subtree's notes into " +
10 "one coherent, readable output. " +
11 "\n\n" +
12 "Starting from any node, Book walks the entire subtree depth-first, collecting notes " +
13 "from every descendant. The result preserves the tree's hierarchy: each node becomes " +
14 "a section, children become subsections, notes become content blocks within their " +
15 "section. The structure of the tree becomes the structure of the document. " +
16 "\n\n" +
17 "Filters control what appears in the output. latestVersionOnly shows only the most " +
18 "recent version of each note. lastNoteOnly shows only the last note per node. " +
19 "leafNotesOnly includes notes only from leaf nodes, skipping intermediate branches. " +
20 "filesOnly and textOnly filter by content type. Status filters (active, completed) " +
21 "control which nodes are included based on their current status. Nodes that fail the " +
22 "status filter are excluded unless they have children that pass. " +
23 "\n\n" +
24 "Books can be shared. The generate endpoint creates a persistent Book record with a " +
25 "unique share ID and a hash of the filter settings. If someone generates the same " +
26 "book with the same settings, the existing share ID is reused rather than creating a " +
27 "duplicate. The share URL is publicly accessible without authentication. Anyone with " +
28 "the link sees the compiled output. " +
29 "\n\n" +
30 "If html-rendering is installed, both the private book view and the shared link render " +
31 "as styled HTML pages with a table of contents, filter controls, and section navigation. " +
32 "Without html-rendering, the API returns raw JSON with the full nested structure.",
33
34 needs: {
35 models: ["Node", "Note"],
36 },
37
38 optional: {
39 extensions: ["html-rendering", "treeos-base"],
40 },
41
42 provides: {
43 models: {
44 Book: "./model.js",
45 },
46 routes: "./routes.js",
47 tools: false,
48 jobs: false,
49 orchestrator: false,
50 energyActions: {},
51 sessionTypes: {},
52 cli: [
53 { command: "book", scope: ["tree"], description: "View compiled notes for current tree", method: "GET", endpoint: "/root/:rootId/book" },
54 ],
55 },
56};
57
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const BookSettingsSchema = new mongoose.Schema(
5 {
6 // existing flags
7 latestVersionOnly: { type: Boolean, default: false },
8 lastNoteOnly: { type: Boolean, default: false },
9 leafNotesOnly: { type: Boolean, default: false },
10 filesOnly: { type: Boolean, default: false },
11 textOnly: { type: Boolean, default: false },
12
13 // status / truth filters
14 active: { type: Boolean, default: false },
15 completed: { type: Boolean, default: false },
16 true: { type: Boolean, default: false },
17
18 // table of contents
19 toc: { type: Boolean, default: false },
20 tocDepth: { type: Number, default: 0 },
21 },
22 { _id: false }
23);
24
25const BookSchema = new mongoose.Schema({
26 _id: { type: String, default: uuidv4 },
27
28 nodeId: {
29 type: String,
30 ref: "Node",
31 required: true,
32 index: true,
33 },
34
35 settings: {
36 type: BookSettingsSchema,
37 required: true,
38 },
39
40 settingsHash: {
41 type: String,
42 required: true,
43 index: true,
44 },
45
46 shareId: {
47 type: String,
48 unique: true,
49 index: true,
50 },
51
52 createdBy: {
53 type: String,
54 ref: "User",
55 default: null,
56 },
57
58 createdAt: { type: Date, default: Date.now },
59});
60
61const Book = mongoose.model("Book", BookSchema);
62
63export default Book;
64
1/* --------------------------------------------------------- */
2/* Book pages (renderBookPage, renderSharedBookPage) */
3/* --------------------------------------------------------- */
4
5import mime from "mime-types";
6import { getLandUrl } from "../../../canopy/identity.js";
7import { page } from "../../html-rendering/html/layout.js";
8import { escapeHtml, renderMedia } from "../../html-rendering/html/utils.js";
9
10/* ── Shared helpers ─────────────────────────────── */
11
12function renderBookNode(node, depth, token, version) {
13 const level = Math.min(depth, 5);
14 const H = `h${level}`;
15 const qs = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
16
17 let html = `
18 <section class="book-section depth-${depth}" id="toc-${node.nodeId}">
19 <${H}>${escapeHtml(node.nodeName ?? node.nodeId)}</${H}>
20 `;
21
22 for (const note of node.notes) {
23 const noteUrl = `/api/v1/node/${node.nodeId}/${note.version}/notes/${note.noteId}${qs}`;
24
25 if (note.type === "text") {
26 html += `
27 <div class="note-content">
28 <a href="${noteUrl}" class="note-link">${escapeHtml(note.content)}</a>
29 </div>
30 `;
31 }
32
33 if (note.type === "file") {
34 const fileUrl = `/api/v1/uploads/${note.content}${
35 token ? `?token=${encodeURIComponent(token)}` : ""
36 }`;
37 const mimeType = mime.lookup(note.content) || "";
38
39 html += `
40 <div class="file-container">
41 <a href="${noteUrl}" class="note-link file-link">${escapeHtml(note.content)}</a>
42 ${renderMedia(fileUrl, mimeType)}
43 </div>
44 `;
45 }
46 }
47
48 for (const child of node.children) {
49 html += renderBookNode(child, depth + 1, token, version);
50 }
51
52 html += `</section>`;
53 return html;
54}
55
56function renderToc(node, maxDepth, depth = 1, isRoot = false) {
57 const children = node.children || [];
58 const hasChildren = children.length > 0 && (maxDepth === 0 || isRoot || depth < maxDepth);
59
60 const childList = hasChildren
61 ? `<ul class="toc-list">${children.map((c) => renderToc(c, maxDepth, isRoot ? 1 : depth + 1, false)).join("")}</ul>`
62 : "";
63
64 if (isRoot) return childList;
65
66 const name = escapeHtml(node.nodeName ?? node.nodeId);
67 const link = `<a href="javascript:void(0)" onclick="tocScroll('toc-${node.nodeId}')" class="toc-link">${name}</a>`;
68
69 return `<li>${link}${childList}</li>`;
70}
71
72function renderTocBlock(book, maxDepth) {
73 const inner = renderToc(book, maxDepth, 1, true);
74 return `<nav class="book-toc"><div class="toc-title">Table of Contents</div>${inner}</nav>`;
75}
76
77function getBookDepth(node, depth = 0) {
78 const children = node.children || [];
79 if (children.length === 0) return depth;
80 return Math.max(...children.map((c) => getBookDepth(c, depth + 1)));
81}
82
83const parseBool = (v) => v === "true";
84
85function normalizeStatusFilters(query) {
86 const parse = (v) =>
87 v === "true" ? true : v === "false" ? false : undefined;
88
89 const filters = {
90 active: parse(query.active),
91 trimmed: parse(query.trimmed),
92 completed: parse(query.completed),
93 };
94
95 const hasAny = Object.values(filters).some((v) => v !== undefined);
96 return hasAny ? filters : null;
97}
98
99/* ── Shared book CSS (used by both pages) ───────── */
100
101const bookContentStyles = `
102 /* Layered Glass Sections - Each depth gets more opaque glass */
103 .book-section {
104 margin-bottom: 40px;
105 position: relative;
106 }
107
108 .book-section.depth-1 {
109 margin-bottom: 48px;
110 padding: 24px;
111 background: rgba(255, 255, 255, 0.08);
112 border-radius: 12px;
113 border: 1px solid rgba(255, 255, 255, 0.15);
114 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
115 }
116
117 .book-section.depth-2 {
118 margin-bottom: 32px;
119 margin-left: 8px;
120 padding: 20px;
121 background: rgba(255, 255, 255, 0.06);
122 border-radius: 10px;
123 border: 1px solid rgba(255, 255, 255, 0.12);
124 }
125
126 .book-section.depth-3 {
127 margin-bottom: 24px;
128 margin-left: 8px;
129 padding: 16px;
130 background: rgba(255, 255, 255, 0.04);
131 border-radius: 8px;
132 border: 1px solid rgba(255, 255, 255, 0.1);
133 }
134
135 .book-section.depth-4 {
136 margin-bottom: 20px;
137 margin-left: 8px;
138 padding: 12px;
139 background: rgba(255, 255, 255, 0.03);
140 border-radius: 6px;
141 border: 1px solid rgba(255, 255, 255, 0.08);
142 }
143
144 .book-section.depth-5 {
145 margin-bottom: 16px;
146 margin-left: 8px;
147 padding: 10px;
148 background: rgba(255, 255, 255, 0.02);
149 border-radius: 6px;
150 border: 1px solid rgba(255, 255, 255, 0.06);
151 }
152
153 /* Heading Hierarchy */
154 h1, h2, h3, h4, h5 {
155 font-weight: 600;
156 line-height: 1.3;
157 margin: 0 0 16px 0;
158 color: white;
159 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
160 letter-spacing: -0.5px;
161 }
162
163 h1 {
164 font-size: 36px;
165 margin-top: 48px;
166 margin-bottom: 24px;
167 padding-bottom: 16px;
168 border-bottom: 2px solid rgba(255, 255, 255, 0.3);
169 }
170
171 .book-section.depth-1:first-child h1 {
172 margin-top: 0;
173 }
174
175 h2 {
176 font-size: 30px;
177 margin-top: 40px;
178 margin-bottom: 20px;
179 padding-bottom: 12px;
180 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
181 }
182
183 h3 {
184 font-size: 24px;
185 margin-top: 32px;
186 margin-bottom: 16px;
187 }
188
189 h4 {
190 font-size: 20px;
191 margin-top: 24px;
192 margin-bottom: 12px;
193 }
194
195 h5 {
196 font-size: 18px;
197 margin-top: 20px;
198 margin-bottom: 10px;
199 }
200
201 /* File Containers - Deeper Glass */
202 .file-container {
203 margin: 24px 0;
204 padding: 20px;
205 background: rgba(255, 255, 255, 0.15);
206 backdrop-filter: blur(18px);
207 border: 1px solid rgba(255, 255, 255, 0.3);
208 border-radius: 12px;
209 transition: all 0.3s;
210 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
211 }
212
213 .file-container:hover {
214 border-color: rgba(255, 255, 255, 0.5);
215 box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
216 background: rgba(255, 255, 255, 0.2);
217 }
218
219 .file-container .note-link {
220 display: inline-block;
221 margin-bottom: 12px;
222 color: white;
223 font-size: 16px;
224 font-weight: 600;
225 padding: 4px 8px;
226 margin: -4px -8px 8px;
227 }
228
229 .file-container .note-link:hover {
230 background-color: rgba(255, 255, 255, 0.15);
231 text-decoration: underline;
232 }
233
234 /* Media Elements */
235 img {
236 max-width: 100%;
237 height: auto;
238 border-radius: 8px;
239 margin-top: 12px;
240 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
241 border: 1px solid rgba(255, 255, 255, 0.2);
242 }
243
244 video, audio {
245 max-width: 100%;
246 margin-top: 12px;
247 border-radius: 8px;
248 border: 1px solid rgba(255, 255, 255, 0.2);
249 }
250
251 iframe {
252 width: 100%;
253 height: 600px;
254 border: none;
255 border-radius: 8px;
256 margin-top: 12px;
257 border: 1px solid rgba(255, 255, 255, 0.2);
258 }
259
260 /* Empty State */
261 .empty-state {
262 text-align: center;
263 padding: 80px 40px;
264 }
265
266 .empty-state-icon {
267 font-size: 64px;
268 margin-bottom: 16px;
269 filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
270 }
271
272 .empty-state-text {
273 font-size: 24px;
274 color: white;
275 margin-bottom: 8px;
276 font-weight: 600;
277 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
278 }
279
280 .empty-state-subtext {
281 font-size: 16px;
282 color: rgba(255, 255, 255, 0.8);
283 }
284`;
285
286const bookNavStyles = `
287 /* Top Navigation Bar - Glass */
288 .top-nav {
289 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
290 backdrop-filter: blur(22px) saturate(140%);
291 -webkit-backdrop-filter: blur(22px) saturate(140%);
292 padding: 10px 20px;
293 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
294 inset 0 1px 0 rgba(255, 255, 255, 0.25);
295 border-bottom: 1px solid rgba(255, 255, 255, 0.28);
296 position: sticky;
297 top: 0;
298 z-index: 100;
299 animation: fadeInUp 0.5s ease-out;
300 }
301
302 .top-nav-content {
303 max-width: 900px;
304 margin: 0 auto;
305 }
306
307 .page-title {
308 font-size: 20px;
309 font-weight: 600;
310 color: white;
311 margin-bottom: 12px;
312 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
313 letter-spacing: -0.3px;
314 }
315`;
316
317const bookFilterStyles = `
318 /* Glass Filter Buttons */
319 .filters {
320 display: flex;
321 gap: 8px;
322 flex-wrap: wrap;
323 }
324
325 .filter-button {
326 padding: 8px 14px;
327 font-size: 13px;
328 font-weight: 600;
329 border-radius: 980px;
330 border: 1px solid rgba(255, 255, 255, 0.25);
331 background: rgba(255, 255, 255, 0.15);
332 backdrop-filter: blur(10px);
333 color: white;
334 cursor: pointer;
335 transition: all 0.3s;
336 font-family: inherit;
337 white-space: nowrap;
338 position: relative;
339 overflow: hidden;
340 }
341
342 .filter-button::before {
343 content: "";
344 position: absolute;
345 inset: -40%;
346 background: radial-gradient(
347 120% 60% at 0% 0%,
348 rgba(255, 255, 255, 0.35),
349 transparent 60%
350 );
351 opacity: 0;
352 transform: translateX(-30%) translateY(-10%);
353 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
354 pointer-events: none;
355 }
356
357 .filter-button:hover {
358 background: rgba(255, 255, 255, 0.25);
359 transform: translateY(-1px);
360 }
361
362 .filter-button:hover::before {
363 opacity: 1;
364 transform: translateX(30%) translateY(10%);
365 }
366
367 .filter-button.active {
368 background: rgba(255, 255, 255, 0.35);
369 border-color: rgba(255, 255, 255, 0.5);
370 box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15),
371 inset 0 1px 0 rgba(255, 255, 255, 0.4);
372 }
373
374 .filter-button.active:hover {
375 background: rgba(255, 255, 255, 0.45);
376 transform: translateY(-2px);
377 box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2);
378 }
379
380 .toc-select {
381 padding: 8px 14px;
382 font-size: 13px;
383 font-weight: 600;
384 border-radius: 980px;
385 border: 1px solid rgba(255, 255, 255, 0.25);
386 background: rgba(255, 255, 255, 0.15);
387 backdrop-filter: blur(10px);
388 color: white;
389 cursor: pointer;
390 font-family: inherit;
391 appearance: none;
392 -webkit-appearance: none;
393 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
394 background-repeat: no-repeat;
395 background-position: right 12px center;
396 padding-right: 30px;
397 }
398
399 .toc-select option {
400 background: #5a56c4;
401 color: white;
402 }
403`;
404
405const bookTocStyles = `
406 html { scroll-behavior: smooth; }
407
408 .book-toc {
409 max-width: 900px;
410 margin: 20px auto 24px;
411 padding: 20px 28px;
412 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
413 backdrop-filter: blur(22px) saturate(140%);
414 -webkit-backdrop-filter: blur(22px) saturate(140%);
415 border: 1px solid rgba(255, 255, 255, 0.28);
416 border-radius: 16px;
417 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
418 inset 0 1px 0 rgba(255, 255, 255, 0.25);
419 }
420
421 .toc-title {
422 font-size: 18px;
423 font-weight: 700;
424 color: white;
425 margin-bottom: 10px;
426 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
427 }
428
429 .toc-list {
430 list-style: none;
431 padding-left: 18px;
432 margin: 0;
433 }
434
435 .book-toc > .toc-list {
436 padding-left: 0;
437 }
438
439 .book-toc li {
440 margin: 2px 0;
441 }
442
443 .toc-link {
444 display: inline-block;
445 color: white;
446 text-decoration: none;
447 padding: 3px 0;
448 font-size: 15px;
449 font-weight: 500;
450 transition: opacity 0.2s;
451 }
452
453 .toc-link:hover {
454 opacity: 0.7;
455 text-decoration: underline;
456 }
457
458 .book-toc > .toc-list > li > .toc-link {
459 font-weight: 700;
460 font-size: 16px;
461 }
462`;
463
464const bookLazyScript = `
465 const lazyObserver = new IntersectionObserver(
466 (entries, observer) => {
467 entries.forEach(entry => {
468 if (!entry.isIntersecting) return;
469
470 const el = entry.target;
471 const src = el.dataset.src;
472
473 if (src) {
474 el.src = src;
475 el.removeAttribute("data-src");
476 }
477
478 observer.unobserve(el);
479 });
480 },
481 { rootMargin: "200px" }
482 );
483
484 document
485 .querySelectorAll(".lazy-media[data-src]")
486 .forEach(el => lazyObserver.observe(el));
487`;
488
489const bookToggleScript = `
490 function toggleFlag(flag) {
491 const url = new URL(window.location.href);
492
493 if (url.searchParams.has(flag)) {
494 url.searchParams.delete(flag);
495 } else {
496 url.searchParams.set(flag, "true");
497 }
498
499 url.searchParams.set("html", "true");
500 window.location.href = url.toString();
501 }
502
503 function toggleStatus(flag) {
504 const url = new URL(window.location.href);
505 const params = url.searchParams;
506
507 const defaults = {
508 active: true,
509 completed: true,
510 trimmed: false,
511 };
512
513 const current = params.has(flag)
514 ? params.get(flag) === "true"
515 : defaults[flag];
516
517 const next = !current;
518
519 if (next === defaults[flag]) {
520 params.delete(flag);
521 } else {
522 params.set(flag, String(next));
523 }
524
525 params.set("html", "true");
526 window.location.href = url.toString();
527 }
528
529 async function generateShare() {
530 const params = Object.fromEntries(new URLSearchParams(window.location.search));
531 const res = await fetch(window.location.pathname + "/generate", {
532 method: "POST",
533 headers: { "Content-Type": "application/json" },
534 body: JSON.stringify(params),
535 });
536
537 const data = await res.json();
538 const redirect = data.redirect || data.data?.redirect;
539 if (redirect) {
540 window.location.href = redirect;
541 }
542 }
543
544 function setTocDepth(val) {
545 const url = new URL(window.location.href);
546 if (val === "0") {
547 url.searchParams.delete("tocDepth");
548 } else {
549 url.searchParams.set("tocDepth", val);
550 }
551 url.searchParams.set("html", "true");
552 window.location.href = url.toString();
553 }
554`;
555
556/* ── Exported render functions ──────────────────── */
557
558export function renderBookPage({
559 nodeId,
560 token,
561 title,
562 content,
563 options,
564 tocEnabled,
565 tocDepth,
566 isStatusActive,
567 isStatusCompleted,
568 isStatusTrimmed,
569 book,
570 hasContent,
571}) {
572 const treeDepth = hasContent ? Math.min(getBookDepth(book), 5) : 0;
573
574 let tocDepthSelect = "";
575 if (tocEnabled && hasContent && treeDepth > 1) {
576 let opts = `<option value="0" ${tocDepth === 0 ? "selected" : ""}>All Depths</option>`;
577 for (let i = 1; i <= treeDepth; i++) {
578 opts += `<option value="${i}" ${tocDepth === i ? "selected" : ""}>Depth ${i}${i === 5 ? " (max)" : ""}</option>`;
579 }
580 tocDepthSelect = `<select class="toc-select" onchange="setTocDepth(this.value)">${opts}</select>`;
581 }
582
583 const bookContent = hasContent
584 ? renderBookNode(book, 1, token)
585 : `
586 <div class="empty-state">
587 <div class="empty-state-icon">\ud83d\udcd6</div>
588 <div class="empty-state-text">No content</div>
589 <div class="empty-state-subtext">
590 This node has no notes or child notes under the current filters.
591 </div>
592 </div>
593 `;
594
595 const css = `
596 /* ── Book page overrides on base ── */
597 body { padding: 0; }
598
599${bookNavStyles}
600
601 .nav-buttons {
602 display: flex;
603 justify-content: space-between;
604 align-items: center;
605 gap: 8px;
606 flex-wrap: wrap;
607 margin-bottom: 4px;
608 }
609
610 .nav-left {
611 display: flex;
612 gap: 8px;
613 flex-wrap: wrap;
614 }
615
616 /* Glass Navigation Buttons */
617 .nav-button {
618 display: inline-flex;
619 align-items: center;
620 gap: 6px;
621 padding: 8px 14px;
622 background: rgba(255, 255, 255, 0.2);
623 backdrop-filter: blur(10px);
624 color: white;
625 text-decoration: none;
626 border-radius: 980px;
627 font-weight: 600;
628 font-size: 14px;
629 transition: all 0.3s;
630 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
631 border: 1px solid rgba(255, 255, 255, 0.3);
632 position: relative;
633 overflow: hidden;
634 cursor: pointer;
635 touch-action: manipulation;
636 }
637
638 .nav-button::before {
639 content: "";
640 position: absolute;
641 inset: -40%;
642 background: radial-gradient(
643 120% 60% at 0% 0%,
644 rgba(255, 255, 255, 0.35),
645 transparent 60%
646 );
647 opacity: 0;
648 transform: translateX(-30%) translateY(-10%);
649 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
650 pointer-events: none;
651 }
652
653 .nav-button:hover {
654 background: rgba(255, 255, 255, 0.3);
655 transform: translateY(-2px);
656 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
657 }
658
659 .nav-button:hover::before {
660 opacity: 1;
661 transform: translateX(30%) translateY(10%);
662 }
663
664${bookFilterStyles}
665
666 /* Content Container */
667 .content-wrapper {
668 padding: 24px 20px;
669 }
670
671 .content {
672 max-width: 900px;
673 margin: 0 auto;
674 font-family: "Charter", "Georgia", "Iowan Old Style", "Times New Roman", serif;
675 line-height: 1.7;
676 word-wrap: break-word;
677 overflow-wrap: break-word;
678 animation: fadeInUp 0.6s ease-out 0.1s both;
679 }
680
681${bookContentStyles}
682
683 /* Note Content - Glowing Text */
684 .note-content {
685 margin: 16px 0 28px 0;
686 padding: 0;
687 font-size: 18px;
688 line-height: 1.8;
689 color: white;
690 word-wrap: break-word;
691 overflow-wrap: break-word;
692 font-weight: 400;
693 }
694
695 .note-link {
696 color: inherit;
697 text-decoration: none;
698 white-space: pre-wrap;
699 word-wrap: break-word;
700 overflow-wrap: break-word;
701 display: block;
702 padding: 12px 16px;
703 margin: -12px -16px;
704 border-radius: 8px;
705 transition: all 0.3s;
706 position: relative;
707 overflow: hidden;
708 }
709
710 .note-link::before {
711 content: "";
712 position: absolute;
713 inset: 0;
714 background: linear-gradient(
715 110deg,
716 transparent 40%,
717 rgba(255, 255, 255, 0.2),
718 transparent 60%
719 );
720 opacity: 0;
721 transform: translateX(-100%);
722 pointer-events: none;
723 }
724
725 .note-link:hover {
726 background-color: rgba(255, 255, 255, 0.1);
727 transform: translateX(4px);
728 }
729
730 .note-link:hover::before {
731 opacity: 1;
732 animation: glassShimmer 1s ease forwards;
733 }
734
735 @keyframes glassShimmer {
736 0% {
737 opacity: 0;
738 transform: translateX(-120%) skewX(-15deg);
739 }
740 50% {
741 opacity: 1;
742 }
743 100% {
744 opacity: 0;
745 transform: translateX(120%) skewX(-15deg);
746 }
747 }
748
749 .note-link:active {
750 background-color: rgba(255, 255, 255, 0.15);
751 }
752
753 /* Responsive Design */
754 @media (max-width: 1024px) {
755 }
756
757 @media (max-width: 768px) {
758 .top-nav {
759 padding: 12px 16px;
760 }
761
762 .nav-button {
763 padding: 8px 12px;
764 font-size: 13px;
765 }
766
767 .page-title {
768 font-size: 18px;
769 }
770
771 .filter-button {
772 padding: 6px 12px;
773 font-size: 12px;
774 }
775
776 .content-wrapper {
777 padding: 24px 16px;
778 }
779
780 h1 {
781 font-size: 30px;
782 }
783
784 h2 {
785 font-size: 26px;
786 }
787
788 h3 {
789 font-size: 22px;
790 }
791
792 h4 {
793 font-size: 19px;
794 }
795
796 h5 {
797 font-size: 17px;
798 }
799
800 .note-content {
801 font-size: 17px;
802 }
803
804 .book-section.depth-2,
805 .book-section.depth-3,
806 .book-section.depth-4,
807 .book-section.depth-5 {
808 margin-left: 4px;
809 }
810 }
811
812 @media (max-width: 480px) {
813 .nav-buttons {
814 flex-direction: column;
815 align-items: stretch;
816 }
817
818 .nav-left {
819 width: 100%;
820 flex-direction: column;
821 }
822
823 .nav-button {
824 justify-content: center;
825 width: 100%;
826 }
827
828 .book-section.depth-1,
829 .book-section.depth-2,
830 .book-section.depth-3,
831 .book-section.depth-4,
832 .book-section.depth-5 {
833 margin-left: 0;
834 padding: 12px;
835 }
836 }
837
838${bookTocStyles}
839 `;
840
841 const body = `
842 <!-- Top Navigation -->
843 <div class="top-nav">
844 <div class="top-nav-content">
845 <div class="nav-buttons">
846 <div class="nav-left">
847 <a href="/api/v1/root/${nodeId}?token=${encodeURIComponent(token ?? "")}&html" class="nav-button">
848 \u2190 Back to Tree
849 </a>
850
851 </div>
852 <button class="nav-button" onclick="generateShare()">
853 \ud83d\udd17 Generate Share Link
854 </button>
855 </div>
856
857<div class="page-title">Book: ${escapeHtml(title)}</div>
858
859 <!-- Filters -->
860 <div class="filters">
861 <button onclick="toggleFlag('latestVersionOnly')" class="filter-button ${
862 options.latestVersionOnly ? "active" : ""
863 }">
864 Latest Versions Only
865 </button>
866 <button onclick="toggleFlag('lastNoteOnly')" class="filter-button ${
867 options.lastNoteOnly ? "active" : ""
868 }">
869 Most Recent Note
870 </button>
871 <button onclick="toggleFlag('leafNotesOnly')" class="filter-button ${
872 options.leafNotesOnly ? "active" : ""
873 }">
874 Leaf Details Only
875 </button>
876 <button onclick="toggleFlag('filesOnly')" class="filter-button ${
877 options.filesOnly ? "active" : ""
878 }">
879 Files Only
880 </button>
881 <button onclick="toggleFlag('textOnly')" class="filter-button ${
882 options.textOnly ? "active" : ""
883 }">
884 Text Only
885 </button>
886 <button onclick="toggleStatus('active')" class="filter-button ${
887 isStatusActive ? "active" : ""
888 }">
889 Active
890 </button>
891 <button onclick="toggleStatus('completed')" class="filter-button ${
892 isStatusCompleted ? "active" : ""
893 }">
894 Completed
895 </button>
896 <button onclick="toggleStatus('trimmed')" class="filter-button ${
897 isStatusTrimmed ? "active" : ""
898 }">
899 Trimmed
900 </button>
901 <button onclick="toggleFlag('toc')" class="filter-button ${
902 tocEnabled ? "active" : ""
903 }">
904 Table of Contents
905 </button>
906 ${tocDepthSelect}
907 </div>
908 </div>
909 </div>
910
911 <!-- Content -->
912 <div class="content-wrapper">
913 ${tocEnabled && hasContent ? renderTocBlock(book, tocDepth) : ""}
914 <div class="content">
915 ${bookContent}
916 </div>
917 </div>
918 `;
919
920 const js = `
921 function tocScroll(id) {
922 var el = document.getElementById(id);
923 if (!el) return;
924 var nav = document.querySelector('.top-nav');
925 var offset = nav ? nav.offsetHeight + 12 : 12;
926 var top = el.getBoundingClientRect().top + window.scrollY - offset;
927 window.scrollTo({ top: top, behavior: 'smooth' });
928 }
929
930${bookLazyScript}
931
932${bookToggleScript}
933 `;
934
935 return page({
936 title: `Book: ${escapeHtml(title)}`,
937 css,
938 body,
939 js,
940 });
941}
942
943export function renderSharedBookPage({
944 nodeId,
945 title,
946 content,
947 shareTocEnabled,
948 shareTocDepth,
949 book,
950 hasContent,
951}) {
952 const css = `
953 /* ── Shared book page overrides on base ── */
954 body { padding: 0; }
955
956${bookNavStyles}
957
958 .nav-buttons {
959 display: flex;
960 align-items: center;
961 gap: 8px;
962 flex-wrap: nowrap;
963 }
964
965 /* Glass Navigation Buttons */
966 .nav-button {
967 display: inline-flex;
968 align-items: center;
969 justify-content: center;
970 gap: 4px;
971 padding: 8px 10px;
972 flex: 1;
973 background: rgba(255, 255, 255, 0.2);
974 backdrop-filter: blur(10px);
975 color: white;
976 text-decoration: none;
977 border-radius: 980px;
978 font-weight: 600;
979 font-size: 13px;
980 white-space: nowrap;
981 transition: all 0.3s;
982 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
983 border: 1px solid rgba(255, 255, 255, 0.3);
984 position: relative;
985 overflow: hidden;
986 cursor: pointer;
987 touch-action: manipulation;
988 }
989
990 .nav-button::before {
991 content: "";
992 position: absolute;
993 inset: -40%;
994 background: radial-gradient(
995 120% 60% at 0% 0%,
996 rgba(255, 255, 255, 0.35),
997 transparent 60%
998 );
999 opacity: 0;
1000 transform: translateX(-30%) translateY(-10%);
1001 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1002 pointer-events: none;
1003 }
1004
1005 .nav-button:hover {
1006 background: rgba(255, 255, 255, 0.3);
1007 transform: translateY(-2px);
1008 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
1009 }
1010
1011 .nav-button:hover::before {
1012 opacity: 1;
1013 transform: translateX(30%) translateY(10%);
1014 }
1015
1016${bookFilterStyles}
1017
1018 /* Content Container */
1019 .content-wrapper {
1020 padding: 24px 20px;
1021 }
1022
1023 .content {
1024 max-width: 900px;
1025 margin: 0 auto;
1026 font-family: "Charter", "Georgia", "Iowan Old Style", "Times New Roman", serif;
1027 line-height: 1.7;
1028 word-wrap: break-word;
1029 overflow-wrap: break-word;
1030 animation: fadeInUp 0.6s ease-out 0.1s both;
1031 }
1032
1033${bookContentStyles}
1034
1035 /* Note Content - Glowing Text */
1036 .note-content {
1037 margin: 16px 0 28px 0;
1038 padding: 0;
1039 font-size: 18px;
1040 line-height: 1.8;
1041 color: #F5F5DC;
1042 word-wrap: break-word;
1043 overflow-wrap: break-word;
1044 font-weight: 400;
1045 }
1046
1047 .note-link {
1048 color: inherit;
1049 text-decoration: none;
1050 white-space: pre-wrap;
1051 word-wrap: break-word;
1052 overflow-wrap: break-word;
1053 display: block;
1054 padding: 12px 16px;
1055 margin: -12px -16px;
1056 border-radius: 8px;
1057 transition: all 0.3s;
1058 position: relative;
1059 overflow: hidden;
1060 }
1061
1062 .note-link::before {
1063 content: "";
1064 position: absolute;
1065 inset: 0;
1066 background: linear-gradient(
1067 110deg,
1068 transparent 40%,
1069 rgba(255, 255, 255, 0.2),
1070 transparent 60%
1071 );
1072 opacity: 0;
1073 transform: translateX(-100%);
1074 pointer-events: none;
1075 }
1076
1077 .note-link:hover {
1078 background-color: rgba(255, 255, 255, 0.1);
1079 transform: translateX(4px);
1080 }
1081
1082 .note-link:hover::before {
1083 opacity: 1;
1084 animation: glassShimmer 1s ease forwards;
1085 }
1086
1087 @keyframes glassShimmer {
1088 0% {
1089 opacity: 0;
1090 transform: translateX(-120%) skewX(-15deg);
1091 }
1092 50% {
1093 opacity: 1;
1094 }
1095 100% {
1096 opacity: 0;
1097 transform: translateX(120%) skewX(-15deg);
1098 }
1099 }
1100
1101 .note-link:active {
1102 background-color: rgba(255, 255, 255, 0.15);
1103 }
1104
1105 /* Responsive Design */
1106 @media (max-width: 1024px) {
1107 }
1108
1109 @media (max-width: 768px) {
1110 .top-nav {
1111 padding: 12px 16px;
1112 }
1113
1114 .nav-button {
1115 padding: 8px 12px;
1116 font-size: 13px;
1117 }
1118
1119 .page-title {
1120 font-size: 18px;
1121 }
1122
1123 .filter-button {
1124 padding: 6px 12px;
1125 font-size: 12px;
1126 }
1127
1128 .content-wrapper {
1129 padding: 24px 16px;
1130 }
1131
1132 h1 {
1133 font-size: 30px;
1134 }
1135
1136 h2 {
1137 font-size: 26px;
1138 }
1139
1140 h3 {
1141 font-size: 22px;
1142 }
1143
1144 h4 {
1145 font-size: 19px;
1146 }
1147
1148 h5 {
1149 font-size: 17px;
1150 }
1151
1152 .note-content {
1153 font-size: 17px;
1154 }
1155
1156 .book-section.depth-2,
1157 .book-section.depth-3,
1158 .book-section.depth-4,
1159 .book-section.depth-5 {
1160 margin-left: 4px;
1161 }
1162 }
1163
1164 @media (max-width: 480px) {
1165 .nav-button {
1166 padding: 8px 6px;
1167 font-size: 11px;
1168 gap: 2px;
1169 }
1170
1171 .book-section.depth-1,
1172 .book-section.depth-2,
1173 .book-section.depth-3,
1174 .book-section.depth-4,
1175 .book-section.depth-5 {
1176 margin-left: 0;
1177 padding: 12px;
1178 }
1179 }
1180
1181${bookTocStyles}
1182
1183 .share-book-title {
1184 max-width: 900px;
1185 margin: 24px auto 0;
1186 font-size: 28px;
1187 font-weight: 700;
1188 color: white;
1189 text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
1190 text-align: center;
1191 }
1192
1193 /* Title toggle active state */
1194 .nav-button.active {
1195 background: rgba(255, 255, 255, 0.4);
1196 border-color: rgba(255, 255, 255, 0.5);
1197 }
1198
1199 /* Hide titles mode */
1200 #bookContent.hide-titles h1,
1201 #bookContent.hide-titles h2,
1202 #bookContent.hide-titles h3,
1203 #bookContent.hide-titles h4,
1204 #bookContent.hide-titles h5 {
1205 display: none;
1206 }
1207
1208 /* TOC scroll-to-top circle */
1209 .toc-top-btn {
1210 position: fixed;
1211 top: 60px;
1212 right: 16px;
1213 z-index: 200;
1214 width: 42px;
1215 height: 42px;
1216 border-radius: 50%;
1217 border: 1px solid rgba(255, 255, 255, 0.3);
1218 background: rgba(var(--glass-water-rgb), 0.5);
1219 backdrop-filter: blur(16px);
1220 -webkit-backdrop-filter: blur(16px);
1221 color: white;
1222 font-size: 18px;
1223 cursor: pointer;
1224 display: flex;
1225 align-items: center;
1226 justify-content: center;
1227 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
1228 opacity: 0;
1229 pointer-events: none;
1230 transition: opacity 0.3s, transform 0.3s;
1231 touch-action: manipulation;
1232 }
1233
1234 .toc-top-btn.visible {
1235 opacity: 1;
1236 pointer-events: auto;
1237 }
1238
1239 .toc-top-btn:hover {
1240 background: rgba(var(--glass-water-rgb), 0.7);
1241 transform: scale(1.1);
1242 }
1243 `;
1244
1245 const body = `
1246 <!-- Share Nav -->
1247 <div class="top-nav">
1248 <div class="top-nav-content">
1249 <div class="nav-buttons">
1250 <a href="/" class="nav-button" onclick="event.preventDefault();window.top.location.href='/';">Home</a>
1251 <button class="nav-button" id="copyUrlBtn">Copy URL</button>
1252 <button class="nav-button" id="copyTextBtn">Copy Text</button>
1253 <button class="nav-button" id="toggleTitlesBtn" onclick="toggleTitles()" title="Toggle Titles">Aa</button>
1254 </div>
1255 </div>
1256 </div>
1257
1258 ${shareTocEnabled && hasContent ? `<button class="toc-top-btn" id="tocTopBtn" onclick="window.scrollTo({top:0,behavior:'smooth'})">▲</button>` : ""}
1259
1260 <!-- Content -->
1261 <div class="content-wrapper">
1262 ${shareTocEnabled && hasContent ? `<div class="share-book-title">${escapeHtml(title)}</div>${renderTocBlock(book, shareTocDepth)}` : ""}
1263 <div class="content" id="bookContent">
1264 ${content}
1265 </div>
1266 </div>
1267 `;
1268
1269 const js = `
1270 function tocScroll(id) {
1271 var el = document.getElementById(id);
1272 if (!el) return;
1273 var nav = document.querySelector('.top-nav');
1274 var offset = nav ? nav.offsetHeight + 12 : 12;
1275 var top = el.getBoundingClientRect().top + window.scrollY - offset;
1276 window.scrollTo({ top: top, behavior: 'smooth' });
1277 }
1278
1279 function toggleTitles() {
1280 var bc = document.getElementById('bookContent');
1281 var btn = document.getElementById('toggleTitlesBtn');
1282 bc.classList.toggle('hide-titles');
1283 if (bc.classList.contains('hide-titles')) {
1284 btn.classList.add('active');
1285 } else {
1286 btn.classList.remove('active');
1287 }
1288 }
1289
1290 ${shareTocEnabled && hasContent ? `
1291 (function() {
1292 var tocBtn = document.getElementById('tocTopBtn');
1293 if (!tocBtn) return;
1294 window.addEventListener('scroll', function() {
1295 if (window.scrollY > 200) {
1296 tocBtn.classList.add('visible');
1297 } else {
1298 tocBtn.classList.remove('visible');
1299 }
1300 }, { passive: true });
1301 })();
1302 ` : ""}
1303
1304 document.getElementById("copyUrlBtn").addEventListener("click", function() {
1305 var url = new URL(window.location.href);
1306 url.searchParams.delete("token");
1307 if (!url.searchParams.has("html")) url.searchParams.set("html", "");
1308 navigator.clipboard.writeText(url.toString()).then(function() {
1309 this.textContent = "Copied";
1310 setTimeout(function() { document.getElementById("copyUrlBtn").textContent = "Copy URL"; }, 900);
1311 }.bind(this));
1312 });
1313
1314 document.getElementById("copyTextBtn").addEventListener("click", function() {
1315 var text = document.getElementById("bookContent").innerText;
1316 navigator.clipboard.writeText(text).then(function() {
1317 document.getElementById("copyTextBtn").textContent = "Copied";
1318 setTimeout(function() { document.getElementById("copyTextBtn").textContent = "Copy Text"; }, 900);
1319 });
1320 });
1321
1322${bookLazyScript}
1323
1324${bookToggleScript}
1325 `;
1326
1327 return page({
1328 title: `Book: ${escapeHtml(title)} - TreeOS`,
1329 css,
1330 body,
1331 js,
1332 });
1333}
1334
1335export { parseBool, normalizeStatusFilters, renderBookNode };
1336
1import express from "express";
2import Book from "./model.js";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import {
6 getBook as coreGetBook,
7 generateBook as coreGenerateBook,
8} from "./core.js";
9import { getExtension } from "../loader.js";
10import { renderBookPage, renderSharedBookPage, parseBool, normalizeStatusFilters, renderBookNode } from "./pages/book.js";
11let htmlAuth = authenticate;
12export function resolveHtmlAuth() {
13 const htmlExt = getExtension("html-rendering");
14 if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
15}
16
17function notFoundPage(req, res, message = "This page doesn't exist or may have been moved.") {
18 const fn = getExtension("html-rendering")?.exports?.notFoundPage;
19 if (fn) return fn(req, res, message);
20 return sendError(res, 404, ERR.NODE_NOT_FOUND, message);
21}
22
23const router = express.Router();
24
25router.get("/root/:nodeId/book", htmlAuth, async (req, res) => {
26 try {
27 const { nodeId } = req.params;
28
29 const options = {
30 latestVersionOnly: parseBool(req.query.latestVersionOnly),
31 lastNoteOnly: parseBool(req.query.lastNoteOnly),
32 leafNotesOnly: parseBool(req.query.leafNotesOnly),
33 filesOnly: parseBool(req.query.filesOnly),
34 textOnly: parseBool(req.query.textOnly),
35 statusFilters: normalizeStatusFilters(req.query),
36 };
37
38 const tocEnabled = parseBool(req.query.toc);
39 const tocDepth = parseInt(req.query.tocDepth) || 0;
40
41 const wantHtml = req.query.html !== undefined;
42 const { book } = await coreGetBook({ nodeId, options });
43
44 const hasContent =
45 !!book && (book.notes?.length > 0 || book.children?.length > 0);
46 const q = req.query;
47
48 const isStatusActive = q.active === undefined ? true : q.active === "true";
49 const isStatusCompleted =
50 q.completed === undefined ? true : q.completed === "true";
51 const isStatusTrimmed = q.trimmed === "true";
52
53 if (wantHtml && getExtension("html-rendering")) {
54 const token = req.query.token || "";
55 const title = book?.nodeName ?? book?.nodeId ?? `Node ${nodeId}`;
56 const content = hasContent
57 ? renderBookNode(book, 1, token)
58 : `
59 <div class="empty-state">
60 <div class="empty-state-icon"></div>
61 <div class="empty-state-text">No content</div>
62 <div class="empty-state-subtext">
63 This node has no notes or child notes under the current filters.
64 </div>
65 </div>
66 `;
67
68 return res.send(
69 renderBookPage({
70 nodeId,
71 token,
72 title,
73 content,
74 options,
75 tocEnabled,
76 tocDepth,
77 isStatusActive,
78 isStatusCompleted,
79 isStatusTrimmed,
80 book,
81 hasContent,
82 }),
83 );
84 }
85
86 return sendOk(res, { book });
87 } catch (err) {
88 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
89 }
90});
91
92router.post("/root/:nodeId/book/generate", authenticate, async (req, res) => {
93 try {
94 const { nodeId } = req.params;
95
96 const settings = {
97 latestVersionOnly: !!req.body.latestVersionOnly,
98 lastNoteOnly: !!req.body.lastNoteOnly,
99 leafNotesOnly: !!req.body.leafNotesOnly,
100 filesOnly: !!req.body.filesOnly,
101 textOnly: !!req.body.textOnly,
102 active: req.body.active !== undefined ? !!req.body.active : true,
103 completed: req.body.completed !== undefined ? !!req.body.completed : true,
104 toc: !!req.body.toc,
105 tocDepth: parseInt(req.body.tocDepth) || 0,
106 };
107
108 const { shareId } = await coreGenerateBook({
109 nodeId,
110 settings,
111 userId: req.userId,
112 });
113
114 return sendOk(res, {
115 redirect: `/api/v1/root/${nodeId}/book/share/${shareId}?html`,
116 });
117 } catch (err) {
118 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
119 }
120});
121
122router.get("/root/:nodeId/book/share/:shareId", async (req, res) => {
123 try {
124 const { nodeId, shareId } = req.params;
125 const wantHtml = req.query.html !== undefined;
126
127 const bookRecord = await Book.findOne({ shareId }).lean();
128 if (!bookRecord) {
129 return notFoundPage(
130 req,
131 res,
132 "This book doesn't exist or may have been removed.",
133 );
134 }
135
136 if (bookRecord.nodeId !== nodeId) {
137 return notFoundPage(req, res, "This book link is invalid.");
138 }
139
140 const options = {
141 latestVersionOnly: bookRecord.settings.latestVersionOnly,
142 lastNoteOnly: bookRecord.settings.lastNoteOnly,
143 leafNotesOnly: bookRecord.settings.leafNotesOnly,
144 filesOnly: bookRecord.settings.filesOnly,
145 textOnly: bookRecord.settings.textOnly,
146 statusFilters: bookRecord.settings,
147 };
148
149 const shareTocEnabled = !!bookRecord.settings.toc;
150 const shareTocDepth = bookRecord.settings.tocDepth || 0;
151
152 const { book } = await coreGetBook({ nodeId, options });
153 const hasContent =
154 !!book && (book.notes?.length > 0 || book.children?.length > 0);
155
156 if (wantHtml && getExtension("html-rendering")) {
157 const token = req.query.token || "";
158 const title = book?.nodeName ?? book?.nodeId ?? `Node ${nodeId}`;
159 const content = hasContent
160 ? renderBookNode(book, 1, token)
161 : `
162 <div class="empty-state">
163 <div class="empty-state-icon"></div>
164 <div class="empty-state-text">No content</div>
165 <div class="empty-state-subtext">
166 This node has no notes or child notes under the current filters.
167 </div>
168 </div>
169 `;
170
171 return res.send(
172 renderSharedBookPage({
173 nodeId,
174 shareId,
175 title,
176 content,
177 shareTocEnabled,
178 shareTocDepth,
179 book,
180 hasContent,
181 }),
182 );
183 }
184
185 return sendOk(res, { book });
186 } catch (err) {
187 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
188 }
189});
190
191export default router;
192
Loading comments...