aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
blob: bb2438c43cfb3692d4886b4727f1a481c53817cf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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('/');
}