tiny-essentials 1.24.5 → 1.25.1
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 +8 -8
- package/changelog/1/25/0.md +13 -0
- package/changelog/1/25/1.md +18 -0
- package/dist/v1/TinyAnalogClock.min.js +1 -0
- package/dist/v1/TinyBasicsEs.min.js +1 -1
- package/dist/v1/TinyEssentials.min.js +1 -1
- package/dist/v1/TinyTextDiffer.min.js +1 -0
- package/dist/v1/basics/html.cjs +151 -2
- package/dist/v1/basics/html.d.mts +109 -0
- package/dist/v1/basics/html.mjs +135 -2
- package/dist/v1/build/TinyAnalogClock.cjs +7 -0
- package/dist/v1/build/TinyAnalogClock.d.mts +3 -0
- package/dist/v1/build/TinyAnalogClock.mjs +2 -0
- package/dist/v1/build/TinyTextDiffer.cjs +7 -0
- package/dist/v1/build/TinyTextDiffer.d.mts +3 -0
- package/dist/v1/build/TinyTextDiffer.mjs +2 -0
- package/dist/v1/index.cjs +4 -0
- package/dist/v1/index.d.mts +3 -1
- package/dist/v1/index.mjs +3 -1
- package/dist/v1/libs/TinyAnalogClock.cjs +738 -0
- package/dist/v1/libs/TinyAnalogClock.d.mts +342 -0
- package/dist/v1/libs/TinyAnalogClock.mjs +653 -0
- package/dist/v1/libs/TinyRateLimiter.cjs +215 -31
- package/dist/v1/libs/TinyRateLimiter.d.mts +179 -25
- package/dist/v1/libs/TinyRateLimiter.mjs +215 -31
- package/dist/v1/libs/TinyTextDiffer.cjs +288 -0
- package/dist/v1/libs/TinyTextDiffer.d.mts +109 -0
- package/dist/v1/libs/TinyTextDiffer.mjs +255 -0
- package/docs/v1/README.md +5 -8
- package/docs/v1/basics/html.md +69 -0
- package/docs/v1/libs/TinyAnalogClock.md +295 -0
- package/docs/v1/libs/TinyRateLimiter.md +11 -0
- package/docs/v1/libs/TinyTextDiffer.md +114 -0
- package/package.json +12 -4
- package/docs/v1/Ai-Tips.md +0 -51
- package/docs/v1/Personal-Ai-Prompts(portuguese).md +0 -134
- package/docs/v1/Personal-Ai-Prompts.md +0 -113
|
@@ -1,44 +1,172 @@
|
|
|
1
|
-
/** @typedef {(groupId: string) => void} OnMemoryExceeded */
|
|
2
|
-
/** @typedef {(groupId: string) => void} OnGroupExpired */
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
2
|
+
* Callback triggered when a group's stored hit history exceeds
|
|
3
|
+
* the configured memory limit.
|
|
5
4
|
*
|
|
6
|
-
* This
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* This callback is purely informational and does not block execution.
|
|
6
|
+
*
|
|
7
|
+
* @typedef {(groupId: string) => void} OnMemoryExceeded
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Callback triggered when a group is considered expired and removed
|
|
11
|
+
* during the cleanup process.
|
|
12
|
+
*
|
|
13
|
+
* This usually happens when the group remains inactive longer than
|
|
14
|
+
* its configured TTL or the global maxIdle value.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {(groupId: string) => void} OnGroupExpired
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* A lightweight and flexible rate limiter supporting both user-based
|
|
20
|
+
* and group-based throttling.
|
|
21
|
+
*
|
|
22
|
+
* ## Core Concepts
|
|
23
|
+
* - Every user belongs to a group.
|
|
24
|
+
* - If no explicit group is assigned, the user acts as their own group.
|
|
25
|
+
* - All users within the same group share the same hit history and limits.
|
|
26
|
+
*
|
|
27
|
+
* ## Supported Limiting Strategies
|
|
28
|
+
* - Max number of hits
|
|
29
|
+
* - Time-based sliding window
|
|
30
|
+
* - Combination of both
|
|
31
|
+
*
|
|
32
|
+
* ## Extra Features
|
|
33
|
+
* - Per-group TTL (time-to-live)
|
|
34
|
+
* - Automatic cleanup of inactive groups
|
|
35
|
+
* - Optional memory cap per group
|
|
36
|
+
* - Runtime metrics and statistics
|
|
37
|
+
*
|
|
38
|
+
* ## Interval Window (Sliding Window) Behavior
|
|
39
|
+
*
|
|
40
|
+
* This rate limiter uses a **sliding time window** strategy when `interval`
|
|
41
|
+
* is configured.
|
|
42
|
+
*
|
|
43
|
+
* ### How it works
|
|
44
|
+
* - Each hit is stored as a timestamp (Date.now()).
|
|
45
|
+
* - On every new hit and rate check, timestamps older than:
|
|
46
|
+
*
|
|
47
|
+
* `now - interval`
|
|
48
|
+
*
|
|
49
|
+
* are discarded.
|
|
50
|
+
*
|
|
51
|
+
* - Only hits that occurred within the last `interval` milliseconds
|
|
52
|
+
* are considered valid.
|
|
53
|
+
*
|
|
54
|
+
* ### Practical example
|
|
55
|
+
* If:
|
|
56
|
+
* - interval = 10_000 (10 seconds)
|
|
57
|
+
* - maxHits = 5
|
|
58
|
+
*
|
|
59
|
+
* Then:
|
|
60
|
+
* - Any group may perform **up to 5 hits in any rolling 10-second window**.
|
|
61
|
+
* - The window moves continuously with time.
|
|
62
|
+
*
|
|
63
|
+
* This avoids burst issues common in fixed-window rate limiters.
|
|
64
|
+
*
|
|
65
|
+
* This class is designed to be deterministic, predictable,
|
|
66
|
+
* and safe to use in long-running Node.js processes.
|
|
9
67
|
*/
|
|
10
68
|
class TinyRateLimiter {
|
|
11
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Maximum number of timestamps stored per group.
|
|
71
|
+
* When exceeded, older entries are discarded.
|
|
72
|
+
*
|
|
73
|
+
* `null` means unlimited.
|
|
74
|
+
*
|
|
75
|
+
* @type {number|null}
|
|
76
|
+
*/
|
|
12
77
|
#maxMemory = null;
|
|
13
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Internal timer reference used for periodic cleanup.
|
|
80
|
+
*
|
|
81
|
+
* @type {NodeJS.Timeout|null}
|
|
82
|
+
*/
|
|
14
83
|
#cleanupTimer = null;
|
|
15
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Maximum number of allowed hits within the interval window.
|
|
86
|
+
*
|
|
87
|
+
* If `null`, hit count is not enforced.
|
|
88
|
+
*
|
|
89
|
+
* @type {number|null|undefined}
|
|
90
|
+
*/
|
|
16
91
|
#maxHits = null;
|
|
17
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Sliding window duration in milliseconds.
|
|
94
|
+
*
|
|
95
|
+
* When defined, the rate limiter operates in **sliding window mode**:
|
|
96
|
+
* only hits that occurred within the last `interval` milliseconds
|
|
97
|
+
* are considered when evaluating limits.
|
|
98
|
+
*
|
|
99
|
+
* If `null`, time-based limiting is disabled and only `maxHits`
|
|
100
|
+
* (if defined) is used.
|
|
101
|
+
*
|
|
102
|
+
* @type {number|null|undefined}
|
|
103
|
+
*/
|
|
18
104
|
#interval = null;
|
|
19
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Interval (in milliseconds) at which cleanup runs automatically.
|
|
107
|
+
*
|
|
108
|
+
* If `null`, cleanup must be triggered manually.
|
|
109
|
+
*
|
|
110
|
+
* @type {number|null|undefined}
|
|
111
|
+
*/
|
|
20
112
|
#cleanupInterval = null;
|
|
21
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Maximum allowed inactivity time (in milliseconds)
|
|
115
|
+
* before a group is considered expired.
|
|
116
|
+
*
|
|
117
|
+
* @type {number|null|undefined}
|
|
118
|
+
*/
|
|
22
119
|
#maxIdle = null;
|
|
23
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Stores hit timestamps per group.
|
|
122
|
+
*
|
|
123
|
+
* Key: groupId
|
|
124
|
+
* Value: array of timestamps (ms)
|
|
125
|
+
*
|
|
126
|
+
* @type {Map<string, number[]>}
|
|
127
|
+
*/
|
|
24
128
|
groupData = new Map(); // groupId -> timestamps[]
|
|
25
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Stores the timestamp of the most recent activity per group.
|
|
131
|
+
*
|
|
132
|
+
* Used for expiration and cleanup logic.
|
|
133
|
+
*
|
|
134
|
+
* @type {Map<string, number>}
|
|
135
|
+
*/
|
|
26
136
|
lastSeen = new Map(); // groupId -> timestamp
|
|
27
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Maps user IDs to their assigned group IDs.
|
|
139
|
+
*
|
|
140
|
+
* @type {Map<string, string>}
|
|
141
|
+
*/
|
|
28
142
|
userToGroup = new Map(); // userId -> groupId
|
|
29
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Flags whether an ID represents a true group or an implicit user group.
|
|
145
|
+
*
|
|
146
|
+
* `true` → explicit group
|
|
147
|
+
* `false` → implicit (user acting as group)
|
|
148
|
+
*
|
|
149
|
+
* @type {Map<string, boolean>}
|
|
150
|
+
*/
|
|
30
151
|
groupFlags = new Map(); // groupId -> boolean
|
|
31
152
|
/**
|
|
153
|
+
* Per-group TTL (time-to-live) overrides.
|
|
154
|
+
*
|
|
155
|
+
* If not defined for a group, `maxIdle` is used instead.
|
|
156
|
+
*
|
|
32
157
|
* @type {Map<string, number>}
|
|
33
|
-
* Stores TTL (in ms) for each groupId individually
|
|
34
158
|
*/
|
|
35
159
|
groupTTL = new Map();
|
|
36
160
|
/**
|
|
161
|
+
* Callback invoked when a group's memory limit is exceeded.
|
|
162
|
+
*
|
|
37
163
|
* @type {null|OnMemoryExceeded}
|
|
38
164
|
*/
|
|
39
165
|
#onMemoryExceeded = null;
|
|
40
166
|
/**
|
|
41
|
-
*
|
|
167
|
+
* Assign a callback to be notified when a group's stored
|
|
168
|
+
* hit history exceeds the configured memory limit.
|
|
169
|
+
*
|
|
42
170
|
* @param {OnMemoryExceeded} callback
|
|
43
171
|
*/
|
|
44
172
|
setOnMemoryExceeded(callback) {
|
|
@@ -47,12 +175,14 @@ class TinyRateLimiter {
|
|
|
47
175
|
this.#onMemoryExceeded = callback;
|
|
48
176
|
}
|
|
49
177
|
/**
|
|
50
|
-
*
|
|
178
|
+
* Removes the memory-exceeded callback.
|
|
51
179
|
*/
|
|
52
180
|
clearOnMemoryExceeded() {
|
|
53
181
|
this.#onMemoryExceeded = null;
|
|
54
182
|
}
|
|
55
183
|
/**
|
|
184
|
+
* Callback invoked when a group expires and is removed.
|
|
185
|
+
*
|
|
56
186
|
* @type {null|OnGroupExpired}
|
|
57
187
|
*/
|
|
58
188
|
#onGroupExpired = null;
|
|
@@ -70,18 +200,27 @@ class TinyRateLimiter {
|
|
|
70
200
|
this.#onGroupExpired = callback;
|
|
71
201
|
}
|
|
72
202
|
/**
|
|
73
|
-
*
|
|
203
|
+
* Removes the group-expiration callback.
|
|
74
204
|
*/
|
|
75
205
|
clearOnGroupExpired() {
|
|
76
206
|
this.#onGroupExpired = null;
|
|
77
207
|
}
|
|
78
208
|
/**
|
|
209
|
+
* Creates a new TinyRateLimiter instance.
|
|
210
|
+
*
|
|
211
|
+
* At least one of `maxHits` or `interval` must be provided.
|
|
212
|
+
*
|
|
79
213
|
* @param {Object} options
|
|
80
|
-
* @param {number|null} [options.maxMemory]
|
|
81
|
-
*
|
|
82
|
-
* @param {number} [options.
|
|
83
|
-
*
|
|
84
|
-
* @param {number} [options.
|
|
214
|
+
* @param {number|null} [options.maxMemory=100000]
|
|
215
|
+
* Maximum timestamps stored per group (memory cap).
|
|
216
|
+
* @param {number} [options.maxHits]
|
|
217
|
+
* Maximum number of allowed hits.
|
|
218
|
+
* @param {number} [options.interval]
|
|
219
|
+
* Sliding time window in milliseconds.
|
|
220
|
+
* @param {number} [options.cleanupInterval]
|
|
221
|
+
* Interval (ms) for automatic cleanup execution.
|
|
222
|
+
* @param {number} [options.maxIdle=300000]
|
|
223
|
+
* Maximum inactivity time (ms) before a group expires.
|
|
85
224
|
*/
|
|
86
225
|
constructor({ maxHits, interval, cleanupInterval, maxIdle = 300000, maxMemory = 100000 }) {
|
|
87
226
|
/** @param {number|undefined} val */
|
|
@@ -166,10 +305,14 @@ class TinyRateLimiter {
|
|
|
166
305
|
this.groupTTL.delete(groupId);
|
|
167
306
|
}
|
|
168
307
|
/**
|
|
169
|
-
*
|
|
308
|
+
* Assigns a user to a group.
|
|
309
|
+
*
|
|
310
|
+
* If the user already has recorded hits, those hits
|
|
311
|
+
* are merged into the target group.
|
|
312
|
+
*
|
|
170
313
|
* @param {string} userId
|
|
171
314
|
* @param {string} groupId
|
|
172
|
-
* @throws {Error} If
|
|
315
|
+
* @throws {Error} If the user belongs to another group
|
|
173
316
|
*/
|
|
174
317
|
assignToGroup(userId, groupId) {
|
|
175
318
|
const existingGroup = this.userToGroup.get(userId);
|
|
@@ -211,7 +354,11 @@ class TinyRateLimiter {
|
|
|
211
354
|
this.groupFlags.set(groupId, true);
|
|
212
355
|
}
|
|
213
356
|
/**
|
|
214
|
-
*
|
|
357
|
+
* Resolves the effective group ID for a user.
|
|
358
|
+
*
|
|
359
|
+
* If the user is not explicitly assigned to a group,
|
|
360
|
+
* the user ID itself is treated as the group ID.
|
|
361
|
+
*
|
|
215
362
|
* @param {string} userId
|
|
216
363
|
* @returns {string}
|
|
217
364
|
*/
|
|
@@ -219,7 +366,25 @@ class TinyRateLimiter {
|
|
|
219
366
|
return this.userToGroup.get(userId) || userId; // fallback: use userId as own group
|
|
220
367
|
}
|
|
221
368
|
/**
|
|
222
|
-
*
|
|
369
|
+
* Registers a hit for a user and applies the sliding window logic.
|
|
370
|
+
*
|
|
371
|
+
* ⚠️ **Important usage notice**
|
|
372
|
+
* This method **must be called before** `isRateLimited(userId)`
|
|
373
|
+
* in order for rate limit checks to work correctly.
|
|
374
|
+
*
|
|
375
|
+
* ### Sliding window cleanup
|
|
376
|
+
* When `interval` is configured:
|
|
377
|
+
* - The current timestamp is added to the group's history.
|
|
378
|
+
* - All timestamps older than `now - interval` are immediately removed.
|
|
379
|
+
*
|
|
380
|
+
* This ensures that the stored history always represents
|
|
381
|
+
* the **current active window**.
|
|
382
|
+
*
|
|
383
|
+
* ### Important notes
|
|
384
|
+
* - Cleanup happens on every hit, not on a fixed schedule.
|
|
385
|
+
* - The window continuously moves forward in time.
|
|
386
|
+
* - Memory usage is naturally bounded by time, and optionally by `maxMemory`.
|
|
387
|
+
*
|
|
223
388
|
* @param {string} userId
|
|
224
389
|
*/
|
|
225
390
|
hit(userId) {
|
|
@@ -252,7 +417,22 @@ class TinyRateLimiter {
|
|
|
252
417
|
}
|
|
253
418
|
}
|
|
254
419
|
/**
|
|
255
|
-
*
|
|
420
|
+
* Checks whether a user is currently rate limited.
|
|
421
|
+
*
|
|
422
|
+
* ### Evaluation process
|
|
423
|
+
* When `interval` is defined:
|
|
424
|
+
* 1. The cutoff time is calculated as:
|
|
425
|
+
*
|
|
426
|
+
* `Date.now() - interval`
|
|
427
|
+
*
|
|
428
|
+
* 2. Only hits newer than this cutoff are counted.
|
|
429
|
+
* 3. If `maxHits` is defined:
|
|
430
|
+
* - The group is limited when the count exceeds `maxHits`.
|
|
431
|
+
* 4. If `maxHits` is not defined:
|
|
432
|
+
* - Any hit within the window causes a limited state.
|
|
433
|
+
*
|
|
434
|
+
* This guarantees consistent behavior regardless of when hits occur.
|
|
435
|
+
*
|
|
256
436
|
* @param {string} userId
|
|
257
437
|
* @returns {boolean}
|
|
258
438
|
*/
|
|
@@ -389,7 +569,11 @@ class TinyRateLimiter {
|
|
|
389
569
|
return Object.fromEntries(this.userToGroup);
|
|
390
570
|
}
|
|
391
571
|
/**
|
|
392
|
-
*
|
|
572
|
+
* Returns the configured sliding window size in milliseconds.
|
|
573
|
+
*
|
|
574
|
+
* This value represents how far back in time hits are considered
|
|
575
|
+
* valid for rate limiting decisions.
|
|
576
|
+
*
|
|
393
577
|
* @returns {number}
|
|
394
578
|
*/
|
|
395
579
|
getInterval() {
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents the result of a single text segment comparison.
|
|
5
|
+
* @typedef {Object} DiffResult
|
|
6
|
+
* @property {string} value - The actual string content of the segment.
|
|
7
|
+
* @property {"normal"|"added"|"deleted"} type - The status of the segment relative to the comparison.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A utility class to store a history of text strings and compute the differences
|
|
12
|
+
* between any two versions, generating a detailed diff output.
|
|
13
|
+
*/
|
|
14
|
+
class TinyTextDiffer {
|
|
15
|
+
/** @type {string[]} */
|
|
16
|
+
#history = [];
|
|
17
|
+
|
|
18
|
+
/** @type {boolean} */
|
|
19
|
+
#destroyed = false;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retrieves a shallow copy of the current history array.
|
|
23
|
+
* @returns {string[]}
|
|
24
|
+
*/
|
|
25
|
+
get history() {
|
|
26
|
+
return [...this.#history];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Overwrites the current history array, ensuring all elements are valid strings.
|
|
31
|
+
* @param {string[]} history
|
|
32
|
+
*/
|
|
33
|
+
set history(history) {
|
|
34
|
+
if (!Array.isArray(history)) {
|
|
35
|
+
throw new TypeError('History data must be provided as an array.');
|
|
36
|
+
}
|
|
37
|
+
if (!history.every((item) => typeof item === 'string')) {
|
|
38
|
+
throw new TypeError('All items in the history array must be strings.');
|
|
39
|
+
}
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
this.#history = [...history];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Gets the total number of versions currently stored in the history.
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
get size() {
|
|
49
|
+
return this.#history.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initializes a new instance of TinyTextDiffer.
|
|
54
|
+
* @param {string[]} [history=[]]
|
|
55
|
+
*/
|
|
56
|
+
constructor(history = []) {
|
|
57
|
+
this.history = history;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @throws {Error}
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
#checkDestroyed() {
|
|
65
|
+
if (this.#destroyed) {
|
|
66
|
+
throw new Error('Cannot perform operations on a destroyed TinyTextDiffer instance.');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {any} index
|
|
72
|
+
* @throws {TypeError}
|
|
73
|
+
* @returns {void}
|
|
74
|
+
*/
|
|
75
|
+
#checkIndex(index) {
|
|
76
|
+
if (typeof index !== 'number') {
|
|
77
|
+
throw new TypeError('The provided index must be a valid number.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {any} text
|
|
83
|
+
* @throws {TypeError}
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
#checkText(text) {
|
|
87
|
+
if (typeof text !== 'string') {
|
|
88
|
+
throw new TypeError('The provided text must be a valid string.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Retrieves the text string at the specified index.
|
|
94
|
+
* @param {number} index
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
get(index) {
|
|
98
|
+
this.#checkDestroyed();
|
|
99
|
+
this.#checkIndex(index);
|
|
100
|
+
if (typeof this.#history[index] === 'undefined') {
|
|
101
|
+
throw new Error(`No text version found at index ${index}.`);
|
|
102
|
+
}
|
|
103
|
+
return this.#history[index];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks if a text string exists at the specified index.
|
|
108
|
+
* @param {number} index
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
has(index) {
|
|
112
|
+
this.#checkDestroyed();
|
|
113
|
+
this.#checkIndex(index);
|
|
114
|
+
return typeof this.#history[index] !== 'undefined';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Appends a new text string to the end of the history.
|
|
119
|
+
* @param {string} text
|
|
120
|
+
* @returns {void}
|
|
121
|
+
*/
|
|
122
|
+
add(text) {
|
|
123
|
+
this.#checkDestroyed();
|
|
124
|
+
this.#checkText(text);
|
|
125
|
+
this.#history.push(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Removes and returns the last text string from the history.
|
|
130
|
+
* @returns {string | undefined}
|
|
131
|
+
*/
|
|
132
|
+
remove() {
|
|
133
|
+
this.#checkDestroyed();
|
|
134
|
+
return this.#history.pop();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Inserts a text string at the specified index position.
|
|
139
|
+
* @param {number} index
|
|
140
|
+
* @param {string} text
|
|
141
|
+
* @returns {void}
|
|
142
|
+
*/
|
|
143
|
+
addAt(index, text) {
|
|
144
|
+
this.#checkDestroyed();
|
|
145
|
+
this.#checkIndex(index);
|
|
146
|
+
this.#checkText(text);
|
|
147
|
+
this.#history.splice(index, 0, text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Removes the text string at the specified index position.
|
|
152
|
+
* @param {number} index
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
removeAt(index) {
|
|
156
|
+
this.#checkDestroyed();
|
|
157
|
+
this.#checkIndex(index);
|
|
158
|
+
const oldSize = this.#history.length;
|
|
159
|
+
this.#history.splice(index, 1);
|
|
160
|
+
const newSize = this.#history.length;
|
|
161
|
+
return oldSize !== newSize;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Empties the entire history.
|
|
166
|
+
* @returns {void}
|
|
167
|
+
*/
|
|
168
|
+
clear() {
|
|
169
|
+
this.#checkDestroyed();
|
|
170
|
+
this.#history = [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Compares multiple pairs of text strings from the history based on their indices.
|
|
175
|
+
* Each pair of indices (e.g., index1 and index2) will produce a DiffResult array.
|
|
176
|
+
* @param {...number} indexes - An even number of indices to be compared in pairs.
|
|
177
|
+
* @returns {(DiffResult[])[]} An array of DiffResult arrays for each compared pair.
|
|
178
|
+
*/
|
|
179
|
+
compare(...indexes) {
|
|
180
|
+
this.#checkDestroyed();
|
|
181
|
+
|
|
182
|
+
/** @type {number} */
|
|
183
|
+
const totalIndexes = indexes.length;
|
|
184
|
+
|
|
185
|
+
if (totalIndexes === 0 || totalIndexes % 2 !== 0) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'The compare method requires an even number of indices to form comparison pairs.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @type {(DiffResult[])[]} */
|
|
192
|
+
const results = [];
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < totalIndexes; i += 2) {
|
|
195
|
+
const i2 = i + 1;
|
|
196
|
+
if (i2 > totalIndexes - 1) continue;
|
|
197
|
+
this.#checkIndex(indexes[i]);
|
|
198
|
+
this.#checkIndex(indexes[i2]);
|
|
199
|
+
|
|
200
|
+
/** @type {string} */
|
|
201
|
+
const str1 = this.#history[indexes[i]];
|
|
202
|
+
/** @type {string} */
|
|
203
|
+
const str2 = this.#history[indexes[i2]];
|
|
204
|
+
|
|
205
|
+
results.push(this._computeDiff(str1, str2));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Computes the Longest Common Subsequence (LCS) to generate the diff result.
|
|
213
|
+
* @private
|
|
214
|
+
* @param {string} str1
|
|
215
|
+
* @param {string} str2
|
|
216
|
+
* @returns {DiffResult[]}
|
|
217
|
+
*/
|
|
218
|
+
_computeDiff(str1, str2) {
|
|
219
|
+
/** @type {number} */
|
|
220
|
+
const len1 = str1.length;
|
|
221
|
+
/** @type {number} */
|
|
222
|
+
const len2 = str2.length;
|
|
223
|
+
|
|
224
|
+
/** @type {number[][]} */
|
|
225
|
+
const matrix = Array(len1 + 1)
|
|
226
|
+
.fill(null)
|
|
227
|
+
.map(() => Array(len2 + 1).fill(0));
|
|
228
|
+
|
|
229
|
+
/** @type {number} */
|
|
230
|
+
let i = 1;
|
|
231
|
+
/** @type {number} */
|
|
232
|
+
let j = 1;
|
|
233
|
+
|
|
234
|
+
for (i = 1; i <= len1; i++) {
|
|
235
|
+
for (j = 1; j <= len2; j++) {
|
|
236
|
+
if (str1[i - 1] === str2[j - 1]) {
|
|
237
|
+
matrix[i][j] = matrix[i - 1][j - 1] + 1;
|
|
238
|
+
} else {
|
|
239
|
+
matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
i = len1;
|
|
245
|
+
j = len2;
|
|
246
|
+
|
|
247
|
+
/** @type {DiffResult[]} */
|
|
248
|
+
const result = [];
|
|
249
|
+
|
|
250
|
+
while (i > 0 || j > 0) {
|
|
251
|
+
if (i > 0 && j > 0 && str1[i - 1] === str2[j - 1]) {
|
|
252
|
+
result.unshift({ value: str1[i - 1], type: 'normal' });
|
|
253
|
+
i--;
|
|
254
|
+
j--;
|
|
255
|
+
} else if (j > 0 && (i === 0 || matrix[i][j - 1] >= matrix[i - 1][j])) {
|
|
256
|
+
result.unshift({ value: str2[j - 1], type: 'added' });
|
|
257
|
+
j--;
|
|
258
|
+
} else if (i > 0 && (j === 0 || matrix[i][j - 1] < matrix[i - 1][j])) {
|
|
259
|
+
result.unshift({ value: str1[i - 1], type: 'deleted' });
|
|
260
|
+
i--;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return result.reduce((/** @type {DiffResult[]} */ acc, /** @type {DiffResult} */ curr) => {
|
|
265
|
+
/** @type {number} */
|
|
266
|
+
const lastIndex = acc.length - 1;
|
|
267
|
+
|
|
268
|
+
if (acc.length > 0 && acc[lastIndex].type === curr.type) {
|
|
269
|
+
acc[lastIndex].value += curr.value;
|
|
270
|
+
} else {
|
|
271
|
+
acc.push(curr);
|
|
272
|
+
}
|
|
273
|
+
return acc;
|
|
274
|
+
}, []);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Cleans up internal references and marks the instance as destroyed to prevent memory leaks.
|
|
279
|
+
* @returns {void}
|
|
280
|
+
*/
|
|
281
|
+
destroy() {
|
|
282
|
+
if (this.#destroyed) return;
|
|
283
|
+
this.#history = [];
|
|
284
|
+
this.#destroyed = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = TinyTextDiffer;
|