aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-26 14:56:44 +0100
committerAgando <roduccomm@gmail.com>2026-10-26 14:56:44 +0100
commit979254cf303af562574e6a6a2c86416fe2d1e7e8 (patch)
treeb582a81720479bed25da1d181503a3c9a456e3d0
parentb79d70926ae88a3b10a13e2e029bbb7f487b8cf4 (diff)
feat(newos4): mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets
Executes .hermes/plans/2026-10-24_051600-newos4-mobile-download-share.md (approved via the feedback dashboard; marker autonomy: safe). Front-end only, v4.6.94 -> v4.6.95. T1 modules/ext/filemgr.js — URL.revokeObjectURL ran in the same tick as a.click(); iOS Safari and Android Chrome hand the blob: URL to the download manager asynchronously, so the transfer was aborted while the status line still said "Downloaded". Now revoked after 30 s. T2 download honours the multi-selection via _getSelectedPaths() (falling back to _selectedFile), reports "⬇ Downloaded N files" and "❌ Skipped <path>" instead of a silent partial success. T3 share: navigator.share -> clipboard.writeText -> inline read-only <input> (focused, selected). prompt() is suppressed in iOS standalone PWAs and most Android WebViews, which is why tapping 🔗 did nothing. Unresolvable space now reports "🔗 Share unavailable — no space resolved". T4 both preview toolbar buttons: min-height/min-width 44px, touch-action manipulation, 1em, gap 10px (was 24px/0.85em/6px — the smallest tap targets in the UI). T5 _previewShareUrl falls back _resolveSpaceName -> _detectSpaceName (webdav.js:22) -> page path, and returns null rather than emitting /spaces//<path>. T6 the URL helpers are now module-scope pure functions exported for Node, so modules/test/test-download-url.js tests the shipped code instead of a local copy (4 -> 6 cases). Also fixes a latent bug that made require() impossible: `typeof window.webx11` throws under Node because typeof does not protect a member access on an undefined global. Verified on the served bundle (filemgr.js is concatenated into critical-ext-bundle.js, NOT core-bundle.js — the plan's T7 grep pointed at the wrong file): HTTP 200, 129 867 B, 2x navigator.share, 3x fmBuildShareUrl, deferred revoke present, 11x min-height:44px. Live browser probe on /spaces/newos4/main.html: _previewShareUrl('docs/a.md') -> https://webx11.duckdns.org/spaces/newos4/docs/a.md (no double slash); with navigator.share absent and clipboard blocked, clicking 🔗 produced .fm-share-input containing that URL; button style ends min-height:44px;min-width:44px;touch-action:manipulation; toolbar gap:10px. node modules/test/test-download-url.js -> 6 PASS. lint-no-undef clean (55 files). maintain: PASS version v4.6.95 x3, syntax OK, HTTP 200 /main.html. Also adds admin/docs/session-overview-2026-10-26.md (glm-5.3-compressed session record).
-rw-r--r--main.html6
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/critical-ext-bundle.js142
-rw-r--r--modules/ext/filemgr.js140
-rw-r--r--modules/test/test-download-url.js55
5 files changed, 251 insertions, 94 deletions
diff --git a/main.html b/main.html
index a44540d..1249536 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.94"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.95"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.94"></script>
+<script src="modules/core/module-loader.js?v=v4.6.95"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.94"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.95"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 7d851ee..92f08fa 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.94';
+ const LATEST_VER = 'v4.6.95';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js
index dc3a858..0c92381 100644
--- a/modules/ext/critical-ext-bundle.js
+++ b/modules/ext/critical-ext-bundle.js
@@ -931,6 +931,38 @@ if (typeof window.webx11 !== 'undefined' && window.webx11) {
}
/* === filemgr.js === */
+// 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the
+// shipped code instead of a copy of it. No DOM, no terminal, no module state.
+function fmDetectSpaceName(pathname) {
+ try {
+ const parts = String(pathname || '').split('/').filter(Boolean);
+ const i = parts.indexOf('spaces');
+ return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
+ } catch (e) { return ''; }
+}
+
+function fmExtractSpaceNameFromUrl(url) {
+ if (!url) return '';
+ try {
+ const parts = new URL(url).pathname.split('/').filter(Boolean);
+ const i = parts.indexOf('spaces');
+ return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
+ } catch (e) { return ''; }
+}
+
+// Returns null when no space can be resolved — never emits /spaces//<path>.
+function fmBuildShareUrl(space, path) {
+ if (!space) return null;
+ const clean = String(path || '').replace(/^\/+/, '');
+ if (!clean) return null;
+ return 'https://webx11.duckdns.org/spaces/' + space + '/' +
+ clean.split('/').map(encodeURIComponent).join('/');
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl };
+}
+
class FileManagerModule {
constructor() {
this.commands = {};
@@ -1876,24 +1908,25 @@ class FileManagerModule {
// 2026-10-21: preview pane had no fullscreen and no share action.
_previewShell(path, innerHtml) {
- return '<div class="fm-preview-toolbar" style="display:flex;gap:6px;justify-content:flex-end;' +
+ return '<div class="fm-preview-toolbar" style="display:flex;gap:10px;justify-content:flex-end;' +
'padding:6px 8px;border-bottom:1px solid var(--border-color);flex-shrink:0">' +
'<button class="fm-btn-fs" title="Fullscreen preview" aria-label="Fullscreen preview" style="background:var(--bg-tertiary);' +
- 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' +
- 'cursor:pointer;font-size:0.85em;min-height:24px;">⛶</button>' +
+ 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' +
+ 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">⛶</button>' +
'<button class="fm-btn-share" title="Copy share link" aria-label="Copy share link" style="background:var(--bg-tertiary);' +
- 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' +
- 'cursor:pointer;font-size:0.85em;min-height:24px;">🔗</button>' +
+ 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' +
+ 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">🔗</button>' +
'</div><div class="fm-preview-body" style="flex:1;min-height:0;overflow:auto">' + innerHtml + '</div>';
}
_previewShareUrl(path) {
const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules
? this.terminal.moduleManager.modules.WebDAVModule : null;
- const space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || '';
- const clean = String(path).replace(/^\/+/, '');
- return 'https://webx11.duckdns.org/spaces/' + space + '/' +
- clean.split('/').map(encodeURIComponent).join('/');
+ let space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || '';
+ // T5: the fallback detector already shipped in modules/ext/webdav.js:22 — use it.
+ if (!space && wdav && wdav._detectSpaceName) space = wdav._detectSpaceName() || '';
+ if (!space) space = fmDetectSpaceName(window.location.pathname);
+ return fmBuildShareUrl(space, path); // null when nothing resolves — caller reports it
}
// 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push)
@@ -1933,13 +1966,40 @@ class FileManagerModule {
};
if (shBtn) shBtn.onclick = () => {
const url = this._previewShareUrl(path);
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(url)
- .then(() => this._updateStatus('🔗 Link copied: ' + url))
- .catch(() => prompt('Copy share link:', url));
- } else {
- prompt('Copy share link:', url);
+ if (!url) { this._updateStatus('🔗 Share unavailable — no space resolved'); return; }
+ // T3: prompt() is suppressed in iOS standalone PWAs and most Android WebViews, so the
+ // last resort is a real input the user can select — never a dialog that cannot appear.
+ const inlineFallback = () => {
+ const bar = pane.querySelector('.fm-preview-toolbar');
+ let inp = pane.querySelector('.fm-share-input');
+ if (!inp && bar) {
+ inp = document.createElement('input');
+ inp.className = 'fm-share-input';
+ inp.readOnly = true;
+ inp.setAttribute('aria-label', 'Share link');
+ inp.style.cssText = 'flex:1;min-width:0;min-height:44px;padding:0 10px;font-size:1em;' +
+ 'border:1px solid var(--border-color);border-radius:4px;' +
+ 'background:var(--bg-primary);color:var(--text-color)';
+ inp.value = url;
+ bar.insertBefore(inp, bar.firstChild);
+ }
+ if (inp) { inp.focus(); inp.select(); }
+ this._updateStatus('🔗 Select and copy');
+ };
+ const copyThen = () => {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url)
+ .then(() => this._updateStatus('🔗 Link copied: ' + url))
+ .catch(inlineFallback);
+ } else { inlineFallback(); }
+ };
+ if (navigator.share) {
+ navigator.share({ title: 'newOS4 file', url })
+ .then(() => this._updateStatus('🔗 Shared'))
+ .catch(copyThen);
+ return;
}
+ copyThen();
};
}
@@ -1970,26 +2030,31 @@ class FileManagerModule {
}
_actionDownload() {
- if (!this._selectedFile) {
- this._updateStatus('No file selected to download');
- return;
- }
- const content = this.terminal.fs.readFile(this._selectedFile);
- if (content === null || content === false) {
- this._updateStatus('❌ File not found');
- return;
- }
- const fileName = this._selectedFile.split('/').pop() || 'file';
- const blob = new Blob([content], { type: 'application/octet-stream' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- this._updateStatus('⬇ Downloaded: ' + fileName);
+ // T2: honour the multi-selection the way every sibling action does (_getSelectedPaths).
+ let targets = this._getSelectedPaths ? this._getSelectedPaths() : [];
+ if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : [];
+ if (!targets.length) { this._updateStatus('No file selected to download'); return; }
+ const saved = [], skipped = [];
+ for (const p of targets) {
+ const content = this.terminal.fs.readFile(p);
+ if (content === null || content === false) { skipped.push(p); continue; }
+ const fileName = p.split('/').pop() || 'file';
+ const blob = new Blob([content], { type: 'application/octet-stream' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ // T1: iOS Safari / Android Chrome hand blob: to the download manager asynchronously.
+ // Revoking in the same tick aborts the transfer while the UI still said "Downloaded".
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
+ saved.push(fileName);
+ }
+ if (saved.length > 1) this._updateStatus('⬇ Downloaded ' + saved.length + ' files');
+ else if (saved.length === 1) this._updateStatus('⬇ Downloaded: ' + saved[0]);
+ if (skipped.length) this._updateStatus('❌ Skipped ' + skipped.join(', '));
}
_actionEdit() {
@@ -2078,8 +2143,11 @@ class FileManagerModule {
}
}
-if (typeof window.webx11 !== 'undefined' && window.webx11) {
- webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js');
+// 2026-10-24: `typeof window.webx11` throws under Node (member access on an undefined global),
+// which would make the module un-require-able by modules/test/test-download-url.js.
+if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) {
+ window.webx11.moduleManager.registerModule(new FileManagerModule(),
+ 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js');
}
/* === diff.js === */
diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js
index 01d1da9..120e07e 100644
--- a/modules/ext/filemgr.js
+++ b/modules/ext/filemgr.js
@@ -1,3 +1,35 @@
+// 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the
+// shipped code instead of a copy of it. No DOM, no terminal, no module state.
+function fmDetectSpaceName(pathname) {
+ try {
+ const parts = String(pathname || '').split('/').filter(Boolean);
+ const i = parts.indexOf('spaces');
+ return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
+ } catch (e) { return ''; }
+}
+
+function fmExtractSpaceNameFromUrl(url) {
+ if (!url) return '';
+ try {
+ const parts = new URL(url).pathname.split('/').filter(Boolean);
+ const i = parts.indexOf('spaces');
+ return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
+ } catch (e) { return ''; }
+}
+
+// Returns null when no space can be resolved — never emits /spaces//<path>.
+function fmBuildShareUrl(space, path) {
+ if (!space) return null;
+ const clean = String(path || '').replace(/^\/+/, '');
+ if (!clean) return null;
+ return 'https://webx11.duckdns.org/spaces/' + space + '/' +
+ clean.split('/').map(encodeURIComponent).join('/');
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl };
+}
+
class FileManagerModule {
constructor() {
this.commands = {};
@@ -943,24 +975,25 @@ class FileManagerModule {
// 2026-10-21: preview pane had no fullscreen and no share action.
_previewShell(path, innerHtml) {
- return '<div class="fm-preview-toolbar" style="display:flex;gap:6px;justify-content:flex-end;' +
+ return '<div class="fm-preview-toolbar" style="display:flex;gap:10px;justify-content:flex-end;' +
'padding:6px 8px;border-bottom:1px solid var(--border-color);flex-shrink:0">' +
'<button class="fm-btn-fs" title="Fullscreen preview" aria-label="Fullscreen preview" style="background:var(--bg-tertiary);' +
- 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' +
- 'cursor:pointer;font-size:0.85em;min-height:24px;">⛶</button>' +
+ 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' +
+ 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">⛶</button>' +
'<button class="fm-btn-share" title="Copy share link" aria-label="Copy share link" style="background:var(--bg-tertiary);' +
- 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' +
- 'cursor:pointer;font-size:0.85em;min-height:24px;">🔗</button>' +
+ 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' +
+ 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">🔗</button>' +
'</div><div class="fm-preview-body" style="flex:1;min-height:0;overflow:auto">' + innerHtml + '</div>';
}
_previewShareUrl(path) {
const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules
? this.terminal.moduleManager.modules.WebDAVModule : null;
- const space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || '';
- const clean = String(path).replace(/^\/+/, '');
- return 'https://webx11.duckdns.org/spaces/' + space + '/' +
- clean.split('/').map(encodeURIComponent).join('/');
+ let space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || '';
+ // T5: the fallback detector already shipped in modules/ext/webdav.js:22 — use it.
+ if (!space && wdav && wdav._detectSpaceName) space = wdav._detectSpaceName() || '';
+ if (!space) space = fmDetectSpaceName(window.location.pathname);
+ return fmBuildShareUrl(space, path); // null when nothing resolves — caller reports it
}
// 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push)
@@ -1000,13 +1033,40 @@ class FileManagerModule {
};
if (shBtn) shBtn.onclick = () => {
const url = this._previewShareUrl(path);
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(url)
- .then(() => this._updateStatus('🔗 Link copied: ' + url))
- .catch(() => prompt('Copy share link:', url));
- } else {
- prompt('Copy share link:', url);
+ if (!url) { this._updateStatus('🔗 Share unavailable — no space resolved'); return; }
+ // T3: prompt() is suppressed in iOS standalone PWAs and most Android WebViews, so the
+ // last resort is a real input the user can select — never a dialog that cannot appear.
+ const inlineFallback = () => {
+ const bar = pane.querySelector('.fm-preview-toolbar');
+ let inp = pane.querySelector('.fm-share-input');
+ if (!inp && bar) {
+ inp = document.createElement('input');
+ inp.className = 'fm-share-input';
+ inp.readOnly = true;
+ inp.setAttribute('aria-label', 'Share link');
+ inp.style.cssText = 'flex:1;min-width:0;min-height:44px;padding:0 10px;font-size:1em;' +
+ 'border:1px solid var(--border-color);border-radius:4px;' +
+ 'background:var(--bg-primary);color:var(--text-color)';
+ inp.value = url;
+ bar.insertBefore(inp, bar.firstChild);
+ }
+ if (inp) { inp.focus(); inp.select(); }
+ this._updateStatus('🔗 Select and copy');
+ };
+ const copyThen = () => {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url)
+ .then(() => this._updateStatus('🔗 Link copied: ' + url))
+ .catch(inlineFallback);
+ } else { inlineFallback(); }
+ };
+ if (navigator.share) {
+ navigator.share({ title: 'newOS4 file', url })
+ .then(() => this._updateStatus('🔗 Shared'))
+ .catch(copyThen);
+ return;
}
+ copyThen();
};
}
@@ -1037,26 +1097,31 @@ class FileManagerModule {
}
_actionDownload() {
- if (!this._selectedFile) {
- this._updateStatus('No file selected to download');
- return;
- }
- const content = this.terminal.fs.readFile(this._selectedFile);
- if (content === null || content === false) {
- this._updateStatus('❌ File not found');
- return;
+ // T2: honour the multi-selection the way every sibling action does (_getSelectedPaths).
+ let targets = this._getSelectedPaths ? this._getSelectedPaths() : [];
+ if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : [];
+ if (!targets.length) { this._updateStatus('No file selected to download'); return; }
+ const saved = [], skipped = [];
+ for (const p of targets) {
+ const content = this.terminal.fs.readFile(p);
+ if (content === null || content === false) { skipped.push(p); continue; }
+ const fileName = p.split('/').pop() || 'file';
+ const blob = new Blob([content], { type: 'application/octet-stream' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ // T1: iOS Safari / Android Chrome hand blob: to the download manager asynchronously.
+ // Revoking in the same tick aborts the transfer while the UI still said "Downloaded".
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
+ saved.push(fileName);
}
- const fileName = this._selectedFile.split('/').pop() || 'file';
- const blob = new Blob([content], { type: 'application/octet-stream' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- this._updateStatus('⬇ Downloaded: ' + fileName);
+ if (saved.length > 1) this._updateStatus('⬇ Downloaded ' + saved.length + ' files');
+ else if (saved.length === 1) this._updateStatus('⬇ Downloaded: ' + saved[0]);
+ if (skipped.length) this._updateStatus('❌ Skipped ' + skipped.join(', '));
}
_actionEdit() {
@@ -1145,6 +1210,9 @@ class FileManagerModule {
}
}
-if (typeof window.webx11 !== 'undefined' && window.webx11) {
- webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js');
+// 2026-10-24: `typeof window.webx11` throws under Node (member access on an undefined global),
+// which would make the module un-require-able by modules/test/test-download-url.js.
+if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) {
+ window.webx11.moduleManager.registerModule(new FileManagerModule(),
+ 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js');
}
diff --git a/modules/test/test-download-url.js b/modules/test/test-download-url.js
index 63cabde..7815aae 100644
--- a/modules/test/test-download-url.js
+++ b/modules/test/test-download-url.js
@@ -1,7 +1,13 @@
// node modules/test/test-download-url.js
-// Regression guard for the download/share URL space (2026-10-21).
-// The URL must follow the WebDAV config (the logged-in user's space), NOT the
-// page path — /spaces/newos4/ is the app, not the user.
+// Regression guard for the download/share URL space (2026-10-21, tightened 2026-10-24).
+// The URL must follow the WebDAV config (the logged-in user's space), NOT the page path —
+// /spaces/newos4/ is the app, not the user.
+//
+// 2026-10-24 (T6): this test used to re-implement both helpers locally, so it passed 4/4
+// whatever filemgr.js did. It now requires the shipped functions.
+const { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl } =
+ require('../ext/filemgr.js');
+
const cases = [
{ page: '/spaces/newos4/', cfg: 'https://webx11.duckdns.org/spaces/agando/', want: 'agando' },
{ page: '/spaces/newos4/', cfg: 'https://webx11.duckdns.org/spaces/mindtech/', want: 'mindtech' },
@@ -9,24 +15,39 @@ const cases = [
{ page: '/spaces/newos4/', cfg: '', want: 'newos4' }, // no config → fall back to the page space
];
-function detectSpaceName(path) {
- const parts = path.split('/').filter(Boolean);
- const i = parts.indexOf('spaces');
- return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
-}
-function extractSpaceNameFromUrl(url) {
- if (!url) return '';
- const parts = new URL(url).pathname.split('/').filter(Boolean);
- const i = parts.indexOf('spaces');
- return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
-}
-
let bad = 0;
for (const c of cases) {
- const got = extractSpaceNameFromUrl(c.cfg) || detectSpaceName(c.page);
+ const got = fmExtractSpaceNameFromUrl(c.cfg) || fmDetectSpaceName(c.page);
const ok = got === c.want;
if (!ok) bad++;
console.log((ok ? 'PASS' : 'FAIL'), c.page, 'cfg=' + (c.cfg || '(none)'), '→', got, '(want', c.want + ')');
}
-console.log(bad ? `\n${bad} FAILED` : `\n${cases.length} PASS`);
+
+// T5: an unresolved space must produce null — never a "spaces//" URL.
+{
+ const got = fmBuildShareUrl('', 'docs/x.md');
+ const ok = got === null;
+ if (!ok) bad++;
+ console.log((ok ? 'PASS' : 'FAIL'), 'empty space →', got, '(want null)');
+}
+
+// Paths with spaces must be percent-encoded per segment.
+{
+ const got = fmBuildShareUrl('newos4', 'docs/a file/b&c.md');
+ const want = 'https://webx11.duckdns.org/spaces/newos4/docs/a%20file/b%26c.md';
+ const ok = got === want;
+ if (!ok) bad++;
+ console.log((ok ? 'PASS' : 'FAIL'), 'encoded path →', got);
+}
+
+// Belt and braces: no case may ever produce a double slash after /spaces/.
+for (const c of cases) {
+ const url = fmBuildShareUrl(fmExtractSpaceNameFromUrl(c.cfg) || fmDetectSpaceName(c.page), 'docs/x.md');
+ if (url && url.indexOf('/spaces//') !== -1) {
+ bad++;
+ console.log('FAIL double space slash →', url);
+ }
+}
+
+console.log(bad ? `\n${bad} FAILED` : `\n${cases.length + 2} PASS`);
process.exit(bad ? 1 : 0);