aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-28 22:25:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-28 22:25:07 +0200
commit7cec0e9199753e11b95500da14d3fd89835e1aa3 (patch)
treec134f460c9dba281db2fe32712766cf9524ca9da /packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
parentf04b4e0c4350a5acc539a8f15bc9df4bfd10a537 (diff)
downloadmeshbay-7cec0e9199753e11b95500da14d3fd89835e1aa3.tar.gz
feat(client): save files under a name every platform can write
A node serves the name its disk gave a file; the client now makes it portable at save time (single file, zip entries, zip name) and says so on the transfer row. Same rule as paths.sanitize_for_download, held by a parity test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/portable-name.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/portable-name.js51
1 files changed, 51 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
new file mode 100644
index 0000000..bb2438c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
@@ -0,0 +1,51 @@
+/**
+ * A name that can be written on every platform a member saves to
+ * (docs/MESHBAY_DESIGN.md §10).
+ *
+ * A node serves a file under the name its own disk gave it, and a name that is
+ * fine on ext4 can be impossible on Windows or on an exFAT drive: reserved
+ * characters, a trailing dot or space, `CON` or `aux.txt`. The node never
+ * rewrites a name — it is the string that opens the file — so the client does,
+ * at the moment it saves, and says so.
+ *
+ * The same rules as `meshbay_common.paths.sanitize_for_download`, and held to
+ * them by `test_portable_name_parity.py`: two copies of a rule that differ
+ * decide differently which files get renamed.
+ */
+
+const WINDOWS_RESERVED = new Set([
+ 'CON', 'PRN', 'AUX', 'NUL',
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `COM${i}`),
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `LPT${i}`),
+]);
+
+const RESERVED_CHARS = new Set('<>:"/\\|?*');
+
+const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32;
+
+function isPortable(name) {
+ if (!name || name === '.' || name === '..') return false;
+ for (const c of name) if (reserved(c)) return false;
+ if (name.endsWith(' ') || name.endsWith('.')) return false;
+ return !WINDOWS_RESERVED.has(name.split('.', 1)[0].toUpperCase());
+}
+
+/** `name` if it can be written everywhere, otherwise the nearest name that can. */
+export function portableName(name, replacement = '_') {
+ const text = String(name ?? '');
+ if (isPortable(text)) return text;
+ let out = Array.from(text).map((c) => (reserved(c) ? replacement : c)).join('');
+ out = out.replace(/[ .]+$/, '');
+ const dot = out.indexOf('.');
+ let stem = dot === -1 ? out : out.slice(0, dot);
+ const rest = dot === -1 ? '' : out.slice(dot);
+ if (WINDOWS_RESERVED.has(stem.toUpperCase())) stem += replacement;
+ out = stem + rest;
+ return out || 'unnamed';
+}
+
+/** Every segment of a relative path made portable, keeping `/` between them. */
+export function portablePath(path) {
+ return String(path ?? '').split('/')
+ .map((segment) => (segment ? portableName(segment) : segment)).join('/');
+}