mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
forgejo: add JS unit tests, extract pure functions into viewer-util.js
Extract parseCommitHref, isSimpleTypeSelector, subtractIdMap, and isCommitHash into a new viewer-util.js ES module so they can be tested without a browser. Convert ifcurl.js from an IIFE to an ES module importing from viewer-util.js; update footer.tmpl to type="module". Add node:test suites for viewer-url.js (32 tests) and viewer-util.js (28 tests). Add npm test script and document it in the build section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1a7a6c829
commit
d60bd044dd
9 changed files with 619 additions and 241 deletions
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,82 +6,73 @@
|
|||
// 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");
|
||||
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 <a href="ifc://..."> 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;
|
||||
// Replace <a href="ifc://..."> 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;
|
||||
var ifcUrl = a.getAttribute("href");
|
||||
var img = document.createElement("img");
|
||||
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%";
|
||||
var imgLink = document.createElement("a");
|
||||
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);
|
||||
var code = document.createElement("code");
|
||||
const code = document.createElement("code");
|
||||
code.textContent = ifcUrl;
|
||||
var urlLink = document.createElement("a");
|
||||
const urlLink = document.createElement("a");
|
||||
urlLink.href = ifcUrl;
|
||||
urlLink.appendChild(code);
|
||||
var caption = document.createElement("figcaption");
|
||||
const caption = document.createElement("figcaption");
|
||||
caption.appendChild(urlLink);
|
||||
var figure = document.createElement("figure");
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "ifcurl-preview";
|
||||
figure.appendChild(imgLink);
|
||||
figure.appendChild(caption);
|
||||
a.parentNode.replaceChild(figure, a);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
var host = window.location.host;
|
||||
function init() {
|
||||
const host = window.location.host;
|
||||
|
||||
replaceIfcAnchors();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Case 1: single file view — permalink button in .file-header-right.
|
||||
// -----------------------------------------------------------------------
|
||||
var btnGroup = document.querySelector(".file-header-right .ui.buttons");
|
||||
const 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);
|
||||
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"));
|
||||
}
|
||||
|
|
@ -93,48 +84,48 @@
|
|||
// 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$/);
|
||||
const prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/);
|
||||
if (prMatch) {
|
||||
var prRepoPath = prMatch[1];
|
||||
var prNum = prMatch[2];
|
||||
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) {
|
||||
var baseSha = pr.base && pr.base.sha;
|
||||
var headSha = pr.head && pr.head.sha;
|
||||
const baseSha = pr.base && pr.base.sha;
|
||||
const headSha = pr.head && pr.head.sha;
|
||||
if (!baseSha || !headSha) return;
|
||||
var origin = window.location.origin;
|
||||
const origin = window.location.origin;
|
||||
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
|
||||
var info = parseCommitHref(a.getAttribute("href"));
|
||||
const info = parseCommitHref(a.getAttribute("href"));
|
||||
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
|
||||
if (info.hash !== headSha) return;
|
||||
var baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha
|
||||
const baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha
|
||||
+ "?path=" + encodeURIComponent(info.treePath);
|
||||
var headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha
|
||||
const headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha
|
||||
+ "?path=" + encodeURIComponent(info.treePath);
|
||||
var img = document.createElement("img");
|
||||
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;
|
||||
var imgLink = document.createElement("a");
|
||||
const 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");
|
||||
const 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");
|
||||
const urlLink = document.createElement("a");
|
||||
urlLink.href = headIfc;
|
||||
urlLink.appendChild(code);
|
||||
var container = document.createElement("div");
|
||||
const 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");
|
||||
const fileBox = a.closest(".diff-file-box");
|
||||
const header = fileBox && fileBox.querySelector(".file-header");
|
||||
if (header) {
|
||||
header.insertAdjacentElement("afterend", container);
|
||||
} else {
|
||||
|
|
@ -151,31 +142,31 @@
|
|||
// 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);
|
||||
const 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;
|
||||
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.
|
||||
var parentSha = null;
|
||||
var parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']");
|
||||
let parentSha = null;
|
||||
const parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']");
|
||||
if (parentLink) {
|
||||
var pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i);
|
||||
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) {
|
||||
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");
|
||||
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");
|
||||
|
||||
var ifcPath, imgSrc, viewerIfc;
|
||||
let 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);
|
||||
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) {
|
||||
|
|
@ -192,26 +183,26 @@
|
|||
return;
|
||||
}
|
||||
|
||||
var img = document.createElement("img");
|
||||
const img = document.createElement("img");
|
||||
img.src = imgSrc;
|
||||
img.style.cssText = "max-width:100%;display:block";
|
||||
img.alt = ifcPath;
|
||||
var imgLink = document.createElement("a");
|
||||
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");
|
||||
const 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");
|
||||
const urlLink = document.createElement("a");
|
||||
urlLink.href = viewerIfc;
|
||||
urlLink.appendChild(code);
|
||||
var container = document.createElement("div");
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "margin:8px 0";
|
||||
container.appendChild(imgLink);
|
||||
container.appendChild(urlLink);
|
||||
var header = box.querySelector(".diff-file-header");
|
||||
const header = box.querySelector(".diff-file-header");
|
||||
if (header) header.insertAdjacentElement("afterend", container);
|
||||
});
|
||||
return;
|
||||
|
|
@ -222,17 +213,13 @@
|
|||
// browse-file link (/src/commit/{hash}/{treepath}).
|
||||
// -----------------------------------------------------------------------
|
||||
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
|
||||
var info = parseCommitHref(a.getAttribute("href"));
|
||||
const info = parseCommitHref(a.getAttribute("href"));
|
||||
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
|
||||
var btn = makeViewerLink(info, "3D");
|
||||
const 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();
|
||||
|
|
|
|||
39
forgejo/custom/public/assets/viewer-util.js
Normal file
39
forgejo/custom/public/assets/viewer-util.js
Normal file
|
|
@ -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<number> }.
|
||||
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);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
<script src="/assets/ifcurl.js"></script>
|
||||
<script type="module" src="/assets/ifcurl.js"></script>
|
||||
|
|
|
|||
203
forgejo/tests/viewer-url.test.js
Normal file
203
forgejo/tests/viewer-url.test.js
Normal file
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
162
forgejo/tests/viewer-util.test.js
Normal file
162
forgejo/tests/viewer-util.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue