@photostructure/fs-metadata 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CODE_OF_CONDUCT.md +128 -0
  3. package/CONTRIBUTING.md +46 -0
  4. package/README.md +4 -65
  5. package/SECURITY.md +9 -0
  6. package/dist/index.cjs +247 -229
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.mjs +245 -227
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/types/array.d.ts +0 -5
  11. package/dist/types/debuglog.d.ts +0 -1
  12. package/dist/types/exports.d.ts +2 -1
  13. package/dist/types/mount_point.d.ts +1 -9
  14. package/dist/types/number.d.ts +0 -4
  15. package/dist/types/object.d.ts +0 -4
  16. package/dist/types/options.d.ts +4 -4
  17. package/dist/types/volume_metadata.d.ts +3 -3
  18. package/dist/types/volume_mount_points.d.ts +9 -0
  19. package/package.json +9 -9
  20. package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  21. package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
  22. package/src/array.ts +44 -0
  23. package/src/async.ts +145 -0
  24. package/src/debuglog.ts +30 -0
  25. package/src/defer.ts +30 -0
  26. package/src/error.ts +79 -0
  27. package/src/exports.ts +156 -0
  28. package/src/fs.ts +89 -0
  29. package/src/glob.ts +127 -0
  30. package/src/hidden.ts +249 -0
  31. package/src/index.cts +15 -0
  32. package/src/index.mts +17 -0
  33. package/src/linux/dev_disk.ts +77 -0
  34. package/src/linux/mount_points.ts +91 -0
  35. package/src/linux/mtab.ts +136 -0
  36. package/src/mount_point.ts +58 -0
  37. package/src/number.ts +21 -0
  38. package/src/object.ts +55 -0
  39. package/src/options.ts +179 -0
  40. package/src/path.ts +54 -0
  41. package/src/platform.ts +9 -0
  42. package/src/random.ts +40 -0
  43. package/src/remote_info.ts +161 -0
  44. package/src/setup.ts +69 -0
  45. package/src/string.ts +99 -0
  46. package/src/string_enum.ts +41 -0
  47. package/src/system_volume.ts +75 -0
  48. package/src/test-utils/assert.ts +69 -0
  49. package/src/test-utils/hidden-tests.ts +33 -0
  50. package/src/test-utils/jest-matchers.ts +29 -0
  51. package/src/test-utils/platform.ts +39 -0
  52. package/src/types/native_bindings.ts +64 -0
  53. package/src/types/node-gyp-build.d.ts +6 -0
  54. package/src/unc.ts +63 -0
  55. package/src/units.ts +31 -0
  56. package/src/uuid.ts +24 -0
  57. package/src/volume_health_status.ts +58 -0
  58. package/src/volume_metadata.ts +294 -0
  59. package/src/volume_mount_points.ts +109 -0
  60. package/tsup.config.ts +8 -0
  61. package/dist/types/cache.d.ts +0 -4
