@stonyx/utils 0.2.3-alpha.3 → 0.2.3-alpha.30
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.
- package/README.md +16 -2
- package/dist/date.d.ts +1 -0
- package/dist/date.js +4 -0
- package/dist/file.d.ts +79 -0
- package/dist/file.js +234 -0
- package/dist/fuzzy-match.d.ts +26 -0
- package/dist/fuzzy-match.js +80 -0
- package/dist/object.d.ts +14 -0
- package/dist/object.js +60 -0
- package/dist/plurarize.d.ts +1 -0
- package/dist/plurarize.js +87 -0
- package/dist/promise.d.ts +1 -0
- package/dist/promise.js +5 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.js +33 -0
- package/dist/string.d.ts +5 -0
- package/dist/string.js +32 -0
- package/package.json +41 -10
- package/.claude/improvements.md +0 -73
- package/.claude/project-structure.md +0 -168
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -35
- package/src/date.js +0 -5
- package/src/file.js +0 -145
- package/src/object.js +0 -61
- package/src/plurarize.js +0 -103
- package/src/promise.js +0 -5
- package/src/prompt.js +0 -29
- package/src/string.js +0 -35
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
[](https://github.com/abofs/stonyx-utils/actions/workflows/ci.yml)
|
|
2
|
+
[](https://www.npmjs.com/package/@stonyx/utils)
|
|
3
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
4
|
+
|
|
1
5
|
# stonyx-utils
|
|
2
6
|
|
|
3
|
-
Utilities module for the Stonyx Framework. Provides helpers for files, objects, strings, dates, and
|
|
7
|
+
Utilities module for the Stonyx Framework. Provides helpers for files, objects, strings, dates, promises, and prompts.
|
|
4
8
|
|
|
5
9
|
---
|
|
6
10
|
|
|
@@ -71,10 +75,16 @@ Creates a file at the given path.
|
|
|
71
75
|
|
|
72
76
|
#### `updateFile(filePath, data, options={})`
|
|
73
77
|
|
|
74
|
-
Updates a file atomically by writing to a
|
|
78
|
+
Updates a file atomically by writing to a sibling swap file first, then renaming it over the target. Throws if the file does not exist.
|
|
75
79
|
|
|
76
80
|
* `options.json` — boolean, serialize as JSON.
|
|
77
81
|
|
|
82
|
+
**Concurrency: last writer wins.** Concurrent `updateFile` calls on the same path are safe — each uses a swap file unique to its process and call, so neither caller's swap file can collide with the other's. The rename-time `ENOENT` and the silent cross-caller byte clobber of [#44](https://github.com/abofs/stonyx-utils/issues/44) are both gone. (`updateFile` still throws `ENOENT` from its own precondition when the target does not exist — that is the documented contract above, and it is unrelated to concurrency.) Calls are not serialized: the surviving value is whichever caller renames last.
|
|
83
|
+
|
|
84
|
+
**File mode is preserved; other inode metadata is not.** The swap file is created with the target's permission bits and `chmod`ed to them before the rename, so a `0600` file stays `0600`. ACLs, extended attributes, hard links and ownership are *not* carried across — the target necessarily gets a new inode.
|
|
85
|
+
|
|
86
|
+
**Swap files after abnormal termination.** Cleanup runs on every failure path `updateFile` controls, but a process killed between the write and the rename leaves a `<path>.temp-<pid>-<token>` sibling behind, and no module ships a sweeper. This is a deliberate trade against the previous whole-second swap name, which was reused — and reuse is exactly the collision that caused #44, so uniqueness has to win. Be precise about what that costs: the old name only ever collapsed two orphans that were abandoned within the *same whole second*, so at any ordinary crash cadence it reclaimed nothing either (measured: five crashes 1200 ms apart leave five orphans under both names). The accumulation uniqueness genuinely adds is confined to a sub-second crash loop. Consumers that persist into a directory they scan, sync or commit should sweep or ignore `*.temp-*` siblings of their targets; an app that runs `git add` over its data directory will otherwise commit one.
|
|
87
|
+
|
|
78
88
|
#### `copyFile(sourcePath, targetPath, options={})`
|
|
79
89
|
|
|
80
90
|
Copies a file from source to target.
|
|
@@ -241,6 +251,8 @@ Prompts the user with `(y/N)` and resolves to `true` only if the answer is `"y"`
|
|
|
241
251
|
* `options.input` — Readable stream (default: `process.stdin`).
|
|
242
252
|
* `options.output` — Writable stream (default: `process.stdout`).
|
|
243
253
|
|
|
254
|
+
**TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided. For headless/container deployments, use `autoMigrate` config or provide a custom input stream.
|
|
255
|
+
|
|
244
256
|
#### `prompt(question, options={})`
|
|
245
257
|
|
|
246
258
|
Prompts the user with a question and resolves to the trimmed input string.
|
|
@@ -248,6 +260,8 @@ Prompts the user with a question and resolves to the trimmed input string.
|
|
|
248
260
|
* `options.input` — Readable stream (default: `process.stdin`).
|
|
249
261
|
* `options.output` — Writable stream (default: `process.stdout`).
|
|
250
262
|
|
|
263
|
+
**TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided.
|
|
264
|
+
|
|
251
265
|
```js
|
|
252
266
|
import { confirm, prompt } from '@stonyx/utils/prompt';
|
|
253
267
|
|
package/dist/date.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getTimestamp(dateObject?: Date | null): number;
|
package/dist/date.js
ADDED
package/dist/file.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
interface FileOptions {
|
|
3
|
+
json?: boolean;
|
|
4
|
+
}
|
|
5
|
+
interface ReadFileOptions extends FileOptions {
|
|
6
|
+
missingFileCallback?: (filePath: string) => string | Record<string, unknown>;
|
|
7
|
+
encoding?: BufferEncoding | null;
|
|
8
|
+
}
|
|
9
|
+
interface DeleteFileOptions {
|
|
10
|
+
ignoreAccessFailure?: boolean;
|
|
11
|
+
}
|
|
12
|
+
interface ForEachFileImportOptions {
|
|
13
|
+
ignoreAccessFailure?: boolean;
|
|
14
|
+
recursive?: boolean;
|
|
15
|
+
recursiveNaming?: boolean;
|
|
16
|
+
rawName?: boolean;
|
|
17
|
+
namePrefix?: string;
|
|
18
|
+
fullExport?: boolean;
|
|
19
|
+
}
|
|
20
|
+
interface FileImportMeta {
|
|
21
|
+
name: string;
|
|
22
|
+
stats: import('fs').Stats;
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function createFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Atomically replace the contents of an existing file.
|
|
28
|
+
*
|
|
29
|
+
* Writes to a swap file that is a **sibling** of the target — so `rename` stays
|
|
30
|
+
* on one filesystem and therefore stays atomic — then renames it over the
|
|
31
|
+
* target. The swap name carries `process.pid` and a short random token rather
|
|
32
|
+
* than a timestamp: a whole-second token collided between concurrent callers,
|
|
33
|
+
* which made one caller throw `ENOENT` and the other silently persist bytes it
|
|
34
|
+
* had not written (abofs/stonyx-utils#44).
|
|
35
|
+
*
|
|
36
|
+
* Concurrency contract: **last writer wins**. Unique swap names remove the
|
|
37
|
+
* `ENOENT` and the byte-level clobber, but overlapping calls on one path still
|
|
38
|
+
* race on which value lands last. `updateFile` does not serialize, and callers
|
|
39
|
+
* needing a serialization guarantee must supply their own — an in-process queue
|
|
40
|
+
* would not provide one across processes anyway.
|
|
41
|
+
*
|
|
42
|
+
* The target's mode is read before the write and reapplied to the swap file, so
|
|
43
|
+
* the rename does not widen a deliberately restrictive file (a `0600` database
|
|
44
|
+
* would otherwise become `0666 & ~umask`). Other inode-bound metadata — ACLs,
|
|
45
|
+
* extended attributes, hard links, ownership — is *not* carried across; the
|
|
46
|
+
* target gets a new inode by construction.
|
|
47
|
+
*
|
|
48
|
+
* `rename` is atomic against concurrent *readers*, not against power loss:
|
|
49
|
+
* there is no `fsync`, so this is namespace atomicity, not crash durability.
|
|
50
|
+
* If the target is a symlink it is replaced by a regular file — the link's
|
|
51
|
+
* destination is never written through.
|
|
52
|
+
*
|
|
53
|
+
* Swap files are cleaned up on every failure path this function controls, but
|
|
54
|
+
* a process killed between the write and the rename leaves a permanently
|
|
55
|
+
* distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
|
|
56
|
+
* sweeper ships with this module; `*.temp-*` siblings of a target are safe for
|
|
57
|
+
* a consumer to delete.
|
|
58
|
+
*/
|
|
59
|
+
export declare function updateFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
|
|
60
|
+
interface CopyFileOptions {
|
|
61
|
+
overwrite?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export declare function copyFile(sourcePath: string, targetPath: string, options?: CopyFileOptions): Promise<boolean>;
|
|
64
|
+
export declare function readFile(filePath: string, options: ReadFileOptions & {
|
|
65
|
+
encoding: null;
|
|
66
|
+
}): Promise<Buffer>;
|
|
67
|
+
export declare function readFile(filePath: string, options: ReadFileOptions & {
|
|
68
|
+
json: true;
|
|
69
|
+
}): Promise<Record<string, unknown>>;
|
|
70
|
+
export declare function readFile(filePath: string, options?: ReadFileOptions): Promise<string>;
|
|
71
|
+
export declare function deleteFile(filePath: string, options?: DeleteFileOptions): Promise<void>;
|
|
72
|
+
export declare function deleteFileSync(filePath: string, options?: DeleteFileOptions): void;
|
|
73
|
+
export declare function deleteDirectory(dir: string): Promise<void>;
|
|
74
|
+
export declare function createDirectory(dir: string): Promise<void>;
|
|
75
|
+
export declare function createReadStream(filePath: string, options?: Parameters<typeof fs.createReadStream>[1]): fs.ReadStream;
|
|
76
|
+
export declare function createWriteStream(filePath: string, options?: Parameters<typeof fs.createWriteStream>[1]): fs.WriteStream;
|
|
77
|
+
export declare function forEachFileImport(dir: string, callback: (output: unknown, meta: FileImportMeta) => void | Promise<void>, options?: ForEachFileImportOptions): Promise<void>;
|
|
78
|
+
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
79
|
+
export {};
|
package/dist/file.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { kebabCaseToCamelCase } from './string.js';
|
|
2
|
+
import { objToJson } from './object.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { promises as fsp } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
|
+
function isNodeError(error) {
|
|
8
|
+
return error instanceof Error && 'code' in error;
|
|
9
|
+
}
|
|
10
|
+
export async function createFile(filePath, data, options = {}) {
|
|
11
|
+
try {
|
|
12
|
+
filePath = path.resolve(filePath);
|
|
13
|
+
await createDirectory(path.dirname(filePath));
|
|
14
|
+
await fsp.writeFile(filePath, options.json ? objToJson(data) : String(data), 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Atomically replace the contents of an existing file.
|
|
22
|
+
*
|
|
23
|
+
* Writes to a swap file that is a **sibling** of the target — so `rename` stays
|
|
24
|
+
* on one filesystem and therefore stays atomic — then renames it over the
|
|
25
|
+
* target. The swap name carries `process.pid` and a short random token rather
|
|
26
|
+
* than a timestamp: a whole-second token collided between concurrent callers,
|
|
27
|
+
* which made one caller throw `ENOENT` and the other silently persist bytes it
|
|
28
|
+
* had not written (abofs/stonyx-utils#44).
|
|
29
|
+
*
|
|
30
|
+
* Concurrency contract: **last writer wins**. Unique swap names remove the
|
|
31
|
+
* `ENOENT` and the byte-level clobber, but overlapping calls on one path still
|
|
32
|
+
* race on which value lands last. `updateFile` does not serialize, and callers
|
|
33
|
+
* needing a serialization guarantee must supply their own — an in-process queue
|
|
34
|
+
* would not provide one across processes anyway.
|
|
35
|
+
*
|
|
36
|
+
* The target's mode is read before the write and reapplied to the swap file, so
|
|
37
|
+
* the rename does not widen a deliberately restrictive file (a `0600` database
|
|
38
|
+
* would otherwise become `0666 & ~umask`). Other inode-bound metadata — ACLs,
|
|
39
|
+
* extended attributes, hard links, ownership — is *not* carried across; the
|
|
40
|
+
* target gets a new inode by construction.
|
|
41
|
+
*
|
|
42
|
+
* `rename` is atomic against concurrent *readers*, not against power loss:
|
|
43
|
+
* there is no `fsync`, so this is namespace atomicity, not crash durability.
|
|
44
|
+
* If the target is a symlink it is replaced by a regular file — the link's
|
|
45
|
+
* destination is never written through.
|
|
46
|
+
*
|
|
47
|
+
* Swap files are cleaned up on every failure path this function controls, but
|
|
48
|
+
* a process killed between the write and the rename leaves a permanently
|
|
49
|
+
* distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
|
|
50
|
+
* sweeper ships with this module; `*.temp-*` siblings of a target are safe for
|
|
51
|
+
* a consumer to delete.
|
|
52
|
+
*/
|
|
53
|
+
export async function updateFile(filePath, data, options = {}) {
|
|
54
|
+
try {
|
|
55
|
+
filePath = path.resolve(filePath);
|
|
56
|
+
await fsp.access(filePath);
|
|
57
|
+
// The swap file becomes the target's inode, so it has to carry the target's
|
|
58
|
+
// permission bits or a routine save silently widens them.
|
|
59
|
+
const { mode } = await fsp.stat(filePath);
|
|
60
|
+
const targetMode = mode & 0o7777;
|
|
61
|
+
// pid + random token, never a timestamp: the swap name is a uniqueness
|
|
62
|
+
// token, and whole seconds cannot discriminate between concurrent callers.
|
|
63
|
+
// The token is deliberately short — a full 36-char UUID pushed the name
|
|
64
|
+
// overhead to 48 characters and made ~210-character basenames fail
|
|
65
|
+
// ENAMETOOLONG on a 255-byte NAME_MAX. 6 CSPRNG bytes as base64url is 8
|
|
66
|
+
// characters carrying 48 bits, where the first 8 hex characters of a UUID
|
|
67
|
+
// would be the same width but only 32 bits; at 32 bits a 1000-sample
|
|
68
|
+
// distinctness check collides about once in 8,600 runs. `wx` below makes
|
|
69
|
+
// any residual collision loud rather than corrupting.
|
|
70
|
+
const swapFile = `${filePath}.temp-${process.pid}-${randomBytes(6).toString('base64url')}`;
|
|
71
|
+
// Which stage failed, so the catch can tell "the open collided" from "the
|
|
72
|
+
// open succeeded and something later failed" — see the catch.
|
|
73
|
+
let wrote = false;
|
|
74
|
+
try {
|
|
75
|
+
// `wx` turns any residual name collision into a loud EEXIST rather than a
|
|
76
|
+
// silent overwrite of another caller's swap bytes. `mode` here is masked
|
|
77
|
+
// by the umask, so it only narrows — the chmod below sets the exact bits.
|
|
78
|
+
await fsp.writeFile(swapFile, options.json ? objToJson(data) : String(data), { encoding: 'utf8', flag: 'wx', mode: targetMode });
|
|
79
|
+
wrote = true;
|
|
80
|
+
await fsp.chmod(swapFile, targetMode);
|
|
81
|
+
await fsp.rename(swapFile, filePath);
|
|
82
|
+
}
|
|
83
|
+
catch (swapError) {
|
|
84
|
+
// Leave no orphan behind, and never let the cleanup mask the real error.
|
|
85
|
+
//
|
|
86
|
+
// Exactly one case is not ours to remove: the `wx` open lost a race, so
|
|
87
|
+
// the swap file is another writer's and unlinking it would reintroduce
|
|
88
|
+
// #44. That is EEXIST *from the write stage* — both halves matter.
|
|
89
|
+
// - Code alone is not enough: `rename` surfaces EEXIST on Windows and
|
|
90
|
+
// some network filesystems, and that file is one we created.
|
|
91
|
+
// - Stage alone is not enough: a write that fails after the open
|
|
92
|
+
// succeeded (ENOSPC, EDQUOT, EIO) also lands here with `wrote` false,
|
|
93
|
+
// and skipping it is the orphaned partial payload this PR exists to
|
|
94
|
+
// close.
|
|
95
|
+
const lostTheOpenRace = !wrote && isNodeError(swapError) && swapError.code === 'EEXIST';
|
|
96
|
+
if (!lostTheOpenRace) {
|
|
97
|
+
await fsp.unlink(swapFile).catch(() => { });
|
|
98
|
+
}
|
|
99
|
+
throw swapError;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function copyFile(sourcePath, targetPath, options = {}) {
|
|
107
|
+
try {
|
|
108
|
+
sourcePath = path.resolve(sourcePath);
|
|
109
|
+
targetPath = path.resolve(targetPath);
|
|
110
|
+
await fsp.access(sourcePath);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await fsp.access(targetPath);
|
|
117
|
+
if (!options.overwrite)
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (isNodeError(error) && error.code === 'ENOENT') { /* file doesn't exist — proceed with copy */ }
|
|
122
|
+
else
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await fsp.copyFile(sourcePath, targetPath);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
export async function readFile(filePath, options = {}) {
|
|
134
|
+
try {
|
|
135
|
+
filePath = path.resolve(filePath);
|
|
136
|
+
await fsp.access(filePath);
|
|
137
|
+
const encoding = options.encoding === undefined ? 'utf8' : options.encoding;
|
|
138
|
+
const fileData = await fsp.readFile(filePath, { encoding: encoding });
|
|
139
|
+
if (encoding === null)
|
|
140
|
+
return fileData;
|
|
141
|
+
if (options.json)
|
|
142
|
+
return JSON.parse(fileData);
|
|
143
|
+
return fileData;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const { missingFileCallback } = options;
|
|
147
|
+
if (isNodeError(error) && error.code === 'ENOENT' && missingFileCallback) {
|
|
148
|
+
return missingFileCallback(filePath);
|
|
149
|
+
}
|
|
150
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function deleteFile(filePath, options) {
|
|
154
|
+
try {
|
|
155
|
+
filePath = path.resolve(filePath);
|
|
156
|
+
await fsp.access(filePath);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (options?.ignoreAccessFailure)
|
|
160
|
+
return;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
await fsp.unlink(filePath);
|
|
164
|
+
}
|
|
165
|
+
export function deleteFileSync(filePath, options) {
|
|
166
|
+
try {
|
|
167
|
+
filePath = path.resolve(filePath);
|
|
168
|
+
fs.accessSync(filePath);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (options?.ignoreAccessFailure)
|
|
172
|
+
return;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
fs.unlinkSync(filePath);
|
|
176
|
+
}
|
|
177
|
+
export async function deleteDirectory(dir) {
|
|
178
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
export async function createDirectory(dir) {
|
|
181
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
182
|
+
}
|
|
183
|
+
export function createReadStream(filePath, options) {
|
|
184
|
+
return fs.createReadStream(path.resolve(filePath), options);
|
|
185
|
+
}
|
|
186
|
+
export function createWriteStream(filePath, options) {
|
|
187
|
+
return fs.createWriteStream(path.resolve(filePath), options);
|
|
188
|
+
}
|
|
189
|
+
export async function forEachFileImport(dir, callback, options = {}) {
|
|
190
|
+
if (typeof callback !== 'function')
|
|
191
|
+
throw new Error('Callback must be valid function');
|
|
192
|
+
try {
|
|
193
|
+
await fsp.access(dir);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!options.ignoreAccessFailure)
|
|
197
|
+
throw new Error(`Unable to access directory: ${dir}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const files = await fsp.readdir(dir);
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const filePath = path.join(dir, file);
|
|
203
|
+
const stats = await fsp.stat(filePath);
|
|
204
|
+
if (options.recursive && stats.isDirectory()) {
|
|
205
|
+
const newOptions = { ...options };
|
|
206
|
+
if (options.recursiveNaming) {
|
|
207
|
+
const pathPrefix = options.rawName ? file : `${kebabCaseToCamelCase(file)}`;
|
|
208
|
+
newOptions.namePrefix = options.namePrefix ? `${options.namePrefix}${pathPrefix}/` : `${pathPrefix}/`;
|
|
209
|
+
}
|
|
210
|
+
await forEachFileImport(filePath, callback, newOptions);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!stats.isFile() || !(file.endsWith('.js') || file.endsWith('.ts')))
|
|
214
|
+
continue;
|
|
215
|
+
const prefix = process.platform === 'win32' ? 'file://' : '';
|
|
216
|
+
const rawName = file.replace(/\.(js|ts)$/, '');
|
|
217
|
+
let name = options.rawName ? rawName : kebabCaseToCamelCase(rawName);
|
|
218
|
+
if (options.namePrefix)
|
|
219
|
+
name = `${options.namePrefix}${name}`;
|
|
220
|
+
const exported = await import(prefix + path.resolve(filePath));
|
|
221
|
+
const output = !options.fullExport ? exported.default : exported;
|
|
222
|
+
callback(output, { name, stats, path: filePath });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export async function fileExists(filePath) {
|
|
226
|
+
try {
|
|
227
|
+
filePath = path.resolve(filePath);
|
|
228
|
+
await fsp.access(filePath);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic fuzzy string matching for cross-source reconciliation.
|
|
3
|
+
* Handles Unicode normalization, stop-word filtering, and word-set similarity scoring.
|
|
4
|
+
*/
|
|
5
|
+
export interface FuzzyMatchOptions {
|
|
6
|
+
stopWords?: string[];
|
|
7
|
+
delimiter?: string;
|
|
8
|
+
threshold?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface FuzzyMatchResult<T extends {
|
|
11
|
+
name: string;
|
|
12
|
+
}> {
|
|
13
|
+
entry: T;
|
|
14
|
+
score: number;
|
|
15
|
+
}
|
|
16
|
+
export default class FuzzyMatch {
|
|
17
|
+
stopWords: string[];
|
|
18
|
+
delimiter: string;
|
|
19
|
+
threshold: number;
|
|
20
|
+
constructor(options?: FuzzyMatchOptions);
|
|
21
|
+
normalize(name: string): string;
|
|
22
|
+
similarity(nameA: string, nameB: string): number;
|
|
23
|
+
findBestMatch<T extends {
|
|
24
|
+
name: string;
|
|
25
|
+
}>(nameA: string, nameB: string, entries: T[], threshold?: number): FuzzyMatchResult<T> | null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic fuzzy string matching for cross-source reconciliation.
|
|
3
|
+
* Handles Unicode normalization, stop-word filtering, and word-set similarity scoring.
|
|
4
|
+
*/
|
|
5
|
+
function normalizeString(name, stopWords = []) {
|
|
6
|
+
let result = name
|
|
7
|
+
.normalize('NFD')
|
|
8
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9\s]/g, ' ');
|
|
11
|
+
if (stopWords.length) {
|
|
12
|
+
const pattern = new RegExp(`\\b(${stopWords.join('|')})\\b`, 'g');
|
|
13
|
+
result = result.replace(pattern, '');
|
|
14
|
+
}
|
|
15
|
+
return result.replace(/\s+/g, ' ').trim();
|
|
16
|
+
}
|
|
17
|
+
function wordSet(normalized) {
|
|
18
|
+
return new Set(normalized.split(' ').filter(w => w.length > 1));
|
|
19
|
+
}
|
|
20
|
+
export default class FuzzyMatch {
|
|
21
|
+
stopWords;
|
|
22
|
+
delimiter;
|
|
23
|
+
threshold;
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.stopWords = options.stopWords || [];
|
|
26
|
+
this.delimiter = options.delimiter || '\u00B7';
|
|
27
|
+
this.threshold = options.threshold || 0.35;
|
|
28
|
+
}
|
|
29
|
+
normalize(name) {
|
|
30
|
+
return normalizeString(name, this.stopWords);
|
|
31
|
+
}
|
|
32
|
+
similarity(nameA, nameB) {
|
|
33
|
+
const aN = this.normalize(nameA);
|
|
34
|
+
const sN = this.normalize(nameB);
|
|
35
|
+
if (!aN || !sN)
|
|
36
|
+
return 0;
|
|
37
|
+
if (aN === sN)
|
|
38
|
+
return 1.0;
|
|
39
|
+
if (aN.includes(sN) || sN.includes(aN))
|
|
40
|
+
return 0.9;
|
|
41
|
+
const aWords = wordSet(aN);
|
|
42
|
+
const sWords = wordSet(sN);
|
|
43
|
+
if (aWords.size === 0 || sWords.size === 0)
|
|
44
|
+
return 0;
|
|
45
|
+
let overlap = 0;
|
|
46
|
+
for (const w of aWords) {
|
|
47
|
+
if (sWords.has(w)) {
|
|
48
|
+
overlap++;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
for (const sw of sWords) {
|
|
52
|
+
if (sw.startsWith(w) || w.startsWith(sw)) {
|
|
53
|
+
overlap += 0.7;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return overlap / Math.max(aWords.size, sWords.size);
|
|
60
|
+
}
|
|
61
|
+
findBestMatch(nameA, nameB, entries, threshold) {
|
|
62
|
+
const minScore = threshold ?? this.threshold;
|
|
63
|
+
let bestMatch = null;
|
|
64
|
+
let bestScore = 0;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const parts = entry.name.split(this.delimiter);
|
|
67
|
+
if (parts.length !== 2)
|
|
68
|
+
continue;
|
|
69
|
+
const [entryA, entryB] = parts;
|
|
70
|
+
const normalScore = (this.similarity(nameA, entryA) + this.similarity(nameB, entryB)) / 2;
|
|
71
|
+
const reversedScore = (this.similarity(nameA, entryB) + this.similarity(nameB, entryA)) / 2;
|
|
72
|
+
const score = Math.max(normalScore, reversedScore);
|
|
73
|
+
if (score > bestScore) {
|
|
74
|
+
bestScore = score;
|
|
75
|
+
bestMatch = entry;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return bestScore >= minScore && bestMatch ? { entry: bestMatch, score: bestScore } : null;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/object.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function deepCopy<T>(obj: T): T;
|
|
2
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
export declare function objToJson(obj: JsonValue | Record<string, unknown>, format?: string | number): string;
|
|
6
|
+
export declare function makeArray<T>(obj: T | T[]): T[];
|
|
7
|
+
interface MergeOptions {
|
|
8
|
+
ignoreNewKeys?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function mergeObject(obj1: Record<string, unknown>, obj2: Record<string, unknown>, options?: MergeOptions): Record<string, unknown>;
|
|
11
|
+
export declare function get(obj: Record<string, unknown>, path: string): unknown;
|
|
12
|
+
export declare function get(obj: unknown, path?: unknown): undefined;
|
|
13
|
+
export declare function getOrSet<K, V>(map: Map<K, V>, key: K, defaultValue: V | (() => V)): V;
|
|
14
|
+
export {};
|
package/dist/object.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function deepCopy(obj) {
|
|
2
|
+
return JSON.parse(JSON.stringify(obj));
|
|
3
|
+
}
|
|
4
|
+
export function objToJson(obj, format = '\t') {
|
|
5
|
+
return JSON.stringify(obj, null, format);
|
|
6
|
+
}
|
|
7
|
+
export function makeArray(obj) {
|
|
8
|
+
return Array.isArray(obj) ? obj : [obj];
|
|
9
|
+
}
|
|
10
|
+
function cloneShallow(value) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.slice();
|
|
13
|
+
if (value && typeof value === 'object')
|
|
14
|
+
return { ...value };
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function mergeObject(obj1, obj2, options = {}) {
|
|
18
|
+
if (Array.isArray(obj1) || Array.isArray(obj2))
|
|
19
|
+
throw new Error('Cannot merge arrays.');
|
|
20
|
+
if (obj1 === null || typeof obj1 !== 'object')
|
|
21
|
+
return cloneShallow(obj2);
|
|
22
|
+
if (obj2 === null || typeof obj2 !== 'object')
|
|
23
|
+
return cloneShallow(obj1);
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const key of Object.keys(obj1))
|
|
26
|
+
result[key] = cloneShallow(obj1[key]);
|
|
27
|
+
for (const key of Object.keys(obj2)) {
|
|
28
|
+
if (options.ignoreNewKeys && !(key in obj1))
|
|
29
|
+
continue;
|
|
30
|
+
const val1 = obj1[key];
|
|
31
|
+
const val2 = obj2[key];
|
|
32
|
+
const shouldMerge = val1 && val2 && typeof val1 === 'object' && typeof val2 === 'object' && !Array.isArray(val1) && !Array.isArray(val2);
|
|
33
|
+
result[key] = shouldMerge ? mergeObject(val1, val2, options) : cloneShallow(val2);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
export function get(obj, path) {
|
|
38
|
+
if (arguments.length !== 2)
|
|
39
|
+
return console.error('Get must be called with two arguments; an object and a property key.');
|
|
40
|
+
if (!obj)
|
|
41
|
+
return console.error(`Cannot call get with '${path}' on an undefined object.`);
|
|
42
|
+
if (typeof path !== 'string')
|
|
43
|
+
return console.error('The path provided to get must be a string.');
|
|
44
|
+
let current = obj;
|
|
45
|
+
for (const key of path.split('.')) {
|
|
46
|
+
if (current[key] === undefined)
|
|
47
|
+
return;
|
|
48
|
+
current = current[key];
|
|
49
|
+
}
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
export function getOrSet(map, key, defaultValue) {
|
|
53
|
+
if (!(map instanceof Map))
|
|
54
|
+
throw new Error('First argument to getOrSet must be a Map.');
|
|
55
|
+
if (!map.has(key)) {
|
|
56
|
+
const value = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
|
|
57
|
+
map.set(key, value);
|
|
58
|
+
}
|
|
59
|
+
return map.get(key);
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function pluralize(word: string): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// --- Irregular nouns ---
|
|
2
|
+
const irregular = {
|
|
3
|
+
person: 'people',
|
|
4
|
+
man: 'men',
|
|
5
|
+
woman: 'women',
|
|
6
|
+
child: 'children',
|
|
7
|
+
tooth: 'teeth',
|
|
8
|
+
foot: 'feet',
|
|
9
|
+
mouse: 'mice',
|
|
10
|
+
goose: 'geese',
|
|
11
|
+
ox: 'oxen',
|
|
12
|
+
cactus: 'cacti',
|
|
13
|
+
nucleus: 'nuclei',
|
|
14
|
+
syllabus: 'syllabi',
|
|
15
|
+
focus: 'foci',
|
|
16
|
+
fungus: 'fungi',
|
|
17
|
+
appendix: 'appendices',
|
|
18
|
+
index: 'indices',
|
|
19
|
+
criterion: 'criteria',
|
|
20
|
+
phenomenon: 'phenomena',
|
|
21
|
+
die: 'dice',
|
|
22
|
+
thesis: 'theses',
|
|
23
|
+
analysis: 'analyses',
|
|
24
|
+
crisis: 'crises',
|
|
25
|
+
radius: 'radii',
|
|
26
|
+
corpus: 'corpora',
|
|
27
|
+
};
|
|
28
|
+
// --- Uncountables ---
|
|
29
|
+
const uncountable = new Set([
|
|
30
|
+
'sheep', 'fish', 'deer', 'series', 'species', 'news', 'information',
|
|
31
|
+
'rice', 'moose', 'bison', 'salmon', 'aircraft', 'offspring'
|
|
32
|
+
]);
|
|
33
|
+
// --- Exceptions ---
|
|
34
|
+
const fExceptions = new Set(['chief', 'roof', 'belief', 'chef', 'cliff', 'reef', 'proof', 'brief']);
|
|
35
|
+
// Keep only true irregular -o exceptions (consonant + o but take just "s")
|
|
36
|
+
const oExceptions = new Set(['piano', 'photo', 'halo', 'canto', 'solo']);
|
|
37
|
+
// --- Utility to preserve casing ---
|
|
38
|
+
function applyCasing(original, plural) {
|
|
39
|
+
if (original === original.toUpperCase())
|
|
40
|
+
return plural.toUpperCase();
|
|
41
|
+
if (original === original.toLowerCase())
|
|
42
|
+
return plural.toLowerCase();
|
|
43
|
+
if (original[0] === original[0].toUpperCase()) {
|
|
44
|
+
return plural.charAt(0).toUpperCase() + plural.slice(1);
|
|
45
|
+
}
|
|
46
|
+
return plural;
|
|
47
|
+
}
|
|
48
|
+
// --- Rule-based pluralization ---
|
|
49
|
+
const rules = [
|
|
50
|
+
// quiz -> quizzes, waltz -> waltzes, topaz -> topazes
|
|
51
|
+
[/z$/i, w => (/iz$/i.test(w) ? w + 'zes' : w + 'es')],
|
|
52
|
+
// bus -> buses, box -> boxes, church -> churches, but stomach -> stomachs (exclude -ach)
|
|
53
|
+
[/(s|x|ch|sh)$/i, w => (/ach$/i.test(w) ? w + 's' : w + 'es')],
|
|
54
|
+
// vowel + y -> +s (key -> keys)
|
|
55
|
+
[/[aeiou]y$/i, w => w + 's'],
|
|
56
|
+
// consonant + y -> -ies (city -> cities)
|
|
57
|
+
[/y$/i, w => w.slice(0, -1) + 'ies'],
|
|
58
|
+
// -fe -> -ves (knife -> knives), but not chief/roof/etc
|
|
59
|
+
[/fe$/i, w => (fExceptions.has(w) ? w + 's' : w.slice(0, -2) + 'ves')],
|
|
60
|
+
// -f -> -ves (wolf -> wolves), but not cliff/etc
|
|
61
|
+
[/f$/i, w => (fExceptions.has(w) ? w + 's' : w.slice(0, -1) + 'ves')],
|
|
62
|
+
// -sis -> -ses (analysis -> analyses, thesis -> theses)
|
|
63
|
+
[/sis$/i, w => w.slice(0, -2) + 'ses'],
|
|
64
|
+
// vowel + o -> +s (zoo -> zoos, video -> videos, patio -> patios)
|
|
65
|
+
[/[aeiou]o$/i, w => w + 's'],
|
|
66
|
+
// consonant + o -> usually +es, unless in oExceptions
|
|
67
|
+
[/o$/i, w => (oExceptions.has(w) ? w + 's' : w + 'es')],
|
|
68
|
+
// default: just +s
|
|
69
|
+
[/$/i, w => w + 's']
|
|
70
|
+
];
|
|
71
|
+
// --- Exported pluralizer ---
|
|
72
|
+
export default function pluralize(word) {
|
|
73
|
+
if (typeof word !== 'string' || !/^[a-zA-Z]+$/.test(word))
|
|
74
|
+
return word;
|
|
75
|
+
const lower = word.toLowerCase();
|
|
76
|
+
if (uncountable.has(lower))
|
|
77
|
+
return word;
|
|
78
|
+
if (irregular[lower]) {
|
|
79
|
+
return applyCasing(word, irregular[lower]);
|
|
80
|
+
}
|
|
81
|
+
for (const [pattern, transform] of rules) {
|
|
82
|
+
if (pattern.test(lower)) {
|
|
83
|
+
return applyCasing(word, transform(lower));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return word; // fallback (shouldn't hit)
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sleep(seconds: number): Promise<void>;
|
package/dist/promise.js
ADDED
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'stream';
|
|
2
|
+
interface PromptOptions {
|
|
3
|
+
input?: Readable;
|
|
4
|
+
output?: Writable;
|
|
5
|
+
}
|
|
6
|
+
export declare function confirm(question: string, { input, output }?: PromptOptions): Promise<boolean>;
|
|
7
|
+
export declare function prompt(question: string, { input, output }?: PromptOptions): Promise<string>;
|
|
8
|
+
export {};
|