@nextera.one/tps-standard 0.5.1 → 0.5.3
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 +91 -467
- package/dist/drivers/gregorian.d.ts +18 -0
- package/dist/drivers/gregorian.js +157 -0
- package/dist/drivers/gregorian.js.map +1 -0
- package/dist/drivers/hijri.d.ts +42 -0
- package/dist/drivers/hijri.js +281 -0
- package/dist/drivers/hijri.js.map +1 -0
- package/dist/drivers/holocene.d.ts +25 -0
- package/dist/drivers/holocene.js +132 -0
- package/dist/drivers/holocene.js.map +1 -0
- package/dist/drivers/julian.d.ts +33 -0
- package/dist/drivers/julian.js +225 -0
- package/dist/drivers/julian.js.map +1 -0
- package/dist/drivers/persian.d.ts +33 -0
- package/dist/drivers/persian.js +269 -0
- package/dist/drivers/persian.js.map +1 -0
- package/dist/drivers/tps.d.ts +55 -0
- package/dist/drivers/tps.js +235 -0
- package/dist/drivers/tps.js.map +1 -0
- package/dist/drivers/unix.d.ts +16 -0
- package/dist/drivers/unix.js +76 -0
- package/dist/drivers/unix.js.map +1 -0
- package/dist/index.d.ts +174 -41
- package/dist/index.js +803 -321
- package/dist/index.js.map +1 -0
- package/package.json +9 -3
- package/src/drivers/gregorian.ts +191 -0
- package/src/drivers/hijri.ts +322 -0
- package/src/drivers/holocene.ts +152 -0
- package/src/drivers/julian.ts +255 -0
- package/src/drivers/persian.ts +298 -0
- package/src/drivers/tps.ts +270 -0
- package/src/drivers/unix.ts +79 -0
- package/src/index.ts +959 -366
- package/dist/src/index.js +0 -681
- package/dist/test/src/index.js +0 -963
- package/dist/test/test/persian-calendar.test.js +0 -488
- package/dist/test/test/tps-uid.test.js +0 -295
- package/dist/test/tps-uid.test.js +0 -240
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* TPS: Temporal Positioning System
|
|
3
3
|
* The Universal Protocol for Space-Time Coordinates.
|
|
4
4
|
* @packageDocumentation
|
|
5
|
-
* @version 0.5.
|
|
5
|
+
* @version 0.5.3
|
|
6
6
|
* @license Apache-2.0
|
|
7
7
|
* @copyright 2026 TPS Standards Working Group
|
|
8
8
|
*
|
|
@@ -13,19 +13,59 @@
|
|
|
13
13
|
* - Added geospatial cell systems (S2, H3, Plus Code, what3words)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// built-in drivers are registered automatically; importing them here
|
|
17
|
+
// ensures they are included when the library bundler/tree-shaker runs.
|
|
18
|
+
import { GregorianDriver } from "./drivers/gregorian";
|
|
19
|
+
import { UnixDriver } from "./drivers/unix";
|
|
20
|
+
import { TpsDriver } from "./drivers/tps";
|
|
21
|
+
import { PersianDriver } from "./drivers/persian";
|
|
22
|
+
import { HijriDriver } from "./drivers/hijri";
|
|
23
|
+
import { JulianDriver } from "./drivers/julian";
|
|
24
|
+
import { HoloceneDriver } from "./drivers/holocene";
|
|
25
|
+
|
|
26
|
+
// Calendar codes are plain strings to allow arbitrary user-defined
|
|
27
|
+
// calendars. The library still exports constants for the built-in values but
|
|
28
|
+
// callers may also supply their own codes.
|
|
29
|
+
export const DefaultCalendars = {
|
|
30
|
+
TPS: "tps",
|
|
31
|
+
GREG: "greg",
|
|
32
|
+
HIJ: "hij",
|
|
33
|
+
PER: "per",
|
|
34
|
+
JUL: "jul",
|
|
35
|
+
HOLO: "holo",
|
|
36
|
+
UNIX: "unix",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Specifies the direction of the time-component hierarchy when serializing or
|
|
41
|
+
* deserializing a TPS string. The default is `'descending'` (millennium → … →
|
|
42
|
+
* second), but `'ascending'` produces the reverse order.
|
|
43
|
+
*/
|
|
44
|
+
export enum TimeOrder {
|
|
45
|
+
DESC = "desc",
|
|
46
|
+
ASC = "asc",
|
|
47
|
+
}
|
|
17
48
|
|
|
18
49
|
export interface TPSComponents {
|
|
19
50
|
// --- TEMPORAL ---
|
|
20
|
-
calendar:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
51
|
+
calendar: string;
|
|
52
|
+
// --- REQUIRED TEMPORAL FIELDS ---
|
|
53
|
+
// All of the traditional Gregorian components are now mandatory. This
|
|
54
|
+
// reflects the fact that a valid TPS time object must contain a complete
|
|
55
|
+
// timestamp when using the canonical calendar formats.
|
|
56
|
+
millennium: number;
|
|
57
|
+
century: number;
|
|
58
|
+
year: number;
|
|
59
|
+
month: number;
|
|
60
|
+
day: number;
|
|
61
|
+
hour: number;
|
|
62
|
+
minute: number;
|
|
63
|
+
second: number;
|
|
64
|
+
/** Sub-second precision (0–999). Encoded as the last `m` token. */
|
|
65
|
+
millisecond: number;
|
|
66
|
+
// --- OPTIONAL UNIX BACKUP ---
|
|
67
|
+
// `unixSeconds` remains optional to support the Unix driver and other
|
|
68
|
+
// cases where a simple epoch value is preferred.
|
|
29
69
|
unixSeconds?: number;
|
|
30
70
|
|
|
31
71
|
// --- SPATIAL: GPS Coordinates ---
|
|
@@ -53,6 +93,9 @@ export interface TPSComponents {
|
|
|
53
93
|
/** Logical area within building */
|
|
54
94
|
zone?: string;
|
|
55
95
|
|
|
96
|
+
/** Raw pre-@ space anchor (e.g. adm:city:SA:riyadh, node:api-1, net:ip4:203.0.113.10) */
|
|
97
|
+
spaceAnchor?: string;
|
|
98
|
+
|
|
56
99
|
// --- SPATIAL: Privacy Markers ---
|
|
57
100
|
/** Technical missing data (e.g. server log without GPS) */
|
|
58
101
|
isUnknownLocation?: boolean;
|
|
@@ -69,6 +112,8 @@ export interface TPSComponents {
|
|
|
69
112
|
|
|
70
113
|
// --- CONTEXT ---
|
|
71
114
|
extensions?: Record<string, string>;
|
|
115
|
+
|
|
116
|
+
order?: TimeOrder;
|
|
72
117
|
}
|
|
73
118
|
|
|
74
119
|
// --- PLUGIN ARCHITECTURE ---
|
|
@@ -124,7 +169,7 @@ export interface TPSComponents {
|
|
|
124
169
|
*/
|
|
125
170
|
export interface CalendarDriver {
|
|
126
171
|
/** The calendar code this driver handles (e.g., 'hij', 'jul'). */
|
|
127
|
-
readonly code:
|
|
172
|
+
readonly code: string;
|
|
128
173
|
|
|
129
174
|
/**
|
|
130
175
|
* Human-readable name for this calendar (optional).
|
|
@@ -133,25 +178,25 @@ export interface CalendarDriver {
|
|
|
133
178
|
readonly name?: string;
|
|
134
179
|
|
|
135
180
|
/**
|
|
136
|
-
* Converts a
|
|
181
|
+
* Converts a Date to this calendar's components.
|
|
137
182
|
* @param date - The Gregorian Date object.
|
|
138
183
|
* @returns Partial TPS components for year, month, day, etc.
|
|
139
184
|
*/
|
|
140
|
-
|
|
185
|
+
getComponentsFromDate(date: Date): Partial<TPSComponents>;
|
|
141
186
|
|
|
142
187
|
/**
|
|
143
|
-
* Converts this calendar's components to a
|
|
188
|
+
* Converts this calendar's components to a Date.
|
|
144
189
|
* @param components - Partial TPS components (year, month, day, etc.).
|
|
145
190
|
* @returns A JavaScript Date object.
|
|
146
191
|
*/
|
|
147
|
-
|
|
192
|
+
getDateFromComponents(components: Partial<TPSComponents>): Date;
|
|
148
193
|
|
|
149
194
|
/**
|
|
150
195
|
* Generates a TPS time string for this calendar from a Date.
|
|
151
196
|
* @param date - The Gregorian Date object.
|
|
152
|
-
* @returns A TPS time string (e.g., "T:hij.y1447.
|
|
197
|
+
* @returns A TPS time string (e.g., "T:hij.y1447.m07.d21...").
|
|
153
198
|
*/
|
|
154
|
-
|
|
199
|
+
getFromDate(date: Date): string;
|
|
155
200
|
|
|
156
201
|
// --- NEW ENHANCED METHODS (Optional) ---
|
|
157
202
|
|
|
@@ -172,7 +217,7 @@ export interface CalendarDriver {
|
|
|
172
217
|
* driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
|
|
173
218
|
* ```
|
|
174
219
|
*/
|
|
175
|
-
parseDate
|
|
220
|
+
parseDate(input: string, format?: string): Partial<TPSComponents>;
|
|
176
221
|
|
|
177
222
|
/**
|
|
178
223
|
* Format TPS components to a calendar-specific date string.
|
|
@@ -188,7 +233,7 @@ export interface CalendarDriver {
|
|
|
188
233
|
* driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
|
|
189
234
|
* ```
|
|
190
235
|
*/
|
|
191
|
-
format
|
|
236
|
+
format(components: Partial<TPSComponents>, format?: string): string;
|
|
192
237
|
|
|
193
238
|
/**
|
|
194
239
|
* Validate a calendar-specific date string or components.
|
|
@@ -202,7 +247,7 @@ export interface CalendarDriver {
|
|
|
202
247
|
* driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
|
|
203
248
|
* ```
|
|
204
249
|
*/
|
|
205
|
-
validate
|
|
250
|
+
validate(input: string | Partial<TPSComponents>): boolean;
|
|
206
251
|
|
|
207
252
|
/**
|
|
208
253
|
* Get calendar metadata (month names, day names, etc.).
|
|
@@ -214,7 +259,7 @@ export interface CalendarDriver {
|
|
|
214
259
|
* // → ['Muharram', 'Safar', 'Rabi I', ...]
|
|
215
260
|
* ```
|
|
216
261
|
*/
|
|
217
|
-
getMetadata
|
|
262
|
+
getMetadata(): CalendarMetadata;
|
|
218
263
|
}
|
|
219
264
|
|
|
220
265
|
/**
|
|
@@ -241,8 +286,7 @@ export interface CalendarMetadata {
|
|
|
241
286
|
|
|
242
287
|
export class TPS {
|
|
243
288
|
// --- PLUGIN REGISTRY ---
|
|
244
|
-
private static readonly drivers: Map<
|
|
245
|
-
new Map();
|
|
289
|
+
private static readonly drivers: Map<string, CalendarDriver> = new Map();
|
|
246
290
|
|
|
247
291
|
/**
|
|
248
292
|
* Registers a calendar driver plugin.
|
|
@@ -257,63 +301,236 @@ export class TPS {
|
|
|
257
301
|
* @param code - The calendar code.
|
|
258
302
|
* @returns The driver or undefined.
|
|
259
303
|
*/
|
|
260
|
-
static getDriver(code:
|
|
304
|
+
static getDriver(code: string): CalendarDriver | undefined {
|
|
261
305
|
return this.drivers.get(code);
|
|
262
306
|
}
|
|
263
307
|
|
|
264
308
|
// --- REGEX ---
|
|
265
309
|
// Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
|
|
266
|
-
//
|
|
310
|
+
// Tokens may appear in any order; actual semantic parsing happens in
|
|
311
|
+
// `parseTimeString()` so these patterns are intentionally permissive.
|
|
312
|
+
// regex simply ensures prefix, space, calendar, and token characters;
|
|
313
|
+
// token order is not enforced (parseTimeString handles semantics).
|
|
267
314
|
private static readonly REGEX_URI = new RegExp(
|
|
268
|
-
|
|
269
|
-
// Location part (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
// Optional extensions
|
|
288
|
-
'(?:;(?<extensions>[a-z0-9.\\-_=]+))?' +
|
|
289
|
-
// Optional query params
|
|
290
|
-
'(?:\\?(?<params>[^#]+))?' +
|
|
291
|
-
// Optional context
|
|
292
|
-
'(?:#(?<context>.+))?$',
|
|
315
|
+
"^tps://" +
|
|
316
|
+
// Location part (preserve named captures for space subfields)
|
|
317
|
+
"(?:L:)?(?<space>" +
|
|
318
|
+
"~|-|unknown|redacted|hidden|" +
|
|
319
|
+
"s2=(?<s2>[a-fA-F0-9]+)|" +
|
|
320
|
+
"h3=(?<h3>[a-fA-F0-9]+)|" +
|
|
321
|
+
"plus=(?<plus>[A-Z0-9+]+)|" +
|
|
322
|
+
"w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|" +
|
|
323
|
+
"bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|" +
|
|
324
|
+
"(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?|" +
|
|
325
|
+
"(?<generic>[^@/?#]+)" +
|
|
326
|
+
")" +
|
|
327
|
+
"(?:/A:(?<actor>[^/@]+))?" +
|
|
328
|
+
"@T:(?<calendar>[a-z]{3,4})" +
|
|
329
|
+
"(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
|
|
330
|
+
"(?:![^;?#]+)?" +
|
|
331
|
+
"(?:;(?<extensions>[^?#]+))?" +
|
|
332
|
+
"(?:\\?[^#]+)?" +
|
|
333
|
+
"(?:#.+)?$",
|
|
293
334
|
);
|
|
294
335
|
|
|
295
336
|
private static readonly REGEX_TIME = new RegExp(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
337
|
+
"^T:(?<calendar>[a-z]{3,4})" +
|
|
338
|
+
"(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
|
|
339
|
+
"(?:![^;?#]+)?$",
|
|
299
340
|
);
|
|
300
341
|
|
|
301
342
|
// --- CORE METHODS ---
|
|
302
343
|
|
|
344
|
+
/**
|
|
345
|
+
* SANITIZER: Normalises a raw TPS input string before validation.
|
|
346
|
+
*
|
|
347
|
+
* Pure string-based — no parsing into components, no regex beyond simple
|
|
348
|
+
* character checks, no re-serialisation via buildTimePart / toURI.
|
|
349
|
+
*
|
|
350
|
+
* Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
351
|
+
*/
|
|
352
|
+
static sanitizeTimeInput(input: string): string {
|
|
353
|
+
// ── 1. Whitespace ────────────────────────────────────────────────────────
|
|
354
|
+
let s = input.trim().replace(/\s+/g, "");
|
|
355
|
+
if (!s) return s;
|
|
356
|
+
|
|
357
|
+
// ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
|
|
358
|
+
// The input may contain "/T:" from older versions; we normalise early so
|
|
359
|
+
// subsequent logic can assume only the '@' form.
|
|
360
|
+
if (s.includes("/T:")) {
|
|
361
|
+
s = s.replace(/\/T:/g, "@T:");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── 2. Scheme casing ─────────────────────────────────────────────────────
|
|
365
|
+
if (s.slice(0, 6).toLowerCase() === "tps://") {
|
|
366
|
+
s = "tps://" + s.slice(6);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── 3. T: prefix casing (time-only strings) ──────────────────────────────
|
|
370
|
+
if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
|
|
371
|
+
s = "T:" + s.slice(2);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── 4. Locate T: section ─────────────────────────────────────────────────
|
|
375
|
+
let tStart = -1;
|
|
376
|
+
if (s.startsWith("T:")) {
|
|
377
|
+
tStart = 0;
|
|
378
|
+
} else {
|
|
379
|
+
const atT = s.indexOf("@T:");
|
|
380
|
+
if (atT !== -1) tStart = atT + 1;
|
|
381
|
+
}
|
|
382
|
+
if (tStart === -1) return s; // no T: section — return as-is
|
|
383
|
+
|
|
384
|
+
const beforeT = s.slice(0, tStart); // URI prefix or empty
|
|
385
|
+
const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
|
|
386
|
+
|
|
387
|
+
// Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
|
|
388
|
+
const suffixIdx = timeAndRest.search(/[!;?#]/);
|
|
389
|
+
const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
|
|
390
|
+
const timePart =
|
|
391
|
+
suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
|
|
392
|
+
// timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
|
|
393
|
+
|
|
394
|
+
// Split off calendar code
|
|
395
|
+
const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
|
|
396
|
+
const firstDot = afterColon.indexOf(".");
|
|
397
|
+
const cal = (
|
|
398
|
+
firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon
|
|
399
|
+
).toLowerCase();
|
|
400
|
+
const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
|
|
401
|
+
|
|
402
|
+
// If no calendar code was provided at all (e.g. "T:"), bail out early
|
|
403
|
+
// rather than inventing a default calendar. The string will remain
|
|
404
|
+
// unparsable so validation can report it as invalid.
|
|
405
|
+
if (!cal) {
|
|
406
|
+
return s;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// No tokens at all — fill every slot with 0 and return
|
|
410
|
+
if (!tokenStr) {
|
|
411
|
+
return `${beforeT}T:${cal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── 5. Tokenise ──────────────────────────────────────────────────────────
|
|
415
|
+
// Each raw token: first char = letter prefix, remainder = numeric value
|
|
416
|
+
type Tok = { p: string; v: string };
|
|
417
|
+
const tokens: Tok[] = tokenStr
|
|
418
|
+
.split(".")
|
|
419
|
+
.filter((t) => t.length >= 2 && /^[a-z]/.test(t))
|
|
420
|
+
.map((t) => ({ p: t[0], v: t.slice(1) }));
|
|
421
|
+
|
|
422
|
+
// ── 6. Detect order from non-m tokens (c=7, y=6, d=4, h=3, s=1) ─────────
|
|
423
|
+
const nonMRank: Record<string, number> = { c: 7, y: 6, d: 4, h: 3, s: 1 };
|
|
424
|
+
const nonMSeq = tokens
|
|
425
|
+
.filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
|
|
426
|
+
.map((t) => nonMRank[t.p]);
|
|
427
|
+
|
|
428
|
+
let isAsc = false;
|
|
429
|
+
if (nonMSeq.length >= 2) {
|
|
430
|
+
// ascending when every consecutive rank-diff is positive
|
|
431
|
+
isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── 7. Reverse tokens if ascending ───────────────────────────────────────
|
|
435
|
+
if (isAsc) tokens.reverse();
|
|
436
|
+
|
|
437
|
+
// ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
|
|
438
|
+
// DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
|
|
439
|
+
const mDescRanks = [8, 5, 2, 0];
|
|
440
|
+
const byRank = new Map<number, string>();
|
|
441
|
+
let mIdx = 0;
|
|
442
|
+
|
|
443
|
+
for (const tok of tokens) {
|
|
444
|
+
if (tok.p === "m") {
|
|
445
|
+
if (mIdx < mDescRanks.length) byRank.set(mDescRanks[mIdx++], tok.v);
|
|
446
|
+
} else {
|
|
447
|
+
const r = nonMRank[tok.p];
|
|
448
|
+
if (r !== undefined) byRank.set(r, tok.v);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── 9. Build complete DESC token string, filling gaps with '0' ───────────
|
|
453
|
+
// Full DESC slot sequence: m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
454
|
+
const descSlots: Array<[string, number]> = [
|
|
455
|
+
["m", 8],
|
|
456
|
+
["c", 7],
|
|
457
|
+
["y", 6],
|
|
458
|
+
["m", 5],
|
|
459
|
+
["d", 4],
|
|
460
|
+
["h", 3],
|
|
461
|
+
["m", 2],
|
|
462
|
+
["s", 1],
|
|
463
|
+
["m", 0],
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
const finalTokenStr = descSlots
|
|
467
|
+
.map(([p, r]) => p + (byRank.get(r) ?? "0"))
|
|
468
|
+
.join(".");
|
|
469
|
+
|
|
470
|
+
return `${beforeT}T:${cal}.${finalTokenStr}${timeSuffix}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
303
473
|
static validate(input: string): boolean {
|
|
304
|
-
|
|
305
|
-
|
|
474
|
+
const sanitized = this.sanitizeTimeInput(input);
|
|
475
|
+
if (sanitized.startsWith("tps://")) {
|
|
476
|
+
return this.REGEX_URI.test(sanitized);
|
|
477
|
+
}
|
|
478
|
+
return this.REGEX_TIME.test(sanitized);
|
|
306
479
|
}
|
|
307
480
|
|
|
308
481
|
static parse(input: string): TPSComponents | null {
|
|
309
|
-
|
|
482
|
+
// Always sanitize first so we operate on the canonical form. This also
|
|
483
|
+
// rewrites any legacy "/T:" separators to "@T:" so the regex below can
|
|
484
|
+
// remain strict.
|
|
485
|
+
input = this.sanitizeTimeInput(input);
|
|
486
|
+
|
|
487
|
+
// quick fail via regex to rule out obviously bad strings
|
|
488
|
+
if (input.startsWith("tps://")) {
|
|
310
489
|
const match = this.REGEX_URI.exec(input);
|
|
311
490
|
if (!match || !match.groups) return null;
|
|
312
|
-
|
|
491
|
+
const comp: any = this._mapGroupsToComponents(match.groups);
|
|
492
|
+
// extract the raw time portion and parse it separately
|
|
493
|
+
const atIdx = input.indexOf("@T:");
|
|
494
|
+
let timeStr = "";
|
|
495
|
+
let signature: string | undefined;
|
|
496
|
+
if (atIdx !== -1) {
|
|
497
|
+
timeStr = input.slice(atIdx + 1); // include the leading 'T:'
|
|
498
|
+
// if there's a signature, capture it first
|
|
499
|
+
const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
|
|
500
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
501
|
+
signature = sigMatch.groups.sig;
|
|
502
|
+
}
|
|
503
|
+
// cut off signature, extensions, query, or fragment
|
|
504
|
+
timeStr = timeStr.split(/[!;?#]/)[0];
|
|
505
|
+
}
|
|
506
|
+
if (timeStr) {
|
|
507
|
+
const parsed = this.parseTimeString(timeStr);
|
|
508
|
+
if (!parsed) return null;
|
|
509
|
+
Object.assign(comp, parsed.components);
|
|
510
|
+
comp.order = parsed.order;
|
|
511
|
+
}
|
|
512
|
+
if (signature) {
|
|
513
|
+
comp.signature = signature;
|
|
514
|
+
}
|
|
515
|
+
return comp as TPSComponents;
|
|
313
516
|
}
|
|
517
|
+
// time-only string
|
|
314
518
|
const match = this.REGEX_TIME.exec(input);
|
|
315
519
|
if (!match || !match.groups) return null;
|
|
316
|
-
|
|
520
|
+
// isolate signature if present
|
|
521
|
+
let timeOnly = input;
|
|
522
|
+
let signature: string | undefined;
|
|
523
|
+
const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
|
|
524
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
525
|
+
signature = sigMatch.groups.sig;
|
|
526
|
+
timeOnly = input.split(/[!;?#]/)[0];
|
|
527
|
+
}
|
|
528
|
+
const parsed = this.parseTimeString(timeOnly);
|
|
529
|
+
if (!parsed) return null;
|
|
530
|
+
const comp = parsed.components as TPSComponents;
|
|
531
|
+
if (signature) comp.signature = signature;
|
|
532
|
+
comp.order = parsed.order;
|
|
533
|
+
return comp;
|
|
317
534
|
}
|
|
318
535
|
|
|
319
536
|
/**
|
|
@@ -323,14 +540,16 @@ export class TPS {
|
|
|
323
540
|
*/
|
|
324
541
|
static toURI(comp: TPSComponents): string {
|
|
325
542
|
// 1. Build Space Part (L: anchor)
|
|
326
|
-
let spacePart =
|
|
543
|
+
let spacePart = "L:-"; // Default: unknown
|
|
327
544
|
|
|
328
|
-
if (comp.
|
|
329
|
-
spacePart =
|
|
545
|
+
if (comp.spaceAnchor) {
|
|
546
|
+
spacePart = comp.spaceAnchor;
|
|
547
|
+
} else if (comp.isHiddenLocation) {
|
|
548
|
+
spacePart = "L:~";
|
|
330
549
|
} else if (comp.isRedactedLocation) {
|
|
331
|
-
spacePart =
|
|
550
|
+
spacePart = "L:redacted";
|
|
332
551
|
} else if (comp.isUnknownLocation) {
|
|
333
|
-
spacePart =
|
|
552
|
+
spacePart = "L:-";
|
|
334
553
|
} else if (comp.s2Cell) {
|
|
335
554
|
spacePart = `L:s2=${comp.s2Cell}`;
|
|
336
555
|
} else if (comp.h3Cell) {
|
|
@@ -352,85 +571,87 @@ export class TPS {
|
|
|
352
571
|
}
|
|
353
572
|
|
|
354
573
|
// 2. Build Actor Part (A: anchor) - optional
|
|
355
|
-
let actorPart =
|
|
574
|
+
let actorPart = "";
|
|
356
575
|
if (comp.actor) {
|
|
357
576
|
actorPart = `/A:${comp.actor}`;
|
|
358
577
|
}
|
|
359
578
|
|
|
360
|
-
// 3. Build Time Part
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
|
|
364
|
-
timePart += `.s${comp.unixSeconds}`;
|
|
365
|
-
} else {
|
|
366
|
-
if (comp.millennium !== undefined) timePart += `.m${comp.millennium}`;
|
|
367
|
-
if (comp.century !== undefined) timePart += `.c${comp.century}`;
|
|
368
|
-
if (comp.year !== undefined) timePart += `.y${comp.year}`;
|
|
369
|
-
if (comp.month !== undefined) timePart += `.M${this.pad(comp.month)}`;
|
|
370
|
-
if (comp.day !== undefined) timePart += `.d${this.pad(comp.day)}`;
|
|
371
|
-
if (comp.hour !== undefined) timePart += `.h${this.pad(comp.hour)}`;
|
|
372
|
-
if (comp.minute !== undefined) timePart += `.n${this.pad(comp.minute)}`;
|
|
373
|
-
if (comp.second !== undefined) timePart += `.s${this.pad(comp.second)}`;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// 4. Add Signature (!) - optional
|
|
377
|
-
if (comp.signature) {
|
|
378
|
-
timePart += `!${comp.signature}`;
|
|
379
|
-
}
|
|
579
|
+
// 3. Build Time Part (handles order & signature)
|
|
580
|
+
const timePart = this.buildTimePart(comp);
|
|
380
581
|
|
|
381
582
|
// 5. Build Extensions
|
|
382
|
-
let extPart =
|
|
583
|
+
let extPart = "";
|
|
383
584
|
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
384
585
|
const extStrings = Object.entries(comp.extensions).map(
|
|
385
586
|
([k, v]) => `${k}=${v}`,
|
|
386
587
|
);
|
|
387
|
-
extPart = `;${extStrings.join(
|
|
588
|
+
extPart = `;${extStrings.join(".")}`;
|
|
388
589
|
}
|
|
389
590
|
|
|
390
|
-
|
|
591
|
+
// timePart already begins with 'T:'. The new canonical separator is '@'
|
|
592
|
+
// instead of '/', so we interpolate it accordingly. Actor anchor (if
|
|
593
|
+
// present) still uses a leading slash.
|
|
594
|
+
return `tps://${spacePart}${actorPart}@${timePart}${extPart}`;
|
|
391
595
|
}
|
|
392
596
|
|
|
393
597
|
/**
|
|
394
598
|
* CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
|
|
395
599
|
* Supports plugin drivers for non-Gregorian calendars.
|
|
396
600
|
* @param date - The JS Date object (defaults to Now).
|
|
397
|
-
* @param calendar - The target calendar driver (default
|
|
398
|
-
* @
|
|
601
|
+
* @param calendar - The target calendar driver (default `"tps"`).
|
|
602
|
+
* @param opts - Optional parameters; for built-in calendars the only
|
|
603
|
+
* supported key is `order` which may be `'ascending'` or `'descending'`.
|
|
604
|
+
* @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
|
|
399
605
|
*/
|
|
400
606
|
static fromDate(
|
|
401
607
|
date: Date = new Date(),
|
|
402
|
-
calendar:
|
|
608
|
+
calendar: string = DefaultCalendars.TPS,
|
|
609
|
+
opts?: { order?: TimeOrder },
|
|
403
610
|
): string {
|
|
404
|
-
|
|
405
|
-
const driver = this.drivers.get(
|
|
611
|
+
const normalizedCalendar = calendar.toLowerCase();
|
|
612
|
+
const driver = this.drivers.get(normalizedCalendar);
|
|
406
613
|
if (driver) {
|
|
407
|
-
|
|
614
|
+
// when caller requested an explicit order we can bypass the driver's
|
|
615
|
+
// `fromDate` helper and instead generate components ourselves so that
|
|
616
|
+
// order is honoured even if the driver doesn't know about it. This
|
|
617
|
+
// keeps behaviour identical to the old built-in implementation.
|
|
618
|
+
if (opts?.order) {
|
|
619
|
+
const comp = driver.getComponentsFromDate(date) as TPSComponents;
|
|
620
|
+
comp.calendar = normalizedCalendar;
|
|
621
|
+
comp.order = opts.order;
|
|
622
|
+
return this.buildTimePart(comp);
|
|
623
|
+
}
|
|
624
|
+
return driver.getFromDate(date);
|
|
408
625
|
}
|
|
409
626
|
|
|
410
|
-
//
|
|
411
|
-
|
|
627
|
+
// Fallback for old built-in calendars (shouldn't happen once drivers are
|
|
628
|
+
// registered, but kept for backwards compatibility).
|
|
629
|
+
const comp: TPSComponents = { calendar: normalizedCalendar } as any;
|
|
630
|
+
|
|
631
|
+
if (normalizedCalendar === DefaultCalendars.UNIX) {
|
|
412
632
|
const s = (date.getTime() / 1000).toFixed(3);
|
|
413
|
-
|
|
633
|
+
comp.unixSeconds = parseFloat(s);
|
|
634
|
+
if (opts?.order) comp.order = opts.order;
|
|
635
|
+
return this.buildTimePart(comp);
|
|
414
636
|
}
|
|
415
637
|
|
|
416
|
-
if (
|
|
638
|
+
if (normalizedCalendar === DefaultCalendars.GREG) {
|
|
417
639
|
const fullYear = date.getUTCFullYear();
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
)}.h${this.pad(h)}.n${this.pad(n)}.s${this.pad(s)}`;
|
|
640
|
+
comp.millennium = Math.floor(fullYear / 1000) + 1;
|
|
641
|
+
comp.century = Math.floor((fullYear % 1000) / 100) + 1;
|
|
642
|
+
comp.year = fullYear % 100;
|
|
643
|
+
comp.month = date.getUTCMonth() + 1;
|
|
644
|
+
comp.day = date.getUTCDate();
|
|
645
|
+
comp.hour = date.getUTCHours();
|
|
646
|
+
comp.minute = date.getUTCMinutes();
|
|
647
|
+
comp.second = date.getUTCSeconds();
|
|
648
|
+
comp.millisecond = date.getUTCMilliseconds();
|
|
649
|
+
if (opts?.order) comp.order = opts.order;
|
|
650
|
+
return this.buildTimePart(comp);
|
|
430
651
|
}
|
|
431
652
|
|
|
432
653
|
throw new Error(
|
|
433
|
-
`Calendar driver '${
|
|
654
|
+
`Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`,
|
|
434
655
|
);
|
|
435
656
|
}
|
|
436
657
|
|
|
@@ -441,7 +662,7 @@ export class TPS {
|
|
|
441
662
|
* @param targetCalendar - The target calendar code (e.g., 'hij').
|
|
442
663
|
* @returns A TPS string in the target calendar, or null if invalid.
|
|
443
664
|
*/
|
|
444
|
-
static to(targetCalendar:
|
|
665
|
+
static to(targetCalendar: string, tpsString: string): string | null {
|
|
445
666
|
// 1. Parse to components and convert to Gregorian Date
|
|
446
667
|
const gregDate = this.toDate(tpsString);
|
|
447
668
|
if (!gregDate) return null;
|
|
@@ -457,45 +678,25 @@ export class TPS {
|
|
|
457
678
|
* @returns JS Date object or `null` if invalid.
|
|
458
679
|
*/
|
|
459
680
|
static toDate(tpsString: string): Date | null {
|
|
460
|
-
const
|
|
461
|
-
if (!
|
|
681
|
+
const parsed = this.parse(tpsString);
|
|
682
|
+
if (!parsed) return null;
|
|
462
683
|
|
|
463
|
-
|
|
464
|
-
const driver = this.drivers.get(p.calendar);
|
|
465
|
-
if (driver) {
|
|
466
|
-
return driver.toGregorian(p);
|
|
467
|
-
}
|
|
684
|
+
const cal = parsed.calendar || DefaultCalendars.TPS;
|
|
468
685
|
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
686
|
+
const driver = this.drivers.get(cal);
|
|
687
|
+
if (!driver) {
|
|
688
|
+
console.error(`Calendar driver '${cal}' not registered.`);
|
|
689
|
+
return null;
|
|
472
690
|
}
|
|
473
691
|
|
|
474
|
-
|
|
475
|
-
const m = p.millennium || 0;
|
|
476
|
-
const c = p.century || 1;
|
|
477
|
-
const y = p.year || 0;
|
|
478
|
-
const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
479
|
-
|
|
480
|
-
return new Date(
|
|
481
|
-
Date.UTC(
|
|
482
|
-
fullYear,
|
|
483
|
-
(p.month || 1) - 1,
|
|
484
|
-
p.day || 1,
|
|
485
|
-
p.hour || 0,
|
|
486
|
-
p.minute || 0,
|
|
487
|
-
Math.floor(p.second || 0),
|
|
488
|
-
),
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
return null;
|
|
692
|
+
return driver.getDateFromComponents(parsed);
|
|
492
693
|
}
|
|
493
694
|
|
|
494
695
|
// --- DRIVER CONVENIENCE METHODS ---
|
|
495
696
|
|
|
496
697
|
/**
|
|
497
698
|
* Parse a calendar-specific date string into TPS components.
|
|
498
|
-
* Requires the driver to implement
|
|
699
|
+
* Requires the driver to implement `parseDate`.
|
|
499
700
|
*
|
|
500
701
|
* @param calendar - The calendar code (e.g., 'hij')
|
|
501
702
|
* @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
|
|
@@ -508,11 +709,11 @@ export class TPS {
|
|
|
508
709
|
* // { calendar: 'hij', year: 1447, month: 7, day: 21 }
|
|
509
710
|
*
|
|
510
711
|
* const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
|
|
511
|
-
* // "tps://31.95,35.91@T:hij.y1447.
|
|
712
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
512
713
|
* ```
|
|
513
714
|
*/
|
|
514
715
|
static parseCalendarDate(
|
|
515
|
-
calendar:
|
|
716
|
+
calendar: string,
|
|
516
717
|
dateString: string,
|
|
517
718
|
format?: string,
|
|
518
719
|
): Partial<TPSComponents> | null {
|
|
@@ -522,11 +723,7 @@ export class TPS {
|
|
|
522
723
|
`Calendar driver '${calendar}' not found. Register a driver first.`,
|
|
523
724
|
);
|
|
524
725
|
}
|
|
525
|
-
|
|
526
|
-
throw new Error(
|
|
527
|
-
`Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`,
|
|
528
|
-
);
|
|
529
|
-
}
|
|
726
|
+
// parseDate is guaranteed by the interface, so we can call it directly.
|
|
530
727
|
return driver.parseDate(dateString, format);
|
|
531
728
|
}
|
|
532
729
|
|
|
@@ -543,19 +740,19 @@ export class TPS {
|
|
|
543
740
|
* ```ts
|
|
544
741
|
* // With coordinates
|
|
545
742
|
* TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
|
|
546
|
-
* // "tps://31.95,35.91@T:hij.y1447.
|
|
743
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
547
744
|
*
|
|
548
745
|
* // With privacy flag
|
|
549
746
|
* TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
|
|
550
|
-
* // "tps://hidden@T:hij.y1447.
|
|
747
|
+
* // "tps://hidden@T:hij.y1447.m07.d21"
|
|
551
748
|
*
|
|
552
749
|
* // Without location
|
|
553
750
|
* TPS.fromCalendarDate('hij', '1447-07-21');
|
|
554
|
-
* // "tps://unknown@T:hij.y1447.
|
|
751
|
+
* // "tps://unknown@T:hij.y1447.m07.d21"
|
|
555
752
|
* ```
|
|
556
753
|
*/
|
|
557
754
|
static fromCalendarDate(
|
|
558
|
-
calendar:
|
|
755
|
+
calendar: string,
|
|
559
756
|
dateString: string,
|
|
560
757
|
location?: {
|
|
561
758
|
latitude?: number;
|
|
@@ -573,7 +770,7 @@ export class TPS {
|
|
|
573
770
|
|
|
574
771
|
// Merge with location
|
|
575
772
|
const fullComponents: TPSComponents = {
|
|
576
|
-
calendar,
|
|
773
|
+
calendar: calendar,
|
|
577
774
|
...components,
|
|
578
775
|
...location,
|
|
579
776
|
} as TPSComponents;
|
|
@@ -583,7 +780,7 @@ export class TPS {
|
|
|
583
780
|
|
|
584
781
|
/**
|
|
585
782
|
* Format TPS components to a calendar-specific date string.
|
|
586
|
-
* Requires the driver to implement
|
|
783
|
+
* Requires the driver to implement `format`.
|
|
587
784
|
*
|
|
588
785
|
* @param calendar - The calendar code
|
|
589
786
|
* @param components - TPS components to format
|
|
@@ -592,13 +789,13 @@ export class TPS {
|
|
|
592
789
|
*
|
|
593
790
|
* @example
|
|
594
791
|
* ```ts
|
|
595
|
-
* const tps = TPS.parse('tps://unknown@T:hij.y1447.
|
|
792
|
+
* const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
|
|
596
793
|
* const formatted = TPS.formatCalendarDate('hij', tps);
|
|
597
794
|
* // "1447-07-21"
|
|
598
795
|
* ```
|
|
599
796
|
*/
|
|
600
797
|
static formatCalendarDate(
|
|
601
|
-
calendar:
|
|
798
|
+
calendar: string,
|
|
602
799
|
components: Partial<TPSComponents>,
|
|
603
800
|
format?: string,
|
|
604
801
|
): string {
|
|
@@ -606,33 +803,234 @@ export class TPS {
|
|
|
606
803
|
if (!driver) {
|
|
607
804
|
throw new Error(`Calendar driver '${calendar}' not found.`);
|
|
608
805
|
}
|
|
609
|
-
|
|
610
|
-
throw new Error(`Driver '${calendar}' does not implement format().`);
|
|
611
|
-
}
|
|
806
|
+
// format is guaranteed by the interface, so we can call it directly.
|
|
612
807
|
return driver.format(components, format);
|
|
613
808
|
}
|
|
614
809
|
|
|
615
810
|
// --- INTERNAL HELPERS ---
|
|
616
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Generate the canonical `T:` time string for a set of components. The
|
|
814
|
+
* `order` field (or `comp.order`) controls whether tokens are emitted in
|
|
815
|
+
* ascending or descending hierarchy; if undefined the default
|
|
816
|
+
* `'descending'` orientation is used.
|
|
817
|
+
*
|
|
818
|
+
* Drivers may ignore this helper and produce their own time strings if they
|
|
819
|
+
* implement custom ordering logic.
|
|
820
|
+
*/
|
|
821
|
+
public static buildTimePart(comp: TPSComponents): string {
|
|
822
|
+
const calendar = (comp.calendar || "").toLowerCase();
|
|
823
|
+
if (!/^[a-z]{3,4}$/.test(calendar)) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
let time = `T:${calendar}`;
|
|
830
|
+
if (calendar === DefaultCalendars.UNIX) {
|
|
831
|
+
if (comp.unixSeconds !== undefined) {
|
|
832
|
+
time += `.s${comp.unixSeconds}`;
|
|
833
|
+
}
|
|
834
|
+
return time;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// sequence of [prefix, value, rank]
|
|
838
|
+
// All four of millennium / month / minute / millisecond share the prefix 'm'.
|
|
839
|
+
// Position within the ordered sequence disambiguates them during parsing.
|
|
840
|
+
const tokens: Array<[string, number | undefined, number]> = [
|
|
841
|
+
["m", comp.millennium, 8], // m-token rank 8 → millennium
|
|
842
|
+
["c", comp.century, 7],
|
|
843
|
+
["y", comp.year, 6],
|
|
844
|
+
["m", comp.month, 5], // m-token rank 5 → month
|
|
845
|
+
["d", comp.day, 4],
|
|
846
|
+
["h", comp.hour, 3],
|
|
847
|
+
["m", comp.minute, 2], // m-token rank 2 → minute
|
|
848
|
+
["s", comp.second, 1],
|
|
849
|
+
["m", comp.millisecond, 0], // m-token rank 0 → millisecond
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
const order: TimeOrder = comp.order || TimeOrder.DESC;
|
|
853
|
+
if (order === TimeOrder.ASC) tokens.reverse();
|
|
854
|
+
|
|
855
|
+
for (const [pref, val] of tokens) {
|
|
856
|
+
if (val !== undefined) {
|
|
857
|
+
// seconds may be fractional
|
|
858
|
+
time += `.${pref}${val}`;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (comp.signature) {
|
|
863
|
+
time += `!${comp.signature}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return time;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Parse the *time* portion of a TPS string (optionally beginning with
|
|
871
|
+
* `T:`) into components and determine the component ordering. This helper
|
|
872
|
+
* accepts tokens in **any** sequence and will return an `order` value of
|
|
873
|
+
* `'ascending'` or `'descending'`.
|
|
874
|
+
*
|
|
875
|
+
* The caller is responsible for stripping off a leading signature or other
|
|
876
|
+
* trailer characters; this method will drop anything after `!`, `;`, `?` or
|
|
877
|
+
* `#`.
|
|
878
|
+
*
|
|
879
|
+
* ### `m`-token disambiguation
|
|
880
|
+
* All four of millennium (rank 8), month (rank 5), minute (rank 2) and
|
|
881
|
+
* millisecond (rank 0) share the single-character prefix `m`. They are told
|
|
882
|
+
* apart by their **position relative to the neighbouring tokens**. The
|
|
883
|
+
* algorithm is:
|
|
884
|
+
*
|
|
885
|
+
* 1. Pre-scan the non-`m` tokens (c, y, d, h, s) whose ranks are fixed to
|
|
886
|
+
* determine whether the string is ascending or descending.
|
|
887
|
+
* 2. While iterating, track `lastAssignedRank` – the rank of the most
|
|
888
|
+
* recently processed token (m or non-m).
|
|
889
|
+
* 3. When an `m` token is encountered, derive its rank from `lastAssignedRank`
|
|
890
|
+
* and the detected order:
|
|
891
|
+
* - **DESC** null → 8 (mill) | rank > 5 → 5 (month) | rank > 2 → 2 (min) | else → 0 (ms)
|
|
892
|
+
* - **ASC** null → 0 (ms) | rank < 2 → 2 (min) | rank < 5 → 5 (month) | else → 8 (mill)
|
|
893
|
+
*
|
|
894
|
+
* @param input - Time fragment (e.g. `"T:greg.m3.c1.y26"` or `"greg.m0.s25.m30"`)
|
|
895
|
+
*/
|
|
896
|
+
static parseTimeString(
|
|
897
|
+
input: string,
|
|
898
|
+
): { components: Partial<TPSComponents>; order: TimeOrder } | null {
|
|
899
|
+
let s = input.trim();
|
|
900
|
+
// strip off anything after signature or extensions/query/fragment
|
|
901
|
+
s = s.split(/[!;?#]/)[0];
|
|
902
|
+
if (s.startsWith("T:")) s = s.slice(2);
|
|
903
|
+
const parts = s.split(".");
|
|
904
|
+
if (parts.length === 0) return null;
|
|
905
|
+
const calendar = parts[0];
|
|
906
|
+
const comp: Partial<TPSComponents> = { calendar };
|
|
907
|
+
|
|
908
|
+
// Fixed-rank prefixes (unambiguous regardless of position)
|
|
909
|
+
const fixedRankMap: Record<string, number> = {
|
|
910
|
+
c: 7,
|
|
911
|
+
y: 6,
|
|
912
|
+
d: 4,
|
|
913
|
+
h: 3,
|
|
914
|
+
s: 1,
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// ── Step 1: pre-scan non-m tokens to estimate order ─────────────────────
|
|
918
|
+
// This is only needed to handle the first 'm' token when lastAssignedRank
|
|
919
|
+
// is still null (nothing has been seen yet).
|
|
920
|
+
let initialOrder: TimeOrder = TimeOrder.DESC;
|
|
921
|
+
if (calendar !== DefaultCalendars.UNIX) {
|
|
922
|
+
const nonMRanks: number[] = [];
|
|
923
|
+
for (let i = 1; i < parts.length; i++) {
|
|
924
|
+
const pr = parts[i]?.charAt(0);
|
|
925
|
+
if (pr && pr in fixedRankMap) nonMRanks.push(fixedRankMap[pr]);
|
|
926
|
+
}
|
|
927
|
+
if (nonMRanks.length >= 2) {
|
|
928
|
+
const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
929
|
+
if (isAsc) initialOrder = TimeOrder.ASC;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── Step 2: resolve the semantic rank of an 'm' token ───────────────────
|
|
934
|
+
const assignMRank = (lastRank: number | null, ord: TimeOrder): number => {
|
|
935
|
+
if (ord === TimeOrder.DESC) {
|
|
936
|
+
if (lastRank === null) return 8; // first token → millennium
|
|
937
|
+
if (lastRank > 5) return 5; // after century / year → month
|
|
938
|
+
if (lastRank > 2) return 2; // after day / hour → minute
|
|
939
|
+
return 0; // after second → millisecond
|
|
940
|
+
} else {
|
|
941
|
+
if (lastRank === null) return 0; // first token → millisecond
|
|
942
|
+
if (lastRank < 2) return 2; // after millisecond / second → minute
|
|
943
|
+
if (lastRank < 5) return 5; // after minute / hour / day → month
|
|
944
|
+
return 8; // after month / year / cent → millennium
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
// ── Step 3: iterate and build components ────────────────────────────────
|
|
949
|
+
const ranks: number[] = [];
|
|
950
|
+
let lastAssignedRank: number | null = null;
|
|
951
|
+
|
|
952
|
+
for (let i = 1; i < parts.length; i++) {
|
|
953
|
+
const token = parts[i];
|
|
954
|
+
if (!token) continue;
|
|
955
|
+
const prefix = token.charAt(0);
|
|
956
|
+
const value = token.slice(1);
|
|
957
|
+
|
|
958
|
+
// UNIX calendar: single 's' token carries the full unix timestamp
|
|
959
|
+
if (calendar === DefaultCalendars.UNIX && prefix === "s") {
|
|
960
|
+
comp.unixSeconds = parseFloat(value);
|
|
961
|
+
ranks.push(9);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (prefix === "m") {
|
|
966
|
+
const rank = assignMRank(lastAssignedRank, initialOrder);
|
|
967
|
+
switch (rank) {
|
|
968
|
+
case 8:
|
|
969
|
+
comp.millennium = parseInt(value, 10);
|
|
970
|
+
break;
|
|
971
|
+
case 5:
|
|
972
|
+
comp.month = parseInt(value, 10);
|
|
973
|
+
break;
|
|
974
|
+
case 2:
|
|
975
|
+
comp.minute = parseInt(value, 10);
|
|
976
|
+
break;
|
|
977
|
+
case 0:
|
|
978
|
+
comp.millisecond = parseInt(value, 10);
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
ranks.push(rank);
|
|
982
|
+
lastAssignedRank = rank;
|
|
983
|
+
} else {
|
|
984
|
+
switch (prefix) {
|
|
985
|
+
case "c":
|
|
986
|
+
comp.century = parseInt(value, 10);
|
|
987
|
+
ranks.push(7);
|
|
988
|
+
lastAssignedRank = 7;
|
|
989
|
+
break;
|
|
990
|
+
case "y":
|
|
991
|
+
comp.year = parseInt(value, 10);
|
|
992
|
+
ranks.push(6);
|
|
993
|
+
lastAssignedRank = 6;
|
|
994
|
+
break;
|
|
995
|
+
case "d":
|
|
996
|
+
comp.day = parseInt(value, 10);
|
|
997
|
+
ranks.push(4);
|
|
998
|
+
lastAssignedRank = 4;
|
|
999
|
+
break;
|
|
1000
|
+
case "h":
|
|
1001
|
+
comp.hour = parseInt(value, 10);
|
|
1002
|
+
ranks.push(3);
|
|
1003
|
+
lastAssignedRank = 3;
|
|
1004
|
+
break;
|
|
1005
|
+
case "s":
|
|
1006
|
+
comp.second = parseFloat(value);
|
|
1007
|
+
ranks.push(1);
|
|
1008
|
+
lastAssignedRank = 1;
|
|
1009
|
+
break;
|
|
1010
|
+
default:
|
|
1011
|
+
// unknown prefix – ignore
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ── Step 4: confirm order from the complete rank sequence ────────────────
|
|
1018
|
+
let order: TimeOrder = TimeOrder.DESC;
|
|
1019
|
+
if (ranks.length > 1) {
|
|
1020
|
+
const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
1021
|
+
const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
|
|
1022
|
+
if (isAsc && !isDesc) order = TimeOrder.ASC;
|
|
1023
|
+
// mixed / single direction → defaults to DESC
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return { components: comp, order };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
617
1029
|
private static _mapGroupsToComponents(
|
|
618
1030
|
g: Record<string, string>,
|
|
619
1031
|
): TPSComponents {
|
|
620
1032
|
const components: any = {};
|
|
621
|
-
components.calendar = g.calendar as
|
|
622
|
-
|
|
623
|
-
// Time Mapping
|
|
624
|
-
if (components.calendar === 'unix' && g.unix) {
|
|
625
|
-
components.unixSeconds = parseFloat(g.unix.substring(1));
|
|
626
|
-
} else {
|
|
627
|
-
if (g.millennium) components.millennium = parseInt(g.millennium, 10);
|
|
628
|
-
if (g.century) components.century = parseInt(g.century, 10);
|
|
629
|
-
if (g.year) components.year = parseInt(g.year, 10);
|
|
630
|
-
if (g.month) components.month = parseInt(g.month, 10);
|
|
631
|
-
if (g.day) components.day = parseInt(g.day, 10);
|
|
632
|
-
if (g.hour) components.hour = parseInt(g.hour, 10);
|
|
633
|
-
if (g.minute) components.minute = parseInt(g.minute, 10);
|
|
634
|
-
if (g.second) components.second = parseFloat(g.second);
|
|
635
|
-
}
|
|
1033
|
+
components.calendar = g.calendar as string;
|
|
636
1034
|
|
|
637
1035
|
// Signature Mapping
|
|
638
1036
|
if (g.signature) {
|
|
@@ -647,11 +1045,11 @@ export class TPS {
|
|
|
647
1045
|
// Space Mapping
|
|
648
1046
|
if (g.space) {
|
|
649
1047
|
// Privacy markers
|
|
650
|
-
if (g.space ===
|
|
1048
|
+
if (g.space === "unknown" || g.space === "-") {
|
|
651
1049
|
components.isUnknownLocation = true;
|
|
652
|
-
} else if (g.space ===
|
|
1050
|
+
} else if (g.space === "redacted") {
|
|
653
1051
|
components.isRedactedLocation = true;
|
|
654
|
-
} else if (g.space ===
|
|
1052
|
+
} else if (g.space === "hidden" || g.space === "~") {
|
|
655
1053
|
components.isHiddenLocation = true;
|
|
656
1054
|
}
|
|
657
1055
|
// Geospatial cells
|
|
@@ -671,6 +1069,10 @@ export class TPS {
|
|
|
671
1069
|
if (g.room) components.room = g.room;
|
|
672
1070
|
if (g.zone) components.zone = g.zone;
|
|
673
1071
|
}
|
|
1072
|
+
// Generic pre-@ anchor (adm/node/net/planet/etc)
|
|
1073
|
+
else if (g.generic) {
|
|
1074
|
+
components.spaceAnchor = g.generic;
|
|
1075
|
+
}
|
|
674
1076
|
// GPS coordinates
|
|
675
1077
|
else {
|
|
676
1078
|
if (g.lat) components.latitude = parseFloat(g.lat);
|
|
@@ -682,9 +1084,9 @@ export class TPS {
|
|
|
682
1084
|
// Extensions Mapping
|
|
683
1085
|
if (g.extensions) {
|
|
684
1086
|
const extObj: any = {};
|
|
685
|
-
const parts = g.extensions.split(
|
|
1087
|
+
const parts = g.extensions.split(".");
|
|
686
1088
|
parts.forEach((p: string) => {
|
|
687
|
-
const eqIdx = p.indexOf(
|
|
1089
|
+
const eqIdx = p.indexOf("=");
|
|
688
1090
|
if (eqIdx > 0) {
|
|
689
1091
|
const key = p.substring(0, eqIdx);
|
|
690
1092
|
const val = p.substring(eqIdx + 1);
|
|
@@ -701,13 +1103,18 @@ export class TPS {
|
|
|
701
1103
|
|
|
702
1104
|
return components as TPSComponents;
|
|
703
1105
|
}
|
|
704
|
-
|
|
705
|
-
private static pad(n: number): string {
|
|
706
|
-
const s = n.toString();
|
|
707
|
-
return s.length < 2 ? '0' + s : s;
|
|
708
|
-
}
|
|
709
1106
|
}
|
|
710
1107
|
|
|
1108
|
+
// register built-in drivers and set default
|
|
1109
|
+
// (tps and gregorian provide canonical conversions before unix)
|
|
1110
|
+
TPS.registerDriver(new TpsDriver());
|
|
1111
|
+
TPS.registerDriver(new GregorianDriver());
|
|
1112
|
+
TPS.registerDriver(new UnixDriver());
|
|
1113
|
+
TPS.registerDriver(new PersianDriver());
|
|
1114
|
+
TPS.registerDriver(new HijriDriver());
|
|
1115
|
+
TPS.registerDriver(new JulianDriver());
|
|
1116
|
+
TPS.registerDriver(new HoloceneDriver());
|
|
1117
|
+
|
|
711
1118
|
// --- TPS-UID v1 Types ---
|
|
712
1119
|
|
|
713
1120
|
/**
|
|
@@ -715,7 +1122,7 @@ export class TPS {
|
|
|
715
1122
|
*/
|
|
716
1123
|
export type TPSUID7RBDecodeResult = {
|
|
717
1124
|
/** Version identifier */
|
|
718
|
-
version:
|
|
1125
|
+
version: "tpsuid7rb";
|
|
719
1126
|
/** Epoch milliseconds (UTC) */
|
|
720
1127
|
epochMs: number;
|
|
721
1128
|
/** Whether the TPS payload was compressed */
|
|
@@ -756,7 +1163,7 @@ export type TPSUID7RBEncodeOptions = {
|
|
|
756
1163
|
*
|
|
757
1164
|
* @example
|
|
758
1165
|
* ```ts
|
|
759
|
-
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.
|
|
1166
|
+
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.m01.d09';
|
|
760
1167
|
*
|
|
761
1168
|
* // Encode to binary
|
|
762
1169
|
* const bytes = TPSUID7RB.encodeBinary(tps);
|
|
@@ -776,7 +1183,7 @@ export class TPSUID7RB {
|
|
|
776
1183
|
/** Version 1 */
|
|
777
1184
|
private static readonly VER = 0x01;
|
|
778
1185
|
/** String prefix for base64url encoded form */
|
|
779
|
-
private static readonly PREFIX =
|
|
1186
|
+
private static readonly PREFIX = "tpsuid7rb_";
|
|
780
1187
|
/** Regex for validating base64url encoded form */
|
|
781
1188
|
public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|
|
782
1189
|
|
|
@@ -792,15 +1199,18 @@ export class TPSUID7RB {
|
|
|
792
1199
|
* @param opts - Encoding options (compress, epochMs override)
|
|
793
1200
|
* @returns Binary TPS-UID as Uint8Array
|
|
794
1201
|
*/
|
|
795
|
-
static encodeBinary(
|
|
796
|
-
|
|
797
|
-
|
|
1202
|
+
static encodeBinary(
|
|
1203
|
+
tps: string,
|
|
1204
|
+
opts: TPSUID7RBEncodeOptions = {},
|
|
1205
|
+
): Uint8Array {
|
|
1206
|
+
const compress = opts.compress ?? false;
|
|
1207
|
+
const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
|
|
798
1208
|
|
|
799
1209
|
if (!Number.isInteger(epochMs) || epochMs < 0) {
|
|
800
|
-
throw new Error(
|
|
1210
|
+
throw new Error("epochMs must be a non-negative integer");
|
|
801
1211
|
}
|
|
802
1212
|
if (epochMs > 0xffffffffffff) {
|
|
803
|
-
throw new Error(
|
|
1213
|
+
throw new Error("epochMs exceeds 48-bit range");
|
|
804
1214
|
}
|
|
805
1215
|
|
|
806
1216
|
const flags = compress ? 0x01 : 0x00;
|
|
@@ -866,7 +1276,7 @@ export class TPSUID7RB {
|
|
|
866
1276
|
static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
|
|
867
1277
|
// Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
|
|
868
1278
|
if (bytes.length < 17) {
|
|
869
|
-
throw new Error(
|
|
1279
|
+
throw new Error("TPSUID7RB: too short");
|
|
870
1280
|
}
|
|
871
1281
|
|
|
872
1282
|
// MAGIC
|
|
@@ -876,7 +1286,7 @@ export class TPSUID7RB {
|
|
|
876
1286
|
bytes[2] !== 0x55 ||
|
|
877
1287
|
bytes[3] !== 0x37
|
|
878
1288
|
) {
|
|
879
|
-
throw new Error(
|
|
1289
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
880
1290
|
}
|
|
881
1291
|
|
|
882
1292
|
// VERSION
|
|
@@ -905,7 +1315,7 @@ export class TPSUID7RB {
|
|
|
905
1315
|
offset += bytesRead;
|
|
906
1316
|
|
|
907
1317
|
if (offset + tpsLen > bytes.length) {
|
|
908
|
-
throw new Error(
|
|
1318
|
+
throw new Error("TPSUID7RB: length overflow");
|
|
909
1319
|
}
|
|
910
1320
|
|
|
911
1321
|
// TPS payload
|
|
@@ -913,7 +1323,7 @@ export class TPSUID7RB {
|
|
|
913
1323
|
const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
|
|
914
1324
|
const tps = new TextDecoder().decode(tpsUtf8);
|
|
915
1325
|
|
|
916
|
-
return { version:
|
|
1326
|
+
return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
|
|
917
1327
|
}
|
|
918
1328
|
|
|
919
1329
|
/**
|
|
@@ -925,7 +1335,7 @@ export class TPSUID7RB {
|
|
|
925
1335
|
* @returns Base64url encoded TPS-UID with prefix
|
|
926
1336
|
*/
|
|
927
1337
|
static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
|
|
928
|
-
const bytes = this.encodeBinary(tps, opts);
|
|
1338
|
+
const bytes = this.encodeBinary(tps, opts ?? {});
|
|
929
1339
|
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
930
1340
|
}
|
|
931
1341
|
|
|
@@ -938,7 +1348,7 @@ export class TPSUID7RB {
|
|
|
938
1348
|
static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
|
|
939
1349
|
const s = id.trim();
|
|
940
1350
|
if (!s.startsWith(this.PREFIX)) {
|
|
941
|
-
throw new Error(
|
|
1351
|
+
throw new Error("TPSUID7RB: missing prefix");
|
|
942
1352
|
}
|
|
943
1353
|
const b64 = s.slice(this.PREFIX.length);
|
|
944
1354
|
const bytes = this.base64UrlDecode(b64);
|
|
@@ -967,9 +1377,23 @@ export class TPSUID7RB {
|
|
|
967
1377
|
longitude?: number;
|
|
968
1378
|
altitude?: number;
|
|
969
1379
|
compress?: boolean;
|
|
1380
|
+
order?: TimeOrder;
|
|
970
1381
|
}): string {
|
|
971
1382
|
const now = new Date();
|
|
972
|
-
const
|
|
1383
|
+
const time = TPS.fromDate(now, DefaultCalendars.TPS, {
|
|
1384
|
+
order: opts?.order,
|
|
1385
|
+
});
|
|
1386
|
+
let space = "unknown";
|
|
1387
|
+
|
|
1388
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
1389
|
+
space = `${opts.latitude},${opts.longitude}`;
|
|
1390
|
+
if (opts.altitude !== undefined) {
|
|
1391
|
+
space += `,${opts.altitude}m`;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const tps = `tps://${space}@${time}`;
|
|
1396
|
+
|
|
973
1397
|
return this.encodeBinaryB64(tps, {
|
|
974
1398
|
compress: opts?.compress,
|
|
975
1399
|
epochMs: now.getTime(),
|
|
@@ -983,99 +1407,21 @@ export class TPSUID7RB {
|
|
|
983
1407
|
/**
|
|
984
1408
|
* Generate a TPS string from a Date and optional location.
|
|
985
1409
|
*/
|
|
986
|
-
private static generateTPSString(
|
|
987
|
-
date: Date,
|
|
988
|
-
opts?: { latitude?: number; longitude?: number; altitude?: number },
|
|
989
|
-
): string {
|
|
990
|
-
const fullYear = date.getUTCFullYear();
|
|
991
|
-
const m = Math.floor(fullYear / 1000) + 1;
|
|
992
|
-
const c = Math.floor((fullYear % 1000) / 100) + 1;
|
|
993
|
-
const y = fullYear % 100;
|
|
994
|
-
const M = date.getUTCMonth() + 1;
|
|
995
|
-
const d = date.getUTCDate();
|
|
996
|
-
const h = date.getUTCHours();
|
|
997
|
-
const n = date.getUTCMinutes();
|
|
998
|
-
const s = date.getUTCSeconds();
|
|
999
|
-
|
|
1000
|
-
const pad = (num: number) => num.toString().padStart(2, '0');
|
|
1001
|
-
|
|
1002
|
-
const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(
|
|
1003
|
-
h,
|
|
1004
|
-
)}.n${pad(n)}.s${pad(s)}`;
|
|
1005
|
-
|
|
1006
|
-
let spacePart = 'unknown';
|
|
1007
|
-
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
1008
|
-
spacePart = `${opts.latitude},${opts.longitude}`;
|
|
1009
|
-
if (opts.altitude !== undefined) {
|
|
1010
|
-
spacePart += `,${opts.altitude}m`;
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
return `tps://${spacePart}@${timePart}`;
|
|
1015
|
-
}
|
|
1016
1410
|
|
|
1017
1411
|
/**
|
|
1018
1412
|
* Parse epoch milliseconds from a TPS string.
|
|
1019
1413
|
* Supports both URI format (tps://...) and time-only format (T:greg...)
|
|
1020
1414
|
*/
|
|
1021
1415
|
static epochMsFromTPSString(tps: string): number {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
} else {
|
|
1032
|
-
throw new Error('TPS: unrecognized format');
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
if (!time.startsWith('T:greg.')) {
|
|
1036
|
-
throw new Error('TPS: only T:greg.* parsing is supported');
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// Extract m (millennium), c (century), y (year)
|
|
1040
|
-
const mMatch = time.match(/\.m(-?\d+)/);
|
|
1041
|
-
const cMatch = time.match(/\.c(\d+)/);
|
|
1042
|
-
const yMatch = time.match(/\.y(\d{1,4})/);
|
|
1043
|
-
const MMatch = time.match(/\.M(\d{1,2})/);
|
|
1044
|
-
const dMatch = time.match(/\.d(\d{1,2})/);
|
|
1045
|
-
const hMatch = time.match(/\.h(\d{1,2})/);
|
|
1046
|
-
const nMatch = time.match(/\.n(\d{1,2})/);
|
|
1047
|
-
const sMatch = time.match(/\.s(\d{1,2})/);
|
|
1048
|
-
|
|
1049
|
-
// Calculate full year from millennium, century, year
|
|
1050
|
-
let fullYear: number;
|
|
1051
|
-
if (mMatch && cMatch && yMatch) {
|
|
1052
|
-
const millennium = parseInt(mMatch[1], 10);
|
|
1053
|
-
const century = parseInt(cMatch[1], 10);
|
|
1054
|
-
const year = parseInt(yMatch[1], 10);
|
|
1055
|
-
fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
1056
|
-
} else if (yMatch) {
|
|
1057
|
-
// Fallback: interpret y as 2-digit year
|
|
1058
|
-
let year = parseInt(yMatch[1], 10);
|
|
1059
|
-
if (year < 100) {
|
|
1060
|
-
year = year <= 69 ? 2000 + year : 1900 + year;
|
|
1061
|
-
}
|
|
1062
|
-
fullYear = year;
|
|
1063
|
-
} else {
|
|
1064
|
-
throw new Error('TPS: missing year component');
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
const month = MMatch ? parseInt(MMatch[1], 10) : 1;
|
|
1068
|
-
const day = dMatch ? parseInt(dMatch[1], 10) : 1;
|
|
1069
|
-
const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
|
|
1070
|
-
const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
|
|
1071
|
-
const second = sMatch ? parseInt(sMatch[1], 10) : 0;
|
|
1072
|
-
|
|
1073
|
-
const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
|
|
1074
|
-
if (!Number.isFinite(epoch)) {
|
|
1075
|
-
throw new Error('TPS: failed to compute epochMs');
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
return epoch;
|
|
1416
|
+
const date = TPS.toDate(tps);
|
|
1417
|
+
if (date) return date.getTime();
|
|
1418
|
+
|
|
1419
|
+
// If parse fails due to unsupported/extended extension payloads,
|
|
1420
|
+
// strip extensions/query/fragment and retry. Epoch only depends on time.
|
|
1421
|
+
const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
|
|
1422
|
+
const retryDate = TPS.toDate(stripped);
|
|
1423
|
+
if (!retryDate) throw new Error("TPS: unable to parse date for epoch");
|
|
1424
|
+
return retryDate.getTime();
|
|
1079
1425
|
}
|
|
1080
1426
|
|
|
1081
1427
|
// ---------------------------
|
|
@@ -1108,7 +1454,7 @@ export class TPSUID7RB {
|
|
|
1108
1454
|
|
|
1109
1455
|
const n = Number(v);
|
|
1110
1456
|
if (!Number.isSafeInteger(n)) {
|
|
1111
|
-
throw new Error(
|
|
1457
|
+
throw new Error("TPSUID7RB: u48 not safe integer");
|
|
1112
1458
|
}
|
|
1113
1459
|
return n;
|
|
1114
1460
|
}
|
|
@@ -1116,7 +1462,7 @@ export class TPSUID7RB {
|
|
|
1116
1462
|
/** Encode unsigned integer as LEB128 varint */
|
|
1117
1463
|
private static uvarintEncode(n: number): Uint8Array {
|
|
1118
1464
|
if (!Number.isInteger(n) || n < 0) {
|
|
1119
|
-
throw new Error(
|
|
1465
|
+
throw new Error("uvarint must be non-negative int");
|
|
1120
1466
|
}
|
|
1121
1467
|
const out: number[] = [];
|
|
1122
1468
|
let x = n >>> 0;
|
|
@@ -1138,12 +1484,12 @@ export class TPSUID7RB {
|
|
|
1138
1484
|
let i = 0;
|
|
1139
1485
|
while (true) {
|
|
1140
1486
|
if (offset + i >= bytes.length) {
|
|
1141
|
-
throw new Error(
|
|
1487
|
+
throw new Error("uvarint overflow");
|
|
1142
1488
|
}
|
|
1143
1489
|
const b = bytes[offset + i];
|
|
1144
1490
|
if (b < 0x80) {
|
|
1145
1491
|
if (i > 9 || (i === 9 && b > 1)) {
|
|
1146
|
-
throw new Error(
|
|
1492
|
+
throw new Error("uvarint too large");
|
|
1147
1493
|
}
|
|
1148
1494
|
x |= b << s;
|
|
1149
1495
|
return { value: x >>> 0, bytesRead: i + 1 };
|
|
@@ -1152,7 +1498,7 @@ export class TPSUID7RB {
|
|
|
1152
1498
|
s += 7;
|
|
1153
1499
|
i++;
|
|
1154
1500
|
if (i > 10) {
|
|
1155
|
-
throw new Error(
|
|
1501
|
+
throw new Error("uvarint too long");
|
|
1156
1502
|
}
|
|
1157
1503
|
}
|
|
1158
1504
|
}
|
|
@@ -1164,35 +1510,35 @@ export class TPSUID7RB {
|
|
|
1164
1510
|
/** Encode bytes to base64url (no padding) */
|
|
1165
1511
|
private static base64UrlEncode(bytes: Uint8Array): string {
|
|
1166
1512
|
// Node.js environment
|
|
1167
|
-
if (typeof Buffer !==
|
|
1513
|
+
if (typeof Buffer !== "undefined") {
|
|
1168
1514
|
return Buffer.from(bytes)
|
|
1169
|
-
.toString(
|
|
1170
|
-
.replace(/\+/g,
|
|
1171
|
-
.replace(/\//g,
|
|
1172
|
-
.replace(/=+$/g,
|
|
1515
|
+
.toString("base64")
|
|
1516
|
+
.replace(/\+/g, "-")
|
|
1517
|
+
.replace(/\//g, "_")
|
|
1518
|
+
.replace(/=+$/g, "");
|
|
1173
1519
|
}
|
|
1174
1520
|
// Browser environment
|
|
1175
|
-
let binary =
|
|
1521
|
+
let binary = "";
|
|
1176
1522
|
for (let i = 0; i < bytes.length; i++) {
|
|
1177
1523
|
binary += String.fromCharCode(bytes[i]);
|
|
1178
1524
|
}
|
|
1179
1525
|
return btoa(binary)
|
|
1180
|
-
.replace(/\+/g,
|
|
1181
|
-
.replace(/\//g,
|
|
1182
|
-
.replace(/=+$/g,
|
|
1526
|
+
.replace(/\+/g, "-")
|
|
1527
|
+
.replace(/\//g, "_")
|
|
1528
|
+
.replace(/=+$/g, "");
|
|
1183
1529
|
}
|
|
1184
1530
|
|
|
1185
1531
|
/** Decode base64url to bytes */
|
|
1186
1532
|
private static base64UrlDecode(b64url: string): Uint8Array {
|
|
1187
1533
|
// Add padding
|
|
1188
1534
|
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
1189
|
-
const b64 = (b64url +
|
|
1190
|
-
.replace(/-/g,
|
|
1191
|
-
.replace(/_/g,
|
|
1535
|
+
const b64 = (b64url + "=".repeat(padLen))
|
|
1536
|
+
.replace(/-/g, "+")
|
|
1537
|
+
.replace(/_/g, "/");
|
|
1192
1538
|
|
|
1193
1539
|
// Node.js environment
|
|
1194
|
-
if (typeof Buffer !==
|
|
1195
|
-
return new Uint8Array(Buffer.from(b64,
|
|
1540
|
+
if (typeof Buffer !== "undefined") {
|
|
1541
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
1196
1542
|
}
|
|
1197
1543
|
// Browser environment
|
|
1198
1544
|
const binary = atob(b64);
|
|
@@ -1210,33 +1556,33 @@ export class TPSUID7RB {
|
|
|
1210
1556
|
/** Compress using zlib deflate raw */
|
|
1211
1557
|
private static deflateRaw(data: Uint8Array): Uint8Array {
|
|
1212
1558
|
// Node.js environment
|
|
1213
|
-
if (typeof require !==
|
|
1559
|
+
if (typeof require !== "undefined") {
|
|
1214
1560
|
try {
|
|
1215
1561
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1216
|
-
const zlib = require(
|
|
1562
|
+
const zlib = require("zlib");
|
|
1217
1563
|
return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
|
|
1218
1564
|
} catch {
|
|
1219
|
-
throw new Error(
|
|
1565
|
+
throw new Error("TPSUID7RB: compression not available");
|
|
1220
1566
|
}
|
|
1221
1567
|
}
|
|
1222
1568
|
// Browser: would need pako or similar library
|
|
1223
|
-
throw new Error(
|
|
1569
|
+
throw new Error("TPSUID7RB: compression not available in browser");
|
|
1224
1570
|
}
|
|
1225
1571
|
|
|
1226
1572
|
/** Decompress using zlib inflate raw */
|
|
1227
1573
|
private static inflateRaw(data: Uint8Array): Uint8Array {
|
|
1228
1574
|
// Node.js environment
|
|
1229
|
-
if (typeof require !==
|
|
1575
|
+
if (typeof require !== "undefined") {
|
|
1230
1576
|
try {
|
|
1231
1577
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1232
|
-
const zlib = require(
|
|
1578
|
+
const zlib = require("zlib");
|
|
1233
1579
|
return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
|
|
1234
1580
|
} catch {
|
|
1235
|
-
throw new Error(
|
|
1581
|
+
throw new Error("TPSUID7RB: decompression failed");
|
|
1236
1582
|
}
|
|
1237
1583
|
}
|
|
1238
1584
|
// Browser: would need pako or similar library
|
|
1239
|
-
throw new Error(
|
|
1585
|
+
throw new Error("TPSUID7RB: decompression not available in browser");
|
|
1240
1586
|
}
|
|
1241
1587
|
|
|
1242
1588
|
// ---------------------------
|
|
@@ -1271,7 +1617,7 @@ export class TPSUID7RB {
|
|
|
1271
1617
|
|
|
1272
1618
|
// Validate epoch
|
|
1273
1619
|
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
1274
|
-
throw new Error(
|
|
1620
|
+
throw new Error("epochMs must be a valid 48-bit non-negative integer");
|
|
1275
1621
|
}
|
|
1276
1622
|
|
|
1277
1623
|
// Flags: Bit 0 = compress, Bit 1 = sealed
|
|
@@ -1327,7 +1673,7 @@ export class TPSUID7RB {
|
|
|
1327
1673
|
sealedBytes: Uint8Array,
|
|
1328
1674
|
publicKey: string | Buffer | Uint8Array,
|
|
1329
1675
|
): TPSUID7RBDecodeResult {
|
|
1330
|
-
if (sealedBytes.length < 18) throw new Error(
|
|
1676
|
+
if (sealedBytes.length < 18) throw new Error("TPSUID7RB: too short");
|
|
1331
1677
|
|
|
1332
1678
|
// Check Magic
|
|
1333
1679
|
if (
|
|
@@ -1336,13 +1682,13 @@ export class TPSUID7RB {
|
|
|
1336
1682
|
sealedBytes[2] !== 0x55 ||
|
|
1337
1683
|
sealedBytes[3] !== 0x37
|
|
1338
1684
|
) {
|
|
1339
|
-
throw new Error(
|
|
1685
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
1340
1686
|
}
|
|
1341
1687
|
|
|
1342
1688
|
// Check Flags for Sealed Bit (bit 1)
|
|
1343
1689
|
const flags = sealedBytes[5];
|
|
1344
1690
|
if ((flags & 0x02) === 0) {
|
|
1345
|
-
throw new Error(
|
|
1691
|
+
throw new Error("TPSUID7RB: not a sealed UID");
|
|
1346
1692
|
}
|
|
1347
1693
|
|
|
1348
1694
|
// 1. Parse the structure to find where content ends
|
|
@@ -1357,7 +1703,7 @@ export class TPSUID7RB {
|
|
|
1357
1703
|
const payloadEnd = offset + tpsLen;
|
|
1358
1704
|
|
|
1359
1705
|
if (payloadEnd > sealedBytes.length) {
|
|
1360
|
-
throw new Error(
|
|
1706
|
+
throw new Error("TPSUID7RB: length overflow (truncated)");
|
|
1361
1707
|
}
|
|
1362
1708
|
|
|
1363
1709
|
// The Content to verify matches exactly [0 ... payloadEnd]
|
|
@@ -1365,7 +1711,7 @@ export class TPSUID7RB {
|
|
|
1365
1711
|
|
|
1366
1712
|
// After content: SealType (1 byte) + Signature
|
|
1367
1713
|
if (sealedBytes.length <= payloadEnd + 1) {
|
|
1368
|
-
throw new Error(
|
|
1714
|
+
throw new Error("TPSUID7RB: missing signature data");
|
|
1369
1715
|
}
|
|
1370
1716
|
|
|
1371
1717
|
const sealType = sealedBytes[payloadEnd];
|
|
@@ -1385,7 +1731,7 @@ export class TPSUID7RB {
|
|
|
1385
1731
|
// Verify
|
|
1386
1732
|
const isValid = this.verifyEd25519(content, signature, publicKey);
|
|
1387
1733
|
if (!isValid) {
|
|
1388
|
-
throw new Error(
|
|
1734
|
+
throw new Error("TPSUID7RB: signature verification failed");
|
|
1389
1735
|
}
|
|
1390
1736
|
|
|
1391
1737
|
// Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
|
|
@@ -1403,54 +1749,46 @@ export class TPSUID7RB {
|
|
|
1403
1749
|
data: Uint8Array,
|
|
1404
1750
|
privateKey: string | Buffer | Uint8Array,
|
|
1405
1751
|
): Uint8Array {
|
|
1406
|
-
if (typeof require !==
|
|
1752
|
+
if (typeof require !== "undefined") {
|
|
1407
1753
|
try {
|
|
1408
1754
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1409
|
-
const crypto = require(
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1755
|
+
const crypto = require("crypto");
|
|
1756
|
+
|
|
1757
|
+
let key: any;
|
|
1758
|
+
if (typeof privateKey === "string") {
|
|
1759
|
+
if (privateKey.includes("PRIVATE KEY")) {
|
|
1760
|
+
// PEM format — use directly
|
|
1761
|
+
key = privateKey;
|
|
1762
|
+
} else {
|
|
1763
|
+
// Hex-encoded DER/PKCS8
|
|
1764
|
+
key = crypto.createPrivateKey({
|
|
1765
|
+
key: Buffer.from(privateKey, "hex"),
|
|
1766
|
+
format: "der",
|
|
1767
|
+
type: "pkcs8",
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
} else if (
|
|
1771
|
+
typeof privateKey === "object" &&
|
|
1772
|
+
privateKey !== null &&
|
|
1773
|
+
"asymmetricKeyType" in privateKey
|
|
1774
|
+
) {
|
|
1775
|
+
// Node.js KeyObject (e.g. from crypto.generateKeyPairSync)
|
|
1776
|
+
key = privateKey;
|
|
1777
|
+
} else {
|
|
1778
|
+
// Buffer or Uint8Array — assume DER/PKCS8 encoded
|
|
1779
|
+
key = crypto.createPrivateKey({
|
|
1780
|
+
key: Buffer.from(privateKey as Uint8Array),
|
|
1781
|
+
format: "der",
|
|
1782
|
+
type: "pkcs8",
|
|
1424
1783
|
});
|
|
1425
|
-
// Actually, simpler: construct key object from raw bytes if possible?
|
|
1426
|
-
// Node's crypto is strict. Let's try the simplest:
|
|
1427
|
-
// If hex string provided, convert to buffer.
|
|
1428
1784
|
}
|
|
1429
1785
|
|
|
1430
|
-
// Simpler fallback: If user passed a PEM string, great.
|
|
1431
|
-
// If they passed raw bytes, we might need 'ed25519' key type.
|
|
1432
|
-
// For this implementation, let's target Node's high-level sign/verify
|
|
1433
|
-
// and assume the user provides a VALID key object or compatible format (PEM/DER).
|
|
1434
|
-
// Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
|
|
1435
|
-
|
|
1436
|
-
const key =
|
|
1437
|
-
typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
|
|
1438
|
-
? crypto.createPrivateKey({
|
|
1439
|
-
key: Buffer.from(privateKey, 'hex'),
|
|
1440
|
-
format: 'pem',
|
|
1441
|
-
type: 'pkcs8',
|
|
1442
|
-
}) // Fallback guess
|
|
1443
|
-
: privateKey;
|
|
1444
|
-
|
|
1445
|
-
// Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
|
|
1446
|
-
// Direct raw signing is via crypto.sign(null, data, key).
|
|
1447
1786
|
return new Uint8Array(crypto.sign(null, data, key));
|
|
1448
1787
|
} catch (e) {
|
|
1449
|
-
|
|
1450
|
-
throw new Error('TPSUID7RB: signing failed (check key format)');
|
|
1788
|
+
throw new Error("TPSUID7RB: signing failed (check key format)");
|
|
1451
1789
|
}
|
|
1452
1790
|
}
|
|
1453
|
-
throw new Error(
|
|
1791
|
+
throw new Error("TPSUID7RB: signing not available in browser");
|
|
1454
1792
|
}
|
|
1455
1793
|
|
|
1456
1794
|
private static verifyEd25519(
|
|
@@ -1458,16 +1796,16 @@ export class TPSUID7RB {
|
|
|
1458
1796
|
signature: Uint8Array,
|
|
1459
1797
|
publicKey: string | Buffer | Uint8Array,
|
|
1460
1798
|
): boolean {
|
|
1461
|
-
if (typeof require !==
|
|
1799
|
+
if (typeof require !== "undefined") {
|
|
1462
1800
|
try {
|
|
1463
1801
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1464
|
-
const crypto = require(
|
|
1802
|
+
const crypto = require("crypto");
|
|
1465
1803
|
return crypto.verify(null, data, publicKey, signature);
|
|
1466
1804
|
} catch {
|
|
1467
1805
|
return false;
|
|
1468
1806
|
}
|
|
1469
1807
|
}
|
|
1470
|
-
throw new Error(
|
|
1808
|
+
throw new Error("TPSUID7RB: verification not available in browser");
|
|
1471
1809
|
}
|
|
1472
1810
|
|
|
1473
1811
|
// ---------------------------
|
|
@@ -1477,21 +1815,276 @@ export class TPSUID7RB {
|
|
|
1477
1815
|
/** Generate cryptographically secure random bytes */
|
|
1478
1816
|
private static randomBytes(length: number): Uint8Array {
|
|
1479
1817
|
// Node.js environment
|
|
1480
|
-
if (typeof require !==
|
|
1818
|
+
if (typeof require !== "undefined") {
|
|
1481
1819
|
try {
|
|
1482
1820
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1483
|
-
const crypto = require(
|
|
1821
|
+
const crypto = require("crypto");
|
|
1484
1822
|
return new Uint8Array(crypto.randomBytes(length));
|
|
1485
1823
|
} catch {
|
|
1486
1824
|
// Fallback to crypto.getRandomValues
|
|
1487
1825
|
}
|
|
1488
1826
|
}
|
|
1489
1827
|
// Browser or fallback
|
|
1490
|
-
if (typeof crypto !==
|
|
1828
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
1491
1829
|
const bytes = new Uint8Array(length);
|
|
1492
1830
|
crypto.getRandomValues(bytes);
|
|
1493
1831
|
return bytes;
|
|
1494
1832
|
}
|
|
1495
|
-
throw new Error(
|
|
1833
|
+
throw new Error("TPSUID7RB: no crypto available");
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
|
|
1839
|
+
*
|
|
1840
|
+
* It mirrors common JavaScript `Date` construction patterns:
|
|
1841
|
+
* - `new TpsDate()`
|
|
1842
|
+
* - `new TpsDate(ms)`
|
|
1843
|
+
* - `new TpsDate(isoString)`
|
|
1844
|
+
* - `new TpsDate(tpsString)`
|
|
1845
|
+
* - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
|
|
1846
|
+
*/
|
|
1847
|
+
export class TpsDate {
|
|
1848
|
+
private readonly internal: Date;
|
|
1849
|
+
|
|
1850
|
+
private getTpsComponents(): TPSComponents {
|
|
1851
|
+
const parsed = TPS.parse(this.toTPS(DefaultCalendars.TPS));
|
|
1852
|
+
if (!parsed) {
|
|
1853
|
+
throw new Error("TpsDate: failed to derive TPS components");
|
|
1854
|
+
}
|
|
1855
|
+
return parsed;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
private getTpsFullYear(): number {
|
|
1859
|
+
const comp = this.getTpsComponents();
|
|
1860
|
+
return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
constructor();
|
|
1864
|
+
constructor(value: string | number | Date | TpsDate);
|
|
1865
|
+
constructor(
|
|
1866
|
+
year: number,
|
|
1867
|
+
monthIndex: number,
|
|
1868
|
+
day?: number,
|
|
1869
|
+
hours?: number,
|
|
1870
|
+
minutes?: number,
|
|
1871
|
+
seconds?: number,
|
|
1872
|
+
ms?: number,
|
|
1873
|
+
);
|
|
1874
|
+
constructor(
|
|
1875
|
+
...args:
|
|
1876
|
+
| []
|
|
1877
|
+
| [string | number | Date | TpsDate]
|
|
1878
|
+
| [number, number, number?, number?, number?, number?, number?]
|
|
1879
|
+
) {
|
|
1880
|
+
if (args.length === 0) {
|
|
1881
|
+
this.internal = new Date();
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (args.length === 1) {
|
|
1886
|
+
const value = args[0];
|
|
1887
|
+
if (value instanceof TpsDate) {
|
|
1888
|
+
this.internal = new Date(value.getTime());
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
if (value instanceof Date) {
|
|
1892
|
+
this.internal = new Date(value.getTime());
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
|
|
1896
|
+
const parsed = TPS.toDate(value);
|
|
1897
|
+
if (!parsed) {
|
|
1898
|
+
throw new RangeError(`Invalid TPS date string: ${value}`);
|
|
1899
|
+
}
|
|
1900
|
+
this.internal = parsed;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
this.internal = new Date(value);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
|
|
1909
|
+
this.internal = new Date(
|
|
1910
|
+
year,
|
|
1911
|
+
monthIndex,
|
|
1912
|
+
day ?? 1,
|
|
1913
|
+
hours ?? 0,
|
|
1914
|
+
minutes ?? 0,
|
|
1915
|
+
seconds ?? 0,
|
|
1916
|
+
ms ?? 0,
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
private static looksLikeTPS(input: string): boolean {
|
|
1921
|
+
const s = input.trim();
|
|
1922
|
+
return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
static now(): number {
|
|
1926
|
+
return Date.now();
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
static parse(input: string): number {
|
|
1930
|
+
if (this.looksLikeTPS(input)) {
|
|
1931
|
+
const d = TPS.toDate(input);
|
|
1932
|
+
return d ? d.getTime() : Number.NaN;
|
|
1933
|
+
}
|
|
1934
|
+
return Date.parse(input);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
static UTC(
|
|
1938
|
+
year: number,
|
|
1939
|
+
monthIndex: number,
|
|
1940
|
+
day?: number,
|
|
1941
|
+
hours?: number,
|
|
1942
|
+
minutes?: number,
|
|
1943
|
+
seconds?: number,
|
|
1944
|
+
ms?: number,
|
|
1945
|
+
): number {
|
|
1946
|
+
return Date.UTC(
|
|
1947
|
+
year,
|
|
1948
|
+
monthIndex,
|
|
1949
|
+
day ?? 1,
|
|
1950
|
+
hours ?? 0,
|
|
1951
|
+
minutes ?? 0,
|
|
1952
|
+
seconds ?? 0,
|
|
1953
|
+
ms ?? 0,
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
static fromTPS(tps: string): TpsDate {
|
|
1958
|
+
return new TpsDate(tps);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
toGregorianDate(): Date {
|
|
1962
|
+
return new Date(this.internal.getTime());
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
toDate(): Date {
|
|
1966
|
+
return this.toGregorianDate();
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
toTPS(
|
|
1970
|
+
calendar: string = DefaultCalendars.TPS,
|
|
1971
|
+
opts?: { order?: TimeOrder },
|
|
1972
|
+
): string {
|
|
1973
|
+
return TPS.fromDate(this.internal, calendar, opts);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
toTPSURI(
|
|
1977
|
+
calendar: string = DefaultCalendars.TPS,
|
|
1978
|
+
opts?: {
|
|
1979
|
+
order?: TimeOrder;
|
|
1980
|
+
latitude?: number;
|
|
1981
|
+
longitude?: number;
|
|
1982
|
+
altitude?: number;
|
|
1983
|
+
isUnknownLocation?: boolean;
|
|
1984
|
+
isHiddenLocation?: boolean;
|
|
1985
|
+
isRedactedLocation?: boolean;
|
|
1986
|
+
},
|
|
1987
|
+
): string {
|
|
1988
|
+
const time = this.toTPS(calendar, { order: opts?.order });
|
|
1989
|
+
const comp = TPS.parse(time) as TPSComponents;
|
|
1990
|
+
|
|
1991
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
1992
|
+
comp.latitude = opts.latitude;
|
|
1993
|
+
comp.longitude = opts.longitude;
|
|
1994
|
+
if (opts.altitude !== undefined) comp.altitude = opts.altitude;
|
|
1995
|
+
} else if (opts?.isHiddenLocation) {
|
|
1996
|
+
comp.isHiddenLocation = true;
|
|
1997
|
+
} else if (opts?.isRedactedLocation) {
|
|
1998
|
+
comp.isRedactedLocation = true;
|
|
1999
|
+
} else {
|
|
2000
|
+
comp.isUnknownLocation = true;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
return TPS.toURI(comp);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
getTime(): number {
|
|
2007
|
+
return this.internal.getTime();
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
valueOf(): number {
|
|
2011
|
+
return this.internal.valueOf();
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
toString(): string {
|
|
2015
|
+
return this.toTPS(DefaultCalendars.TPS);
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
toISOString(): string {
|
|
2019
|
+
return this.internal.toISOString();
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
toUTCString(): string {
|
|
2023
|
+
return this.internal.toUTCString();
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
toJSON(): string | null {
|
|
2027
|
+
return this.internal.toJSON();
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
getFullYear(): number {
|
|
2031
|
+
return this.getTpsFullYear();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
getUTCFullYear(): number {
|
|
2035
|
+
return this.getTpsFullYear();
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
getMonth(): number {
|
|
2039
|
+
return this.getTpsComponents().month - 1;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
getUTCMonth(): number {
|
|
2043
|
+
return this.getTpsComponents().month - 1;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
getDate(): number {
|
|
2047
|
+
return this.getTpsComponents().day;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
getUTCDate(): number {
|
|
2051
|
+
return this.getTpsComponents().day;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
getHours(): number {
|
|
2055
|
+
return this.getTpsComponents().hour;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
getUTCHours(): number {
|
|
2059
|
+
return this.getTpsComponents().hour;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
getMinutes(): number {
|
|
2063
|
+
return this.getTpsComponents().minute;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
getUTCMinutes(): number {
|
|
2067
|
+
return this.getTpsComponents().minute;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
getSeconds(): number {
|
|
2071
|
+
return this.getTpsComponents().second;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
getUTCSeconds(): number {
|
|
2075
|
+
return this.getTpsComponents().second;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
getMilliseconds(): number {
|
|
2079
|
+
return this.getTpsComponents().millisecond;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
getUTCMilliseconds(): number {
|
|
2083
|
+
return this.getTpsComponents().millisecond;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
[Symbol.toPrimitive](hint: string): string | number {
|
|
2087
|
+
if (hint === "number") return this.valueOf();
|
|
2088
|
+
return this.toString();
|
|
1496
2089
|
}
|
|
1497
2090
|
}
|