diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4c0aa9e..b6b9517 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -105,3 +105,4 @@ {"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} +{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."} diff --git a/forgejo/README.md b/forgejo/README.md index 36ff5aa..569ed12 100644 --- a/forgejo/README.md +++ b/forgejo/README.md @@ -43,8 +43,9 @@ forgejo/ ifc_url_test.go ← Go tests (copy into source tree) custom/public/assets/ viewer.html ← browser IFC viewer (no rebuild needed) - viewer-url.js ← viewer URL logic module - ifcurl.js ← "View in 3D" + PR diff injection (no rebuild needed) + viewer-url.js ← ifc:// URL parse/build/resolve logic (ES module) + viewer-util.js ← shared pure utilities: parseCommitHref, isSimpleTypeSelector, etc. + ifcurl.js ← "View in 3D" + PR diff injection (ES module, no rebuild needed) templates/custom/ footer.tmpl ← loads ifcurl.js (no rebuild needed) server-config/ @@ -137,6 +138,7 @@ Node.js 18+ and npm (used only at build time; not required on the server). cd forgejo/ npm install # installs pinned versions from package-lock.json npm run build # produces the four files below +npm test # run JavaScript unit tests (viewer-url.js, viewer-util.js) ``` Output files (committed to the repository): @@ -174,6 +176,7 @@ These files can be updated at any time without recompiling Forgejo. sudo mkdir -p /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/viewer.html /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/viewer-url.js /var/lib/forgejo/custom/public/assets/ +sudo cp forgejo/custom/public/assets/viewer-util.js /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/viewer-deps.js /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/fragments-worker.js /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/web-ifc.wasm /var/lib/forgejo/custom/public/assets/ @@ -181,6 +184,8 @@ sudo cp forgejo/custom/public/assets/web-ifc-mt.wasm /var/lib/forgejo/custo sudo cp forgejo/custom/public/assets/ifcurl.js /var/lib/forgejo/custom/public/assets/ ``` +`ifcurl.js` is an ES module and imports from `viewer-util.js` at the same path, so both files must be deployed together. + Served at `/assets/viewer.html`, `/assets/viewer-deps.js`, etc. Note: the correct CustomPath is `/var/lib/forgejo/custom` (Forgejo's default when `FORGEJO_CUSTOM` is diff --git a/forgejo/custom/public/assets/ifcurl.js b/forgejo/custom/public/assets/ifcurl.js index 843fa3c..b95e9f5 100644 --- a/forgejo/custom/public/assets/ifcurl.js +++ b/forgejo/custom/public/assets/ifcurl.js @@ -6,233 +6,220 @@ // Deploy to: /var/lib/forgejo/custom/public/assets/ifcurl.js // // Uses commit-hash hrefs (always unambiguous) to construct ifc:// URLs. -(function () { - "use strict"; +import { parseCommitHref } from "./viewer-util.js"; - var VIEWER_BASE = "/assets/viewer.html"; +const VIEWER_BASE = "/assets/viewer.html"; - // Parse /owner/repo/src/commit/{40hex}/{treepath} - // Returns {repoPath, hash, treePath} or null. - function parseCommitHref(href) { - var m = href.match(/^\/([^/]+\/[^/]+)\/src\/commit\/([0-9a-f]{40})\/(.+)$/i); - if (!m) return null; - return { repoPath: m[1], hash: m[2], treePath: m[3] }; - } - - function makeViewerLink(info, label, cls) { - var host = window.location.host; - var ifcUrl = "ifc://" + host + "/" + info.repoPath +function makeViewerLink(info, label, cls) { + const host = window.location.host; + const ifcUrl = "ifc://" + host + "/" + info.repoPath + "@" + info.hash + "?path=" + encodeURIComponent(info.treePath); - var a = document.createElement("a"); - a.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl); - a.target = "_blank"; - a.rel = "noopener noreferrer"; - a.className = cls || "ui mini basic button"; - a.textContent = label; - return a; + const a = document.createElement("a"); + a.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl); + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.className = cls || "ui mini basic button"; + a.textContent = label; + return a; +} + +// Replace links in rendered markdown with preview figures. +// Handles [title](ifc://...) links produced by Goldmark's standard link parser — +// no Go extension required. Skips anchors already inside .ifcurl-preview (those +// were rendered server-side by ifc_url.go when the patched binary is in use). +function replaceIfcAnchors() { + const origin = window.location.origin; + document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) { + if (a.closest(".ifcurl-preview")) return; + const ifcUrl = a.getAttribute("href"); + const img = document.createElement("img"); + img.src = origin + "/preview?url=" + encodeURIComponent(ifcUrl); + img.alt = "IFC preview"; + img.loading = "lazy"; + img.style.maxWidth = "100%"; + const imgLink = document.createElement("a"); + imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl); + imgLink.title = ifcUrl; + imgLink.target = "_blank"; + imgLink.rel = "noopener noreferrer"; + imgLink.appendChild(img); + const code = document.createElement("code"); + code.textContent = ifcUrl; + const urlLink = document.createElement("a"); + urlLink.href = ifcUrl; + urlLink.appendChild(code); + const caption = document.createElement("figcaption"); + caption.appendChild(urlLink); + const figure = document.createElement("figure"); + figure.className = "ifcurl-preview"; + figure.appendChild(imgLink); + figure.appendChild(caption); + a.parentNode.replaceChild(figure, a); + }); +} + +function init() { + const host = window.location.host; + + replaceIfcAnchors(); + + // ----------------------------------------------------------------------- + // Case 1: single file view — permalink button in .file-header-right. + // ----------------------------------------------------------------------- + const btnGroup = document.querySelector(".file-header-right .ui.buttons"); + if (btnGroup) { + // Permalink href or current URL when already at a commit. + const a = document.querySelector('a[href*="/src/commit/"]'); + const href = a ? a.getAttribute("href") : window.location.pathname; + const info = parseCommitHref(href); + if (info && info.treePath.toLowerCase().endsWith(".ifc")) { + btnGroup.appendChild(makeViewerLink(info, "View in 3D")); + } + return; } - // Replace links in rendered markdown with preview figures. - // Handles [title](ifc://...) links produced by Goldmark's standard link parser — - // no Go extension required. Skips anchors already inside .ifcurl-preview (those - // were rendered server-side by ifc_url.go when the patched binary is in use). - function replaceIfcAnchors() { - var origin = window.location.origin; - document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) { - if (a.closest(".ifcurl-preview")) return; - var ifcUrl = a.getAttribute("href"); - var img = document.createElement("img"); - img.src = origin + "/preview?url=" + encodeURIComponent(ifcUrl); - img.alt = "IFC preview"; - img.loading = "lazy"; - img.style.maxWidth = "100%"; - var imgLink = document.createElement("a"); - imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl); - imgLink.title = ifcUrl; + // ----------------------------------------------------------------------- + // Case 3: PR diff page — inject a render_diff image below the header of + // each .ifc file in the diff. Requires Nginx to proxy /render_diff to + // the ifcurl preview service (see forgejo/README.md). + // ----------------------------------------------------------------------- + const prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/); + if (prMatch) { + const prRepoPath = prMatch[1]; + const prNum = prMatch[2]; + fetch("/api/v1/repos/" + prRepoPath + "/pulls/" + prNum) + .then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function(pr) { + const baseSha = pr.base && pr.base.sha; + const headSha = pr.head && pr.head.sha; + if (!baseSha || !headSha) return; + const origin = window.location.origin; + document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) { + const info = parseCommitHref(a.getAttribute("href")); + if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return; + if (info.hash !== headSha) return; + const baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha + + "?path=" + encodeURIComponent(info.treePath); + const headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha + + "?path=" + encodeURIComponent(info.treePath); + const img = document.createElement("img"); + img.src = origin + "/render_diff" + + "?base=" + encodeURIComponent(baseIfc) + + "&head=" + encodeURIComponent(headIfc); + img.style.cssText = "max-width:100%;display:block"; + img.alt = info.treePath; + const imgLink = document.createElement("a"); + imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc); + imgLink.target = "_blank"; + imgLink.rel = "noopener noreferrer"; + imgLink.appendChild(img); + const code = document.createElement("code"); + code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px"; + code.textContent = headIfc; + const urlLink = document.createElement("a"); + urlLink.href = headIfc; + urlLink.appendChild(code); + const container = document.createElement("div"); + container.style.cssText = "margin:8px 0"; + container.appendChild(imgLink); + container.appendChild(urlLink); + const fileBox = a.closest(".diff-file-box"); + const header = fileBox && fileBox.querySelector(".file-header"); + if (header) { + header.insertAdjacentElement("afterend", container); + } else { + a.insertAdjacentElement("afterend", container); + } + }); + }) + .catch(function() {}); // silently skip on API error + return; + } + + // ----------------------------------------------------------------------- + // Case 4: individual commit page — inject a render_diff image (or plain + // preview for added/deleted files) below the header of each .ifc file + // in the diff. + // ----------------------------------------------------------------------- + const commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i); + if (commitMatch) { + const repoPath = commitMatch[1]; + const headSha = commitMatch[2]; + const origin = window.location.origin; + + // Extract parent SHA from the "parent" commit link at the top of the page. + let parentSha = null; + const parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']"); + if (parentLink) { + const pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i); + if (pm) parentSha = pm[1]; + } + + document.querySelectorAll(".diff-file-box").forEach(function(box) { + const newFile = box.getAttribute("data-new-filename") || ""; + const oldFile = box.getAttribute("data-old-filename") || ""; + const newIsIfc = newFile.toLowerCase().endsWith(".ifc"); + const oldIsIfc = oldFile.toLowerCase().endsWith(".ifc"); + + let ifcPath, imgSrc, viewerIfc; + if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) { + // Modified: show colour-coded diff + ifcPath = newFile; + const baseIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath); + viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath); + imgSrc = origin + "/render_diff?base=" + encodeURIComponent(baseIfc) + "&head=" + encodeURIComponent(viewerIfc); + } else if (newIsIfc) { + // Added (or renamed to .ifc): plain preview of new version + ifcPath = newFile; + viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath); + imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc); + } else if (oldIsIfc && parentSha) { + // Deleted (or renamed from .ifc): plain preview of old version + ifcPath = oldFile; + viewerIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath); + imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc); + } else { + return; + } + + const img = document.createElement("img"); + img.src = imgSrc; + img.style.cssText = "max-width:100%;display:block"; + img.alt = ifcPath; + const imgLink = document.createElement("a"); + imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(viewerIfc); imgLink.target = "_blank"; imgLink.rel = "noopener noreferrer"; imgLink.appendChild(img); - var code = document.createElement("code"); - code.textContent = ifcUrl; - var urlLink = document.createElement("a"); - urlLink.href = ifcUrl; + const code = document.createElement("code"); + code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px"; + code.textContent = viewerIfc; + const urlLink = document.createElement("a"); + urlLink.href = viewerIfc; urlLink.appendChild(code); - var caption = document.createElement("figcaption"); - caption.appendChild(urlLink); - var figure = document.createElement("figure"); - figure.className = "ifcurl-preview"; - figure.appendChild(imgLink); - figure.appendChild(caption); - a.parentNode.replaceChild(figure, a); + const container = document.createElement("div"); + container.style.cssText = "margin:8px 0"; + container.appendChild(imgLink); + container.appendChild(urlLink); + const header = box.querySelector(".diff-file-header"); + if (header) header.insertAdjacentElement("afterend", container); }); + return; } - function init() { - var host = window.location.host; + // ----------------------------------------------------------------------- + // Case 2: file history page — inject a "3D" link next to each commit's + // browse-file link (/src/commit/{hash}/{treepath}). + // ----------------------------------------------------------------------- + document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) { + const info = parseCommitHref(a.getAttribute("href")); + if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return; + const btn = makeViewerLink(info, "3D"); + btn.style.marginLeft = "4px"; + a.insertAdjacentElement("afterend", btn); + }); +} - replaceIfcAnchors(); - - // ----------------------------------------------------------------------- - // Case 1: single file view — permalink button in .file-header-right. - // ----------------------------------------------------------------------- - var btnGroup = document.querySelector(".file-header-right .ui.buttons"); - if (btnGroup) { - // Permalink href or current URL when already at a commit. - var a = document.querySelector('a[href*="/src/commit/"]'); - var href = a ? a.getAttribute("href") : window.location.pathname; - var info = parseCommitHref(href); - if (info && info.treePath.toLowerCase().endsWith(".ifc")) { - btnGroup.appendChild(makeViewerLink(info, "View in 3D")); - } - return; - } - - // ----------------------------------------------------------------------- - // Case 3: PR diff page — inject a render_diff image below the header of - // each .ifc file in the diff. Requires Nginx to proxy /render_diff to - // the ifcurl preview service (see forgejo/README.md). - // ----------------------------------------------------------------------- - var prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/); - if (prMatch) { - var prRepoPath = prMatch[1]; - var prNum = prMatch[2]; - fetch("/api/v1/repos/" + prRepoPath + "/pulls/" + prNum) - .then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); }) - .then(function(pr) { - var baseSha = pr.base && pr.base.sha; - var headSha = pr.head && pr.head.sha; - if (!baseSha || !headSha) return; - var origin = window.location.origin; - document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) { - var info = parseCommitHref(a.getAttribute("href")); - if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return; - if (info.hash !== headSha) return; - var baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha - + "?path=" + encodeURIComponent(info.treePath); - var headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha - + "?path=" + encodeURIComponent(info.treePath); - var img = document.createElement("img"); - img.src = origin + "/render_diff" - + "?base=" + encodeURIComponent(baseIfc) - + "&head=" + encodeURIComponent(headIfc); - img.style.cssText = "max-width:100%;display:block"; - img.alt = info.treePath; - var imgLink = document.createElement("a"); - imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc); - imgLink.target = "_blank"; - imgLink.rel = "noopener noreferrer"; - imgLink.appendChild(img); - var code = document.createElement("code"); - code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px"; - code.textContent = headIfc; - var urlLink = document.createElement("a"); - urlLink.href = headIfc; - urlLink.appendChild(code); - var container = document.createElement("div"); - container.style.cssText = "margin:8px 0"; - container.appendChild(imgLink); - container.appendChild(urlLink); - var fileBox = a.closest(".diff-file-box"); - var header = fileBox && fileBox.querySelector(".file-header"); - if (header) { - header.insertAdjacentElement("afterend", container); - } else { - a.insertAdjacentElement("afterend", container); - } - }); - }) - .catch(function() {}); // silently skip on API error - return; - } - - // ----------------------------------------------------------------------- - // Case 4: individual commit page — inject a render_diff image (or plain - // preview for added/deleted files) below the header of each .ifc file - // in the diff. - // ----------------------------------------------------------------------- - var commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i); - if (commitMatch) { - var repoPath = commitMatch[1]; - var headSha = commitMatch[2]; - var origin = window.location.origin; - - // Extract parent SHA from the "parent" commit link at the top of the page. - var parentSha = null; - var parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']"); - if (parentLink) { - var pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i); - if (pm) parentSha = pm[1]; - } - - document.querySelectorAll(".diff-file-box").forEach(function(box) { - var newFile = box.getAttribute("data-new-filename") || ""; - var oldFile = box.getAttribute("data-old-filename") || ""; - var newIsIfc = newFile.toLowerCase().endsWith(".ifc"); - var oldIsIfc = oldFile.toLowerCase().endsWith(".ifc"); - - var ifcPath, imgSrc, viewerIfc; - if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) { - // Modified: show colour-coded diff - ifcPath = newFile; - var baseIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath); - viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath); - imgSrc = origin + "/render_diff?base=" + encodeURIComponent(baseIfc) + "&head=" + encodeURIComponent(viewerIfc); - } else if (newIsIfc) { - // Added (or renamed to .ifc): plain preview of new version - ifcPath = newFile; - viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath); - imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc); - } else if (oldIsIfc && parentSha) { - // Deleted (or renamed from .ifc): plain preview of old version - ifcPath = oldFile; - viewerIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath); - imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc); - } else { - return; - } - - var img = document.createElement("img"); - img.src = imgSrc; - img.style.cssText = "max-width:100%;display:block"; - img.alt = ifcPath; - var imgLink = document.createElement("a"); - imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(viewerIfc); - imgLink.target = "_blank"; - imgLink.rel = "noopener noreferrer"; - imgLink.appendChild(img); - var code = document.createElement("code"); - code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px"; - code.textContent = viewerIfc; - var urlLink = document.createElement("a"); - urlLink.href = viewerIfc; - urlLink.appendChild(code); - var container = document.createElement("div"); - container.style.cssText = "margin:8px 0"; - container.appendChild(imgLink); - container.appendChild(urlLink); - var header = box.querySelector(".diff-file-header"); - if (header) header.insertAdjacentElement("afterend", container); - }); - return; - } - - // ----------------------------------------------------------------------- - // Case 2: file history page — inject a "3D" link next to each commit's - // browse-file link (/src/commit/{hash}/{treepath}). - // ----------------------------------------------------------------------- - document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) { - var info = parseCommitHref(a.getAttribute("href")); - if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return; - var btn = makeViewerLink(info, "3D"); - btn.style.marginLeft = "4px"; - a.insertAdjacentElement("afterend", btn); - }); - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } -})(); +// ES modules are deferred by default, so the DOM is always ready here. +init(); diff --git a/forgejo/custom/public/assets/viewer-util.js b/forgejo/custom/public/assets/viewer-util.js new file mode 100644 index 0000000..c96aa53 --- /dev/null +++ b/forgejo/custom/public/assets/viewer-util.js @@ -0,0 +1,39 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +// +// Pure utility functions shared between viewer.js, ifcurl.js, and tests. + +// Parse a Forgejo browse path: /owner/repo/src/commit/{40hex}/{treepath} +// Returns {repoPath, hash, treePath} or null. +export function parseCommitHref(href) { + const m = href.match(/^\/([^/]+\/[^/]+)\/src\/commit\/([0-9a-f]{40})\/(.+)$/i); + if (!m) return null; + return { repoPath: m[1], hash: m[2], treePath: m[3] }; +} + +// Returns true if the selector contains only IFC type names joined by "+". +// E.g. "IfcWall", "IfcWall+IfcSlab". Property filters and name= require +// server-side resolution via /select. +export function isSimpleTypeSelector(selectorStr) { + return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim())); +} + +// Set-subtract toRemove from allItems across all model IDs. +// Both arguments are ModelIdMap: { [modelId]: Iterable }. +export function subtractIdMap(allItems, toRemove) { + const result = {}; + for (const [modelId, allIds] of Object.entries(allItems)) { + const removeSet = new Set(toRemove[modelId] ?? []); + const remaining = []; + for (const id of allIds) { + if (!removeSet.has(id)) remaining.push(id); + } + if (remaining.length > 0) result[modelId] = remaining; + } + return result; +} + +// Returns true if ref looks like a git commit hash (7–40 hex chars). +export function isCommitHash(ref) { + return /^[0-9a-f]{7,40}$/.test(ref); +} diff --git a/forgejo/custom/public/assets/viewer.js b/forgejo/custom/public/assets/viewer.js index 05fc3de..04aaa73 100644 --- a/forgejo/custom/public/assets/viewer.js +++ b/forgejo/custom/public/assets/viewer.js @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT import { THREE, OBC, JSZip } from "/assets/viewer-deps.js"; import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.js"; +import { isSimpleTypeSelector, subtractIdMap, isCommitHash } from "./viewer-util.js"; const params = new URLSearchParams(window.location.search); const ifcUrl = params.get("url") ?? ""; @@ -459,23 +460,6 @@ function setupClipper(components, world, clips) { // server-side via GET /select, then applied by GUID in the viewer. // Requires the ifcurl service to be running. // ----------------------------------------------------------------------- -function isSimpleTypeSelector(selectorStr) { - return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim())); -} - -// Returns a ModelIdMap containing all items in allItems that are NOT in toRemove. -function subtractIdMap(allItems, toRemove) { - const result = {}; - for (const [modelId, allIds] of Object.entries(allItems)) { - const removeSet = new Set(toRemove[modelId] ?? []); - const remaining = []; - for (const id of allIds) { - if (!removeSet.has(id)) remaining.push(id); - } - if (remaining.length > 0) result[modelId] = remaining; - } - return result; -} // Apply a fragment material style to the given items via FragmentsManager. async function applyFragmentStyle(components, items, color, opacity) { @@ -982,10 +966,6 @@ async function populateRefList(host, repoSuffix) { // ----------------------------------------------------------------------- // Commit-pin indicator helpers. // ----------------------------------------------------------------------- -function isCommitHash(ref) { - return /^[0-9a-f]{7,40}$/.test(ref); -} - async function fetchBranchForCommit(host, repoSuffix, hash) { const proto = host.startsWith("localhost") ? "http" : "https"; const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`; diff --git a/forgejo/package.json b/forgejo/package.json index c3f90bf..7d3ddf9 100644 --- a/forgejo/package.json +++ b/forgejo/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "build": "node build.js" + "build": "node build.js", + "test": "node --test tests/*.test.js" }, "dependencies": { "@thatopen/components": "3.4.2", diff --git a/forgejo/templates/custom/footer.tmpl b/forgejo/templates/custom/footer.tmpl index 37985b3..885f4d0 100644 --- a/forgejo/templates/custom/footer.tmpl +++ b/forgejo/templates/custom/footer.tmpl @@ -1 +1 @@ - + diff --git a/forgejo/tests/viewer-url.test.js b/forgejo/tests/viewer-url.test.js new file mode 100644 index 0000000..54a4b44 --- /dev/null +++ b/forgejo/tests/viewer-url.test.js @@ -0,0 +1,203 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { parseIfcUrl, buildIfcUrl, toRawUrl } from "../custom/public/assets/viewer-url.js"; + +// --------------------------------------------------------------------------- +// parseIfcUrl +// --------------------------------------------------------------------------- + +describe("parseIfcUrl", () => { + test("returns null for non-ifc URLs", () => { + assert.equal(parseIfcUrl("https://example.com"), null); + assert.equal(parseIfcUrl(""), null); + assert.equal(parseIfcUrl(null), null); + assert.equal(parseIfcUrl(undefined), null); + }); + + test("parses host and repo path", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?path=model.ifc"); + assert.equal(r.host, "example.com"); + assert.equal(r.repoSuffix, "org/repo"); + assert.equal(r.userAt, ""); + }); + + test("parses user@ authority", () => { + const r = parseIfcUrl("ifc://git@example.com/org/repo@heads/main?path=model.ifc"); + assert.equal(r.userAt, "git@"); + assert.equal(r.host, "example.com"); + assert.equal(r.repo, "git@example.com/org/repo"); + }); + + test("parses heads/ ref", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?path=a.ifc"); + assert.equal(r.ref, "heads/main"); + }); + + test("parses tags/ ref", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@tags/v1.0?path=a.ifc"); + assert.equal(r.ref, "tags/v1.0"); + }); + + test("parses bare commit hash ref", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@abc123def456?path=a.ifc"); + assert.equal(r.ref, "abc123def456"); + }); + + test("parses path parameter", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?path=subdir/model.ifc"); + assert.equal(r.path, "subdir/model.ifc"); + }); + + test("parses selector parameter", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?selector=IfcWall"); + assert.equal(r.selector, "IfcWall"); + }); + + test("parses camera parameter", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?camera=1,2,3,4,5,6,7,8,9"); + assert.equal(r.camera, "1,2,3,4,5,6,7,8,9"); + }); + + test("parses fov parameter", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?fov=60"); + assert.equal(r.fov, "60"); + }); + + test("parses scale parameter", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?scale=0.01"); + assert.equal(r.scale, "0.01"); + }); + + test("defaults missing parameters to empty string", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main"); + assert.equal(r.path, ""); + assert.equal(r.selector, ""); + assert.equal(r.camera, ""); + assert.equal(r.fov, ""); + assert.equal(r.scale, ""); + }); + + test("builds repo field as userAt + host + / + repoSuffix", () => { + const r = parseIfcUrl("ifc://example.com/org/repo@heads/main"); + assert.equal(r.repo, "example.com/org/repo"); + }); +}); + +// --------------------------------------------------------------------------- +// buildIfcUrl +// --------------------------------------------------------------------------- + +describe("buildIfcUrl", () => { + test("returns null when repo is missing", () => { + assert.equal(buildIfcUrl("", "heads/main", "model.ifc", ""), null); + assert.equal(buildIfcUrl(null, "heads/main", "", ""), null); + }); + + test("returns null when ref is missing", () => { + assert.equal(buildIfcUrl("example.com/org/repo", "", "model.ifc", ""), null); + assert.equal(buildIfcUrl("example.com/org/repo", null, "", ""), null); + }); + + test("builds minimal URL with no path or selector", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "", ""); + assert.equal(url, "ifc://example.com/org/repo@heads/main"); + }); + + test("includes path in query string", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "model.ifc", ""); + assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc"); + }); + + test("includes selector in query string", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall"); + assert.equal(url, "ifc://example.com/org/repo@heads/main?selector=IfcWall"); + }); + + test("includes both path and selector", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "model.ifc", "IfcWall"); + assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc&selector=IfcWall"); + }); + + test("does not percent-encode commas in selector", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall,Name=\"Core\""); + assert.ok(url.includes(","), "comma should not be encoded"); + assert.ok(!url.includes("%2C"), "%2C should not appear"); + }); + + test("does not percent-encode + in selector", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall+IfcSlab"); + assert.ok(!url.includes("%2B"), "%2B should not appear"); + }); + + test("does not percent-encode $ in selector (GlobalId syntax)", () => { + const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "325Q7Fhnf67OZC$$r43uzK"); + assert.ok(!url.includes("%24"), "%24 should not appear"); + }); +}); + +// --------------------------------------------------------------------------- +// toRawUrl +// --------------------------------------------------------------------------- + +describe("toRawUrl", () => { + // GitHub + test("generates GitHub raw URL for heads/ ref", () => { + const url = toRawUrl("ifc://github.com/org/repo@heads/main?path=model.ifc"); + assert.equal(url, "https://raw.githubusercontent.com/org/repo/main/model.ifc"); + }); + + test("generates GitHub raw URL for tags/ ref", () => { + const url = toRawUrl("ifc://github.com/org/repo@tags/v1.0?path=model.ifc"); + assert.equal(url, "https://raw.githubusercontent.com/org/repo/v1.0/model.ifc"); + }); + + test("generates GitHub raw URL for bare commit hash", () => { + const url = toRawUrl("ifc://github.com/org/repo@abc123?path=model.ifc"); + assert.equal(url, "https://raw.githubusercontent.com/org/repo/abc123/model.ifc"); + }); + + // GitLab + test("generates GitLab raw URL for heads/ ref", () => { + const url = toRawUrl("ifc://gitlab.com/org/repo@heads/main?path=model.ifc"); + assert.equal(url, "https://gitlab.com/org/repo/-/raw/main/model.ifc"); + }); + + test("generates GitLab raw URL for tags/ ref", () => { + const url = toRawUrl("ifc://gitlab.com/org/repo@tags/v1.0?path=model.ifc"); + assert.equal(url, "https://gitlab.com/org/repo/-/raw/v1.0/model.ifc"); + }); + + // Forgejo / Gitea (codeberg.org, self-hosted, etc.) + test("generates Forgejo raw URL for heads/ ref", () => { + const url = toRawUrl("ifc://codeberg.org/org/repo@heads/main?path=model.ifc"); + assert.equal(url, "https://codeberg.org/org/repo/raw/branch/main/model.ifc"); + }); + + test("generates Forgejo raw URL for tags/ ref", () => { + const url = toRawUrl("ifc://codeberg.org/org/repo@tags/v1.0?path=model.ifc"); + assert.equal(url, "https://codeberg.org/org/repo/raw/tag/v1.0/model.ifc"); + }); + + test("generates Forgejo raw URL for bare commit hash", () => { + const url = toRawUrl("ifc://codeberg.org/org/repo@abc123?path=model.ifc"); + assert.equal(url, "https://codeberg.org/org/repo/raw/commit/abc123/model.ifc"); + }); + + test("generates Forgejo raw URL for self-hosted instance", () => { + const url = toRawUrl("ifc://git.example.com/org/repo@heads/main?path=model.ifc"); + assert.equal(url, "https://git.example.com/org/repo/raw/branch/main/model.ifc"); + }); + + test("uses http for localhost Forgejo (local dev instance)", () => { + const url = toRawUrl("ifc://localhost:3000/org/repo@heads/main?path=model.ifc"); + assert.equal(url, "http://localhost:3000/org/repo/raw/branch/main/model.ifc"); + }); + + test("includes subdirectory file path", () => { + const url = toRawUrl("ifc://codeberg.org/org/repo@heads/main?path=subdir/model.ifc"); + assert.ok(url.endsWith("/subdir/model.ifc"), `expected path suffix, got: ${url}`); + }); +}); diff --git a/forgejo/tests/viewer-util.test.js b/forgejo/tests/viewer-util.test.js new file mode 100644 index 0000000..f8572aa --- /dev/null +++ b/forgejo/tests/viewer-util.test.js @@ -0,0 +1,162 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { parseCommitHref, isSimpleTypeSelector, subtractIdMap, isCommitHash } + from "../custom/public/assets/viewer-util.js"; + +// --------------------------------------------------------------------------- +// parseCommitHref +// --------------------------------------------------------------------------- + +describe("parseCommitHref", () => { + test("parses a standard Forgejo browse path", () => { + const r = parseCommitHref("/org/repo/src/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/model.ifc"); + assert.equal(r.repoPath, "org/repo"); + assert.equal(r.hash, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); + assert.equal(r.treePath, "model.ifc"); + }); + + test("parses a path with subdirectories", () => { + const r = parseCommitHref("/org/repo/src/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/subdir/model.ifc"); + assert.equal(r.treePath, "subdir/model.ifc"); + }); + + test("returns null for non-commit paths", () => { + assert.equal(parseCommitHref("/org/repo/src/branch/main/model.ifc"), null); + assert.equal(parseCommitHref("/org/repo"), null); + assert.equal(parseCommitHref(""), null); + }); + + test("returns null if hash is not 40 hex chars", () => { + assert.equal(parseCommitHref("/org/repo/src/commit/abc123/model.ifc"), null); + }); + + test("is case-insensitive for hex hash", () => { + const r = parseCommitHref("/org/repo/src/commit/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2/model.ifc"); + assert.ok(r !== null); + assert.equal(r.hash, "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"); + }); +}); + +// --------------------------------------------------------------------------- +// isSimpleTypeSelector +// --------------------------------------------------------------------------- + +describe("isSimpleTypeSelector", () => { + test("accepts a single IFC type", () => { + assert.equal(isSimpleTypeSelector("IfcWall"), true); + }); + + test("accepts multiple types joined by +", () => { + assert.equal(isSimpleTypeSelector("IfcWall+IfcSlab"), true); + assert.equal(isSimpleTypeSelector("IfcWall+IfcSlab+IfcBeam"), true); + }); + + test("accepts subtypes", () => { + assert.equal(isSimpleTypeSelector("IfcWallStandardCase"), true); + }); + + test("accepts lowercase ifc prefix", () => { + assert.equal(isSimpleTypeSelector("ifcWall"), true); + }); + + test("rejects name filter", () => { + assert.equal(isSimpleTypeSelector('IfcWall, Name="Core Wall"'), false); + }); + + test("rejects property set filter", () => { + assert.equal(isSimpleTypeSelector("IfcWall[Pset_WallCommon.IsExternal=true]"), false); + }); + + test("rejects bare GlobalId (not an IFC type name)", () => { + assert.equal(isSimpleTypeSelector("325Q7Fhnf67OZC$$r43uzK"), false); + }); + + test("rejects empty string", () => { + assert.equal(isSimpleTypeSelector(""), false); + }); +}); + +// --------------------------------------------------------------------------- +// subtractIdMap +// --------------------------------------------------------------------------- + +describe("subtractIdMap", () => { + test("removes ids present in toRemove", () => { + const all = { m1: [1, 2, 3, 4] }; + const remove = { m1: [2, 4] }; + const result = subtractIdMap(all, remove); + assert.deepEqual(result, { m1: [1, 3] }); + }); + + test("omits model keys where all ids are removed", () => { + const all = { m1: [1, 2] }; + const remove = { m1: [1, 2] }; + const result = subtractIdMap(all, remove); + assert.deepEqual(result, {}); + }); + + test("leaves models untouched that are not in toRemove", () => { + const all = { m1: [1, 2], m2: [3, 4] }; + const remove = { m1: [1] }; + const result = subtractIdMap(all, remove); + assert.deepEqual(result, { m1: [2], m2: [3, 4] }); + }); + + test("handles empty toRemove", () => { + const all = { m1: [1, 2, 3] }; + const result = subtractIdMap(all, {}); + assert.deepEqual(result, { m1: [1, 2, 3] }); + }); + + test("handles multiple models in toRemove", () => { + const all = { m1: [1, 2], m2: [3, 4] }; + const remove = { m1: [2], m2: [3] }; + const result = subtractIdMap(all, remove); + assert.deepEqual(result, { m1: [1], m2: [4] }); + }); +}); + +// --------------------------------------------------------------------------- +// isCommitHash +// --------------------------------------------------------------------------- + +describe("isCommitHash", () => { + test("accepts a full 40-char SHA", () => { + assert.equal(isCommitHash("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), true); + }); + + test("accepts a 7-char short SHA", () => { + assert.equal(isCommitHash("a1b2c3d"), true); + }); + + test("accepts lengths between 7 and 40", () => { + assert.equal(isCommitHash("a1b2c3d4e5f6"), true); + }); + + test("rejects heads/ ref", () => { + assert.equal(isCommitHash("heads/main"), false); + }); + + test("rejects tags/ ref", () => { + assert.equal(isCommitHash("tags/v1.0"), false); + }); + + test("rejects uppercase hex (hashes are lowercase)", () => { + assert.equal(isCommitHash("A1B2C3D"), false); + }); + + test("rejects fewer than 7 chars", () => { + assert.equal(isCommitHash("a1b2c3"), false); + }); + + test("rejects more than 40 chars", () => { + assert.equal(isCommitHash("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2x"), false); + }); + + test("rejects empty string", () => { + assert.equal(isCommitHash(""), false); + }); +});