package/src/object.ts ADDED
@@ -0,0 +1,55 @@
1
+ // src/object.js
2
+
3
+ import { isNotBlank, isString } from "./string.js";
4
+
5
+ /**
6
+ * Check if a value is an object
7
+ */
8
+ export function isObject(value: unknown): value is object {
9
+ // typeof null is 'object', so we need to check for that case YAY JAVASCRIPT
10
+ return value != null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ /**
14
+ * Map a value to another value, or undefined if the value is undefined
15
+ */
16
+ export function map<T, U>(
17
+ obj: T | undefined,
18
+ fn: (value: T) => U,
19
+ ): U | undefined {
20
+ return obj == null ? undefined : fn(obj);
21
+ }
22
+
23
+ /**
24
+ * Omit the specified fields from an object
25
+ */
26
+ export function omit<T extends object, K extends keyof T>(
27
+ obj: T,
28
+ ...keys: K[]
29
+ ): Omit<T, K> {
30
+ const result = {} as Omit<T, K>;
31
+ const keysSet = new Set(keys);
32
+
33
+ // OH THE TYPING HUGEMANATEE
34
+ for (const key of Object.keys(obj) as Array<keyof Omit<T, K>>) {
35
+ if (!keysSet.has(key as unknown as K)) {
36
+ result[key] = obj[key];
37
+ }
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ export function compactValues<T extends object>(
44
+ obj: T | undefined,
45
+ ): Partial<T> {
46
+ const result = {} as Partial<T>;
47
+ if (obj == null || !isObject(obj)) return {};
48
+ for (const [key, value] of Object.entries(obj)) {
49
+ // skip blank strings and nullish values:
50
+ if (value != null && (!isString(value) || isNotBlank(value))) {
51
+ result[key as keyof T] = value as T[keyof T];
52
+ }
53
+ }
54
+ return result;
55
+ }
package/src/options.ts ADDED
@@ -0,0 +1,179 @@
1
+ // src/options.ts
2
+
3
+ import { availableParallelism } from "node:os";
4
+ import { compactValues, isObject } from "./object.js";
5
+ import { isWindows } from "./platform.js";
6
+
7
+ /**
8
+ * Configuration options for filesystem operations.
9
+ *
10
+ * @see {@link optionsWithDefaults} for creating an options object with default values
11
+ * @see {@link OptionsDefault} for the default values
12
+ */
13
+ export interface Options {
14
+ /**
15
+ * Timeout in milliseconds for filesystem operations.
16
+ *
17
+ * Disable timeouts by setting this to 0.
18
+ *
19
+ * @see {@link TimeoutMsDefault}.
20
+ */
21
+ timeoutMs: number;
22
+
23
+ /**
24
+ * Maximum number of concurrent filesystem operations.
25
+ *
26
+ * Defaults to {@link https://nodejs.org/api/os.html#osavailableparallelism | availableParallelism}.
27
+ */
28
+ maxConcurrency: number;
29
+
30
+ /**
31
+ * On Linux and macOS, mount point pathnames that matches any of these glob
32
+ * patterns will have {@link MountPoint.isSystemVolume} set to true.
33
+ *
34
+ * @see {@link SystemPathPatternsDefault} for the default value
35
+ */
36
+ systemPathPatterns: string[];
37
+
38
+ /**
39
+ * On Linux and macOS, volumes whose filesystem matches any of these strings
40
+ * will have {@link MountPoint.isSystemVolume} set to true.
41
+ *
42
+ * @see {@link SystemFsTypesDefault} for the default value
43
+ */
44
+ systemFsTypes: string[];
45
+
46
+ /**
47
+ * On Linux, use the first mount point table in this array that is readable.
48
+ *
49
+ * @see {@link LinuxMountTablePathsDefault} for the default values
50
+ */
51
+ linuxMountTablePaths: string[];
52
+
53
+ /**
54
+ * Should system volumes be included in result arrays? Defaults to true on
55
+ * Windows and false elsewhere.
56
+ */
57
+ includeSystemVolumes: boolean;
58
+ }
59
+
60
+ /**
61
+ * Default timeout in milliseconds for {@link Options.timeoutMs}.
62
+ *
63
+ * Note that this timeout may be insufficient for some devices, like spun-down
64
+ * optical drives or network shares that need to spin up or reconnect.
65
+ */
66
+ export const TimeoutMsDefault = 5_000 as const;
67
+
68
+ /**
69
+ * System paths and globs that indicate system volumes
70
+ */
71
+ export const SystemPathPatternsDefault = [
72
+ "/boot",
73
+ "/boot/efi",
74
+ "/dev",
75
+ "/dev/**",
76
+ "/proc/**",
77
+ "/run",
78
+ "/run/credentials/**",
79
+ "/run/lock",
80
+ "/run/snapd/**",
81
+ "/run/user/*/doc",
82
+ "/run/user/*/gvfs",
83
+ "/snap/**",
84
+ "/sys/**",
85
+ "**/#snapshot", // Synology and Kubernetes volume snapshots
86
+
87
+ // windows for linux:
88
+ "/mnt/wslg/distro",
89
+ "/mnt/wslg/doc",
90
+ "/mnt/wslg/versions.txt",
91
+ "/usr/lib/wsl/drivers",
92
+
93
+ // MacOS stuff:
94
+ "/private/var/vm", // macOS swap
95
+ "/System/Volumes/Hardware",
96
+ "/System/Volumes/iSCPreboot",
97
+ "/System/Volumes/Preboot",
98
+ "/System/Volumes/Recovery",
99
+ "/System/Volumes/Reserved",
100
+ "/System/Volumes/Update",
101
+ "/System/Volumes/VM",
102
+ "/System/Volumes/xarts",
103
+ ];
104
+
105
+ /**
106
+ * Filesystem types that indicate system volumes
107
+ */
108
+ export const SystemFsTypesDefault = [
109
+ "autofs",
110
+ "binfmt_misc",
111
+ "cgroup",
112
+ "cgroup2",
113
+ "configfs",
114
+ "debugfs",
115
+ "devpts",
116
+ "devtmpfs",
117
+ "efivarfs",
118
+ "fusectl",
119
+ "fuse.snapfuse",
120
+ "hugetlbfs",
121
+ "mqueue",
122
+ "none",
123
+ "proc",
124
+ "pstore",
125
+ "rootfs",
126
+ "securityfs",
127
+ "snap*",
128
+ "squashfs",
129
+ "sysfs",
130
+ "tmpfs",
131
+ ] as const;
132
+
133
+ export const LinuxMountTablePathsDefault = [
134
+ "/proc/self/mounts",
135
+ "/proc/mounts",
136
+ "/etc/mtab",
137
+ ] as const;
138
+
139
+ /**
140
+ * Should {@link getAllVolumeMetadata} include system volumes by
141
+ * default?
142
+ */
143
+ export const IncludeSystemVolumesDefault = isWindows;
144
+
145
+ /**
146
+ * Default {@link Options} object.
147
+ *
148
+ * @see {@link optionsWithDefaults} for creating an options object with default values
149
+ */
150
+ export const OptionsDefault: Options = {
151
+ timeoutMs: TimeoutMsDefault,
152
+ maxConcurrency: availableParallelism(),
153
+ systemPathPatterns: [...SystemPathPatternsDefault],
154
+ systemFsTypes: [...SystemFsTypesDefault],
155
+ linuxMountTablePaths: [...LinuxMountTablePathsDefault],
156
+ includeSystemVolumes: IncludeSystemVolumesDefault,
157
+ } as const;
158
+
159
+ /**
160
+ * Create an {@link Options} object using default values from
161
+ * {@link OptionsDefault} for missing fields.
162
+ */
163
+ export function optionsWithDefaults<T extends Options>(
164
+ overrides: Partial<T> = {},
165
+ ): T {
166
+ if (!isObject(overrides)) {
167
+ throw new TypeError(
168
+ "options(): expected an object, got " +
169
+ typeof overrides +
170
+ ": " +
171
+ JSON.stringify(overrides),
172
+ );
173
+ }
174
+
175
+ return {
176
+ ...OptionsDefault,
177
+ ...(compactValues(overrides) as T),
178
+ };
179
+ }
package/src/path.ts ADDED
@@ -0,0 +1,54 @@
1
+ // src/path.ts
2
+
3
+ import { dirname, resolve } from "node:path";
4
+ import { isWindows } from "./platform.js";
5
+ import { isBlank } from "./string.js";
6
+
7
+ export function normalizePath(
8
+ mountPoint: string | undefined,
9
+ ): string | undefined {
10
+ if (isBlank(mountPoint)) return undefined;
11
+
12
+ const result = isWindows
13
+ ? normalizeWindowsPath(mountPoint)
14
+ : normalizePosixPath(mountPoint);
15
+
16
+ // Make sure the native code doesn't see anything weird:
17
+ return result != null ? resolve(result) : undefined;
18
+ }
19
+
20
+ /**
21
+ * Normalizes a Linux or macOS mount point by removing any trailing slashes.
22
+ * This is a no-op for root mount points.
23
+ */
24
+ export function normalizePosixPath(
25
+ mountPoint: string | undefined,
26
+ ): string | undefined {
27
+ return isBlank(mountPoint)
28
+ ? undefined
29
+ : mountPoint === "/"
30
+ ? mountPoint
31
+ : mountPoint.replace(/\/+$/, "");
32
+ }
33
+
34
+ /**
35
+ * Normalizes a Windows mount point by ensuring drive letters end with a
36
+ * backslash.
37
+ */
38
+ export function normalizeWindowsPath(mountPoint: string): string {
39
+ // Terrible things happen if we give syscalls "C:" instead of "C:\"
40
+
41
+ return /^[a-z]:$/i.test(mountPoint)
42
+ ? mountPoint.toUpperCase() + "\\"
43
+ : mountPoint;
44
+ }
45
+
46
+ /**
47
+ * @return true if `path` is the root directory--this is platform-specific. Only
48
+ * "/" on linux/macOS is considered a root directory. On Windows, the root
49
+ * directory is a drive letter followed by a colon, e.g. "C:\".
50
+ */
51
+ export function isRootDirectory(path: string): boolean {
52
+ const n = normalizePath(path);
53
+ return n == null ? false : isWindows ? dirname(n) === n : n === "/";
54
+ }
@@ -0,0 +1,9 @@
1
+ // src/platform.ts
2
+
3
+ import { platform } from "node:os";
4
+
5
+ const p = platform();
6
+
7
+ export const isLinux = p === "linux";
8
+ export const isWindows = p === "win32";
9
+ export const isMacOS = p === "darwin";
package/src/random.ts ADDED
@@ -0,0 +1,40 @@
1
+ // src/random.ts
2
+
3
+ import { randomInt } from "node:crypto";
4
+
5
+ const CharCode_a = "a".charCodeAt(0);
6
+ /**
7
+ * @return a random character `[a-z]`
8
+ */
9
+ export function randomLetter(): string {
10
+ return String.fromCharCode(CharCode_a + randomInt(0, 26));
11
+ }
12
+
13
+ export function randomLetters(length: number): string {
14
+ return Array.from({ length }, randomLetter).join("");
15
+ }
16
+
17
+ /**
18
+ * Shuffle an array using the Fisher-Yates (Knuth) algorithm.
19
+ * @param a The array to shuffle
20
+ * @returns A shallow shuffled copy of `a`
21
+ */
22
+ export function shuffle<T>(a: T[]): T[] {
23
+ if (a.length <= 1) return a;
24
+ a = [...a]; // Copy the array
25
+ for (let i = a.length - 1; i > 0; i--) {
26
+ // Pick a random index from 0 to i
27
+ const j = Math.floor(Math.random() * (i + 1));
28
+ // Swap elements at indices i and j
29
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
30
+ [a[i], a[j]] = [a[j]!, a[i]!];
31
+ }
32
+ return a;
33
+ }
34
+
35
+ export function pickRandom<T>(a: T[]): T {
36
+ if (a == null || a.length === 0) {
37
+ throw new Error("Cannot pick from an empty array");
38
+ }
39
+ return a[randomInt(0, a.length)] as T;
40
+ }
@@ -0,0 +1,161 @@
1
+ // src/remote_info.ts
2
+
3
+ import { debug } from "./debuglog.js";
4
+ import { compactValues, isObject } from "./object.js";
5
+ import { isWindows } from "./platform.js";
6
+ import { isBlank, isNotBlank } from "./string.js";
7
+
8
+ /**
9
+ * Represents remote filesystem information.
10
+ */
11
+ export interface RemoteInfo {
12
+ /**
13
+ * We can sometimes fetch a URI of the resource (like "smb://server/share" or
14
+ * "file:///media/user/usb")
15
+ */
16
+ uri?: string;
17
+ /**
18
+ * Protocol used to access the share.
19
+ */
20
+ protocol?: string;
21
+ /**
22
+ * Does the protocol seem to be a remote filesystem?
23
+ */
24
+ remote: boolean;
25
+ /**
26
+ * If remote, may include the username used to access the share.
27
+ *
28
+ * This will be undefined on NFS and other remote filesystem types that do
29
+ * authentication out of band.
30
+ */
31
+ remoteUser?: string;
32
+ /**
33
+ * If remote, the ip or hostname hosting the share (like "rusty" or "10.1.1.3")
34
+ */
35
+ remoteHost?: string;
36
+ /**
37
+ * If remote, the name of the share (like "homes")
38
+ */
39
+ remoteShare?: string;
40
+ }
41
+
42
+ export function isRemoteInfo(obj: unknown): obj is RemoteInfo {
43
+ if (!isObject(obj)) return false;
44
+ const { remoteHost, remoteShare } = obj as Partial<RemoteInfo>;
45
+ return isNotBlank(remoteHost) && isNotBlank(remoteShare);
46
+ }
47
+
48
+ const NETWORK_FS_TYPES = new Set([
49
+ "9p",
50
+ "afp",
51
+ "afs",
52
+ "beegfs",
53
+ "ceph",
54
+ "cifs",
55
+ "ftp",
56
+ "fuse.cephfs",
57
+ "fuse.glusterfs",
58
+ "fuse.sshfs",
59
+ "fuse",
60
+ "gfs2",
61
+ "glusterfs",
62
+ "lustre",
63
+ "ncpfs",
64
+ "nfs",
65
+ "nfs4",
66
+ "smb",
67
+ "smbfs",
68
+ "sshfs",
69
+ "webdav",
70
+ ]);
71
+
72
+ export function normalizeProtocol(protocol: string): string {
73
+ return (protocol ?? "").toLowerCase().replace(/:$/, "");
74
+ }
75
+
76
+ export function isRemoteFsType(fstype: string | undefined): boolean {
77
+ return isNotBlank(fstype) && NETWORK_FS_TYPES.has(normalizeProtocol(fstype));
78
+ }
79
+
80
+ export function parseURL(s: string): URL | undefined {
81
+ try {
82
+ return isBlank(s) ? undefined : new URL(s);
83
+ } catch {
84
+ return;
85
+ }
86
+ }
87
+
88
+ export function extractRemoteInfo(
89
+ fsSpec: string | undefined,
90
+ ): RemoteInfo | undefined {
91
+ if (fsSpec == null || isBlank(fsSpec)) return;
92
+
93
+ if (isWindows) {
94
+ fsSpec = fsSpec.replace(/\\/g, "/");
95
+ }
96
+
97
+ const url = parseURL(fsSpec);
98
+
99
+ if (url?.protocol === "file:") {
100
+ return {
101
+ remote: false,
102
+ uri: fsSpec,
103
+ };
104
+ }
105
+
106
+ const patterns = [
107
+ // CIFS/SMB pattern: //hostname/share or //user@host/share
108
+ {
109
+ regex:
110
+ /^\/\/(?:(?<remoteUser>[^/@]+)@)?(?<remoteHost>[^/@]+)\/(?<remoteShare>.+)$/,
111
+ },
112
+ // NFS pattern: hostname:/share
113
+ {
114
+ protocol: "nfs",
115
+ regex: /^(?<remoteHost>[^:]+):\/(?!\/)(?<remoteShare>.+)$/,
116
+ },
117
+ ];
118
+
119
+ for (const { protocol, regex } of patterns) {
120
+ const o = compactValues({
121
+ protocol,
122
+ remote: true,
123
+ ...(fsSpec.match(regex)?.groups ?? {}),
124
+ });
125
+ if (isRemoteInfo(o)) {
126
+ debug("[extractRemoteInfo] matched pattern: %o", o);
127
+ return o;
128
+ }
129
+ }
130
+
131
+ // Let's try URL last, as nfs mounts are URI-ish
132
+ try {
133
+ // try to parse fsSpec as a uri:
134
+ const parsed = new URL(fsSpec);
135
+ if (parsed != null) {
136
+ debug("[extractRemoteInfo] parsed URL: %o", parsed);
137
+ const protocol = normalizeProtocol(parsed.protocol);
138
+ if (!isRemoteFsType(protocol)) {
139
+ // don't set remoteUser, remoteHost, or remoteShare, it's not remote!
140
+ return {
141
+ uri: fsSpec,
142
+ remote: false,
143
+ };
144
+ } else {
145
+ return compactValues({
146
+ uri: fsSpec,
147
+ protocol,
148
+ remote: true,
149
+ remoteUser: parsed.username,
150
+ remoteHost: parsed.hostname,
151
+ // URL pathname includes leading slash:
152
+ remoteShare: parsed.pathname.replace(/^\//, ""),
153
+ }) as unknown as RemoteInfo;
154
+ }
155
+ }
156
+ } catch {
157
+ // ignore
158
+ }
159
+
160
+ return;
161
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,69 @@
1
+ // src/exports.ts
2
+
3
+ import NodeGypBuild from "node-gyp-build";
4
+ import { debug, debugLogContext, isDebugEnabled } from "./debuglog.js";
5
+ import { defer } from "./defer.js";
6
+ import { ExportedFunctions } from "./exports.js";
7
+ import { findAncestorDir } from "./fs.js";
8
+ import {
9
+ getHiddenMetadata,
10
+ HideMethod,
11
+ isHidden,
12
+ isHiddenRecursive,
13
+ setHidden,
14
+ } from "./hidden.js";
15
+ import { optionsWithDefaults } from "./options.js";
16
+ import type { NativeBindings } from "./types/native_bindings.js";
17
+ import { getAllVolumeMetadata, getVolumeMetadata } from "./volume_metadata.js";
18
+ import {
19
+ getVolumeMountPoints,
20
+ type GetVolumeMountPointOptions,
21
+ } from "./volume_mount_points.js";
22
+
23
+ export function setup(dirname: string): ExportedFunctions {
24
+ const nativeFn = defer<Promise<NativeBindings>>(async () => {
25
+ const start = Date.now();
26
+ try {
27
+ const dir = await findAncestorDir(dirname, "binding.gyp");
28
+ if (dir == null) {
29
+ throw new Error(
30
+ "Could not find bindings.gyp in any ancestor directory of " + dirname,
31
+ );
32
+ }
33
+ const bindings = NodeGypBuild(dir) as NativeBindings;
34
+ bindings.setDebugLogging(isDebugEnabled());
35
+ bindings.setDebugPrefix(debugLogContext() + ":native");
36
+ return bindings;
37
+ } catch (error) {
38
+ debug("Loading native bindings failed: %s", error);
39
+ throw error;
40
+ } finally {
41
+ debug(`Native bindings took %d ms to load`, Date.now() - start);
42
+ }
43
+ });
44
+
45
+ return {
46
+ getVolumeMountPoints: (opts: Partial<GetVolumeMountPointOptions> = {}) =>
47
+ getVolumeMountPoints(optionsWithDefaults(opts), nativeFn),
48
+
49
+ getVolumeMetadata: (mountPoint: string, opts = {}) =>
50
+ getVolumeMetadata({ ...optionsWithDefaults(opts), mountPoint }, nativeFn),
51
+
52
+ getAllVolumeMetadata: (opts = {}) =>
53
+ getAllVolumeMetadata(optionsWithDefaults(opts), nativeFn),
54
+
55
+ isHidden: (pathname: string) => isHidden(pathname, nativeFn),
56
+
57
+ isHiddenRecursive: (pathname: string) =>
58
+ isHiddenRecursive(pathname, nativeFn),
59
+
60
+ getHiddenMetadata: (pathname: string) =>
61
+ getHiddenMetadata(pathname, nativeFn),
62
+
63
+ setHidden: (
64
+ pathname: string,
65
+ hidden: boolean,
66
+ method: HideMethod = "auto",
67
+ ) => setHidden(pathname, hidden, method, nativeFn),
68
+ } as const;
69
+ }
package/src/string.ts ADDED
@@ -0,0 +1,99 @@
1
+ // src/string.ts
2
+
3
+ export function isString(input: unknown): input is string {
4
+ return typeof input === "string";
5
+ }
6
+
7
+ export function toS(input: unknown): string {
8
+ return isString(input) ? input : input == null ? "" : String(input);
9
+ }
10
+
11
+ /**
12
+ * @return true iff the input is a string and has at least one non-whitespace character
13
+ */
14
+ export function isNotBlank(input: unknown): input is string {
15
+ return typeof input === "string" && input.trim().length > 0;
16
+ }
17
+
18
+ /**
19
+ * @return true iff the input is not a string or only has non-whitespace characters
20
+ */
21
+ export function isBlank(input: unknown): input is undefined {
22
+ return !isNotBlank(input);
23
+ }
24
+
25
+ export function toNotBlank(input: unknown): string | undefined {
26
+ return isNotBlank(input) ? input : undefined;
27
+ }
28
+
29
+ /**
30
+ * Decodes a string containing octal (\000-\377) and/or hexadecimal
31
+ * (\x00-\xFF) escape sequences
32
+ * @param input The string containing escape sequences to decode
33
+ * @returns The decoded string with escape sequences converted to their
34
+ * corresponding characters
35
+ * @throws Error if an invalid escape sequence is encountered
36
+ */
37
+ export function decodeEscapeSequences(input: string): string {
38
+ const escapeRegex = /\\(?:([0-7]{2,6})|x([0-9a-fA-F]{2,4}))/g;
39
+
40
+ return input.replace(escapeRegex, (match, octal, hex) => {
41
+ // Handle octal escape sequences
42
+ if (octal != null) {
43
+ return String.fromCharCode(parseInt(octal, 8));
44
+ }
45
+
46
+ // Handle hexadecimal escape sequences
47
+ if (hex != null) {
48
+ return String.fromCharCode(parseInt(hex, 16));
49
+ }
50
+
51
+ // This should never happen due to the regex pattern
52
+ throw new Error(`Invalid escape sequence: ${match}`);
53
+ });
54
+ }
55
+
56
+ const AlphaNumericRE = /[a-z0-9.-_]/i;
57
+
58
+ export function encodeEscapeSequences(input: string): string {
59
+ return input
60
+ .split("")
61
+ .map((char) => {
62
+ const encodedChar = AlphaNumericRE.test(char)
63
+ ? char
64
+ : "\\" + char.charCodeAt(0).toString(8).padStart(2, "0");
65
+ return encodedChar;
66
+ })
67
+ .join("");
68
+ }
69
+
70
+ /**
71
+ * Sort an array of strings using the locale-aware collation algorithm.
72
+ *
73
+ * @param arr The array of strings to sort. The original array **is sorted in
74
+ * place**.
75
+ */
76
+ export function sortByLocale(
77
+ arr: string[],
78
+ locales?: Intl.LocalesArgument,
79
+ options?: Intl.CollatorOptions,
80
+ ): string[] {
81
+ return arr.sort((a, b) => a.localeCompare(b, locales, options));
82
+ }
83
+
84
+ /**
85
+ * Sort an array of objects using the locale-aware collation algorithm.
86
+ *
87
+ * @param arr The array of objects to sort.
88
+ * @param fn The function to extract the key to sort by from each object.
89
+ * @param locales The locales to use for sorting.
90
+ * @param options The collation options to use for sorting.
91
+ */
92
+ export function sortObjectsByLocale<T>(
93
+ arr: T[],
94
+ fn: (key: T) => string,
95
+ locales?: Intl.LocalesArgument,
96
+ options?: Intl.CollatorOptions,
97
+ ): T[] {
98
+ return arr.sort((a, b) => fn(a).localeCompare(fn(b), locales, options));
99
+ }