summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
blob: 34085aece02adaac6541ba52d2e2ae9f38530c7f (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
52
53
54
55
56
/**
 * 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('<>:"/\\|?*');
// The bidirectional controls: "invoice\u202efdp.exe" displays as
// "invoiceexe.pdf", and a saved name must say what the file is.
const BIDI_CONTROLS = new Set('\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e'
                              + '\u2066\u2067\u2068\u2069');

const reserved = (c) => RESERVED_CHARS.has(c) || BIDI_CONTROLS.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('/');
}