diff --git a/AGENTS.md b/AGENTS.md index 6a00798..eac080b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,10 +44,13 @@ CSS file (Gruvbox, light + dark, theme toggle without reload). - **Article prev/next** (`scripts/prev_next.js`) is scoped to the article's category (`blog_data.articles[].category`); falls back to the global list when uncategorized. - **Menu sorting** (`scripts/menu_date_sort.js`, `shareHtmlLocation=body:end`): reads - `blog_data.articles[].date` (generator exposes the `date` label, fallback `dateCreated`, - `YYYY-MM-DD`), re-sorts each category's menu entries by date descending and prefixes the - label with `YYYY-MM-DD ` (icons preserved). Non-article entries are left - in place. + `blog_data.articles[].date` (ISO, used for sorting via `data-pubdate`) and displays + `dateText` (`Fri 2026-08-28`), re-sorts each category's menu entries by date descending + and prefixes the label with `Fri 2026-08-28 ` (icons preserved). + Non-article entries are left in place. +- **Dates**: all visible dates (post cards, garden table) are rendered by the generator's + `dateText()` helper as `Fri 2026-08-28` (English weekday + ISO date); `blog_data.date` + stays ISO for sorting, `blog_data.dateText` is added for display. JSON-LD keeps ISO dates. - **Thumbnails** (`thumbSrc()`): prefer a child note of the article titled `*_thumb.*` (e.g. `ngc281_thumb.jpg`) → `api/notes//download`; otherwise use the first `` from the note content. Layout: image left, summary right (`.blog-post-inner`), @@ -55,6 +58,14 @@ CSS file (Gruvbox, light + dark, theme toggle without reload). - Regenerates `feed.xml`, `sitemap.xml`, `state_map` (garden states) and `blog_data` (search index: id/title/url/summary/tags/category). Sets `shareDescription` from `summary`. +## Publishing workflow (blog_publish_manager) + +New notes are created under `Unpublished/` (the `Unpublished` root mirrors the blog +categories). Setting `publish=true` makes the manager clone the note into the matching blog +category (`ensureNoteIsPresentInParent`) and run the generator; setting it back to `false` +moves the clone back out of the blog. So: create the note under `Unpublished/`, +set the other labels, and set `publish=true` **last**. + ## Wiring / attributes (the ground truth) - All inheritable relations live on the **blog root**: `~shareCss → blog_share_theme.css`, @@ -81,12 +92,19 @@ CSS file (Gruvbox, light + dark, theme toggle without reload). - Embedded images: `#content img:not(.blog-thumb) { border-radius:8px }`. - **Figure captions**: `#content figcaption` is a Gruvbox chip (secondary bg, highlight border, 8px radius, muted italic text, centered). -- **3D models** (`scripts/3d_model_viewer.js`): `.step`/`.stp`/`.stl` file notes (title ends - in the extension) or inline attachment links (link text ends in the extension) get an - interactive three.js viewer (drag/zoom), 8px rounded box + caption. STEP is parsed by - occt-import-js (OpenCascade WASM). All five libraries are hosted as local notes and - referenced via `__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/ - `__OCCT_WASM_ID__`. User guide: `docs/embed-3d-model.md`. +- **3D models** (`scripts/3d_model_viewer.js`): renders `.step`/`.stp`/`.stl` as file notes + (title ends in the extension), inline attachment links, or links to published model file + notes from text notes (resolved via `window.blogData`). The viewer is **full-bleed** + (`~100vw`, `height: min(70vh, 900px)`, ResizeObserver), with a loading progress bar and a + caption that always shows `name · size · triangle count`. STEP is parsed by occt-import-js + (OpenCascade WASM) in a **Web Worker** (non-blocking) with + `linearDeflection: 0.0002, angularDeflection: 0.1`. Libraries hosted as local notes via + `__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/`__OCCT_WASM_ID__`. + User guide: `docs/embed-3d-model.md`. +- **3D viewer in the Trilium app** (independent of the blog): `app/3d-model-viewer-app.jsx` + (widget, `#widget=3dModelViewer`, `parent: note-detail-pane`) + `app/3d-worker.js` (STEP + worker, loaded from a code note instead of a Blob so the app CSP allows it). The widget + renders the viewer for file notes whose title ends in `.step`/`.stp`/`.stl`. - Images in the share are served full-size only (no resize endpoint) → keep the `_thumb` child-note convention for small thumbnails. - Built-in share footer navigation and book children list are hidden @@ -116,10 +134,27 @@ CSS file (Gruvbox, light + dark, theme toggle without reload). for bandwidth-friendly thumbnails. - Comments/commits in the repo are English; communication with the owner is German. -## Releasing changes +## Releasing changes (do this after every change set) -Edit the corresponding Trilium note (live), verify on `richard.familie-zink.org`, then mirror -the anonymized file into this repo (replace `richard.familie-zink.org` → `https://YOUR-INSTANCE.example.com`, -`Richard's Blog` → `Your Blog`, and the note-ID tokens → `__…__`), update `README.md` if the -feature list changed, commit and push to `main`. Never commit real note IDs, the ETAPI token, -or personal data (name/domain are allowed in the README demo badge only). \ No newline at end of file +1. Edit the corresponding Trilium note (live) and verify on `richard.familie-zink.org`. +2. Mirror the anonymized file(s) into this repo: replace + `richard.familie-zink.org` → `https://YOUR-INSTANCE.example.com`, + `Richard's Blog` → `Your Blog`, `by fam.zink` → `by the author`, and every note-ID + token → `__…_ID__` (mappings are the tokens already used in `blog_generator.js` / + `scripts/*.js`). New component notes (e.g. the app widget) get a `__…_ID__` token in + the repo reference file. +3. Check licenses: `THIRD_PARTY_LICENSES.md` must list every vendored third-party file; + original code stays GPL-3.0. Update it if new libraries were added. +4. Update docs if behaviour changed: `docs/embed-3d-model.md` (3D viewer), `README.md` + (features/layout) and this file. +5. Commit (English message) and push to `main` with the neutral identity + `trilium-share-gruvbox `. + +Never commit real note IDs, the ETAPI token, or personal data (name/domain are allowed +in the README demo badge only). + +## Repo gotchas + +- shields.io badges: a literal `-` in label/message must be escaped as `--` in the badge URL + (e.g. `familie-zink.org` → `familie--zink.org`), otherwise shields renders a "404 badge not + found" error badge. \ No newline at end of file diff --git a/README.md b/README.md index a4c2d60..b268a32 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Trilium Share – Gruvbox Blog System [![License](https://img.shields.io/badge/License-GPL--3.0-fb4934)](LICENSE) -[![Live demo](https://img.shields.io/badge/Live%20demo-richard.familie-zink.org-8ec07c)](https://richard.familie-zink.org) +[![Live demo](https://img.shields.io/badge/Live%20demo-richard.familie--zink.org-8ec07c)](https://richard.familie-zink.org) [![TriliumNext](https://img.shields.io/badge/TriliumNext-Public%20Share-83a598)](https://github.com/TriliumNext/Trilium) [![Theme](https://img.shields.io/badge/Theme-Gruvbox-98971a)](https://github.com/morhetz/gruvbox) [![Built with](https://img.shields.io/badge/Built%20with-Opencode-fe8019)](https://opencode.ai) @@ -27,7 +27,8 @@ no external dependencies (except `highlight.js`, vendored in `lib/`). - Gruvbox light/dark theme for the share, with a theme toggle in the header. - Blog index, category pages, sub-categories, tags and tag overview. - **Menu sorting**: articles in the left menu are sorted by publication date (newest first) - and labelled `YYYY-MM-DD ` automatically. + and labelled `Fri 2026-08-28 ` automatically. All date displays + (post cards, garden table, menu) include the weekday (`dateText` from the generator). - **Article thumbnails**: each post card shows the first image of the article on the left (bounded by the teaser height), summary on the right, lazy-loaded. If an article has a child note named `*_thumb.*` (e.g. `ngc281_thumb.jpg`), that image is used instead – ideal for @@ -44,10 +45,15 @@ no external dependencies (except `highlight.js`, vendored in `lib/`). (`templates/share/page.ejs`). - **Widget rendering** for Mermaid, Excalidraw (canvas) and MindElixir (mind map) notes, with a collapsible, highlighted "Source" block and copy button. -- **3D models**: `.step` / `.stp` / `.stl` files (as file notes or inline attachments) render - in an interactive three.js viewer (STEP via occt-import-js, OpenCascade WASM) – drag to - rotate, scroll to zoom, 8px rounded box with caption. All libraries are hosted locally. - See [docs/embed-3d-model.md](docs/embed-3d-model.md). +- **3D models**: `.step` / `.stp` / `.stl` files (as file notes, as inline attachments, or linked + from text notes) render in an interactive three.js viewer (STEP via occt-import-js, OpenCascade + WASM, parsed in a **Web Worker** so the page stays responsive). The viewer is **full-bleed** + (~`100vw`) with a viewport-based height, drag to rotate / scroll to zoom, rounded 8px box, a + loading progress bar and a caption that always shows `name · size · triangle count`. + STEP tessellation is dense (≈0.1–1 M+ triangles for assemblies). See + [docs/embed-3d-model.md](docs/embed-3d-model.md). +- **3D viewer inside Trilium (app widget)**: the same viewer runs in the Trilium app itself + (independent of the blog) via a `note-detail-pane` widget – see `app/`. - Code highlighting (highlight.js + line numbers) and copy buttons on all code boxes. - Optional "Buy me a coffee" button with locally-hosted Cookie font. - `blog_generator` (backend) builds index/feed/sitemap/search-data; `blog_publish_manager` moves @@ -66,6 +72,8 @@ no external dependencies (except `highlight.js`, vendored in `lib/`). | `lib/3d/*` | three.js + OrbitControls + STLLoader + occt-import-js (WASM) for the 3D viewer | code / file | | `lib/hljs_line_numbers.js` | highlight.js line-numbers plugin | code | | `scripts/*.js` | frontend share snippets (content is pasted **verbatim** into a code note, including the ``; if (dataNote.getContent() !== html) { diff --git a/docs/embed-3d-model.md b/docs/embed-3d-model.md index 78692c6..5bfd3d6 100644 --- a/docs/embed-3d-model.md +++ b/docs/embed-3d-model.md @@ -1,7 +1,12 @@ # Embedding a 3D model (STEP / STL) in a note The share can render `.step` / `.stp` / `.stl` models interactively (three.js + occt-import-js, -hosted locally). There are two ways to embed a model, depending on how you store the file. +hosted locally). The viewer fills the available screen width (full-bleed, ~`100vw`) with a +height based on the viewport (`min(70vh, 900px)`), so models use the maximum available space +instead of the readable content width. The caption below the model always shows +` · · ` (e.g. `Wrench.stl · 711 KB · 14.6k triangles`). + +There are three ways to embed a model, depending on how you store the file. ## Option A – Model as its own note (file note) @@ -9,7 +14,7 @@ hosted locally). There are two ways to embed a model, depending on how you store e.g. `bracket.step` or `rear_housing.stp`. 2. Put it under a category in the blog and set `publish=true`. 3. The note's share page then shows the interactive **3D viewer** automatically: - drag to rotate, scroll to zoom; below the model there is a caption with the file name. + drag to rotate, scroll to zoom. ## Option B – Model embedded inline in a text note (attachment) @@ -18,13 +23,27 @@ hosted locally). There are two ways to embed a model, depending on how you store 2. **Rename the attachment** so its title ends in `.step`, `.stp` or `.stl` (the viewer detects a model by the file name). Example: `flange.step`. 3. **Insert the attachment link** into the note body: - right-click the attachment → “Insert link”, or drag the attachment into the text. - (Trilium creates a link showing the file name.) + right-click the attachment → "Insert link", or drag the attachment into the text. 4. On the share, that link is automatically replaced by the embedded 3D viewer. +## Option C – Model embedded inline from a file note (recommended for blog posts) + +This lets you write blog posts that contain models inline, referencing a model that also +exists as its own (published) file note — the model stays in one place. + +1. Create the model as a **file note** (title ends in `.step` / `.stp` / `.stl`), + publish it (see Option A). The viewer needs it in `blog_data`, i.e. it must be a + published article reachable in the blog tree. +2. In your **text note**, add a link to that file note, e.g. + `[Wrench.stl](#root/)` (markdown) or drag the note reference into the editor. +3. On the share, the link to a model file note is replaced by the inline viewer. + You can embed several models per post this way. + > Tip: STL (plain triangle meshes) is rendered with three.js only; STEP (CAD B-rep) is -> parsed by occt-import-js (OpenCASCADE WASM, LGPL-2.1). The first STEP view downloads the -> ~7.6 MB WASM once and then caches it in the browser. +> parsed by occt-import-js (OpenCASCADE WASM, LGPL-2.1) in a **Web Worker** (non-blocking). +> The first STEP view downloads the ~7.6 MB WASM once and then caches it in the browser. +> STEP tessellation is set to `linearDeflection: 0.0002, angularDeflection: 0.1` +> (dense meshes, typically a few hundred k to >1 M triangles for assemblies). ## Requirements @@ -33,4 +52,15 @@ hosted locally). There are two ways to embed a model, depending on how you store - The five libraries must exist as notes and be referenced by the script via the placeholder tokens in `scripts/3d_model_viewer.js`: `__THREE_ID__`, `__THREE_ORBIT_ID__`, `__THREE_STL_ID__`, `__OCCT_JS_ID__`, - `__OCCT_WASM_ID__` (see the README installation for the note titles). \ No newline at end of file + `__OCCT_WASM_ID__` (see the README installation for the note titles). +- For **inline embedding from a file note (Option C)** the `blog_data` note must be + present and the target model must be a published article (the viewer resolves + `./` links against `window.blogData`). + +## Trilium app widget (independent of the share) + +The same viewer is also available **inside Trilium itself** (independent of the blog): +a `note-detail-pane` widget (`app/3d-model-viewer-app.jsx` + `app/3d-worker.js`) renders +the viewer whenever you open a file note whose title ends in `.step` / `.stp` / `.stl`. +The worker is loaded from a code note (not a Blob) so it works under the app's CSP. +Wire it by giving the JSX note the label `#widget=3dModelViewer` and reload the app. \ No newline at end of file diff --git a/scripts/3d_model_viewer.js b/scripts/3d_model_viewer.js index 665d712..eba271c 100644 --- a/scripts/3d_model_viewer.js +++ b/scripts/3d_model_viewer.js @@ -11,7 +11,7 @@ var MODEL_RE = /\.(stl|step|stp)$/i; - var STEP_PARAMS = { linearDeflection: 0.0005, angularDeflection: 0.2 }; + var STEP_PARAMS = { linearDeflection: 0.0002, angularDeflection: 0.1 }; var WORKER_SRC = [ "self.onmessage = function (e) {", @@ -153,11 +153,21 @@ } function showData(extra) { var d = el.querySelector(".model-viewer-data"); - if (!d) return; - var parts = []; - if (fileSize) parts.push(formatBytes(fileSize)); - if (extra) parts.push(extra); - if (parts.length) { d.textContent = parts.join(" \u00b7 "); d.style.display = "block"; } + if (d) { + var parts = []; + if (fileSize) parts.push(formatBytes(fileSize)); + if (extra) parts.push(extra); + if (parts.length) { d.textContent = parts.join(" \u00b7 "); d.style.display = "block"; } + } + var cap = el.querySelector(".model-viewer-caption"); + if (cap && extra) { + var stats = []; + if (fileSize) stats.push(formatBytes(fileSize)); + stats.push(extra); + var name = cap.getAttribute("data-model-name"); + if (!name) { name = (cap.textContent || "").trim(); cap.setAttribute("data-model-name", name); } + cap.textContent = (name ? name + " \u00b7 " : "") + stats.join(" \u00b7 "); + } } function loadThree(cb) { @@ -176,14 +186,16 @@ loadThree(function () { var wrap = el.querySelector(".model-viewer-canvas") || el; - var w = wrap.clientWidth || el.clientWidth || 400; - var h = Math.min(520, Math.max(320, Math.round(w * 0.62))); + function canvasSize() { + return { w: wrap.clientWidth || el.clientWidth || 400, h: wrap.clientHeight || 420 }; + } + var initSize = canvasSize(); var renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setSize(w, h); + renderer.setSize(initSize.w, initSize.h); renderer.setClearColor(0x1d2021, 1); var scene = new THREE.Scene(); - var camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 1000000); + var camera = new THREE.PerspectiveCamera(45, initSize.w / initSize.h, 0.1, 1000000); camera.position.set(300, 220, 320); scene.add(new THREE.AmbientLight(0xffffff, 0.55)); var dir = new THREE.DirectionalLight(0xffffff, 1.0); dir.position.set(1, 1.5, 1); scene.add(dir); @@ -193,6 +205,15 @@ var group = new THREE.Group(); scene.add(group); + var resizeObs = new ResizeObserver(function () { + var s = canvasSize(); + if (!s.w || !s.h) return; + renderer.setSize(s.w, s.h); + camera.aspect = s.w / s.h; + camera.updateProjectionMatrix(); + }); + resizeObs.observe(wrap); + function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); @@ -226,6 +247,8 @@ fetchProgress(OCCT_WASM_ABS, function () {}).then(function (wasmBuf) { setStatus("Parsing STEP\u2026"); setProgress(null); + var t0 = Date.now(); + var parseTimer = setInterval(function () { setStatus("Parsing STEP\u2026 (" + Math.round((Date.now() - t0) / 1000) + "s)"); }, 1000); var blob = new Blob([WORKER_SRC], { type: "application/javascript" }); var wurl = URL.createObjectURL(blob); var worker = new Worker(wurl); @@ -234,7 +257,7 @@ if (e.data.type === "ready") { worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS }, [data.buffer]); } else if (e.data.type === "done") { - settled = true; worker.terminate(); URL.revokeObjectURL(wurl); + settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); if (!e.data.success) { onError("Could not parse STEP file."); return; } var tris = 0; (e.data.meshes || []).forEach(function (m) { @@ -251,17 +274,17 @@ showCanvas(); showData(formatCount(tris) + " triangles"); } else if (e.data.type === "error") { - settled = true; worker.terminate(); URL.revokeObjectURL(wurl); + settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); onError(e.data.message || "Could not parse STEP file."); } }; worker.onerror = function (e) { - if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); onError("STEP worker error: " + (e && e.message || "unknown")); } + if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); onError("STEP worker error: " + (e && e.message || "unknown")); } }; worker.postMessage({ type: "loadLib", libUrl: OCCT_JS_ABS, wasm: new Uint8Array(wasmBuf) }, [wasmBuf]); }).catch(function () { onError("Could not download STEP parser."); }); } else { - var geo = new THREE.STLLoader().parse(data); + var geo = new THREE.STLLoader().parse(data.buffer); geo.computeVertexNormals(); var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.25, roughness: 0.55, flatShading: true }); group.add(new THREE.Mesh(geo, mat)); @@ -299,5 +322,26 @@ a.replaceWith(el); initViewer(el, href, m[0]); }); + + var blog = window.blogData; + var byAlias = {}; + if (blog && blog.articles) { + blog.articles.forEach(function (a) { + var al = String(a.url || "").split("/").pop(); + if (al) byAlias[al] = a; + }); + } + document.querySelectorAll("#content a[href]").forEach(function (a) { + var m = String(a.getAttribute("href") || "").match(/\.\/([^/?#]+)$/); + if (!m) return; + var art = byAlias[m[1]]; + if (!art) return; + var t = String(art.title || "").trim(); + var mm = t.match(MODEL_RE); + if (!mm) return; + var el = makeViewer(t); + a.replaceWith(el); + initViewer(el, "api/notes/" + art.id + "/download", mm[0]); + }); })(); \ No newline at end of file diff --git a/scripts/menu_date_sort.js b/scripts/menu_date_sort.js index 979ec36..e98b232 100644 --- a/scripts/menu_date_sort.js +++ b/scripts/menu_date_sort.js @@ -13,9 +13,9 @@ function shorten(t) { t = String(t || "").replace(/\s+/g, " ").trim(); - if (t.length <= 26) return t; - var cut = t.slice(0, 26).replace(/\s+\S*$/, ""); - return (cut.length ? cut : t.slice(0, 26)) + "…"; + if (t.length <= 32) return t; + var cut = t.slice(0, 32).replace(/\s+\S*$/, ""); + return (cut.length ? cut : t.slice(0, 32)) + "…"; } menu.querySelectorAll("li").forEach(function (li) { @@ -25,8 +25,9 @@ if (!m) return; var art = byAlias[m[1]]; if (!art) return; - var date = String(art.date || "").slice(0, 10); - li.setAttribute("data-pubdate", date); + var pub = String(art.date || "").slice(0, 10); + var date = String(art.dateText || pub || ""); + li.setAttribute("data-pubdate", pub); var span = a.querySelector("span"); if (span) { var label = (date ? date + " " : "") + shorten(a.textContent.replace(/\s+/g, " ").trim()); diff --git a/theme/blog_share_theme.css b/theme/blog_share_theme.css index d0dd1c8..649ba99 100644 --- a/theme/blog_share_theme.css +++ b/theme/blog_share_theme.css @@ -1,5 +1,5 @@ /* - * Gruvbox Share Theme for Your Blog (Trilium Public Share) + * Gruvbox Share Theme for Richard's Blog (Trilium Public Share) * Palette: bg #282828 / #3c3836 / #504945 / #1d2021 fg #ebdbb2 / #fbf1c7 * red #fb4934 green #b8bb26 yellow #fabd2f * blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019 @@ -811,7 +811,10 @@ html.theme-light #content code { /* ---------- 3D model viewer (STL / STEP) ---------- */ .model-viewer { position: relative; + box-sizing: border-box; + width: calc(100vw - 24px); margin: 1em 0; + margin-left: calc(50% - (100vw - 24px) / 2); background: #1d2021; border: 1px solid var(--background-highlight); border-radius: 8px; @@ -836,11 +839,20 @@ html.theme-light #content code { .model-viewer-canvas { position: relative; + width: 100%; + height: min(70vh, 900px); + min-height: 420px; } .model-viewer-status { - padding: 48px; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; text-align: center; + padding: 48px; + box-sizing: border-box; color: #a89984; font-size: 0.9em; } @@ -850,9 +862,13 @@ html.theme-light #content code { } .model-viewer-progress { - position: relative; + position: absolute; + left: 0; + right: 0; + bottom: 40px; height: 6px; - margin: 0 48px 22px; + width: min(60%, 480px); + margin: 0 auto; background: #3c3836; border-radius: 3px; overflow: hidden; @@ -899,7 +915,7 @@ html.theme-light #content code { .model-viewer canvas { display: block; width: 100%; - height: auto; + height: 100%; } /* ---------- Bildunterschriften (Gruvbox) ---------- */