EXTENSION for TreeOS
book
Trees store knowledge as scattered notes across dozens or hundreds of nodes. Reading a tree means navigating branch by branch, node by node. That works for the AI and for the person who built it. It does not work when you need to hand someone a single document that contains everything from a branch. Book compiles a subtree's notes into one coherent, readable output. Starting from any node, Book walks the entire subtree depth-first, collecting notes from every descendant. The result preserves the tree's hierarchy: each node becomes a section, children become subsections, notes become content blocks within their section. The structure of the tree becomes the structure of the document. Filters control what appears in the output. latestVersionOnly shows only the most recent version of each note. lastNoteOnly shows only the last note per node. leafNotesOnly includes notes only from leaf nodes, skipping intermediate branches. filesOnly and textOnly filter by content type. Status filters (active, completed) control which nodes are included based on their current status. Nodes that fail the status filter are excluded unless they have children that pass. Books can be shared. The generate endpoint creates a persistent Book record with a unique share ID and a hash of the filter settings. If someone generates the same book with the same settings, the existing share ID is reused rather than creating a duplicate. The share URL is publicly accessible without authentication. Anyone with the link sees the compiled output. If html-rendering is installed, both the private book view and the shared link render as styled HTML pages with a table of contents, filter controls, and section navigation. Without html-rendering, the API returns raw JSON with the full nested structure.
v1.0.1 by TreeOS Site 0 downloads 6 files 1,806 lines 46.1 KB published 38d ago
treeos ext install book
View changelog

Manifest

Provides

  • 1 models
  • routes
  • 1 CLI commands

Requires

  • models: Node, Note

Optional

  • extensions: html-rendering, treeos-base
SHA256: 9e5a811c1a6307399abd14e19cce292a534428963693bd64f294a8fc66f0a317

CLI Commands

CommandMethodDescription
bookGETView compiled notes for current tree

Source Code

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'})">&#9650;</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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star book

Comments

Loading comments...

Post comments from the CLI: treeos ext comment book "your comment"
Max 3 comments per extension. One star and one flag per user.