@nextera.one/tps-standard 0.5.33 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +133 -56
- package/dist/date.d.ts +54 -0
- package/dist/date.js +174 -0
- package/dist/date.js.map +1 -0
- package/dist/driver-manager.d.ts +34 -0
- package/dist/driver-manager.js +53 -0
- package/dist/driver-manager.js.map +1 -0
- package/dist/drivers/chinese.d.ts +25 -0
- package/dist/drivers/chinese.js +485 -0
- package/dist/drivers/chinese.js.map +1 -0
- package/dist/drivers/gregorian.d.ts +3 -5
- package/dist/drivers/gregorian.js +26 -19
- package/dist/drivers/gregorian.js.map +1 -1
- package/dist/drivers/hijri.d.ts +1 -16
- package/dist/drivers/hijri.js +9 -102
- package/dist/drivers/hijri.js.map +1 -1
- package/dist/drivers/holocene.d.ts +6 -3
- package/dist/drivers/holocene.js +7 -20
- package/dist/drivers/holocene.js.map +1 -1
- package/dist/drivers/julian.d.ts +3 -10
- package/dist/drivers/julian.js +11 -71
- package/dist/drivers/julian.js.map +1 -1
- package/dist/drivers/persian.d.ts +1 -6
- package/dist/drivers/persian.js +17 -92
- package/dist/drivers/persian.js.map +1 -1
- package/dist/drivers/tps.d.ts +11 -28
- package/dist/drivers/tps.js +8 -58
- package/dist/drivers/tps.js.map +1 -1
- package/dist/drivers/unix.d.ts +5 -6
- package/dist/drivers/unix.js +10 -32
- package/dist/drivers/unix.js.map +1 -1
- package/dist/esm/date.js +170 -0
- package/dist/esm/date.js.map +1 -0
- package/dist/esm/driver-manager.js +49 -0
- package/dist/esm/driver-manager.js.map +1 -0
- package/dist/esm/drivers/chinese.js +481 -0
- package/dist/esm/drivers/chinese.js.map +1 -0
- package/dist/esm/drivers/gregorian.js +160 -0
- package/dist/esm/drivers/gregorian.js.map +1 -0
- package/dist/esm/drivers/hijri.js +184 -0
- package/dist/esm/drivers/hijri.js.map +1 -0
- package/dist/esm/drivers/holocene.js +115 -0
- package/dist/esm/drivers/holocene.js.map +1 -0
- package/dist/esm/drivers/julian.js +161 -0
- package/dist/esm/drivers/julian.js.map +1 -0
- package/dist/esm/drivers/persian.js +190 -0
- package/dist/esm/drivers/persian.js.map +1 -0
- package/dist/esm/drivers/tps.js +181 -0
- package/dist/esm/drivers/tps.js.map +1 -0
- package/dist/esm/drivers/unix.js +50 -0
- package/dist/esm/drivers/unix.js.map +1 -0
- package/dist/esm/index.js +873 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/types.js +28 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/uid.js +221 -0
- package/dist/esm/uid.js.map +1 -0
- package/dist/esm/utils/calendar.js +126 -0
- package/dist/esm/utils/calendar.js.map +1 -0
- package/dist/esm/utils/env.js +76 -0
- package/dist/esm/utils/env.js.map +1 -0
- package/dist/esm/utils/timezone.js +168 -0
- package/dist/esm/utils/timezone.js.map +1 -0
- package/dist/esm/utils/tps-string.js +160 -0
- package/dist/esm/utils/tps-string.js.map +1 -0
- package/dist/index.d.ts +84 -466
- package/dist/index.js +430 -1095
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +103 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/uid.d.ts +48 -0
- package/dist/uid.js +225 -0
- package/dist/uid.js.map +1 -0
- package/dist/utils/calendar.d.ts +55 -0
- package/dist/utils/calendar.js +136 -0
- package/dist/utils/calendar.js.map +1 -0
- package/dist/utils/env.d.ts +12 -0
- package/dist/utils/env.js +79 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/timezone.d.ts +32 -0
- package/dist/utils/timezone.js +173 -0
- package/dist/utils/timezone.js.map +1 -0
- package/dist/utils/tps-string.d.ts +12 -0
- package/dist/utils/tps-string.js +164 -0
- package/dist/utils/tps-string.js.map +1 -0
- package/package.json +20 -5
- package/src/date.ts +243 -0
- package/src/driver-manager.ts +54 -0
- package/src/drivers/chinese.ts +542 -0
- package/src/drivers/gregorian.ts +29 -27
- package/src/drivers/hijri.ts +13 -113
- package/src/drivers/holocene.ts +11 -12
- package/src/drivers/julian.ts +18 -72
- package/src/drivers/persian.ts +25 -92
- package/src/drivers/tps.ts +16 -55
- package/src/drivers/unix.ts +12 -33
- package/src/index.ts +384 -1556
- package/src/types.ts +131 -0
- package/src/uid.ts +308 -0
- package/src/utils/calendar.ts +161 -0
- package/src/utils/env.ts +88 -0
- package/src/utils/timezone.ts +182 -0
- package/src/utils/tps-string.ts +166 -0
package/src/index.ts
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
* TPS: Temporal Positioning System
|
|
3
3
|
* The Universal Protocol for Space-Time Coordinates.
|
|
4
4
|
* @packageDocumentation
|
|
5
|
-
* @version 0.
|
|
5
|
+
* @version 0.6.0
|
|
6
6
|
* @license Apache-2.0
|
|
7
7
|
* @copyright 2026 TPS Standards Working Group
|
|
8
8
|
*
|
|
9
|
+
* v0.5.35 Changes:
|
|
10
|
+
* - Added TPS.now(), TPS.diff(), TPS.add() convenience methods
|
|
11
|
+
* - Added Chinese Lunisolar (chin) calendar driver
|
|
12
|
+
* - Added DriverManager (driver registry separated from TPS class)
|
|
13
|
+
* - Added timezone utility (src/utils/timezone.ts) with IANA + offset support
|
|
14
|
+
* - TPS.toDate() now respects ;tz= extensions when present
|
|
15
|
+
* - ESM dual-mode exports + browser IIFE bundle
|
|
16
|
+
*
|
|
9
17
|
* v0.5.0 Changes:
|
|
10
18
|
* - Added Actor anchor (A:) for provenance tracking
|
|
11
19
|
* - Added Signature (!) for cryptographic verification
|
|
@@ -22,278 +30,36 @@ import { PersianDriver } from "./drivers/persian";
|
|
|
22
30
|
import { HijriDriver } from "./drivers/hijri";
|
|
23
31
|
import { JulianDriver } from "./drivers/julian";
|
|
24
32
|
import { HoloceneDriver } from "./drivers/holocene";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*/
|
|
44
|
-
export enum TimeOrder {
|
|
45
|
-
DESC = "desc",
|
|
46
|
-
ASC = "asc",
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface TPSComponents {
|
|
50
|
-
// --- TEMPORAL ---
|
|
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.
|
|
69
|
-
unixSeconds?: number;
|
|
70
|
-
|
|
71
|
-
// --- SPATIAL: GPS Coordinates ---
|
|
72
|
-
latitude?: number;
|
|
73
|
-
longitude?: number;
|
|
74
|
-
altitude?: number;
|
|
75
|
-
|
|
76
|
-
// --- SPATIAL: Geospatial Cells ---
|
|
77
|
-
/** Google S2 cell ID (hierarchical, prefix-searchable) */
|
|
78
|
-
s2Cell?: string;
|
|
79
|
-
/** Uber H3 cell ID (hexagonal grid) */
|
|
80
|
-
h3Cell?: string;
|
|
81
|
-
/** Open Location Code / Plus Code */
|
|
82
|
-
plusCode?: string;
|
|
83
|
-
/** what3words address (e.g. "filled.count.soap") */
|
|
84
|
-
what3words?: string;
|
|
85
|
-
|
|
86
|
-
// --- SPATIAL: Structural Anchors ---
|
|
87
|
-
/** Physical building identifier */
|
|
88
|
-
building?: string;
|
|
89
|
-
/** Vertical division (level) */
|
|
90
|
-
floor?: string;
|
|
91
|
-
/** Enclosed space identifier */
|
|
92
|
-
room?: string;
|
|
93
|
-
/** Logical area within building */
|
|
94
|
-
zone?: string;
|
|
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
|
-
|
|
99
|
-
// --- SPATIAL: Privacy Markers ---
|
|
100
|
-
/** Technical missing data (e.g. server log without GPS) */
|
|
101
|
-
isUnknownLocation?: boolean;
|
|
102
|
-
/** Removed for legal/security reasons (e.g. GDPR) */
|
|
103
|
-
isRedactedLocation?: boolean;
|
|
104
|
-
/** Masked by user preference (e.g. "Don't show my location") */
|
|
105
|
-
isHiddenLocation?: boolean;
|
|
106
|
-
|
|
107
|
-
// --- PROVENANCE ---
|
|
108
|
-
/** Actor anchor - identifies observer/witness (e.g. "did:web:sensor.example.com", "node:gateway-01") */
|
|
109
|
-
actor?: string;
|
|
110
|
-
/** Verification hash appended to time (e.g. "sha256:8f3e2a...") */
|
|
111
|
-
signature?: string;
|
|
112
|
-
|
|
113
|
-
// --- CONTEXT ---
|
|
114
|
-
extensions?: Record<string, string>;
|
|
115
|
-
|
|
116
|
-
order?: TimeOrder;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// --- PLUGIN ARCHITECTURE ---
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Interface for Calendar Driver plugins.
|
|
123
|
-
* Implementations provide conversion logic to/from Gregorian and support for
|
|
124
|
-
* external calendar libraries.
|
|
125
|
-
*
|
|
126
|
-
* @example Using a driver to parse a Hijri date string
|
|
127
|
-
* ```ts
|
|
128
|
-
* const driver = TPS.getDriver('hij');
|
|
129
|
-
* if (driver?.parseDate) {
|
|
130
|
-
* const components = driver.parseDate('1447-07-21');
|
|
131
|
-
* const gregDate = driver.toGregorian(components);
|
|
132
|
-
* const tpsString = TPS.fromDate(gregDate, 'hij');
|
|
133
|
-
* }
|
|
134
|
-
* ```
|
|
135
|
-
*
|
|
136
|
-
* @example Wrapping an external library (moment-hijri)
|
|
137
|
-
* ```ts
|
|
138
|
-
* import moment from 'moment-hijri';
|
|
139
|
-
*
|
|
140
|
-
* class HijriDriver implements CalendarDriver {
|
|
141
|
-
* readonly code = 'hij';
|
|
142
|
-
*
|
|
143
|
-
* parseDate(input: string, format?: string): Partial<TPSComponents> {
|
|
144
|
-
* const m = moment(input, format || 'iYYYY-iMM-iDD');
|
|
145
|
-
* return {
|
|
146
|
-
* calendar: 'hij',
|
|
147
|
-
* year: m.iYear(),
|
|
148
|
-
* month: m.iMonth() + 1,
|
|
149
|
-
* day: m.iDate()
|
|
150
|
-
* };
|
|
151
|
-
* }
|
|
152
|
-
*
|
|
153
|
-
* fromGregorian(date: Date): Partial<TPSComponents> {
|
|
154
|
-
* const m = moment(date);
|
|
155
|
-
* return {
|
|
156
|
-
* calendar: 'hij',
|
|
157
|
-
* year: m.iYear(),
|
|
158
|
-
* month: m.iMonth() + 1,
|
|
159
|
-
* day: m.iDate(),
|
|
160
|
-
* hour: m.hour(),
|
|
161
|
-
* minute: m.minute(),
|
|
162
|
-
* second: m.second()
|
|
163
|
-
* };
|
|
164
|
-
* }
|
|
165
|
-
*
|
|
166
|
-
* // ... other methods
|
|
167
|
-
* }
|
|
168
|
-
* ```
|
|
169
|
-
*/
|
|
170
|
-
export interface CalendarDriver {
|
|
171
|
-
/** The calendar code this driver handles (e.g., 'hij', 'jul'). */
|
|
172
|
-
readonly code: string;
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Human-readable name for this calendar (optional).
|
|
176
|
-
* @example "Hijri (Islamic)"
|
|
177
|
-
*/
|
|
178
|
-
readonly name?: string;
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Converts a Date to this calendar's components.
|
|
182
|
-
* @param date - The Gregorian Date object.
|
|
183
|
-
* @returns Partial TPS components for year, month, day, etc.
|
|
184
|
-
*/
|
|
185
|
-
getComponentsFromDate(date: Date): Partial<TPSComponents>;
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Converts this calendar's components to a Date.
|
|
189
|
-
* @param components - Partial TPS components (year, month, day, etc.).
|
|
190
|
-
* @returns A JavaScript Date object.
|
|
191
|
-
*/
|
|
192
|
-
getDateFromComponents(components: Partial<TPSComponents>): Date;
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Generates a TPS time string for this calendar from a Date.
|
|
196
|
-
* @param date - The Gregorian Date object.
|
|
197
|
-
* @returns A TPS time string (e.g., "T:hij.y1447.m07.d21...").
|
|
198
|
-
*/
|
|
199
|
-
getFromDate(date: Date): string;
|
|
200
|
-
|
|
201
|
-
// --- NEW ENHANCED METHODS (Optional) ---
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Parse a calendar-specific date string into TPS components.
|
|
205
|
-
* This allows drivers to handle native date formats from external libraries.
|
|
206
|
-
*
|
|
207
|
-
* @param input - Date string in calendar-native format (e.g., '1447-07-21' for Hijri)
|
|
208
|
-
* @param format - Optional format string (driver-specific, e.g., 'iYYYY-iMM-iDD')
|
|
209
|
-
* @returns Partial TPS components
|
|
210
|
-
*
|
|
211
|
-
* @example
|
|
212
|
-
* ```ts
|
|
213
|
-
* // Hijri driver
|
|
214
|
-
* driver.parseDate('1447-07-21'); // → { year: 1447, month: 7, day: 21, calendar: 'hij' }
|
|
215
|
-
*
|
|
216
|
-
* // With time
|
|
217
|
-
* driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
|
|
218
|
-
* ```
|
|
219
|
-
*/
|
|
220
|
-
parseDate(input: string, format?: string): Partial<TPSComponents>;
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Format TPS components to a calendar-specific date string.
|
|
224
|
-
* Inverse of parseDate().
|
|
225
|
-
*
|
|
226
|
-
* @param components - TPS components to format
|
|
227
|
-
* @param format - Optional format string (driver-specific)
|
|
228
|
-
* @returns Formatted date string in calendar-native format
|
|
229
|
-
*
|
|
230
|
-
* @example
|
|
231
|
-
* ```ts
|
|
232
|
-
* driver.format({ year: 1447, month: 7, day: 21 }); // → '1447-07-21'
|
|
233
|
-
* driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
|
|
234
|
-
* ```
|
|
235
|
-
*/
|
|
236
|
-
format(components: Partial<TPSComponents>, format?: string): string;
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Validate a calendar-specific date string or components.
|
|
240
|
-
*
|
|
241
|
-
* @param input - Date string or components to validate
|
|
242
|
-
* @returns true if valid for this calendar
|
|
243
|
-
*
|
|
244
|
-
* @example
|
|
245
|
-
* ```ts
|
|
246
|
-
* driver.validate('1447-13-01'); // → false (month 13 invalid)
|
|
247
|
-
* driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
|
|
248
|
-
* ```
|
|
249
|
-
*/
|
|
250
|
-
validate(input: string | Partial<TPSComponents>): boolean;
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Get calendar metadata (month names, day names, etc.).
|
|
254
|
-
* Useful for UI rendering.
|
|
255
|
-
*
|
|
256
|
-
* @example
|
|
257
|
-
* ```ts
|
|
258
|
-
* driver.getMetadata().monthNames
|
|
259
|
-
* // → ['Muharram', 'Safar', 'Rabi I', ...]
|
|
260
|
-
* ```
|
|
261
|
-
*/
|
|
262
|
-
getMetadata(): CalendarMetadata;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Metadata about a calendar system.
|
|
267
|
-
*/
|
|
268
|
-
export interface CalendarMetadata {
|
|
269
|
-
/** Human-readable calendar name */
|
|
270
|
-
name: string;
|
|
271
|
-
/** Month names in order (1-12 or 1-13) */
|
|
272
|
-
monthNames?: string[];
|
|
273
|
-
/** Short month names */
|
|
274
|
-
monthNamesShort?: string[];
|
|
275
|
-
/** Day of week names (Sunday=0 or locale-specific) */
|
|
276
|
-
dayNames?: string[];
|
|
277
|
-
/** Short day names */
|
|
278
|
-
dayNamesShort?: string[];
|
|
279
|
-
/** Whether this calendar is lunar-based */
|
|
280
|
-
isLunar?: boolean;
|
|
281
|
-
/** Number of months per year */
|
|
282
|
-
monthsPerYear?: number;
|
|
283
|
-
/** Epoch year (for reference) */
|
|
284
|
-
epochYear?: number;
|
|
285
|
-
}
|
|
33
|
+
import { ChineseDriver } from "./drivers/chinese";
|
|
34
|
+
|
|
35
|
+
export * from "./types";
|
|
36
|
+
export * from "./uid";
|
|
37
|
+
export * from "./date";
|
|
38
|
+
export { Env } from "./utils/env";
|
|
39
|
+
export { DriverManager } from "./driver-manager";
|
|
40
|
+
export { utcToLocal, localToUtc, getOffsetString } from "./utils/timezone";
|
|
41
|
+
import { DriverManager } from "./driver-manager";
|
|
42
|
+
import { buildTimePart, parseTimeString } from "./utils/tps-string";
|
|
43
|
+
import { localToUtc } from "./utils/timezone";
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
CalendarDriver,
|
|
47
|
+
TPSComponents,
|
|
48
|
+
TimeOrder,
|
|
49
|
+
DefaultCalendars,
|
|
50
|
+
} from "./types";
|
|
286
51
|
|
|
287
52
|
export class TPS {
|
|
288
53
|
// --- PLUGIN REGISTRY ---
|
|
289
|
-
|
|
54
|
+
/** Shared DriverManager instance — use TPS.driverManager for direct access. */
|
|
55
|
+
static readonly driverManager = new DriverManager();
|
|
290
56
|
|
|
291
57
|
/**
|
|
292
58
|
* Registers a calendar driver plugin.
|
|
293
59
|
* @param driver - The driver instance to register.
|
|
294
60
|
*/
|
|
295
61
|
static registerDriver(driver: CalendarDriver): void {
|
|
296
|
-
this.
|
|
62
|
+
this.driverManager.register(driver);
|
|
297
63
|
}
|
|
298
64
|
|
|
299
65
|
/**
|
|
@@ -302,41 +68,44 @@ export class TPS {
|
|
|
302
68
|
* @returns The driver or undefined.
|
|
303
69
|
*/
|
|
304
70
|
static getDriver(code: string): CalendarDriver | undefined {
|
|
305
|
-
return this.
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// --- REGEX ---
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
71
|
+
return this.driverManager.get(code);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- REGEX (v0.6.0) ---
|
|
75
|
+
// The URI and time regexes are intentionally permissive in the location &
|
|
76
|
+
// extension sections — detailed semantic parsing happens in
|
|
77
|
+
// _mapGroupsToComponents() and the layer parsers below.
|
|
78
|
+
//
|
|
79
|
+
// Structure:
|
|
80
|
+
// tps://[location]/A:[actor]@T:[cal].[tokens];[ext];...#C:[ctx];...
|
|
81
|
+
//
|
|
82
|
+
// The `;` separator is used consistently:
|
|
83
|
+
// - between location layers (before @T:)
|
|
84
|
+
// - between extensions (after T: tokens, before #)
|
|
85
|
+
// - between context key=val pairs (after #C:)
|
|
314
86
|
private static readonly REGEX_URI = new RegExp(
|
|
315
87
|
"^tps://" +
|
|
316
|
-
// Location
|
|
317
|
-
"(
|
|
318
|
-
|
|
319
|
-
"
|
|
320
|
-
|
|
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>[^/@]+))?" +
|
|
88
|
+
// Location: everything up to optional /A: actor and then @T:
|
|
89
|
+
"(?<location>[^@]+?)" +
|
|
90
|
+
// Optional actor overlay
|
|
91
|
+
"(?:/A:(?<actor>[^@]+))?" +
|
|
92
|
+
// Time section
|
|
328
93
|
"@T:(?<calendar>[a-z]{3,4})" +
|
|
329
|
-
"(?:\\.
|
|
330
|
-
|
|
331
|
-
"(
|
|
332
|
-
|
|
333
|
-
"(
|
|
94
|
+
"(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
|
|
95
|
+
// Optional signature
|
|
96
|
+
"(?:!(?<signature>[^;#]+))?" +
|
|
97
|
+
// Optional extensions (;KEY:val;key=val;...)
|
|
98
|
+
"(?:;(?<extensions>[^#]+))?" +
|
|
99
|
+
// Optional context fragment (#C:key=val;...)
|
|
100
|
+
"(?:#C:(?<context>.+))?$",
|
|
334
101
|
);
|
|
335
102
|
|
|
336
103
|
private static readonly REGEX_TIME = new RegExp(
|
|
337
104
|
"^T:(?<calendar>[a-z]{3,4})" +
|
|
338
|
-
"(?:\\.
|
|
339
|
-
"(?:![
|
|
105
|
+
"(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
|
|
106
|
+
"(?:!(?<signature>[^;#]+))?" +
|
|
107
|
+
"(?:;(?<extensions>[^#]+))?" +
|
|
108
|
+
"(?:#C:(?<context>.+))?$",
|
|
340
109
|
);
|
|
341
110
|
|
|
342
111
|
// --- CORE METHODS ---
|
|
@@ -354,6 +123,22 @@ export class TPS {
|
|
|
354
123
|
let s = input.trim().replace(/\s+/g, "");
|
|
355
124
|
if (!s) return s;
|
|
356
125
|
|
|
126
|
+
// ── 1.2 Compact scheme normalization (v0.6.0) ──────────────────────────
|
|
127
|
+
// TPS:... → tps://... (generic compact)
|
|
128
|
+
// NIP4:x → tps://net:ip4:x (IPv4 shorthand)
|
|
129
|
+
// NIP6:x → tps://net:ip6:x (IPv6 shorthand)
|
|
130
|
+
// NODE:x → tps://node:x (logical node shorthand)
|
|
131
|
+
if (/^TPS:/i.test(s) && !s.toLowerCase().startsWith("tps://")) {
|
|
132
|
+
// TPS:L:... or TPS:lat,lon... → tps://...
|
|
133
|
+
s = "tps://" + s.slice(4); // strip 'TPS:'
|
|
134
|
+
} else if (/^NIP4:/i.test(s)) {
|
|
135
|
+
s = "tps://net:ip4:" + s.slice(5);
|
|
136
|
+
} else if (/^NIP6:/i.test(s)) {
|
|
137
|
+
s = "tps://net:ip6:" + s.slice(5);
|
|
138
|
+
} else if (/^NODE:/i.test(s)) {
|
|
139
|
+
s = "tps://node:" + s.slice(5);
|
|
140
|
+
}
|
|
141
|
+
|
|
357
142
|
// ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
|
|
358
143
|
// The input may contain "/T:" from older versions; we normalise early so
|
|
359
144
|
// subsequent logic can assume only the '@' form.
|
|
@@ -504,7 +289,7 @@ export class TPS {
|
|
|
504
289
|
timeStr = timeStr.split(/[!;?#]/)[0];
|
|
505
290
|
}
|
|
506
291
|
if (timeStr) {
|
|
507
|
-
const parsed =
|
|
292
|
+
const parsed = parseTimeString(timeStr);
|
|
508
293
|
if (!parsed) return null;
|
|
509
294
|
Object.assign(comp, parsed.components);
|
|
510
295
|
comp.order = parsed.order;
|
|
@@ -524,12 +309,33 @@ export class TPS {
|
|
|
524
309
|
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
525
310
|
signature = sigMatch.groups.sig;
|
|
526
311
|
timeOnly = input.split(/[!;?#]/)[0];
|
|
312
|
+
} else {
|
|
313
|
+
// Strip extension/query/fragment suffix so parseTimeString sees only tokens
|
|
314
|
+
timeOnly = input.split(/[;?#]/)[0];
|
|
527
315
|
}
|
|
528
|
-
const parsed =
|
|
316
|
+
const parsed = parseTimeString(timeOnly);
|
|
529
317
|
if (!parsed) return null;
|
|
530
318
|
const comp = parsed.components as TPSComponents;
|
|
531
319
|
if (signature) comp.signature = signature;
|
|
532
320
|
comp.order = parsed.order;
|
|
321
|
+
|
|
322
|
+
// Route through the same group mapper used by REGEX_URI for consistency
|
|
323
|
+
// (handles extensions ;KEY:val and context #C:key=val)
|
|
324
|
+
const syntheticGroups: Record<string, string> = {
|
|
325
|
+
calendar: match.groups.calendar ?? "",
|
|
326
|
+
signature: match.groups.signature ?? "",
|
|
327
|
+
extensions: match.groups.extensions ?? "",
|
|
328
|
+
context: match.groups.context ?? "",
|
|
329
|
+
location: "", // no location in time-only string
|
|
330
|
+
actor: "",
|
|
331
|
+
};
|
|
332
|
+
const mappedComp = this._mapGroupsToComponents(syntheticGroups);
|
|
333
|
+
// Merge temporal components from parseTimeString with mapped metadata
|
|
334
|
+
Object.assign(comp, {
|
|
335
|
+
signature: mappedComp.signature || comp.signature,
|
|
336
|
+
extensions: mappedComp.extensions || comp.extensions,
|
|
337
|
+
context: mappedComp.context,
|
|
338
|
+
});
|
|
533
339
|
return comp;
|
|
534
340
|
}
|
|
535
341
|
|
|
@@ -539,59 +345,87 @@ export class TPS {
|
|
|
539
345
|
* @returns Full URI string (e.g. "tps://...").
|
|
540
346
|
*/
|
|
541
347
|
static toURI(comp: TPSComponents): string {
|
|
542
|
-
// 1.
|
|
543
|
-
|
|
348
|
+
// ── 1. Location layers (v0.6.0) ──────────────────────────────────────────
|
|
349
|
+
// Build an ordered list of location layer strings, then join with ";"
|
|
350
|
+
const layers: string[] = [];
|
|
544
351
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
spacePart = "L:~";
|
|
352
|
+
// Privacy shorthand takes priority
|
|
353
|
+
if (comp.isHiddenLocation) {
|
|
354
|
+
layers.push("L:~");
|
|
549
355
|
} else if (comp.isRedactedLocation) {
|
|
550
|
-
|
|
356
|
+
layers.push("L:redacted");
|
|
551
357
|
} else if (comp.isUnknownLocation) {
|
|
552
|
-
|
|
358
|
+
layers.push("L:-");
|
|
359
|
+
} else if (comp.spaceAnchor) {
|
|
360
|
+
// Generic / legacy anchor (adm:, planet:, etc.)
|
|
361
|
+
layers.push(comp.spaceAnchor);
|
|
362
|
+
} else if (comp.ipv4) {
|
|
363
|
+
layers.push(`net:ip4:${comp.ipv4}`);
|
|
364
|
+
} else if (comp.ipv6) {
|
|
365
|
+
layers.push(`net:ip6:${comp.ipv6}`);
|
|
366
|
+
} else if (comp.nodeName) {
|
|
367
|
+
layers.push(`node:${comp.nodeName}`);
|
|
553
368
|
} else if (comp.s2Cell) {
|
|
554
|
-
|
|
369
|
+
layers.push(`S2:${comp.s2Cell}`);
|
|
555
370
|
} else if (comp.h3Cell) {
|
|
556
|
-
|
|
557
|
-
} else if (comp.plusCode) {
|
|
558
|
-
spacePart = `L:plus=${comp.plusCode}`;
|
|
371
|
+
layers.push(`H3:${comp.h3Cell}`);
|
|
559
372
|
} else if (comp.what3words) {
|
|
560
|
-
|
|
373
|
+
layers.push(`3W:${comp.what3words}`);
|
|
374
|
+
} else if (comp.plusCode) {
|
|
375
|
+
layers.push(`plus:${comp.plusCode}`);
|
|
561
376
|
} else if (comp.building) {
|
|
562
|
-
|
|
563
|
-
if (comp.floor)
|
|
564
|
-
if (comp.room)
|
|
565
|
-
if (comp.
|
|
377
|
+
layers.push(`bldg:${comp.building}`);
|
|
378
|
+
if (comp.floor) layers.push(`floor:${comp.floor}`);
|
|
379
|
+
if (comp.room) layers.push(`room:${comp.room}`);
|
|
380
|
+
if (comp.door) layers.push(`door:${comp.door}`);
|
|
381
|
+
if (comp.zone) layers.push(`zone:${comp.zone}`);
|
|
566
382
|
} else if (comp.latitude !== undefined && comp.longitude !== undefined) {
|
|
567
|
-
|
|
568
|
-
if (comp.altitude !== undefined) {
|
|
569
|
-
|
|
570
|
-
|
|
383
|
+
let gps = `L:${comp.latitude},${comp.longitude}`;
|
|
384
|
+
if (comp.altitude !== undefined) gps += `,${comp.altitude}m`;
|
|
385
|
+
layers.push(gps);
|
|
386
|
+
} else {
|
|
387
|
+
layers.push("L:-"); // unknown fallback
|
|
571
388
|
}
|
|
572
389
|
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
390
|
+
// Place layer (P:) — appended after primary location
|
|
391
|
+
if (
|
|
392
|
+
comp.placeCountryCode || comp.placeCountryName ||
|
|
393
|
+
comp.placeCityCode || comp.placeCityName
|
|
394
|
+
) {
|
|
395
|
+
const pParts: string[] = [];
|
|
396
|
+
if (comp.placeCountryCode) pParts.push(`cc=${comp.placeCountryCode}`);
|
|
397
|
+
if (comp.placeCountryName) pParts.push(`cn=${comp.placeCountryName}`);
|
|
398
|
+
if (comp.placeCityCode) pParts.push(`ci=${comp.placeCityCode}`);
|
|
399
|
+
if (comp.placeCityName) pParts.push(`ct=${comp.placeCityName}`);
|
|
400
|
+
layers.push(`P:${pParts.join(",")}`);
|
|
577
401
|
}
|
|
578
402
|
|
|
579
|
-
|
|
580
|
-
|
|
403
|
+
const locationStr = layers.join(";");
|
|
404
|
+
|
|
405
|
+
// ── 2. Actor (/A:...) ─────────────────────────────────────────────────────
|
|
406
|
+
const actorPart = comp.actor ? `/A:${comp.actor}` : "";
|
|
407
|
+
|
|
408
|
+
// ── 3. Time (mandatory 9 tokens) ─────────────────────────────────────────
|
|
409
|
+
const timePart = buildTimePart(comp);
|
|
581
410
|
|
|
582
|
-
//
|
|
411
|
+
// ── 4. Extensions (;KEY:val;...) ─────────────────────────────────────────
|
|
583
412
|
let extPart = "";
|
|
584
413
|
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
585
|
-
const extStrings = Object.entries(comp.extensions).map(
|
|
586
|
-
(
|
|
587
|
-
|
|
588
|
-
|
|
414
|
+
const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
|
|
415
|
+
// Emit as KEY:val (preferred v0.6.0 style)
|
|
416
|
+
return `${k.toUpperCase()}:${v}`;
|
|
417
|
+
});
|
|
418
|
+
extPart = `;${extStrings.join(";")}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── 5. Context (#C:key=val;...) ──────────────────────────────────────────
|
|
422
|
+
let contextPart = "";
|
|
423
|
+
if (comp.context && Object.keys(comp.context).length > 0) {
|
|
424
|
+
const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
|
|
425
|
+
contextPart = `#C:${ctxStrings.join(";")}`;
|
|
589
426
|
}
|
|
590
427
|
|
|
591
|
-
|
|
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}`;
|
|
428
|
+
return `tps://${locationStr}${actorPart}@${timePart}${extPart}${contextPart}`;
|
|
595
429
|
}
|
|
596
430
|
|
|
597
431
|
/**
|
|
@@ -609,7 +443,7 @@ export class TPS {
|
|
|
609
443
|
opts?: { order?: TimeOrder },
|
|
610
444
|
): string {
|
|
611
445
|
const normalizedCalendar = calendar.toLowerCase();
|
|
612
|
-
const driver = this.
|
|
446
|
+
const driver = this.driverManager.get(normalizedCalendar);
|
|
613
447
|
if (driver) {
|
|
614
448
|
// when caller requested an explicit order we can bypass the driver's
|
|
615
449
|
// `fromDate` helper and instead generate components ourselves so that
|
|
@@ -619,7 +453,7 @@ export class TPS {
|
|
|
619
453
|
const comp = driver.getComponentsFromDate(date) as TPSComponents;
|
|
620
454
|
comp.calendar = normalizedCalendar;
|
|
621
455
|
comp.order = opts.order;
|
|
622
|
-
return
|
|
456
|
+
return buildTimePart(comp);
|
|
623
457
|
}
|
|
624
458
|
return driver.getFromDate(date);
|
|
625
459
|
}
|
|
@@ -632,7 +466,7 @@ export class TPS {
|
|
|
632
466
|
const s = (date.getTime() / 1000).toFixed(3);
|
|
633
467
|
comp.unixSeconds = parseFloat(s);
|
|
634
468
|
if (opts?.order) comp.order = opts.order;
|
|
635
|
-
return
|
|
469
|
+
return buildTimePart(comp);
|
|
636
470
|
}
|
|
637
471
|
|
|
638
472
|
if (normalizedCalendar === DefaultCalendars.GREG) {
|
|
@@ -647,7 +481,7 @@ export class TPS {
|
|
|
647
481
|
comp.second = date.getUTCSeconds();
|
|
648
482
|
comp.millisecond = date.getUTCMilliseconds();
|
|
649
483
|
if (opts?.order) comp.order = opts.order;
|
|
650
|
-
return
|
|
484
|
+
return buildTimePart(comp);
|
|
651
485
|
}
|
|
652
486
|
|
|
653
487
|
throw new Error(
|
|
@@ -683,13 +517,24 @@ export class TPS {
|
|
|
683
517
|
|
|
684
518
|
const cal = parsed.calendar || DefaultCalendars.TPS;
|
|
685
519
|
|
|
686
|
-
const driver = this.
|
|
520
|
+
const driver = this.driverManager.get(cal);
|
|
687
521
|
if (!driver) {
|
|
688
522
|
console.error(`Calendar driver '${cal}' not registered.`);
|
|
689
523
|
return null;
|
|
690
524
|
}
|
|
691
525
|
|
|
692
|
-
|
|
526
|
+
const date = driver.getDateFromComponents(parsed);
|
|
527
|
+
|
|
528
|
+
// If the URI has a ;tz= extension, the calendar date was expressed in local
|
|
529
|
+
// time. Convert from local → UTC using the timezone utility.
|
|
530
|
+
const tz = parsed.extensions?.["tz"];
|
|
531
|
+
if (tz && date) {
|
|
532
|
+
const localMs = date.getTime();
|
|
533
|
+
const utcMs = localToUtc(localMs, tz);
|
|
534
|
+
return new Date(utcMs);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return date;
|
|
693
538
|
}
|
|
694
539
|
|
|
695
540
|
// --- DRIVER CONVENIENCE METHODS ---
|
|
@@ -717,7 +562,7 @@ export class TPS {
|
|
|
717
562
|
dateString: string,
|
|
718
563
|
format?: string,
|
|
719
564
|
): Partial<TPSComponents> | null {
|
|
720
|
-
const driver = this.
|
|
565
|
+
const driver = this.driverManager.get(calendar);
|
|
721
566
|
if (!driver) {
|
|
722
567
|
throw new Error(
|
|
723
568
|
`Calendar driver '${calendar}' not found. Register a driver first.`,
|
|
@@ -799,7 +644,7 @@ export class TPS {
|
|
|
799
644
|
components: Partial<TPSComponents>,
|
|
800
645
|
format?: string,
|
|
801
646
|
): string {
|
|
802
|
-
const driver = this.
|
|
647
|
+
const driver = this.driverManager.get(calendar);
|
|
803
648
|
if (!driver) {
|
|
804
649
|
throw new Error(`Calendar driver '${calendar}' not found.`);
|
|
805
650
|
}
|
|
@@ -807,1033 +652,260 @@ export class TPS {
|
|
|
807
652
|
return driver.format(components, format);
|
|
808
653
|
}
|
|
809
654
|
|
|
810
|
-
// ---
|
|
655
|
+
// --- CONVENIENCE METHODS ---
|
|
811
656
|
|
|
812
657
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
658
|
+
* Returns a TPS time string for the current moment.
|
|
659
|
+
* Shorthand for `TPS.fromDate(new Date(), calendar, opts)`.
|
|
660
|
+
*
|
|
661
|
+
* @param calendar - Calendar code. Defaults to 'greg'.
|
|
662
|
+
* @param opts - Optional `order` (ASC/DESC) parameter.
|
|
663
|
+
* @returns TPS time string.
|
|
817
664
|
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
665
|
+
* @example
|
|
666
|
+
* ```ts
|
|
667
|
+
* TPS.now(); // "T:greg.m3.c1.y26.m3.d4.h06.m30.s00.m0"
|
|
668
|
+
* TPS.now('hij'); // "T:hij.y1447.m09.d05.h06.m30.s00"
|
|
669
|
+
* ```
|
|
820
670
|
*/
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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;
|
|
671
|
+
static now(
|
|
672
|
+
calendar: string = DefaultCalendars.GREG,
|
|
673
|
+
opts?: { order?: TimeOrder },
|
|
674
|
+
): string {
|
|
675
|
+
return this.fromDate(new Date(), calendar, opts) as string;
|
|
867
676
|
}
|
|
868
677
|
|
|
869
678
|
/**
|
|
870
|
-
*
|
|
871
|
-
* `
|
|
872
|
-
* accepts tokens in **any** sequence and will return an `order` value of
|
|
873
|
-
* `'ascending'` or `'descending'`.
|
|
679
|
+
* Returns the difference in milliseconds between two TPS strings.
|
|
680
|
+
* The result is `t2 - t1`; negative if t1 is after t2.
|
|
874
681
|
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
682
|
+
* @param t1 - First TPS string (subtracted from t2).
|
|
683
|
+
* @param t2 - Second TPS string.
|
|
684
|
+
* @returns Milliseconds between the two moments, or NaN on parse failure.
|
|
878
685
|
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```ts
|
|
688
|
+
* const ms = TPS.diff('T:greg.m3.c1.y26.m1.d1.h0.m0.s0.m0',
|
|
689
|
+
* 'T:greg.m3.c1.y26.m1.d2.h0.m0.s0.m0');
|
|
690
|
+
* // 86_400_000 (one day)
|
|
691
|
+
* ```
|
|
692
|
+
*/
|
|
693
|
+
static diff(t1: string, t2: string): number {
|
|
694
|
+
const d1 = this.toDate(t1);
|
|
695
|
+
const d2 = this.toDate(t2);
|
|
696
|
+
if (!d1 || !d2) return NaN;
|
|
697
|
+
return d2.getTime() - d1.getTime();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Returns a new TPS string shifted by the given duration.
|
|
702
|
+
* The result is in the same calendar as the original string.
|
|
884
703
|
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
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)
|
|
704
|
+
* @param tpsStr - Source TPS string.
|
|
705
|
+
* @param duration - Object with optional `days`, `hours`, `minutes`, `seconds`, `milliseconds`.
|
|
706
|
+
* @returns Shifted TPS string, or null if the input is invalid.
|
|
893
707
|
*
|
|
894
|
-
* @
|
|
708
|
+
* @example
|
|
709
|
+
* ```ts
|
|
710
|
+
* const t = 'T:greg.m3.c1.y26.m1.d9.h14.m30.s25.m0';
|
|
711
|
+
* TPS.add(t, { days: 7 }); // one week later
|
|
712
|
+
* TPS.add(t, { hours: -2 }); // two hours earlier
|
|
713
|
+
* ```
|
|
895
714
|
*/
|
|
896
|
-
static
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
const
|
|
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
|
-
}
|
|
715
|
+
static add(
|
|
716
|
+
tpsStr: string,
|
|
717
|
+
duration: {
|
|
718
|
+
days?: number;
|
|
719
|
+
hours?: number;
|
|
720
|
+
minutes?: number;
|
|
721
|
+
seconds?: number;
|
|
722
|
+
milliseconds?: number;
|
|
723
|
+
},
|
|
724
|
+
): string | null {
|
|
725
|
+
const date = this.toDate(tpsStr);
|
|
726
|
+
if (!date) return null;
|
|
964
727
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
}
|
|
728
|
+
const parsed = this.parse(tpsStr);
|
|
729
|
+
const calendar = parsed?.calendar ?? DefaultCalendars.GREG;
|
|
730
|
+
const order = parsed?.order;
|
|
1016
731
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
// mixed / single direction → defaults to DESC
|
|
1024
|
-
}
|
|
732
|
+
const deltaMs =
|
|
733
|
+
(duration.days ?? 0) * 86_400_000 +
|
|
734
|
+
(duration.hours ?? 0) * 3_600_000 +
|
|
735
|
+
(duration.minutes ?? 0) * 60_000 +
|
|
736
|
+
(duration.seconds ?? 0) * 1_000 +
|
|
737
|
+
(duration.milliseconds ?? 0);
|
|
1025
738
|
|
|
1026
|
-
|
|
739
|
+
const shifted = new Date(date.getTime() + deltaMs);
|
|
740
|
+
return this.fromDate(
|
|
741
|
+
shifted,
|
|
742
|
+
calendar,
|
|
743
|
+
order ? { order } : undefined,
|
|
744
|
+
) as string;
|
|
1027
745
|
}
|
|
1028
746
|
|
|
747
|
+
// --- INTERNAL HELPERS ---
|
|
748
|
+
|
|
1029
749
|
private static _mapGroupsToComponents(
|
|
1030
750
|
g: Record<string, string>,
|
|
1031
751
|
): TPSComponents {
|
|
1032
752
|
const components: any = {};
|
|
1033
753
|
components.calendar = g.calendar as string;
|
|
1034
754
|
|
|
1035
|
-
// Signature
|
|
755
|
+
// ── Signature ────────────────────────────────────────────────────────────
|
|
1036
756
|
if (g.signature) {
|
|
1037
757
|
components.signature = g.signature;
|
|
1038
758
|
}
|
|
1039
759
|
|
|
1040
|
-
// Actor
|
|
760
|
+
// ── Actor (/A:...) ────────────────────────────────────────────────────────
|
|
1041
761
|
if (g.actor) {
|
|
1042
|
-
components.actor = g.actor;
|
|
762
|
+
components.actor = g.actor.trim();
|
|
1043
763
|
}
|
|
1044
764
|
|
|
1045
|
-
//
|
|
1046
|
-
if (g.
|
|
1047
|
-
|
|
1048
|
-
if (g.space === "unknown" || g.space === "-") {
|
|
1049
|
-
components.isUnknownLocation = true;
|
|
1050
|
-
} else if (g.space === "redacted") {
|
|
1051
|
-
components.isRedactedLocation = true;
|
|
1052
|
-
} else if (g.space === "hidden" || g.space === "~") {
|
|
1053
|
-
components.isHiddenLocation = true;
|
|
1054
|
-
}
|
|
1055
|
-
// Geospatial cells
|
|
1056
|
-
else if (g.s2) {
|
|
1057
|
-
components.s2Cell = g.s2;
|
|
1058
|
-
} else if (g.h3) {
|
|
1059
|
-
components.h3Cell = g.h3;
|
|
1060
|
-
} else if (g.plus) {
|
|
1061
|
-
components.plusCode = g.plus;
|
|
1062
|
-
} else if (g.w3w) {
|
|
1063
|
-
components.what3words = g.w3w;
|
|
1064
|
-
}
|
|
1065
|
-
// Structural anchors
|
|
1066
|
-
else if (g.bldg) {
|
|
1067
|
-
components.building = g.bldg;
|
|
1068
|
-
if (g.floor) components.floor = g.floor;
|
|
1069
|
-
if (g.room) components.room = g.room;
|
|
1070
|
-
if (g.zone) components.zone = g.zone;
|
|
1071
|
-
}
|
|
1072
|
-
// Generic pre-@ anchor (adm/node/net/planet/etc)
|
|
1073
|
-
else if (g.generic) {
|
|
1074
|
-
components.spaceAnchor = g.generic;
|
|
1075
|
-
}
|
|
1076
|
-
// GPS coordinates
|
|
1077
|
-
else {
|
|
1078
|
-
if (g.lat) components.latitude = parseFloat(g.lat);
|
|
1079
|
-
if (g.lon) components.longitude = parseFloat(g.lon);
|
|
1080
|
-
if (g.alt) components.altitude = parseFloat(g.alt);
|
|
1081
|
-
}
|
|
765
|
+
// ── Location layers (v0.6.0: multi-layer, ;-separated) ───────────────────
|
|
766
|
+
if (g.location) {
|
|
767
|
+
this._parseLocationLayers(g.location, components);
|
|
1082
768
|
}
|
|
1083
769
|
|
|
1084
|
-
// Extensions
|
|
770
|
+
// ── Extensions (;KEY:val or ;key=val after T: tokens) ────────────────────
|
|
1085
771
|
if (g.extensions) {
|
|
1086
|
-
const extObj:
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
772
|
+
const extObj: Record<string, string> = {};
|
|
773
|
+
g.extensions.split(";").forEach((part: string) => {
|
|
774
|
+
part = part.trim();
|
|
775
|
+
if (!part) return;
|
|
776
|
+
const colonIdx = part.indexOf(":");
|
|
777
|
+
const eqIdx = part.indexOf("=");
|
|
778
|
+
if (colonIdx > 0 && (eqIdx < 0 || colonIdx < eqIdx)) {
|
|
779
|
+
// KEY:val form (e.g. TZ:+03:00)
|
|
780
|
+
const key = part.substring(0, colonIdx).toLowerCase();
|
|
781
|
+
const val = part.substring(colonIdx + 1);
|
|
782
|
+
if (key && val !== undefined) extObj[key] = val;
|
|
783
|
+
} else if (eqIdx > 0) {
|
|
784
|
+
// key=val form (e.g. tz=+03:00)
|
|
785
|
+
const key = part.substring(0, eqIdx).toLowerCase();
|
|
786
|
+
const val = part.substring(eqIdx + 1);
|
|
787
|
+
if (key && val !== undefined) extObj[key] = val;
|
|
1099
788
|
}
|
|
1100
789
|
});
|
|
1101
|
-
components.extensions = extObj;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
return components as TPSComponents;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
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
|
-
|
|
1118
|
-
// --- TPS-UID v1 Types ---
|
|
1119
|
-
|
|
1120
|
-
/**
|
|
1121
|
-
* Decoded result from TPSUID7RB binary format.
|
|
1122
|
-
*/
|
|
1123
|
-
export type TPSUID7RBDecodeResult = {
|
|
1124
|
-
/** Version identifier */
|
|
1125
|
-
version: "tpsuid7rb";
|
|
1126
|
-
/** Epoch milliseconds (UTC) */
|
|
1127
|
-
epochMs: number;
|
|
1128
|
-
/** Whether the TPS payload was compressed */
|
|
1129
|
-
compressed: boolean;
|
|
1130
|
-
/** 32-bit nonce for collision prevention */
|
|
1131
|
-
nonce: number;
|
|
1132
|
-
/** The original TPS string (exact reconstruction) */
|
|
1133
|
-
tps: string;
|
|
1134
|
-
};
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* Encoding options for TPSUID7RB.
|
|
1138
|
-
*/
|
|
1139
|
-
export type TPSUID7RBEncodeOptions = {
|
|
1140
|
-
/** Enable zlib compression of TPS payload */
|
|
1141
|
-
compress?: boolean;
|
|
1142
|
-
/** Override epoch milliseconds (default: parsed from TPS) */
|
|
1143
|
-
epochMs?: number;
|
|
1144
|
-
};
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
|
|
1148
|
-
*
|
|
1149
|
-
* A time-first, reversible identifier that binds an event to a TPS coordinate.
|
|
1150
|
-
* Unlike UUIDs, TPS-UID identifies events in spacetime and allows exact
|
|
1151
|
-
* reconstruction of the original TPS string.
|
|
1152
|
-
*
|
|
1153
|
-
* Binary Schema (all integers big-endian):
|
|
1154
|
-
* ```
|
|
1155
|
-
* MAGIC 4 bytes "TPU7"
|
|
1156
|
-
* VER 1 byte 0x01
|
|
1157
|
-
* FLAGS 1 byte bit0 = compression flag
|
|
1158
|
-
* TIME 6 bytes epoch_ms (48-bit unsigned)
|
|
1159
|
-
* NONCE 4 bytes 32-bit random
|
|
1160
|
-
* LEN varint length of TPS payload
|
|
1161
|
-
* TPS bytes UTF-8 TPS string (raw or zlib-compressed)
|
|
1162
|
-
* ```
|
|
1163
|
-
*
|
|
1164
|
-
* @example
|
|
1165
|
-
* ```ts
|
|
1166
|
-
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.m01.d09';
|
|
1167
|
-
*
|
|
1168
|
-
* // Encode to binary
|
|
1169
|
-
* const bytes = TPSUID7RB.encodeBinary(tps);
|
|
1170
|
-
*
|
|
1171
|
-
* // Encode to base64url string
|
|
1172
|
-
* const id = TPSUID7RB.encodeBinaryB64(tps);
|
|
1173
|
-
* // → "tpsuid7rb_AFRQV..."
|
|
1174
|
-
*
|
|
1175
|
-
* // Decode back to original TPS
|
|
1176
|
-
* const decoded = TPSUID7RB.decodeBinaryB64(id);
|
|
1177
|
-
* console.log(decoded.tps); // exact original TPS
|
|
1178
|
-
* ```
|
|
1179
|
-
*/
|
|
1180
|
-
export class TPSUID7RB {
|
|
1181
|
-
/** Magic bytes: "TPU7" */
|
|
1182
|
-
private static readonly MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
|
|
1183
|
-
/** Version 1 */
|
|
1184
|
-
private static readonly VER = 0x01;
|
|
1185
|
-
/** String prefix for base64url encoded form */
|
|
1186
|
-
private static readonly PREFIX = "tpsuid7rb_";
|
|
1187
|
-
/** Regex for validating base64url encoded form */
|
|
1188
|
-
public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|
|
1189
|
-
|
|
1190
|
-
// ---------------------------
|
|
1191
|
-
// Public API
|
|
1192
|
-
// ---------------------------
|
|
1193
|
-
|
|
1194
|
-
/**
|
|
1195
|
-
* Encode TPS string to binary bytes (Uint8Array).
|
|
1196
|
-
* This is the canonical form for hashing, signing, and storage.
|
|
1197
|
-
*
|
|
1198
|
-
* @param tps - The TPS string to encode
|
|
1199
|
-
* @param opts - Encoding options (compress, epochMs override)
|
|
1200
|
-
* @returns Binary TPS-UID as Uint8Array
|
|
1201
|
-
*/
|
|
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);
|
|
1208
|
-
|
|
1209
|
-
if (!Number.isInteger(epochMs) || epochMs < 0) {
|
|
1210
|
-
throw new Error("epochMs must be a non-negative integer");
|
|
1211
|
-
}
|
|
1212
|
-
if (epochMs > 0xffffffffffff) {
|
|
1213
|
-
throw new Error("epochMs exceeds 48-bit range");
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
const flags = compress ? 0x01 : 0x00;
|
|
1217
|
-
|
|
1218
|
-
// Generate 32-bit nonce
|
|
1219
|
-
const nonceBuf = this.randomBytes(4);
|
|
1220
|
-
const nonce =
|
|
1221
|
-
((nonceBuf[0] << 24) >>> 0) +
|
|
1222
|
-
((nonceBuf[1] << 16) >>> 0) +
|
|
1223
|
-
((nonceBuf[2] << 8) >>> 0) +
|
|
1224
|
-
nonceBuf[3];
|
|
1225
|
-
|
|
1226
|
-
// Encode TPS to UTF-8
|
|
1227
|
-
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
1228
|
-
|
|
1229
|
-
// Optionally compress
|
|
1230
|
-
const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
|
|
1231
|
-
|
|
1232
|
-
// Encode length as varint
|
|
1233
|
-
const lenVar = this.uvarintEncode(payload.length);
|
|
1234
|
-
|
|
1235
|
-
// Construct binary structure
|
|
1236
|
-
const out = new Uint8Array(
|
|
1237
|
-
4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length,
|
|
1238
|
-
);
|
|
1239
|
-
let offset = 0;
|
|
1240
|
-
|
|
1241
|
-
// MAGIC
|
|
1242
|
-
out.set(this.MAGIC, offset);
|
|
1243
|
-
offset += 4;
|
|
1244
|
-
|
|
1245
|
-
// VER
|
|
1246
|
-
out[offset++] = this.VER;
|
|
1247
|
-
|
|
1248
|
-
// FLAGS
|
|
1249
|
-
out[offset++] = flags;
|
|
1250
|
-
|
|
1251
|
-
// TIME (48-bit big-endian)
|
|
1252
|
-
const timeBytes = this.writeU48(epochMs);
|
|
1253
|
-
out.set(timeBytes, offset);
|
|
1254
|
-
offset += 6;
|
|
1255
|
-
|
|
1256
|
-
// NONCE (32-bit big-endian)
|
|
1257
|
-
out.set(nonceBuf, offset);
|
|
1258
|
-
offset += 4;
|
|
1259
|
-
|
|
1260
|
-
// LEN (varint)
|
|
1261
|
-
out.set(lenVar, offset);
|
|
1262
|
-
offset += lenVar.length;
|
|
1263
|
-
|
|
1264
|
-
// TPS payload
|
|
1265
|
-
out.set(payload, offset);
|
|
1266
|
-
|
|
1267
|
-
return out;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
/**
|
|
1271
|
-
* Decode binary bytes back to original TPS string.
|
|
1272
|
-
*
|
|
1273
|
-
* @param bytes - Binary TPS-UID
|
|
1274
|
-
* @returns Decoded result with original TPS string
|
|
1275
|
-
*/
|
|
1276
|
-
static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
|
|
1277
|
-
// Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
|
|
1278
|
-
if (bytes.length < 17) {
|
|
1279
|
-
throw new Error("TPSUID7RB: too short");
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
// MAGIC
|
|
1283
|
-
if (
|
|
1284
|
-
bytes[0] !== 0x54 ||
|
|
1285
|
-
bytes[1] !== 0x50 ||
|
|
1286
|
-
bytes[2] !== 0x55 ||
|
|
1287
|
-
bytes[3] !== 0x37
|
|
1288
|
-
) {
|
|
1289
|
-
throw new Error("TPSUID7RB: bad magic");
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// VERSION
|
|
1293
|
-
const ver = bytes[4];
|
|
1294
|
-
if (ver !== this.VER) {
|
|
1295
|
-
throw new Error(`TPSUID7RB: unsupported version ${ver}`);
|
|
790
|
+
if (Object.keys(extObj).length > 0) components.extensions = extObj;
|
|
1296
791
|
}
|
|
1297
792
|
|
|
1298
|
-
//
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
((bytes[12] << 24) >>> 0) +
|
|
1308
|
-
((bytes[13] << 16) >>> 0) +
|
|
1309
|
-
((bytes[14] << 8) >>> 0) +
|
|
1310
|
-
bytes[15];
|
|
1311
|
-
|
|
1312
|
-
// LEN (varint at offset 16)
|
|
1313
|
-
let offset = 16;
|
|
1314
|
-
const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
|
|
1315
|
-
offset += bytesRead;
|
|
1316
|
-
|
|
1317
|
-
if (offset + tpsLen > bytes.length) {
|
|
1318
|
-
throw new Error("TPSUID7RB: length overflow");
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
// TPS payload
|
|
1322
|
-
const payload = bytes.slice(offset, offset + tpsLen);
|
|
1323
|
-
const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
|
|
1324
|
-
const tps = new TextDecoder().decode(tpsUtf8);
|
|
1325
|
-
|
|
1326
|
-
return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
/**
|
|
1330
|
-
* Encode TPS to base64url string with prefix.
|
|
1331
|
-
* This is the transport/storage form.
|
|
1332
|
-
*
|
|
1333
|
-
* @param tps - The TPS string to encode
|
|
1334
|
-
* @param opts - Encoding options
|
|
1335
|
-
* @returns Base64url encoded TPS-UID with prefix
|
|
1336
|
-
*/
|
|
1337
|
-
static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
|
|
1338
|
-
const bytes = this.encodeBinary(tps, opts ?? {});
|
|
1339
|
-
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
/**
|
|
1343
|
-
* Decode base64url string back to original TPS string.
|
|
1344
|
-
*
|
|
1345
|
-
* @param id - Base64url encoded TPS-UID with prefix
|
|
1346
|
-
* @returns Decoded result with original TPS string
|
|
1347
|
-
*/
|
|
1348
|
-
static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
|
|
1349
|
-
const s = id.trim();
|
|
1350
|
-
if (!s.startsWith(this.PREFIX)) {
|
|
1351
|
-
throw new Error("TPSUID7RB: missing prefix");
|
|
1352
|
-
}
|
|
1353
|
-
const b64 = s.slice(this.PREFIX.length);
|
|
1354
|
-
const bytes = this.base64UrlDecode(b64);
|
|
1355
|
-
return this.decodeBinary(bytes);
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/**
|
|
1359
|
-
* Validate base64url encoded TPS-UID format.
|
|
1360
|
-
* Note: This validates shape only; binary decode is authoritative.
|
|
1361
|
-
*
|
|
1362
|
-
* @param id - String to validate
|
|
1363
|
-
* @returns true if format is valid
|
|
1364
|
-
*/
|
|
1365
|
-
static validateBinaryB64(id: string): boolean {
|
|
1366
|
-
return this.REGEX.test(id.trim());
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
/**
|
|
1370
|
-
* Generate a TPS-UID from the current time and optional location.
|
|
1371
|
-
*
|
|
1372
|
-
* @param opts - Generation options
|
|
1373
|
-
* @returns Base64url encoded TPS-UID
|
|
1374
|
-
*/
|
|
1375
|
-
static generate(opts?: {
|
|
1376
|
-
latitude?: number;
|
|
1377
|
-
longitude?: number;
|
|
1378
|
-
altitude?: number;
|
|
1379
|
-
compress?: boolean;
|
|
1380
|
-
order?: TimeOrder;
|
|
1381
|
-
}): string {
|
|
1382
|
-
const now = new Date();
|
|
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
|
-
|
|
1397
|
-
return this.encodeBinaryB64(tps, {
|
|
1398
|
-
compress: opts?.compress,
|
|
1399
|
-
epochMs: now.getTime(),
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// ---------------------------
|
|
1404
|
-
// TPS String Helpers
|
|
1405
|
-
// ---------------------------
|
|
1406
|
-
|
|
1407
|
-
/**
|
|
1408
|
-
* Generate a TPS string from a Date and optional location.
|
|
1409
|
-
*/
|
|
1410
|
-
|
|
1411
|
-
/**
|
|
1412
|
-
* Parse epoch milliseconds from a TPS string.
|
|
1413
|
-
* Supports both URI format (tps://...) and time-only format (T:greg...)
|
|
1414
|
-
*/
|
|
1415
|
-
static epochMsFromTPSString(tps: string): number {
|
|
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();
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
// ---------------------------
|
|
1428
|
-
// Binary Helpers
|
|
1429
|
-
// ---------------------------
|
|
1430
|
-
|
|
1431
|
-
/** Write 48-bit unsigned integer (big-endian) */
|
|
1432
|
-
private static writeU48(epochMs: number): Uint8Array {
|
|
1433
|
-
const b = new Uint8Array(6);
|
|
1434
|
-
// Use BigInt for proper 48-bit handling
|
|
1435
|
-
const v = BigInt(epochMs);
|
|
1436
|
-
b[0] = Number((v >> 40n) & 0xffn);
|
|
1437
|
-
b[1] = Number((v >> 32n) & 0xffn);
|
|
1438
|
-
b[2] = Number((v >> 24n) & 0xffn);
|
|
1439
|
-
b[3] = Number((v >> 16n) & 0xffn);
|
|
1440
|
-
b[4] = Number((v >> 8n) & 0xffn);
|
|
1441
|
-
b[5] = Number(v & 0xffn);
|
|
1442
|
-
return b;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
/** Read 48-bit unsigned integer (big-endian) */
|
|
1446
|
-
private static readU48(bytes: Uint8Array, offset: number): number {
|
|
1447
|
-
const v =
|
|
1448
|
-
(BigInt(bytes[offset]) << 40n) +
|
|
1449
|
-
(BigInt(bytes[offset + 1]) << 32n) +
|
|
1450
|
-
(BigInt(bytes[offset + 2]) << 24n) +
|
|
1451
|
-
(BigInt(bytes[offset + 3]) << 16n) +
|
|
1452
|
-
(BigInt(bytes[offset + 4]) << 8n) +
|
|
1453
|
-
BigInt(bytes[offset + 5]);
|
|
1454
|
-
|
|
1455
|
-
const n = Number(v);
|
|
1456
|
-
if (!Number.isSafeInteger(n)) {
|
|
1457
|
-
throw new Error("TPSUID7RB: u48 not safe integer");
|
|
1458
|
-
}
|
|
1459
|
-
return n;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
/** Encode unsigned integer as LEB128 varint */
|
|
1463
|
-
private static uvarintEncode(n: number): Uint8Array {
|
|
1464
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
1465
|
-
throw new Error("uvarint must be non-negative int");
|
|
1466
|
-
}
|
|
1467
|
-
const out: number[] = [];
|
|
1468
|
-
let x = n >>> 0;
|
|
1469
|
-
while (x >= 0x80) {
|
|
1470
|
-
out.push((x & 0x7f) | 0x80);
|
|
1471
|
-
x >>>= 7;
|
|
1472
|
-
}
|
|
1473
|
-
out.push(x);
|
|
1474
|
-
return new Uint8Array(out);
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
/** Decode LEB128 varint */
|
|
1478
|
-
private static uvarintDecode(
|
|
1479
|
-
bytes: Uint8Array,
|
|
1480
|
-
offset: number,
|
|
1481
|
-
): { value: number; bytesRead: number } {
|
|
1482
|
-
let x = 0;
|
|
1483
|
-
let s = 0;
|
|
1484
|
-
let i = 0;
|
|
1485
|
-
while (true) {
|
|
1486
|
-
if (offset + i >= bytes.length) {
|
|
1487
|
-
throw new Error("uvarint overflow");
|
|
1488
|
-
}
|
|
1489
|
-
const b = bytes[offset + i];
|
|
1490
|
-
if (b < 0x80) {
|
|
1491
|
-
if (i > 9 || (i === 9 && b > 1)) {
|
|
1492
|
-
throw new Error("uvarint too large");
|
|
793
|
+
// ── Context (#C:key=val;key=val) ─────────────────────────────────────────
|
|
794
|
+
if (g.context) {
|
|
795
|
+
const ctx: Record<string, string> = {};
|
|
796
|
+
g.context.split(";").forEach((part: string) => {
|
|
797
|
+
part = part.trim();
|
|
798
|
+
if (!part) return;
|
|
799
|
+
const eqIdx = part.indexOf("=");
|
|
800
|
+
if (eqIdx > 0) {
|
|
801
|
+
ctx[part.substring(0, eqIdx)] = part.substring(eqIdx + 1);
|
|
1493
802
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
}
|
|
1497
|
-
x |= (b & 0x7f) << s;
|
|
1498
|
-
s += 7;
|
|
1499
|
-
i++;
|
|
1500
|
-
if (i > 10) {
|
|
1501
|
-
throw new Error("uvarint too long");
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
// ---------------------------
|
|
1507
|
-
// Base64url Helpers
|
|
1508
|
-
// ---------------------------
|
|
1509
|
-
|
|
1510
|
-
/** Encode bytes to base64url (no padding) */
|
|
1511
|
-
private static base64UrlEncode(bytes: Uint8Array): string {
|
|
1512
|
-
// Node.js environment
|
|
1513
|
-
if (typeof Buffer !== "undefined") {
|
|
1514
|
-
return Buffer.from(bytes)
|
|
1515
|
-
.toString("base64")
|
|
1516
|
-
.replace(/\+/g, "-")
|
|
1517
|
-
.replace(/\//g, "_")
|
|
1518
|
-
.replace(/=+$/g, "");
|
|
1519
|
-
}
|
|
1520
|
-
// Browser environment
|
|
1521
|
-
let binary = "";
|
|
1522
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
1523
|
-
binary += String.fromCharCode(bytes[i]);
|
|
1524
|
-
}
|
|
1525
|
-
return btoa(binary)
|
|
1526
|
-
.replace(/\+/g, "-")
|
|
1527
|
-
.replace(/\//g, "_")
|
|
1528
|
-
.replace(/=+$/g, "");
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
/** Decode base64url to bytes */
|
|
1532
|
-
private static base64UrlDecode(b64url: string): Uint8Array {
|
|
1533
|
-
// Add padding
|
|
1534
|
-
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
1535
|
-
const b64 = (b64url + "=".repeat(padLen))
|
|
1536
|
-
.replace(/-/g, "+")
|
|
1537
|
-
.replace(/_/g, "/");
|
|
1538
|
-
|
|
1539
|
-
// Node.js environment
|
|
1540
|
-
if (typeof Buffer !== "undefined") {
|
|
1541
|
-
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
1542
|
-
}
|
|
1543
|
-
// Browser environment
|
|
1544
|
-
const binary = atob(b64);
|
|
1545
|
-
const bytes = new Uint8Array(binary.length);
|
|
1546
|
-
for (let i = 0; i < binary.length; i++) {
|
|
1547
|
-
bytes[i] = binary.charCodeAt(i);
|
|
1548
|
-
}
|
|
1549
|
-
return bytes;
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
// ---------------------------
|
|
1553
|
-
// Compression Helpers
|
|
1554
|
-
// ---------------------------
|
|
1555
|
-
|
|
1556
|
-
/** Compress using zlib deflate raw */
|
|
1557
|
-
private static deflateRaw(data: Uint8Array): Uint8Array {
|
|
1558
|
-
// Node.js environment
|
|
1559
|
-
if (typeof require !== "undefined") {
|
|
1560
|
-
try {
|
|
1561
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1562
|
-
const zlib = require("zlib");
|
|
1563
|
-
return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
|
|
1564
|
-
} catch {
|
|
1565
|
-
throw new Error("TPSUID7RB: compression not available");
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
// Browser: would need pako or similar library
|
|
1569
|
-
throw new Error("TPSUID7RB: compression not available in browser");
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
/** Decompress using zlib inflate raw */
|
|
1573
|
-
private static inflateRaw(data: Uint8Array): Uint8Array {
|
|
1574
|
-
// Node.js environment
|
|
1575
|
-
if (typeof require !== "undefined") {
|
|
1576
|
-
try {
|
|
1577
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1578
|
-
const zlib = require("zlib");
|
|
1579
|
-
return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
|
|
1580
|
-
} catch {
|
|
1581
|
-
throw new Error("TPSUID7RB: decompression failed");
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
// Browser: would need pako or similar library
|
|
1585
|
-
throw new Error("TPSUID7RB: decompression not available in browser");
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
// ---------------------------
|
|
1589
|
-
// Cryptographic Sealing (Ed25519)
|
|
1590
|
-
// ---------------------------
|
|
1591
|
-
|
|
1592
|
-
/**
|
|
1593
|
-
* Seal (sign) a TPS string to create a cryptographically verifiable TPS-UID.
|
|
1594
|
-
* This appends an Ed25519 signature to the binary form.
|
|
1595
|
-
*
|
|
1596
|
-
* @param tps - The TPS string to seal
|
|
1597
|
-
* @param privateKey - Ed25519 private key (hex or buffer)
|
|
1598
|
-
* @param opts - Encoding options
|
|
1599
|
-
* @returns Sealed binary TPS-UID
|
|
1600
|
-
*/
|
|
1601
|
-
static seal(
|
|
1602
|
-
tps: string,
|
|
1603
|
-
privateKey: string | Buffer | Uint8Array,
|
|
1604
|
-
opts?: TPSUID7RBEncodeOptions,
|
|
1605
|
-
): Uint8Array {
|
|
1606
|
-
// 1. Create standard binary (unsealed first)
|
|
1607
|
-
// We force the SEAL flag (bit 1) to be 0 initially for the "content to sign"
|
|
1608
|
-
// But wait, we want the signature to cover the header too.
|
|
1609
|
-
// Strategy: Construct the full binary with SEAL flag OFF, sign it, then set SEAL flag ON and append sig.
|
|
1610
|
-
// Actually, the standard way is:
|
|
1611
|
-
// Content = MAGIC + VER + FLAGS(with seal bit set) + TIME + NONCE + LEN + PAYLOAD
|
|
1612
|
-
// Signature = Sign(Content)
|
|
1613
|
-
// Final = Content + SEAL_TYPE + SIGNATURE
|
|
1614
|
-
|
|
1615
|
-
const compress = opts?.compress ?? false;
|
|
1616
|
-
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
1617
|
-
|
|
1618
|
-
// Validate epoch
|
|
1619
|
-
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
1620
|
-
throw new Error("epochMs must be a valid 48-bit non-negative integer");
|
|
803
|
+
});
|
|
804
|
+
if (Object.keys(ctx).length > 0) components.context = ctx;
|
|
1621
805
|
}
|
|
1622
806
|
|
|
1623
|
-
|
|
1624
|
-
const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
|
|
1625
|
-
|
|
1626
|
-
// Generate Nonce
|
|
1627
|
-
const nonceBuf = this.randomBytes(4);
|
|
1628
|
-
|
|
1629
|
-
// Encode Payload
|
|
1630
|
-
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
1631
|
-
const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
|
|
1632
|
-
const lenVar = this.uvarintEncode(payload.length);
|
|
1633
|
-
|
|
1634
|
-
// Construct Content (Header + Payload)
|
|
1635
|
-
const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
|
|
1636
|
-
const content = new Uint8Array(contentLen);
|
|
1637
|
-
let offset = 0;
|
|
1638
|
-
|
|
1639
|
-
content.set(this.MAGIC, offset);
|
|
1640
|
-
offset += 4;
|
|
1641
|
-
content[offset++] = this.VER;
|
|
1642
|
-
content[offset++] = flags;
|
|
1643
|
-
content.set(this.writeU48(epochMs), offset);
|
|
1644
|
-
offset += 6;
|
|
1645
|
-
content.set(nonceBuf, offset);
|
|
1646
|
-
offset += 4;
|
|
1647
|
-
content.set(lenVar, offset);
|
|
1648
|
-
offset += lenVar.length;
|
|
1649
|
-
content.set(payload, offset);
|
|
1650
|
-
|
|
1651
|
-
// Sign the content
|
|
1652
|
-
const signature = this.signEd25519(content, privateKey);
|
|
1653
|
-
const sealType = 0x01; // Ed25519
|
|
1654
|
-
|
|
1655
|
-
// Final Output: Content + SealType (1) + Signature (64)
|
|
1656
|
-
const final = new Uint8Array(contentLen + 1 + signature.length);
|
|
1657
|
-
final.set(content, 0);
|
|
1658
|
-
final.set([sealType], contentLen);
|
|
1659
|
-
final.set(signature, contentLen + 1);
|
|
1660
|
-
|
|
1661
|
-
return final;
|
|
807
|
+
return components as TPSComponents;
|
|
1662
808
|
}
|
|
1663
809
|
|
|
1664
810
|
/**
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1667
|
-
*
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
811
|
+
* Parses a multi-layer location string (before @T:) into component fields.
|
|
812
|
+
* Layers are `;`-separated. Each layer is identified by its prefix token.
|
|
813
|
+
*
|
|
814
|
+
* Supported layers:
|
|
815
|
+
* L:lat,lon[,altm] — GPS
|
|
816
|
+
* L:~|L:-|L:redacted — Privacy markers
|
|
817
|
+
* P:cc=JO,ci=AMM,... — Place (country/city codes and names)
|
|
818
|
+
* S2:token — S2 cell
|
|
819
|
+
* H3:token — H3 cell
|
|
820
|
+
* 3W:word.word.word — What3Words
|
|
821
|
+
* plus:token — Plus Code
|
|
822
|
+
* net:ip4:x.x.x.x — IPv4
|
|
823
|
+
* net:ip6:x::x — IPv6
|
|
824
|
+
* node:name — Logical node/host
|
|
825
|
+
* bldg:name — Building
|
|
826
|
+
* floor:x — Floor
|
|
827
|
+
* room:x — Room
|
|
828
|
+
* door:x — Door
|
|
829
|
+
* zone:x — Zone
|
|
1671
830
|
*/
|
|
1672
|
-
static
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
// Decode LEN
|
|
1698
|
-
const { value: tpsLen, bytesRead } = this.uvarintDecode(
|
|
1699
|
-
sealedBytes,
|
|
1700
|
-
offset,
|
|
1701
|
-
);
|
|
1702
|
-
offset += bytesRead;
|
|
1703
|
-
const payloadEnd = offset + tpsLen;
|
|
1704
|
-
|
|
1705
|
-
if (payloadEnd > sealedBytes.length) {
|
|
1706
|
-
throw new Error("TPSUID7RB: length overflow (truncated)");
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
// The Content to verify matches exactly [0 ... payloadEnd]
|
|
1710
|
-
const content = sealedBytes.slice(0, payloadEnd);
|
|
1711
|
-
|
|
1712
|
-
// After content: SealType (1 byte) + Signature
|
|
1713
|
-
if (sealedBytes.length <= payloadEnd + 1) {
|
|
1714
|
-
throw new Error("TPSUID7RB: missing signature data");
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
const sealType = sealedBytes[payloadEnd];
|
|
1718
|
-
if (sealType !== 0x01) {
|
|
1719
|
-
throw new Error(
|
|
1720
|
-
`TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`,
|
|
1721
|
-
);
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
const signature = sealedBytes.slice(payloadEnd + 1);
|
|
1725
|
-
if (signature.length !== 64) {
|
|
1726
|
-
throw new Error(
|
|
1727
|
-
`TPSUID7RB: invalid Ed25519 signature length ${signature.length}`,
|
|
1728
|
-
);
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
// Verify
|
|
1732
|
-
const isValid = this.verifyEd25519(content, signature, publicKey);
|
|
1733
|
-
if (!isValid) {
|
|
1734
|
-
throw new Error("TPSUID7RB: signature verification failed");
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
// Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
|
|
1738
|
-
// Actually standard logic doesn't expect trailing bytes unless we tell it to.
|
|
1739
|
-
// But since we verified, we can just slice the content and decode that as a strict binary
|
|
1740
|
-
// EXCEPT standard decodeBinary checks strict length.
|
|
1741
|
-
// So we manually decode the components here to be safe and efficient.
|
|
1742
|
-
|
|
1743
|
-
return this.decodeBinary(content); // Reuse strict decoder on the content part
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
// --- Crypto Implementation (Ed25519) ---
|
|
1747
|
-
|
|
1748
|
-
private static signEd25519(
|
|
1749
|
-
data: Uint8Array,
|
|
1750
|
-
privateKey: string | Buffer | Uint8Array,
|
|
1751
|
-
): Uint8Array {
|
|
1752
|
-
if (typeof require !== "undefined") {
|
|
1753
|
-
try {
|
|
1754
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
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",
|
|
1783
|
-
});
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
return new Uint8Array(crypto.sign(null, data, key));
|
|
1787
|
-
} catch (e) {
|
|
1788
|
-
throw new Error("TPSUID7RB: signing failed (check key format)");
|
|
831
|
+
private static _parseLocationLayers(location: string, components: any): void {
|
|
832
|
+
const layers = location.trim().split(";");
|
|
833
|
+
|
|
834
|
+
for (const layer of layers) {
|
|
835
|
+
const l = layer.trim();
|
|
836
|
+
if (!l) continue;
|
|
837
|
+
|
|
838
|
+
// Privacy shorthand
|
|
839
|
+
if (l === "L:~" || l === "L:hidden") { components.isHiddenLocation = true; continue; }
|
|
840
|
+
if (l === "L:-" || l === "L:unknown") { components.isUnknownLocation = true; continue; }
|
|
841
|
+
if (l === "L:redacted") { components.isRedactedLocation = true; continue; }
|
|
842
|
+
|
|
843
|
+
// P: Place layer — P:cc=JO,ci=AMM,cn=Jordan,ct=Amman
|
|
844
|
+
if (l.startsWith("P:")) {
|
|
845
|
+
l.slice(2).split(",").forEach((pair: string) => {
|
|
846
|
+
const eq = pair.indexOf("=");
|
|
847
|
+
if (eq < 1) return;
|
|
848
|
+
const k = pair.substring(0, eq).toLowerCase();
|
|
849
|
+
const v = pair.substring(eq + 1);
|
|
850
|
+
if (k === "cc") components.placeCountryCode = v;
|
|
851
|
+
else if (k === "cn") components.placeCountryName = v;
|
|
852
|
+
else if (k === "ci") components.placeCityCode = v;
|
|
853
|
+
else if (k === "ct") components.placeCityName = v;
|
|
854
|
+
});
|
|
855
|
+
continue;
|
|
1789
856
|
}
|
|
1790
|
-
}
|
|
1791
|
-
throw new Error("TPSUID7RB: signing not available in browser");
|
|
1792
|
-
}
|
|
1793
857
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
} catch {
|
|
1805
|
-
return false;
|
|
858
|
+
// GPS coordinates (L:lat,lon[,alt])
|
|
859
|
+
if (l.startsWith("L:")) {
|
|
860
|
+
const coords = l.slice(2);
|
|
861
|
+
const m = coords.match(/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(-?\d+(?:\.\d+)?)m?)?$/);
|
|
862
|
+
if (m) {
|
|
863
|
+
components.latitude = parseFloat(m[1]);
|
|
864
|
+
components.longitude = parseFloat(m[2]);
|
|
865
|
+
if (m[3]) components.altitude = parseFloat(m[3]);
|
|
866
|
+
}
|
|
867
|
+
continue;
|
|
1806
868
|
}
|
|
1807
|
-
}
|
|
1808
|
-
throw new Error("TPSUID7RB: verification not available in browser");
|
|
1809
|
-
}
|
|
1810
869
|
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
870
|
+
// Geospatial cells
|
|
871
|
+
if (/^S2:/i.test(l)) { components.s2Cell = l.slice(3); continue; }
|
|
872
|
+
if (/^H3:/i.test(l)) { components.h3Cell = l.slice(3); continue; }
|
|
873
|
+
if (/^3W:/i.test(l)) { components.what3words = l.slice(3); continue; }
|
|
874
|
+
if (/^plus:/i.test(l)) { components.plusCode = l.slice(5); continue; }
|
|
875
|
+
|
|
876
|
+
// Network
|
|
877
|
+
if (/^net:ip4:/i.test(l)) { components.ipv4 = l.slice(8); continue; }
|
|
878
|
+
if (/^net:ip6:/i.test(l)) { components.ipv6 = l.slice(8); continue; }
|
|
879
|
+
if (/^node:/i.test(l)) { components.nodeName = l.slice(5); continue; }
|
|
880
|
+
|
|
881
|
+
// Structural
|
|
882
|
+
if (/^bldg:/i.test(l)) { components.building = l.slice(5); continue; }
|
|
883
|
+
if (/^floor:/i.test(l)) { components.floor = l.slice(6); continue; }
|
|
884
|
+
if (/^room:/i.test(l)) { components.room = l.slice(5); continue; }
|
|
885
|
+
if (/^door:/i.test(l)) { components.door = l.slice(5); continue; }
|
|
886
|
+
if (/^zone:/i.test(l)) { components.zone = l.slice(5); continue; }
|
|
887
|
+
|
|
888
|
+
// Fallback: generic space anchor (adm:, planet:, legacy strings)
|
|
889
|
+
if (l) {
|
|
890
|
+
components.spaceAnchor = components.spaceAnchor
|
|
891
|
+
? components.spaceAnchor + ";" + l
|
|
892
|
+
: l;
|
|
1825
893
|
}
|
|
1826
894
|
}
|
|
1827
|
-
// Browser or fallback
|
|
1828
|
-
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
1829
|
-
const bytes = new Uint8Array(length);
|
|
1830
|
-
crypto.getRandomValues(bytes);
|
|
1831
|
-
return bytes;
|
|
1832
|
-
}
|
|
1833
|
-
throw new Error("TPSUID7RB: no crypto available");
|
|
1834
895
|
}
|
|
1835
896
|
}
|
|
1836
897
|
|
|
898
|
+
// register built-in drivers and set default
|
|
899
|
+
// (tps and gregorian provide canonical conversions before unix)
|
|
900
|
+
TPS.registerDriver(new TpsDriver());
|
|
901
|
+
TPS.registerDriver(new GregorianDriver());
|
|
902
|
+
TPS.registerDriver(new UnixDriver());
|
|
903
|
+
TPS.registerDriver(new PersianDriver());
|
|
904
|
+
TPS.registerDriver(new HijriDriver());
|
|
905
|
+
TPS.registerDriver(new JulianDriver());
|
|
906
|
+
TPS.registerDriver(new HoloceneDriver());
|
|
907
|
+
TPS.registerDriver(new ChineseDriver());
|
|
908
|
+
|
|
1837
909
|
/**
|
|
1838
910
|
* `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
|
|
1839
911
|
*
|
|
@@ -1844,247 +916,3 @@ export class TPSUID7RB {
|
|
|
1844
916
|
* - `new TpsDate(tpsString)`
|
|
1845
917
|
* - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
|
|
1846
918
|
*/
|
|
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();
|
|
2089
|
-
}
|
|
2090
|
-
}
|