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