@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/dist/index.js
CHANGED
|
@@ -3,12 +3,44 @@
|
|
|
3
3
|
* TPS: Temporal Positioning System
|
|
4
4
|
* The Universal Protocol for Space-Time Coordinates.
|
|
5
5
|
* @packageDocumentation
|
|
6
|
-
* @version 0.
|
|
7
|
-
* @license
|
|
6
|
+
* @version 0.5.0
|
|
7
|
+
* @license Apache-2.0
|
|
8
8
|
* @copyright 2026 TPS Standards Working Group
|
|
9
|
+
*
|
|
10
|
+
* v0.5.0 Changes:
|
|
11
|
+
* - Added Actor anchor (A:) for provenance tracking
|
|
12
|
+
* - Added Signature (!) for cryptographic verification
|
|
13
|
+
* - Added structural anchors (bldg, floor, room, zone)
|
|
14
|
+
* - Added geospatial cell systems (S2, H3, Plus Code, what3words)
|
|
9
15
|
*/
|
|
10
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.TPSUID7RB = exports.TPS = void 0;
|
|
17
|
+
exports.TpsDate = exports.TPSUID7RB = exports.TPS = exports.TimeOrder = exports.DefaultCalendars = void 0;
|
|
18
|
+
// built-in drivers are registered automatically; importing them here
|
|
19
|
+
// ensures they are included when the library bundler/tree-shaker runs.
|
|
20
|
+
const gregorian_1 = require("./drivers/gregorian");
|
|
21
|
+
const unix_1 = require("./drivers/unix");
|
|
22
|
+
const tps_1 = require("./drivers/tps");
|
|
23
|
+
// Calendar codes are plain strings to allow arbitrary user-defined
|
|
24
|
+
// calendars. The library still exports constants for the built-in values but
|
|
25
|
+
// callers may also supply their own codes.
|
|
26
|
+
exports.DefaultCalendars = {
|
|
27
|
+
TPS: "tps",
|
|
28
|
+
GREG: "greg",
|
|
29
|
+
HIJ: "hij",
|
|
30
|
+
JUL: "jul",
|
|
31
|
+
HOLO: "holo",
|
|
32
|
+
UNIX: "unix",
|
|
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
|
+
var TimeOrder;
|
|
40
|
+
(function (TimeOrder) {
|
|
41
|
+
TimeOrder["DESC"] = "desc";
|
|
42
|
+
TimeOrder["ASC"] = "asc";
|
|
43
|
+
})(TimeOrder || (exports.TimeOrder = TimeOrder = {}));
|
|
12
44
|
class TPS {
|
|
13
45
|
/**
|
|
14
46
|
* Registers a calendar driver plugin.
|
|
@@ -26,22 +58,184 @@ class TPS {
|
|
|
26
58
|
return this.drivers.get(code);
|
|
27
59
|
}
|
|
28
60
|
// --- CORE METHODS ---
|
|
61
|
+
/**
|
|
62
|
+
* SANITIZER: Normalises a raw TPS input string before validation.
|
|
63
|
+
*
|
|
64
|
+
* Pure string-based — no parsing into components, no regex beyond simple
|
|
65
|
+
* character checks, no re-serialisation via buildTimePart / toURI.
|
|
66
|
+
*
|
|
67
|
+
* Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
68
|
+
*/
|
|
69
|
+
static sanitizeTimeInput(input) {
|
|
70
|
+
// ── 1. Whitespace ────────────────────────────────────────────────────────
|
|
71
|
+
let s = input.trim().replace(/\s+/g, "");
|
|
72
|
+
if (!s)
|
|
73
|
+
return s;
|
|
74
|
+
// ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
|
|
75
|
+
// The input may contain "/T:" from older versions; we normalise early so
|
|
76
|
+
// subsequent logic can assume only the '@' form.
|
|
77
|
+
if (s.includes("/T:")) {
|
|
78
|
+
s = s.replace(/\/T:/g, "@T:");
|
|
79
|
+
}
|
|
80
|
+
// ── 2. Scheme casing ─────────────────────────────────────────────────────
|
|
81
|
+
if (s.slice(0, 6).toLowerCase() === "tps://") {
|
|
82
|
+
s = "tps://" + s.slice(6);
|
|
83
|
+
}
|
|
84
|
+
// ── 3. T: prefix casing (time-only strings) ──────────────────────────────
|
|
85
|
+
if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
|
|
86
|
+
s = "T:" + s.slice(2);
|
|
87
|
+
}
|
|
88
|
+
// ── 4. Locate T: section ─────────────────────────────────────────────────
|
|
89
|
+
let tStart = -1;
|
|
90
|
+
if (s.startsWith("T:")) {
|
|
91
|
+
tStart = 0;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const atT = s.indexOf("@T:");
|
|
95
|
+
if (atT !== -1)
|
|
96
|
+
tStart = atT + 1;
|
|
97
|
+
}
|
|
98
|
+
if (tStart === -1)
|
|
99
|
+
return s; // no T: section — return as-is
|
|
100
|
+
const beforeT = s.slice(0, tStart); // URI prefix or empty
|
|
101
|
+
const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
|
|
102
|
+
// Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
|
|
103
|
+
const suffixIdx = timeAndRest.search(/[!;?#]/);
|
|
104
|
+
const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
|
|
105
|
+
const timePart = suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
|
|
106
|
+
// timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
|
|
107
|
+
// Split off calendar code
|
|
108
|
+
const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
|
|
109
|
+
const firstDot = afterColon.indexOf(".");
|
|
110
|
+
const cal = (firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon).toLowerCase();
|
|
111
|
+
const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
|
|
112
|
+
// If no calendar code was provided at all (e.g. "T:"), bail out early
|
|
113
|
+
// rather than inventing a default calendar. The string will remain
|
|
114
|
+
// unparsable so validation can report it as invalid.
|
|
115
|
+
if (!cal) {
|
|
116
|
+
return s;
|
|
117
|
+
}
|
|
118
|
+
// No tokens at all — fill every slot with 0 and return
|
|
119
|
+
// Use tps as the default calendar if none was specified
|
|
120
|
+
const resolvedCal = cal || exports.DefaultCalendars.TPS;
|
|
121
|
+
if (!tokenStr) {
|
|
122
|
+
return `${beforeT}T:${resolvedCal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
|
|
123
|
+
}
|
|
124
|
+
const tokens = tokenStr
|
|
125
|
+
.split(".")
|
|
126
|
+
.filter((t) => t.length >= 2 && /^[a-z]/.test(t))
|
|
127
|
+
.map((t) => ({ p: t[0], v: t.slice(1) }));
|
|
128
|
+
// ── 6. Detect order from non-m tokens (c=7, y=6, d=4, h=3, s=1) ─────────
|
|
129
|
+
const nonMRank = { c: 7, y: 6, d: 4, h: 3, s: 1 };
|
|
130
|
+
const nonMSeq = tokens
|
|
131
|
+
.filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
|
|
132
|
+
.map((t) => nonMRank[t.p]);
|
|
133
|
+
let isAsc = false;
|
|
134
|
+
if (nonMSeq.length >= 2) {
|
|
135
|
+
// ascending when every consecutive rank-diff is positive
|
|
136
|
+
isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
|
|
137
|
+
}
|
|
138
|
+
// ── 7. Reverse tokens if ascending ───────────────────────────────────────
|
|
139
|
+
if (isAsc)
|
|
140
|
+
tokens.reverse();
|
|
141
|
+
// ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
|
|
142
|
+
// DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
|
|
143
|
+
const mDescRanks = [8, 5, 2, 0];
|
|
144
|
+
const byRank = new Map();
|
|
145
|
+
let mIdx = 0;
|
|
146
|
+
for (const tok of tokens) {
|
|
147
|
+
if (tok.p === "m") {
|
|
148
|
+
if (mIdx < mDescRanks.length)
|
|
149
|
+
byRank.set(mDescRanks[mIdx++], tok.v);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const r = nonMRank[tok.p];
|
|
153
|
+
if (r !== undefined)
|
|
154
|
+
byRank.set(r, tok.v);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ── 9. Build complete DESC token string, filling gaps with '0' ───────────
|
|
158
|
+
// Full DESC slot sequence: m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
159
|
+
const descSlots = [
|
|
160
|
+
["m", 8],
|
|
161
|
+
["c", 7],
|
|
162
|
+
["y", 6],
|
|
163
|
+
["m", 5],
|
|
164
|
+
["d", 4],
|
|
165
|
+
["h", 3],
|
|
166
|
+
["m", 2],
|
|
167
|
+
["s", 1],
|
|
168
|
+
["m", 0],
|
|
169
|
+
];
|
|
170
|
+
const finalTokenStr = descSlots
|
|
171
|
+
.map(([p, r]) => p + (byRank.get(r) ?? "0"))
|
|
172
|
+
.join(".");
|
|
173
|
+
return `${beforeT}T:${resolvedCal}.${finalTokenStr}${timeSuffix}`;
|
|
174
|
+
}
|
|
29
175
|
static validate(input) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
176
|
+
const sanitized = this.sanitizeTimeInput(input);
|
|
177
|
+
if (sanitized.startsWith("tps://")) {
|
|
178
|
+
return this.REGEX_URI.test(sanitized);
|
|
179
|
+
}
|
|
180
|
+
return this.REGEX_TIME.test(sanitized);
|
|
33
181
|
}
|
|
34
182
|
static parse(input) {
|
|
35
|
-
|
|
183
|
+
// Always sanitize first so we operate on the canonical form. This also
|
|
184
|
+
// rewrites any legacy "/T:" separators to "@T:" so the regex below can
|
|
185
|
+
// remain strict.
|
|
186
|
+
input = this.sanitizeTimeInput(input);
|
|
187
|
+
// quick fail via regex to rule out obviously bad strings
|
|
188
|
+
if (input.startsWith("tps://")) {
|
|
36
189
|
const match = this.REGEX_URI.exec(input);
|
|
37
190
|
if (!match || !match.groups)
|
|
38
191
|
return null;
|
|
39
|
-
|
|
192
|
+
const comp = this._mapGroupsToComponents(match.groups);
|
|
193
|
+
// extract the raw time portion and parse it separately
|
|
194
|
+
const atIdx = input.indexOf("@T:");
|
|
195
|
+
let timeStr = "";
|
|
196
|
+
let signature;
|
|
197
|
+
if (atIdx !== -1) {
|
|
198
|
+
timeStr = input.slice(atIdx + 1); // include the leading 'T:'
|
|
199
|
+
// if there's a signature, capture it first
|
|
200
|
+
const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
|
|
201
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
202
|
+
signature = sigMatch.groups.sig;
|
|
203
|
+
}
|
|
204
|
+
// cut off signature, extensions, query, or fragment
|
|
205
|
+
timeStr = timeStr.split(/[!;?#]/)[0];
|
|
206
|
+
}
|
|
207
|
+
if (timeStr) {
|
|
208
|
+
const parsed = this.parseTimeString(timeStr);
|
|
209
|
+
if (!parsed)
|
|
210
|
+
return null;
|
|
211
|
+
Object.assign(comp, parsed.components);
|
|
212
|
+
comp.order = parsed.order;
|
|
213
|
+
}
|
|
214
|
+
if (signature) {
|
|
215
|
+
comp.signature = signature;
|
|
216
|
+
}
|
|
217
|
+
return comp;
|
|
40
218
|
}
|
|
219
|
+
// time-only string
|
|
41
220
|
const match = this.REGEX_TIME.exec(input);
|
|
42
221
|
if (!match || !match.groups)
|
|
43
222
|
return null;
|
|
44
|
-
|
|
223
|
+
// isolate signature if present
|
|
224
|
+
let timeOnly = input;
|
|
225
|
+
let signature;
|
|
226
|
+
const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
|
|
227
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
228
|
+
signature = sigMatch.groups.sig;
|
|
229
|
+
timeOnly = input.split(/[!;?#]/)[0];
|
|
230
|
+
}
|
|
231
|
+
const parsed = this.parseTimeString(timeOnly);
|
|
232
|
+
if (!parsed)
|
|
233
|
+
return null;
|
|
234
|
+
const comp = parsed.components;
|
|
235
|
+
if (signature)
|
|
236
|
+
comp.signature = signature;
|
|
237
|
+
comp.order = parsed.order;
|
|
238
|
+
return comp;
|
|
45
239
|
}
|
|
46
240
|
/**
|
|
47
241
|
* SERIALIZER: Converts a components object into a full TPS URI.
|
|
@@ -49,85 +243,116 @@ class TPS {
|
|
|
49
243
|
* @returns Full URI string (e.g. "tps://...").
|
|
50
244
|
*/
|
|
51
245
|
static toURI(comp) {
|
|
52
|
-
// 1. Build Space Part
|
|
53
|
-
let spacePart =
|
|
54
|
-
if (comp.
|
|
55
|
-
spacePart =
|
|
246
|
+
// 1. Build Space Part (L: anchor)
|
|
247
|
+
let spacePart = "L:-"; // Default: unknown
|
|
248
|
+
if (comp.spaceAnchor) {
|
|
249
|
+
spacePart = comp.spaceAnchor;
|
|
250
|
+
}
|
|
251
|
+
else if (comp.isHiddenLocation) {
|
|
252
|
+
spacePart = "L:~";
|
|
56
253
|
}
|
|
57
254
|
else if (comp.isRedactedLocation) {
|
|
58
|
-
spacePart =
|
|
255
|
+
spacePart = "L:redacted";
|
|
59
256
|
}
|
|
60
257
|
else if (comp.isUnknownLocation) {
|
|
61
|
-
spacePart =
|
|
258
|
+
spacePart = "L:-";
|
|
259
|
+
}
|
|
260
|
+
else if (comp.s2Cell) {
|
|
261
|
+
spacePart = `L:s2=${comp.s2Cell}`;
|
|
262
|
+
}
|
|
263
|
+
else if (comp.h3Cell) {
|
|
264
|
+
spacePart = `L:h3=${comp.h3Cell}`;
|
|
265
|
+
}
|
|
266
|
+
else if (comp.plusCode) {
|
|
267
|
+
spacePart = `L:plus=${comp.plusCode}`;
|
|
268
|
+
}
|
|
269
|
+
else if (comp.what3words) {
|
|
270
|
+
spacePart = `L:w3w=${comp.what3words}`;
|
|
271
|
+
}
|
|
272
|
+
else if (comp.building) {
|
|
273
|
+
spacePart = `L:bldg=${comp.building}`;
|
|
274
|
+
if (comp.floor)
|
|
275
|
+
spacePart += `.floor=${comp.floor}`;
|
|
276
|
+
if (comp.room)
|
|
277
|
+
spacePart += `.room=${comp.room}`;
|
|
278
|
+
if (comp.zone)
|
|
279
|
+
spacePart += `.zone=${comp.zone}`;
|
|
62
280
|
}
|
|
63
281
|
else if (comp.latitude !== undefined && comp.longitude !== undefined) {
|
|
64
|
-
spacePart =
|
|
282
|
+
spacePart = `L:${comp.latitude},${comp.longitude}`;
|
|
65
283
|
if (comp.altitude !== undefined) {
|
|
66
284
|
spacePart += `,${comp.altitude}m`;
|
|
67
285
|
}
|
|
68
286
|
}
|
|
69
|
-
// 2. Build
|
|
70
|
-
let
|
|
71
|
-
if (comp.
|
|
72
|
-
|
|
287
|
+
// 2. Build Actor Part (A: anchor) - optional
|
|
288
|
+
let actorPart = "";
|
|
289
|
+
if (comp.actor) {
|
|
290
|
+
actorPart = `/A:${comp.actor}`;
|
|
73
291
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
timePart += `.c${comp.century}`;
|
|
79
|
-
if (comp.year !== undefined)
|
|
80
|
-
timePart += `.y${comp.year}`;
|
|
81
|
-
if (comp.month !== undefined)
|
|
82
|
-
timePart += `.M${this.pad(comp.month)}`;
|
|
83
|
-
if (comp.day !== undefined)
|
|
84
|
-
timePart += `.d${this.pad(comp.day)}`;
|
|
85
|
-
if (comp.hour !== undefined)
|
|
86
|
-
timePart += `.h${this.pad(comp.hour)}`;
|
|
87
|
-
if (comp.minute !== undefined)
|
|
88
|
-
timePart += `.n${this.pad(comp.minute)}`;
|
|
89
|
-
if (comp.second !== undefined)
|
|
90
|
-
timePart += `.s${this.pad(comp.second)}`;
|
|
91
|
-
}
|
|
92
|
-
// 3. Build Extensions
|
|
93
|
-
let extPart = '';
|
|
292
|
+
// 3. Build Time Part (handles order & signature)
|
|
293
|
+
const timePart = this.buildTimePart(comp);
|
|
294
|
+
// 5. Build Extensions
|
|
295
|
+
let extPart = "";
|
|
94
296
|
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
95
|
-
const extStrings = Object.entries(comp.extensions).map(([k, v]) => `${k}
|
|
96
|
-
extPart = `;${extStrings.join(
|
|
297
|
+
const extStrings = Object.entries(comp.extensions).map(([k, v]) => `${k}=${v}`);
|
|
298
|
+
extPart = `;${extStrings.join(".")}`;
|
|
97
299
|
}
|
|
98
|
-
|
|
300
|
+
// timePart already begins with 'T:'. The new canonical separator is '@'
|
|
301
|
+
// instead of '/', so we interpolate it accordingly. Actor anchor (if
|
|
302
|
+
// present) still uses a leading slash.
|
|
303
|
+
return `tps://${spacePart}${actorPart}@${timePart}${extPart}`;
|
|
99
304
|
}
|
|
100
305
|
/**
|
|
101
306
|
* CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
|
|
102
307
|
* Supports plugin drivers for non-Gregorian calendars.
|
|
103
308
|
* @param date - The JS Date object (defaults to Now).
|
|
104
|
-
* @param calendar - The target calendar driver (default
|
|
105
|
-
* @
|
|
309
|
+
* @param calendar - The target calendar driver (default `"tps"`).
|
|
310
|
+
* @param opts - Optional parameters; for built-in calendars the only
|
|
311
|
+
* supported key is `order` which may be `'ascending'` or `'descending'`.
|
|
312
|
+
* @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
|
|
106
313
|
*/
|
|
107
|
-
static fromDate(date = new Date(), calendar =
|
|
108
|
-
|
|
109
|
-
const driver = this.drivers.get(
|
|
314
|
+
static fromDate(date = new Date(), calendar = exports.DefaultCalendars.TPS, opts) {
|
|
315
|
+
const normalizedCalendar = calendar.toLowerCase();
|
|
316
|
+
const driver = this.drivers.get(normalizedCalendar);
|
|
110
317
|
if (driver) {
|
|
111
|
-
|
|
318
|
+
// when caller requested an explicit order we can bypass the driver's
|
|
319
|
+
// `fromDate` helper and instead generate components ourselves so that
|
|
320
|
+
// order is honoured even if the driver doesn't know about it. This
|
|
321
|
+
// keeps behaviour identical to the old built-in implementation.
|
|
322
|
+
if (opts?.order) {
|
|
323
|
+
const comp = driver.getComponentsFromDate(date);
|
|
324
|
+
comp.calendar = normalizedCalendar;
|
|
325
|
+
comp.order = opts.order;
|
|
326
|
+
return this.buildTimePart(comp);
|
|
327
|
+
}
|
|
328
|
+
return driver.getFromDate(date);
|
|
112
329
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
330
|
+
// Fallback for old built-in calendars (shouldn't happen once drivers are
|
|
331
|
+
// registered, but kept for backwards compatibility).
|
|
332
|
+
const comp = { calendar: normalizedCalendar };
|
|
333
|
+
if (normalizedCalendar === exports.DefaultCalendars.UNIX) {
|
|
115
334
|
const s = (date.getTime() / 1000).toFixed(3);
|
|
116
|
-
|
|
335
|
+
comp.unixSeconds = parseFloat(s);
|
|
336
|
+
if (opts?.order)
|
|
337
|
+
comp.order = opts.order;
|
|
338
|
+
return this.buildTimePart(comp);
|
|
117
339
|
}
|
|
118
|
-
if (
|
|
340
|
+
if (normalizedCalendar === exports.DefaultCalendars.GREG) {
|
|
119
341
|
const fullYear = date.getUTCFullYear();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
342
|
+
comp.millennium = Math.floor(fullYear / 1000) + 1;
|
|
343
|
+
comp.century = Math.floor((fullYear % 1000) / 100) + 1;
|
|
344
|
+
comp.year = fullYear % 100;
|
|
345
|
+
comp.month = date.getUTCMonth() + 1;
|
|
346
|
+
comp.day = date.getUTCDate();
|
|
347
|
+
comp.hour = date.getUTCHours();
|
|
348
|
+
comp.minute = date.getUTCMinutes();
|
|
349
|
+
comp.second = date.getUTCSeconds();
|
|
350
|
+
comp.millisecond = date.getUTCMilliseconds();
|
|
351
|
+
if (opts?.order)
|
|
352
|
+
comp.order = opts.order;
|
|
353
|
+
return this.buildTimePart(comp);
|
|
354
|
+
}
|
|
355
|
+
throw new Error(`Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`);
|
|
131
356
|
}
|
|
132
357
|
/**
|
|
133
358
|
* CONVERTER: Converts a TPS string to a Date in a target calendar format.
|
|
@@ -151,31 +376,21 @@ class TPS {
|
|
|
151
376
|
* @returns JS Date object or `null` if invalid.
|
|
152
377
|
*/
|
|
153
378
|
static toDate(tpsString) {
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
379
|
+
const parsed = this.parse(tpsString);
|
|
380
|
+
if (!parsed)
|
|
381
|
+
return null;
|
|
382
|
+
const cal = parsed.calendar || exports.DefaultCalendars.TPS;
|
|
383
|
+
const driver = this.drivers.get(cal);
|
|
384
|
+
if (!driver) {
|
|
385
|
+
console.error(`Calendar driver '${cal}' not registered.`);
|
|
156
386
|
return null;
|
|
157
|
-
// Check for registered driver first
|
|
158
|
-
const driver = this.drivers.get(p.calendar);
|
|
159
|
-
if (driver) {
|
|
160
|
-
return driver.toGregorian(p);
|
|
161
|
-
}
|
|
162
|
-
// Built-in handlers
|
|
163
|
-
if (p.calendar === 'unix' && p.unixSeconds !== undefined) {
|
|
164
|
-
return new Date(p.unixSeconds * 1000);
|
|
165
|
-
}
|
|
166
|
-
if (p.calendar === 'greg') {
|
|
167
|
-
const m = p.millennium || 0;
|
|
168
|
-
const c = p.century || 1;
|
|
169
|
-
const y = p.year || 0;
|
|
170
|
-
const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
171
|
-
return new Date(Date.UTC(fullYear, (p.month || 1) - 1, p.day || 1, p.hour || 0, p.minute || 0, Math.floor(p.second || 0)));
|
|
172
387
|
}
|
|
173
|
-
return
|
|
388
|
+
return driver.getDateFromComponents(parsed);
|
|
174
389
|
}
|
|
175
390
|
// --- DRIVER CONVENIENCE METHODS ---
|
|
176
391
|
/**
|
|
177
392
|
* Parse a calendar-specific date string into TPS components.
|
|
178
|
-
* Requires the driver to implement
|
|
393
|
+
* Requires the driver to implement `parseDate`.
|
|
179
394
|
*
|
|
180
395
|
* @param calendar - The calendar code (e.g., 'hij')
|
|
181
396
|
* @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
|
|
@@ -188,7 +403,7 @@ class TPS {
|
|
|
188
403
|
* // { calendar: 'hij', year: 1447, month: 7, day: 21 }
|
|
189
404
|
*
|
|
190
405
|
* const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
|
|
191
|
-
* // "tps://31.95,35.91@T:hij.y1447.
|
|
406
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
192
407
|
* ```
|
|
193
408
|
*/
|
|
194
409
|
static parseCalendarDate(calendar, dateString, format) {
|
|
@@ -196,9 +411,7 @@ class TPS {
|
|
|
196
411
|
if (!driver) {
|
|
197
412
|
throw new Error(`Calendar driver '${calendar}' not found. Register a driver first.`);
|
|
198
413
|
}
|
|
199
|
-
|
|
200
|
-
throw new Error(`Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`);
|
|
201
|
-
}
|
|
414
|
+
// parseDate is guaranteed by the interface, so we can call it directly.
|
|
202
415
|
return driver.parseDate(dateString, format);
|
|
203
416
|
}
|
|
204
417
|
/**
|
|
@@ -214,15 +427,15 @@ class TPS {
|
|
|
214
427
|
* ```ts
|
|
215
428
|
* // With coordinates
|
|
216
429
|
* TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
|
|
217
|
-
* // "tps://31.95,35.91@T:hij.y1447.
|
|
430
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
218
431
|
*
|
|
219
432
|
* // With privacy flag
|
|
220
433
|
* TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
|
|
221
|
-
* // "tps://hidden@T:hij.y1447.
|
|
434
|
+
* // "tps://hidden@T:hij.y1447.m07.d21"
|
|
222
435
|
*
|
|
223
436
|
* // Without location
|
|
224
437
|
* TPS.fromCalendarDate('hij', '1447-07-21');
|
|
225
|
-
* // "tps://unknown@T:hij.y1447.
|
|
438
|
+
* // "tps://unknown@T:hij.y1447.m07.d21"
|
|
226
439
|
* ```
|
|
227
440
|
*/
|
|
228
441
|
static fromCalendarDate(calendar, dateString, location) {
|
|
@@ -232,7 +445,7 @@ class TPS {
|
|
|
232
445
|
}
|
|
233
446
|
// Merge with location
|
|
234
447
|
const fullComponents = {
|
|
235
|
-
calendar,
|
|
448
|
+
calendar: calendar,
|
|
236
449
|
...components,
|
|
237
450
|
...location,
|
|
238
451
|
};
|
|
@@ -240,7 +453,7 @@ class TPS {
|
|
|
240
453
|
}
|
|
241
454
|
/**
|
|
242
455
|
* Format TPS components to a calendar-specific date string.
|
|
243
|
-
* Requires the driver to implement
|
|
456
|
+
* Requires the driver to implement `format`.
|
|
244
457
|
*
|
|
245
458
|
* @param calendar - The calendar code
|
|
246
459
|
* @param components - TPS components to format
|
|
@@ -249,7 +462,7 @@ class TPS {
|
|
|
249
462
|
*
|
|
250
463
|
* @example
|
|
251
464
|
* ```ts
|
|
252
|
-
* const tps = TPS.parse('tps://unknown@T:hij.y1447.
|
|
465
|
+
* const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
|
|
253
466
|
* const formatted = TPS.formatCalendarDate('hij', tps);
|
|
254
467
|
* // "1447-07-21"
|
|
255
468
|
* ```
|
|
@@ -259,45 +472,272 @@ class TPS {
|
|
|
259
472
|
if (!driver) {
|
|
260
473
|
throw new Error(`Calendar driver '${calendar}' not found.`);
|
|
261
474
|
}
|
|
262
|
-
|
|
263
|
-
throw new Error(`Driver '${calendar}' does not implement format().`);
|
|
264
|
-
}
|
|
475
|
+
// format is guaranteed by the interface, so we can call it directly.
|
|
265
476
|
return driver.format(components, format);
|
|
266
477
|
}
|
|
267
478
|
// --- INTERNAL HELPERS ---
|
|
479
|
+
/**
|
|
480
|
+
* Generate the canonical `T:` time string for a set of components. The
|
|
481
|
+
* `order` field (or `comp.order`) controls whether tokens are emitted in
|
|
482
|
+
* ascending or descending hierarchy; if undefined the default
|
|
483
|
+
* `'descending'` orientation is used.
|
|
484
|
+
*
|
|
485
|
+
* Drivers may ignore this helper and produce their own time strings if they
|
|
486
|
+
* implement custom ordering logic.
|
|
487
|
+
*/
|
|
488
|
+
static buildTimePart(comp) {
|
|
489
|
+
const calendar = (comp.calendar || "").toLowerCase();
|
|
490
|
+
if (!/^[a-z]{3,4}$/.test(calendar)) {
|
|
491
|
+
throw new Error(`Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`);
|
|
492
|
+
}
|
|
493
|
+
let time = `T:${calendar}`;
|
|
494
|
+
if (calendar === exports.DefaultCalendars.UNIX) {
|
|
495
|
+
if (comp.unixSeconds !== undefined) {
|
|
496
|
+
time += `.s${comp.unixSeconds}`;
|
|
497
|
+
}
|
|
498
|
+
return time;
|
|
499
|
+
}
|
|
500
|
+
// sequence of [prefix, value, rank]
|
|
501
|
+
// All four of millennium / month / minute / millisecond share the prefix 'm'.
|
|
502
|
+
// Position within the ordered sequence disambiguates them during parsing.
|
|
503
|
+
const tokens = [
|
|
504
|
+
["m", comp.millennium, 8], // m-token rank 8 → millennium
|
|
505
|
+
["c", comp.century, 7],
|
|
506
|
+
["y", comp.year, 6],
|
|
507
|
+
["m", comp.month, 5], // m-token rank 5 → month
|
|
508
|
+
["d", comp.day, 4],
|
|
509
|
+
["h", comp.hour, 3],
|
|
510
|
+
["m", comp.minute, 2], // m-token rank 2 → minute
|
|
511
|
+
["s", comp.second, 1],
|
|
512
|
+
["m", comp.millisecond, 0], // m-token rank 0 → millisecond
|
|
513
|
+
];
|
|
514
|
+
const order = comp.order || TimeOrder.DESC;
|
|
515
|
+
if (order === TimeOrder.ASC)
|
|
516
|
+
tokens.reverse();
|
|
517
|
+
for (const [pref, val] of tokens) {
|
|
518
|
+
if (val !== undefined) {
|
|
519
|
+
// seconds may be fractional
|
|
520
|
+
time += `.${pref}${val}`;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (comp.signature) {
|
|
524
|
+
time += `!${comp.signature}`;
|
|
525
|
+
}
|
|
526
|
+
return time;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Parse the *time* portion of a TPS string (optionally beginning with
|
|
530
|
+
* `T:`) into components and determine the component ordering. This helper
|
|
531
|
+
* accepts tokens in **any** sequence and will return an `order` value of
|
|
532
|
+
* `'ascending'` or `'descending'`.
|
|
533
|
+
*
|
|
534
|
+
* The caller is responsible for stripping off a leading signature or other
|
|
535
|
+
* trailer characters; this method will drop anything after `!`, `;`, `?` or
|
|
536
|
+
* `#`.
|
|
537
|
+
*
|
|
538
|
+
* ### `m`-token disambiguation
|
|
539
|
+
* All four of millennium (rank 8), month (rank 5), minute (rank 2) and
|
|
540
|
+
* millisecond (rank 0) share the single-character prefix `m`. They are told
|
|
541
|
+
* apart by their **position relative to the neighbouring tokens**. The
|
|
542
|
+
* algorithm is:
|
|
543
|
+
*
|
|
544
|
+
* 1. Pre-scan the non-`m` tokens (c, y, d, h, s) whose ranks are fixed to
|
|
545
|
+
* determine whether the string is ascending or descending.
|
|
546
|
+
* 2. While iterating, track `lastAssignedRank` – the rank of the most
|
|
547
|
+
* recently processed token (m or non-m).
|
|
548
|
+
* 3. When an `m` token is encountered, derive its rank from `lastAssignedRank`
|
|
549
|
+
* and the detected order:
|
|
550
|
+
* - **DESC** null → 8 (mill) | rank > 5 → 5 (month) | rank > 2 → 2 (min) | else → 0 (ms)
|
|
551
|
+
* - **ASC** null → 0 (ms) | rank < 2 → 2 (min) | rank < 5 → 5 (month) | else → 8 (mill)
|
|
552
|
+
*
|
|
553
|
+
* @param input - Time fragment (e.g. `"T:greg.m3.c1.y26"` or `"greg.m0.s25.m30"`)
|
|
554
|
+
*/
|
|
555
|
+
static parseTimeString(input) {
|
|
556
|
+
let s = input.trim();
|
|
557
|
+
// strip off anything after signature or extensions/query/fragment
|
|
558
|
+
s = s.split(/[!;?#]/)[0];
|
|
559
|
+
if (s.startsWith("T:"))
|
|
560
|
+
s = s.slice(2);
|
|
561
|
+
const parts = s.split(".");
|
|
562
|
+
if (parts.length === 0)
|
|
563
|
+
return null;
|
|
564
|
+
const calendar = parts[0];
|
|
565
|
+
const comp = { calendar };
|
|
566
|
+
// Fixed-rank prefixes (unambiguous regardless of position)
|
|
567
|
+
const fixedRankMap = {
|
|
568
|
+
c: 7,
|
|
569
|
+
y: 6,
|
|
570
|
+
d: 4,
|
|
571
|
+
h: 3,
|
|
572
|
+
s: 1,
|
|
573
|
+
};
|
|
574
|
+
// ── Step 1: pre-scan non-m tokens to estimate order ─────────────────────
|
|
575
|
+
// This is only needed to handle the first 'm' token when lastAssignedRank
|
|
576
|
+
// is still null (nothing has been seen yet).
|
|
577
|
+
let initialOrder = TimeOrder.DESC;
|
|
578
|
+
if (calendar !== exports.DefaultCalendars.UNIX) {
|
|
579
|
+
const nonMRanks = [];
|
|
580
|
+
for (let i = 1; i < parts.length; i++) {
|
|
581
|
+
const pr = parts[i]?.charAt(0);
|
|
582
|
+
if (pr && pr in fixedRankMap)
|
|
583
|
+
nonMRanks.push(fixedRankMap[pr]);
|
|
584
|
+
}
|
|
585
|
+
if (nonMRanks.length >= 2) {
|
|
586
|
+
const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
587
|
+
if (isAsc)
|
|
588
|
+
initialOrder = TimeOrder.ASC;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// ── Step 2: resolve the semantic rank of an 'm' token ───────────────────
|
|
592
|
+
const assignMRank = (lastRank, ord) => {
|
|
593
|
+
if (ord === TimeOrder.DESC) {
|
|
594
|
+
if (lastRank === null)
|
|
595
|
+
return 8; // first token → millennium
|
|
596
|
+
if (lastRank > 5)
|
|
597
|
+
return 5; // after century / year → month
|
|
598
|
+
if (lastRank > 2)
|
|
599
|
+
return 2; // after day / hour → minute
|
|
600
|
+
return 0; // after second → millisecond
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
if (lastRank === null)
|
|
604
|
+
return 0; // first token → millisecond
|
|
605
|
+
if (lastRank < 2)
|
|
606
|
+
return 2; // after millisecond / second → minute
|
|
607
|
+
if (lastRank < 5)
|
|
608
|
+
return 5; // after minute / hour / day → month
|
|
609
|
+
return 8; // after month / year / cent → millennium
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
// ── Step 3: iterate and build components ────────────────────────────────
|
|
613
|
+
const ranks = [];
|
|
614
|
+
let lastAssignedRank = null;
|
|
615
|
+
for (let i = 1; i < parts.length; i++) {
|
|
616
|
+
const token = parts[i];
|
|
617
|
+
if (!token)
|
|
618
|
+
continue;
|
|
619
|
+
const prefix = token.charAt(0);
|
|
620
|
+
const value = token.slice(1);
|
|
621
|
+
// UNIX calendar: single 's' token carries the full unix timestamp
|
|
622
|
+
if (calendar === exports.DefaultCalendars.UNIX && prefix === "s") {
|
|
623
|
+
comp.unixSeconds = parseFloat(value);
|
|
624
|
+
ranks.push(9);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (prefix === "m") {
|
|
628
|
+
const rank = assignMRank(lastAssignedRank, initialOrder);
|
|
629
|
+
switch (rank) {
|
|
630
|
+
case 8:
|
|
631
|
+
comp.millennium = parseInt(value, 10);
|
|
632
|
+
break;
|
|
633
|
+
case 5:
|
|
634
|
+
comp.month = parseInt(value, 10);
|
|
635
|
+
break;
|
|
636
|
+
case 2:
|
|
637
|
+
comp.minute = parseInt(value, 10);
|
|
638
|
+
break;
|
|
639
|
+
case 0:
|
|
640
|
+
comp.millisecond = parseInt(value, 10);
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
ranks.push(rank);
|
|
644
|
+
lastAssignedRank = rank;
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
switch (prefix) {
|
|
648
|
+
case "c":
|
|
649
|
+
comp.century = parseInt(value, 10);
|
|
650
|
+
ranks.push(7);
|
|
651
|
+
lastAssignedRank = 7;
|
|
652
|
+
break;
|
|
653
|
+
case "y":
|
|
654
|
+
comp.year = parseInt(value, 10);
|
|
655
|
+
ranks.push(6);
|
|
656
|
+
lastAssignedRank = 6;
|
|
657
|
+
break;
|
|
658
|
+
case "d":
|
|
659
|
+
comp.day = parseInt(value, 10);
|
|
660
|
+
ranks.push(4);
|
|
661
|
+
lastAssignedRank = 4;
|
|
662
|
+
break;
|
|
663
|
+
case "h":
|
|
664
|
+
comp.hour = parseInt(value, 10);
|
|
665
|
+
ranks.push(3);
|
|
666
|
+
lastAssignedRank = 3;
|
|
667
|
+
break;
|
|
668
|
+
case "s":
|
|
669
|
+
comp.second = parseFloat(value);
|
|
670
|
+
ranks.push(1);
|
|
671
|
+
lastAssignedRank = 1;
|
|
672
|
+
break;
|
|
673
|
+
default:
|
|
674
|
+
// unknown prefix – ignore
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// ── Step 4: confirm order from the complete rank sequence ────────────────
|
|
680
|
+
let order = TimeOrder.DESC;
|
|
681
|
+
if (ranks.length > 1) {
|
|
682
|
+
const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
683
|
+
const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
|
|
684
|
+
if (isAsc && !isDesc)
|
|
685
|
+
order = TimeOrder.ASC;
|
|
686
|
+
// mixed / single direction → defaults to DESC
|
|
687
|
+
}
|
|
688
|
+
return { components: comp, order };
|
|
689
|
+
}
|
|
268
690
|
static _mapGroupsToComponents(g) {
|
|
269
691
|
const components = {};
|
|
270
692
|
components.calendar = g.calendar;
|
|
271
|
-
//
|
|
272
|
-
if (
|
|
273
|
-
components.
|
|
693
|
+
// Signature Mapping
|
|
694
|
+
if (g.signature) {
|
|
695
|
+
components.signature = g.signature;
|
|
274
696
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (g.century)
|
|
279
|
-
components.century = parseInt(g.century, 10);
|
|
280
|
-
if (g.year)
|
|
281
|
-
components.year = parseInt(g.year, 10);
|
|
282
|
-
if (g.month)
|
|
283
|
-
components.month = parseInt(g.month, 10);
|
|
284
|
-
if (g.day)
|
|
285
|
-
components.day = parseInt(g.day, 10);
|
|
286
|
-
if (g.hour)
|
|
287
|
-
components.hour = parseInt(g.hour, 10);
|
|
288
|
-
if (g.minute)
|
|
289
|
-
components.minute = parseInt(g.minute, 10);
|
|
290
|
-
if (g.second)
|
|
291
|
-
components.second = parseFloat(g.second);
|
|
697
|
+
// Actor Mapping
|
|
698
|
+
if (g.actor) {
|
|
699
|
+
components.actor = g.actor;
|
|
292
700
|
}
|
|
293
701
|
// Space Mapping
|
|
294
702
|
if (g.space) {
|
|
295
|
-
|
|
703
|
+
// Privacy markers
|
|
704
|
+
if (g.space === "unknown" || g.space === "-") {
|
|
296
705
|
components.isUnknownLocation = true;
|
|
297
|
-
|
|
706
|
+
}
|
|
707
|
+
else if (g.space === "redacted") {
|
|
298
708
|
components.isRedactedLocation = true;
|
|
299
|
-
|
|
709
|
+
}
|
|
710
|
+
else if (g.space === "hidden" || g.space === "~") {
|
|
300
711
|
components.isHiddenLocation = true;
|
|
712
|
+
}
|
|
713
|
+
// Geospatial cells
|
|
714
|
+
else if (g.s2) {
|
|
715
|
+
components.s2Cell = g.s2;
|
|
716
|
+
}
|
|
717
|
+
else if (g.h3) {
|
|
718
|
+
components.h3Cell = g.h3;
|
|
719
|
+
}
|
|
720
|
+
else if (g.plus) {
|
|
721
|
+
components.plusCode = g.plus;
|
|
722
|
+
}
|
|
723
|
+
else if (g.w3w) {
|
|
724
|
+
components.what3words = g.w3w;
|
|
725
|
+
}
|
|
726
|
+
// Structural anchors
|
|
727
|
+
else if (g.bldg) {
|
|
728
|
+
components.building = g.bldg;
|
|
729
|
+
if (g.floor)
|
|
730
|
+
components.floor = g.floor;
|
|
731
|
+
if (g.room)
|
|
732
|
+
components.room = g.room;
|
|
733
|
+
if (g.zone)
|
|
734
|
+
components.zone = g.zone;
|
|
735
|
+
}
|
|
736
|
+
// Generic pre-@ anchor (adm/node/net/planet/etc)
|
|
737
|
+
else if (g.generic) {
|
|
738
|
+
components.spaceAnchor = g.generic;
|
|
739
|
+
}
|
|
740
|
+
// GPS coordinates
|
|
301
741
|
else {
|
|
302
742
|
if (g.lat)
|
|
303
743
|
components.latitude = parseFloat(g.lat);
|
|
@@ -310,28 +750,64 @@ class TPS {
|
|
|
310
750
|
// Extensions Mapping
|
|
311
751
|
if (g.extensions) {
|
|
312
752
|
const extObj = {};
|
|
313
|
-
const parts = g.extensions.split(
|
|
753
|
+
const parts = g.extensions.split(".");
|
|
314
754
|
parts.forEach((p) => {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
755
|
+
const eqIdx = p.indexOf("=");
|
|
756
|
+
if (eqIdx > 0) {
|
|
757
|
+
const key = p.substring(0, eqIdx);
|
|
758
|
+
const val = p.substring(eqIdx + 1);
|
|
759
|
+
if (key && val)
|
|
760
|
+
extObj[key] = val;
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
// Legacy format: first char is key
|
|
764
|
+
const key = p.charAt(0);
|
|
765
|
+
const val = p.substring(1);
|
|
766
|
+
if (key && val)
|
|
767
|
+
extObj[key] = val;
|
|
768
|
+
}
|
|
319
769
|
});
|
|
320
770
|
components.extensions = extObj;
|
|
321
771
|
}
|
|
322
772
|
return components;
|
|
323
773
|
}
|
|
324
|
-
static pad(n) {
|
|
325
|
-
const s = n.toString();
|
|
326
|
-
return s.length < 2 ? '0' + s : s;
|
|
327
|
-
}
|
|
328
774
|
}
|
|
329
775
|
exports.TPS = TPS;
|
|
330
776
|
// --- PLUGIN REGISTRY ---
|
|
331
777
|
TPS.drivers = new Map();
|
|
332
778
|
// --- REGEX ---
|
|
333
|
-
|
|
334
|
-
|
|
779
|
+
// Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
|
|
780
|
+
// Tokens may appear in any order; actual semantic parsing happens in
|
|
781
|
+
// `parseTimeString()` so these patterns are intentionally permissive.
|
|
782
|
+
// regex simply ensures prefix, space, calendar, and token characters;
|
|
783
|
+
// token order is not enforced (parseTimeString handles semantics).
|
|
784
|
+
TPS.REGEX_URI = new RegExp("^tps://" +
|
|
785
|
+
// Location part (preserve named captures for space subfields)
|
|
786
|
+
"(?:L:)?(?<space>" +
|
|
787
|
+
"~|-|unknown|redacted|hidden|" +
|
|
788
|
+
"s2=(?<s2>[a-fA-F0-9]+)|" +
|
|
789
|
+
"h3=(?<h3>[a-fA-F0-9]+)|" +
|
|
790
|
+
"plus=(?<plus>[A-Z0-9+]+)|" +
|
|
791
|
+
"w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|" +
|
|
792
|
+
"bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|" +
|
|
793
|
+
"(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?|" +
|
|
794
|
+
"(?<generic>[^@/?#]+)" +
|
|
795
|
+
")" +
|
|
796
|
+
"(?:/A:(?<actor>[^/@]+))?" +
|
|
797
|
+
"@T:(?<calendar>[a-z]{3,4})" +
|
|
798
|
+
"(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
|
|
799
|
+
"(?:![^;?#]+)?" +
|
|
800
|
+
"(?:;(?<extensions>[^?#]+))?" +
|
|
801
|
+
"(?:\\?[^#]+)?" +
|
|
802
|
+
"(?:#.+)?$");
|
|
803
|
+
TPS.REGEX_TIME = new RegExp("^T:(?<calendar>[a-z]{3,4})" +
|
|
804
|
+
"(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
|
|
805
|
+
"(?:![^;?#]+)?$");
|
|
806
|
+
// register built-in drivers and set default
|
|
807
|
+
// (tps and gregorian provide canonical conversions before unix)
|
|
808
|
+
TPS.registerDriver(new tps_1.TpsDriver());
|
|
809
|
+
TPS.registerDriver(new gregorian_1.GregorianDriver());
|
|
810
|
+
TPS.registerDriver(new unix_1.UnixDriver());
|
|
335
811
|
/**
|
|
336
812
|
* TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
|
|
337
813
|
*
|
|
@@ -352,7 +828,7 @@ TPS.REGEX_TIME = new RegExp('^T:(?<calendar>[a-z]{3,4})\\.(?:(?<unix>s\\d+(?:\\.
|
|
|
352
828
|
*
|
|
353
829
|
* @example
|
|
354
830
|
* ```ts
|
|
355
|
-
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.
|
|
831
|
+
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.m01.d09';
|
|
356
832
|
*
|
|
357
833
|
* // Encode to binary
|
|
358
834
|
* const bytes = TPSUID7RB.encodeBinary(tps);
|
|
@@ -378,14 +854,14 @@ class TPSUID7RB {
|
|
|
378
854
|
* @param opts - Encoding options (compress, epochMs override)
|
|
379
855
|
* @returns Binary TPS-UID as Uint8Array
|
|
380
856
|
*/
|
|
381
|
-
static encodeBinary(tps, opts) {
|
|
382
|
-
const compress = opts
|
|
383
|
-
const epochMs = opts
|
|
857
|
+
static encodeBinary(tps, opts = {}) {
|
|
858
|
+
const compress = opts.compress ?? false;
|
|
859
|
+
const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
|
|
384
860
|
if (!Number.isInteger(epochMs) || epochMs < 0) {
|
|
385
|
-
throw new Error(
|
|
861
|
+
throw new Error("epochMs must be a non-negative integer");
|
|
386
862
|
}
|
|
387
863
|
if (epochMs > 0xffffffffffff) {
|
|
388
|
-
throw new Error(
|
|
864
|
+
throw new Error("epochMs exceeds 48-bit range");
|
|
389
865
|
}
|
|
390
866
|
const flags = compress ? 0x01 : 0x00;
|
|
391
867
|
// Generate 32-bit nonce
|
|
@@ -433,14 +909,14 @@ class TPSUID7RB {
|
|
|
433
909
|
static decodeBinary(bytes) {
|
|
434
910
|
// Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
|
|
435
911
|
if (bytes.length < 17) {
|
|
436
|
-
throw new Error(
|
|
912
|
+
throw new Error("TPSUID7RB: too short");
|
|
437
913
|
}
|
|
438
914
|
// MAGIC
|
|
439
915
|
if (bytes[0] !== 0x54 ||
|
|
440
916
|
bytes[1] !== 0x50 ||
|
|
441
917
|
bytes[2] !== 0x55 ||
|
|
442
918
|
bytes[3] !== 0x37) {
|
|
443
|
-
throw new Error(
|
|
919
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
444
920
|
}
|
|
445
921
|
// VERSION
|
|
446
922
|
const ver = bytes[4];
|
|
@@ -462,13 +938,13 @@ class TPSUID7RB {
|
|
|
462
938
|
const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
|
|
463
939
|
offset += bytesRead;
|
|
464
940
|
if (offset + tpsLen > bytes.length) {
|
|
465
|
-
throw new Error(
|
|
941
|
+
throw new Error("TPSUID7RB: length overflow");
|
|
466
942
|
}
|
|
467
943
|
// TPS payload
|
|
468
944
|
const payload = bytes.slice(offset, offset + tpsLen);
|
|
469
945
|
const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
|
|
470
946
|
const tps = new TextDecoder().decode(tpsUtf8);
|
|
471
|
-
return { version:
|
|
947
|
+
return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
|
|
472
948
|
}
|
|
473
949
|
/**
|
|
474
950
|
* Encode TPS to base64url string with prefix.
|
|
@@ -479,7 +955,7 @@ class TPSUID7RB {
|
|
|
479
955
|
* @returns Base64url encoded TPS-UID with prefix
|
|
480
956
|
*/
|
|
481
957
|
static encodeBinaryB64(tps, opts) {
|
|
482
|
-
const bytes = this.encodeBinary(tps, opts);
|
|
958
|
+
const bytes = this.encodeBinary(tps, opts ?? {});
|
|
483
959
|
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
484
960
|
}
|
|
485
961
|
/**
|
|
@@ -491,7 +967,7 @@ class TPSUID7RB {
|
|
|
491
967
|
static decodeBinaryB64(id) {
|
|
492
968
|
const s = id.trim();
|
|
493
969
|
if (!s.startsWith(this.PREFIX)) {
|
|
494
|
-
throw new Error(
|
|
970
|
+
throw new Error("TPSUID7RB: missing prefix");
|
|
495
971
|
}
|
|
496
972
|
const b64 = s.slice(this.PREFIX.length);
|
|
497
973
|
const bytes = this.base64UrlDecode(b64);
|
|
@@ -515,7 +991,17 @@ class TPSUID7RB {
|
|
|
515
991
|
*/
|
|
516
992
|
static generate(opts) {
|
|
517
993
|
const now = new Date();
|
|
518
|
-
const
|
|
994
|
+
const time = TPS.fromDate(now, exports.DefaultCalendars.TPS, {
|
|
995
|
+
order: opts?.order,
|
|
996
|
+
});
|
|
997
|
+
let space = "unknown";
|
|
998
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
999
|
+
space = `${opts.latitude},${opts.longitude}`;
|
|
1000
|
+
if (opts.altitude !== undefined) {
|
|
1001
|
+
space += `,${opts.altitude}m`;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const tps = `tps://${space}@${time}`;
|
|
519
1005
|
return this.encodeBinaryB64(tps, {
|
|
520
1006
|
compress: opts?.compress,
|
|
521
1007
|
epochMs: now.getTime(),
|
|
@@ -527,19 +1013,27 @@ class TPSUID7RB {
|
|
|
527
1013
|
/**
|
|
528
1014
|
* Generate a TPS string from a Date and optional location.
|
|
529
1015
|
*/
|
|
1016
|
+
// NOTE: this helper is primarily used by `generate()`; drivers and
|
|
1017
|
+
// callers should prefer `TPS.fromDate()` when order or calendars matter.
|
|
530
1018
|
static generateTPSString(date, opts) {
|
|
531
1019
|
const fullYear = date.getUTCFullYear();
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1020
|
+
const comp = {
|
|
1021
|
+
calendar: exports.DefaultCalendars.TPS,
|
|
1022
|
+
millennium: Math.floor(fullYear / 1000) + 1,
|
|
1023
|
+
century: Math.floor((fullYear % 1000) / 100) + 1,
|
|
1024
|
+
year: fullYear % 100,
|
|
1025
|
+
month: date.getUTCMonth() + 1,
|
|
1026
|
+
day: date.getUTCDate(),
|
|
1027
|
+
hour: date.getUTCHours(),
|
|
1028
|
+
minute: date.getUTCMinutes(),
|
|
1029
|
+
second: date.getUTCSeconds(),
|
|
1030
|
+
millisecond: date.getUTCMilliseconds(),
|
|
1031
|
+
};
|
|
1032
|
+
if (opts?.order)
|
|
1033
|
+
comp.order = opts.order;
|
|
1034
|
+
// note: this method belongs to TPSUID7RB, but buildTimePart lives on TPS
|
|
1035
|
+
const timePart = TPS.buildTimePart(comp);
|
|
1036
|
+
let spacePart = "unknown";
|
|
543
1037
|
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
544
1038
|
spacePart = `${opts.latitude},${opts.longitude}`;
|
|
545
1039
|
if (opts.altitude !== undefined) {
|
|
@@ -553,60 +1047,16 @@ class TPSUID7RB {
|
|
|
553
1047
|
* Supports both URI format (tps://...) and time-only format (T:greg...)
|
|
554
1048
|
*/
|
|
555
1049
|
static epochMsFromTPSString(tps) {
|
|
556
|
-
|
|
557
|
-
if (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
else {
|
|
567
|
-
throw new Error('TPS: unrecognized format');
|
|
568
|
-
}
|
|
569
|
-
if (!time.startsWith('T:greg.')) {
|
|
570
|
-
throw new Error('TPS: only T:greg.* parsing is supported');
|
|
571
|
-
}
|
|
572
|
-
// Extract m (millennium), c (century), y (year)
|
|
573
|
-
const mMatch = time.match(/\.m(-?\d+)/);
|
|
574
|
-
const cMatch = time.match(/\.c(\d+)/);
|
|
575
|
-
const yMatch = time.match(/\.y(\d{1,4})/);
|
|
576
|
-
const MMatch = time.match(/\.M(\d{1,2})/);
|
|
577
|
-
const dMatch = time.match(/\.d(\d{1,2})/);
|
|
578
|
-
const hMatch = time.match(/\.h(\d{1,2})/);
|
|
579
|
-
const nMatch = time.match(/\.n(\d{1,2})/);
|
|
580
|
-
const sMatch = time.match(/\.s(\d{1,2})/);
|
|
581
|
-
// Calculate full year from millennium, century, year
|
|
582
|
-
let fullYear;
|
|
583
|
-
if (mMatch && cMatch && yMatch) {
|
|
584
|
-
const millennium = parseInt(mMatch[1], 10);
|
|
585
|
-
const century = parseInt(cMatch[1], 10);
|
|
586
|
-
const year = parseInt(yMatch[1], 10);
|
|
587
|
-
fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
588
|
-
}
|
|
589
|
-
else if (yMatch) {
|
|
590
|
-
// Fallback: interpret y as 2-digit year
|
|
591
|
-
let year = parseInt(yMatch[1], 10);
|
|
592
|
-
if (year < 100) {
|
|
593
|
-
year = year <= 69 ? 2000 + year : 1900 + year;
|
|
594
|
-
}
|
|
595
|
-
fullYear = year;
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
throw new Error('TPS: missing year component');
|
|
599
|
-
}
|
|
600
|
-
const month = MMatch ? parseInt(MMatch[1], 10) : 1;
|
|
601
|
-
const day = dMatch ? parseInt(dMatch[1], 10) : 1;
|
|
602
|
-
const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
|
|
603
|
-
const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
|
|
604
|
-
const second = sMatch ? parseInt(sMatch[1], 10) : 0;
|
|
605
|
-
const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
|
|
606
|
-
if (!Number.isFinite(epoch)) {
|
|
607
|
-
throw new Error('TPS: failed to compute epochMs');
|
|
608
|
-
}
|
|
609
|
-
return epoch;
|
|
1050
|
+
const date = TPS.toDate(tps);
|
|
1051
|
+
if (date)
|
|
1052
|
+
return date.getTime();
|
|
1053
|
+
// If parse fails due to unsupported/extended extension payloads,
|
|
1054
|
+
// strip extensions/query/fragment and retry. Epoch only depends on time.
|
|
1055
|
+
const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
|
|
1056
|
+
const retryDate = TPS.toDate(stripped);
|
|
1057
|
+
if (!retryDate)
|
|
1058
|
+
throw new Error("TPS: unable to parse date for epoch");
|
|
1059
|
+
return retryDate.getTime();
|
|
610
1060
|
}
|
|
611
1061
|
// ---------------------------
|
|
612
1062
|
// Binary Helpers
|
|
@@ -634,14 +1084,14 @@ class TPSUID7RB {
|
|
|
634
1084
|
BigInt(bytes[offset + 5]);
|
|
635
1085
|
const n = Number(v);
|
|
636
1086
|
if (!Number.isSafeInteger(n)) {
|
|
637
|
-
throw new Error(
|
|
1087
|
+
throw new Error("TPSUID7RB: u48 not safe integer");
|
|
638
1088
|
}
|
|
639
1089
|
return n;
|
|
640
1090
|
}
|
|
641
1091
|
/** Encode unsigned integer as LEB128 varint */
|
|
642
1092
|
static uvarintEncode(n) {
|
|
643
1093
|
if (!Number.isInteger(n) || n < 0) {
|
|
644
|
-
throw new Error(
|
|
1094
|
+
throw new Error("uvarint must be non-negative int");
|
|
645
1095
|
}
|
|
646
1096
|
const out = [];
|
|
647
1097
|
let x = n >>> 0;
|
|
@@ -659,12 +1109,12 @@ class TPSUID7RB {
|
|
|
659
1109
|
let i = 0;
|
|
660
1110
|
while (true) {
|
|
661
1111
|
if (offset + i >= bytes.length) {
|
|
662
|
-
throw new Error(
|
|
1112
|
+
throw new Error("uvarint overflow");
|
|
663
1113
|
}
|
|
664
1114
|
const b = bytes[offset + i];
|
|
665
1115
|
if (b < 0x80) {
|
|
666
1116
|
if (i > 9 || (i === 9 && b > 1)) {
|
|
667
|
-
throw new Error(
|
|
1117
|
+
throw new Error("uvarint too large");
|
|
668
1118
|
}
|
|
669
1119
|
x |= b << s;
|
|
670
1120
|
return { value: x >>> 0, bytesRead: i + 1 };
|
|
@@ -673,7 +1123,7 @@ class TPSUID7RB {
|
|
|
673
1123
|
s += 7;
|
|
674
1124
|
i++;
|
|
675
1125
|
if (i > 10) {
|
|
676
|
-
throw new Error(
|
|
1126
|
+
throw new Error("uvarint too long");
|
|
677
1127
|
}
|
|
678
1128
|
}
|
|
679
1129
|
}
|
|
@@ -683,33 +1133,33 @@ class TPSUID7RB {
|
|
|
683
1133
|
/** Encode bytes to base64url (no padding) */
|
|
684
1134
|
static base64UrlEncode(bytes) {
|
|
685
1135
|
// Node.js environment
|
|
686
|
-
if (typeof Buffer !==
|
|
1136
|
+
if (typeof Buffer !== "undefined") {
|
|
687
1137
|
return Buffer.from(bytes)
|
|
688
|
-
.toString(
|
|
689
|
-
.replace(/\+/g,
|
|
690
|
-
.replace(/\//g,
|
|
691
|
-
.replace(/=+$/g,
|
|
1138
|
+
.toString("base64")
|
|
1139
|
+
.replace(/\+/g, "-")
|
|
1140
|
+
.replace(/\//g, "_")
|
|
1141
|
+
.replace(/=+$/g, "");
|
|
692
1142
|
}
|
|
693
1143
|
// Browser environment
|
|
694
|
-
let binary =
|
|
1144
|
+
let binary = "";
|
|
695
1145
|
for (let i = 0; i < bytes.length; i++) {
|
|
696
1146
|
binary += String.fromCharCode(bytes[i]);
|
|
697
1147
|
}
|
|
698
1148
|
return btoa(binary)
|
|
699
|
-
.replace(/\+/g,
|
|
700
|
-
.replace(/\//g,
|
|
701
|
-
.replace(/=+$/g,
|
|
1149
|
+
.replace(/\+/g, "-")
|
|
1150
|
+
.replace(/\//g, "_")
|
|
1151
|
+
.replace(/=+$/g, "");
|
|
702
1152
|
}
|
|
703
1153
|
/** Decode base64url to bytes */
|
|
704
1154
|
static base64UrlDecode(b64url) {
|
|
705
1155
|
// Add padding
|
|
706
1156
|
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
707
|
-
const b64 = (b64url +
|
|
708
|
-
.replace(/-/g,
|
|
709
|
-
.replace(/_/g,
|
|
1157
|
+
const b64 = (b64url + "=".repeat(padLen))
|
|
1158
|
+
.replace(/-/g, "+")
|
|
1159
|
+
.replace(/_/g, "/");
|
|
710
1160
|
// Node.js environment
|
|
711
|
-
if (typeof Buffer !==
|
|
712
|
-
return new Uint8Array(Buffer.from(b64,
|
|
1161
|
+
if (typeof Buffer !== "undefined") {
|
|
1162
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
713
1163
|
}
|
|
714
1164
|
// Browser environment
|
|
715
1165
|
const binary = atob(b64);
|
|
@@ -725,34 +1175,34 @@ class TPSUID7RB {
|
|
|
725
1175
|
/** Compress using zlib deflate raw */
|
|
726
1176
|
static deflateRaw(data) {
|
|
727
1177
|
// Node.js environment
|
|
728
|
-
if (typeof require !==
|
|
1178
|
+
if (typeof require !== "undefined") {
|
|
729
1179
|
try {
|
|
730
1180
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
731
|
-
const zlib = require(
|
|
1181
|
+
const zlib = require("zlib");
|
|
732
1182
|
return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
|
|
733
1183
|
}
|
|
734
1184
|
catch {
|
|
735
|
-
throw new Error(
|
|
1185
|
+
throw new Error("TPSUID7RB: compression not available");
|
|
736
1186
|
}
|
|
737
1187
|
}
|
|
738
1188
|
// Browser: would need pako or similar library
|
|
739
|
-
throw new Error(
|
|
1189
|
+
throw new Error("TPSUID7RB: compression not available in browser");
|
|
740
1190
|
}
|
|
741
1191
|
/** Decompress using zlib inflate raw */
|
|
742
1192
|
static inflateRaw(data) {
|
|
743
1193
|
// Node.js environment
|
|
744
|
-
if (typeof require !==
|
|
1194
|
+
if (typeof require !== "undefined") {
|
|
745
1195
|
try {
|
|
746
1196
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
747
|
-
const zlib = require(
|
|
1197
|
+
const zlib = require("zlib");
|
|
748
1198
|
return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
|
|
749
1199
|
}
|
|
750
1200
|
catch {
|
|
751
|
-
throw new Error(
|
|
1201
|
+
throw new Error("TPSUID7RB: decompression failed");
|
|
752
1202
|
}
|
|
753
1203
|
}
|
|
754
1204
|
// Browser: would need pako or similar library
|
|
755
|
-
throw new Error(
|
|
1205
|
+
throw new Error("TPSUID7RB: decompression not available in browser");
|
|
756
1206
|
}
|
|
757
1207
|
// ---------------------------
|
|
758
1208
|
// Cryptographic Sealing (Ed25519)
|
|
@@ -779,7 +1229,7 @@ class TPSUID7RB {
|
|
|
779
1229
|
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
780
1230
|
// Validate epoch
|
|
781
1231
|
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
782
|
-
throw new Error(
|
|
1232
|
+
throw new Error("epochMs must be a valid 48-bit non-negative integer");
|
|
783
1233
|
}
|
|
784
1234
|
// Flags: Bit 0 = compress, Bit 1 = sealed
|
|
785
1235
|
const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
|
|
@@ -824,18 +1274,18 @@ class TPSUID7RB {
|
|
|
824
1274
|
*/
|
|
825
1275
|
static verifyAndDecode(sealedBytes, publicKey) {
|
|
826
1276
|
if (sealedBytes.length < 18)
|
|
827
|
-
throw new Error(
|
|
1277
|
+
throw new Error("TPSUID7RB: too short");
|
|
828
1278
|
// Check Magic
|
|
829
1279
|
if (sealedBytes[0] !== 0x54 ||
|
|
830
1280
|
sealedBytes[1] !== 0x50 ||
|
|
831
1281
|
sealedBytes[2] !== 0x55 ||
|
|
832
1282
|
sealedBytes[3] !== 0x37) {
|
|
833
|
-
throw new Error(
|
|
1283
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
834
1284
|
}
|
|
835
1285
|
// Check Flags for Sealed Bit (bit 1)
|
|
836
1286
|
const flags = sealedBytes[5];
|
|
837
1287
|
if ((flags & 0x02) === 0) {
|
|
838
|
-
throw new Error(
|
|
1288
|
+
throw new Error("TPSUID7RB: not a sealed UID");
|
|
839
1289
|
}
|
|
840
1290
|
// 1. Parse the structure to find where content ends
|
|
841
1291
|
// We need to parse LEN and Payload to find the split point
|
|
@@ -845,13 +1295,13 @@ class TPSUID7RB {
|
|
|
845
1295
|
offset += bytesRead;
|
|
846
1296
|
const payloadEnd = offset + tpsLen;
|
|
847
1297
|
if (payloadEnd > sealedBytes.length) {
|
|
848
|
-
throw new Error(
|
|
1298
|
+
throw new Error("TPSUID7RB: length overflow (truncated)");
|
|
849
1299
|
}
|
|
850
1300
|
// The Content to verify matches exactly [0 ... payloadEnd]
|
|
851
1301
|
const content = sealedBytes.slice(0, payloadEnd);
|
|
852
1302
|
// After content: SealType (1 byte) + Signature
|
|
853
1303
|
if (sealedBytes.length <= payloadEnd + 1) {
|
|
854
|
-
throw new Error(
|
|
1304
|
+
throw new Error("TPSUID7RB: missing signature data");
|
|
855
1305
|
}
|
|
856
1306
|
const sealType = sealedBytes[payloadEnd];
|
|
857
1307
|
if (sealType !== 0x01) {
|
|
@@ -864,7 +1314,7 @@ class TPSUID7RB {
|
|
|
864
1314
|
// Verify
|
|
865
1315
|
const isValid = this.verifyEd25519(content, signature, publicKey);
|
|
866
1316
|
if (!isValid) {
|
|
867
|
-
throw new Error(
|
|
1317
|
+
throw new Error("TPSUID7RB: signature verification failed");
|
|
868
1318
|
}
|
|
869
1319
|
// Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
|
|
870
1320
|
// Actually standard logic doesn't expect trailing bytes unless we tell it to.
|
|
@@ -875,10 +1325,10 @@ class TPSUID7RB {
|
|
|
875
1325
|
}
|
|
876
1326
|
// --- Crypto Implementation (Ed25519) ---
|
|
877
1327
|
static signEd25519(data, privateKey) {
|
|
878
|
-
if (typeof require !==
|
|
1328
|
+
if (typeof require !== "undefined") {
|
|
879
1329
|
try {
|
|
880
1330
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
881
|
-
const crypto = require(
|
|
1331
|
+
const crypto = require("crypto");
|
|
882
1332
|
// Node's crypto.sign uses PEM or KeyObject, but for raw Ed25519 keys we might need 'crypto.sign(null, data, key)'
|
|
883
1333
|
// or ensure key is properly formatted.
|
|
884
1334
|
// For simplicity in Node 20+, crypto.sign(null, data, privateKey) works if key is KeyObject.
|
|
@@ -890,8 +1340,8 @@ class TPSUID7RB {
|
|
|
890
1340
|
// Let's assume standard Ed25519 standard implementation pattern logic:
|
|
891
1341
|
keyObj = crypto.createPrivateKey({
|
|
892
1342
|
key: Buffer.from(privateKey),
|
|
893
|
-
format:
|
|
894
|
-
type:
|
|
1343
|
+
format: "der", // or 'pem' - strict.
|
|
1344
|
+
type: "pkcs8",
|
|
895
1345
|
});
|
|
896
1346
|
// Actually, simpler: construct key object from raw bytes if possible?
|
|
897
1347
|
// Node's crypto is strict. Let's try the simplest:
|
|
@@ -902,8 +1352,12 @@ class TPSUID7RB {
|
|
|
902
1352
|
// For this implementation, let's target Node's high-level sign/verify
|
|
903
1353
|
// and assume the user provides a VALID key object or compatible format (PEM/DER).
|
|
904
1354
|
// Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
|
|
905
|
-
const key = typeof privateKey ===
|
|
906
|
-
? crypto.createPrivateKey({
|
|
1355
|
+
const key = typeof privateKey === "string" && !privateKey.includes("PRIVATE KEY")
|
|
1356
|
+
? crypto.createPrivateKey({
|
|
1357
|
+
key: Buffer.from(privateKey, "hex"),
|
|
1358
|
+
format: "pem",
|
|
1359
|
+
type: "pkcs8",
|
|
1360
|
+
}) // Fallback guess
|
|
907
1361
|
: privateKey;
|
|
908
1362
|
// Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
|
|
909
1363
|
// Direct raw signing is via crypto.sign(null, data, key).
|
|
@@ -911,23 +1365,23 @@ class TPSUID7RB {
|
|
|
911
1365
|
}
|
|
912
1366
|
catch (e) {
|
|
913
1367
|
// If standard crypto fails (e.g. key format issue), throw
|
|
914
|
-
throw new Error(
|
|
1368
|
+
throw new Error("TPSUID7RB: signing failed (check key format)");
|
|
915
1369
|
}
|
|
916
1370
|
}
|
|
917
|
-
throw new Error(
|
|
1371
|
+
throw new Error("TPSUID7RB: signing not available in browser");
|
|
918
1372
|
}
|
|
919
1373
|
static verifyEd25519(data, signature, publicKey) {
|
|
920
|
-
if (typeof require !==
|
|
1374
|
+
if (typeof require !== "undefined") {
|
|
921
1375
|
try {
|
|
922
1376
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
923
|
-
const crypto = require(
|
|
1377
|
+
const crypto = require("crypto");
|
|
924
1378
|
return crypto.verify(null, data, publicKey, signature);
|
|
925
1379
|
}
|
|
926
1380
|
catch {
|
|
927
1381
|
return false;
|
|
928
1382
|
}
|
|
929
1383
|
}
|
|
930
|
-
throw new Error(
|
|
1384
|
+
throw new Error("TPSUID7RB: verification not available in browser");
|
|
931
1385
|
}
|
|
932
1386
|
// ---------------------------
|
|
933
1387
|
// Random Bytes
|
|
@@ -935,10 +1389,10 @@ class TPSUID7RB {
|
|
|
935
1389
|
/** Generate cryptographically secure random bytes */
|
|
936
1390
|
static randomBytes(length) {
|
|
937
1391
|
// Node.js environment
|
|
938
|
-
if (typeof require !==
|
|
1392
|
+
if (typeof require !== "undefined") {
|
|
939
1393
|
try {
|
|
940
1394
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
941
|
-
const crypto = require(
|
|
1395
|
+
const crypto = require("crypto");
|
|
942
1396
|
return new Uint8Array(crypto.randomBytes(length));
|
|
943
1397
|
}
|
|
944
1398
|
catch {
|
|
@@ -946,12 +1400,12 @@ class TPSUID7RB {
|
|
|
946
1400
|
}
|
|
947
1401
|
}
|
|
948
1402
|
// Browser or fallback
|
|
949
|
-
if (typeof crypto !==
|
|
1403
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
950
1404
|
const bytes = new Uint8Array(length);
|
|
951
1405
|
crypto.getRandomValues(bytes);
|
|
952
1406
|
return bytes;
|
|
953
1407
|
}
|
|
954
|
-
throw new Error(
|
|
1408
|
+
throw new Error("TPSUID7RB: no crypto available");
|
|
955
1409
|
}
|
|
956
1410
|
}
|
|
957
1411
|
exports.TPSUID7RB = TPSUID7RB;
|
|
@@ -960,6 +1414,174 @@ TPSUID7RB.MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
|
|
|
960
1414
|
/** Version 1 */
|
|
961
1415
|
TPSUID7RB.VER = 0x01;
|
|
962
1416
|
/** String prefix for base64url encoded form */
|
|
963
|
-
TPSUID7RB.PREFIX =
|
|
1417
|
+
TPSUID7RB.PREFIX = "tpsuid7rb_";
|
|
964
1418
|
/** Regex for validating base64url encoded form */
|
|
965
1419
|
TPSUID7RB.REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|
|
1420
|
+
/**
|
|
1421
|
+
* `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
|
|
1422
|
+
*
|
|
1423
|
+
* It mirrors common JavaScript `Date` construction patterns:
|
|
1424
|
+
* - `new TpsDate()`
|
|
1425
|
+
* - `new TpsDate(ms)`
|
|
1426
|
+
* - `new TpsDate(isoString)`
|
|
1427
|
+
* - `new TpsDate(tpsString)`
|
|
1428
|
+
* - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
|
|
1429
|
+
*/
|
|
1430
|
+
class TpsDate {
|
|
1431
|
+
getTpsComponents() {
|
|
1432
|
+
const parsed = TPS.parse(this.toTPS(exports.DefaultCalendars.TPS));
|
|
1433
|
+
if (!parsed) {
|
|
1434
|
+
throw new Error("TpsDate: failed to derive TPS components");
|
|
1435
|
+
}
|
|
1436
|
+
return parsed;
|
|
1437
|
+
}
|
|
1438
|
+
getTpsFullYear() {
|
|
1439
|
+
const comp = this.getTpsComponents();
|
|
1440
|
+
return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
|
|
1441
|
+
}
|
|
1442
|
+
constructor(...args) {
|
|
1443
|
+
if (args.length === 0) {
|
|
1444
|
+
this.internal = new Date();
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (args.length === 1) {
|
|
1448
|
+
const value = args[0];
|
|
1449
|
+
if (value instanceof TpsDate) {
|
|
1450
|
+
this.internal = new Date(value.getTime());
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
if (value instanceof Date) {
|
|
1454
|
+
this.internal = new Date(value.getTime());
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
|
|
1458
|
+
const parsed = TPS.toDate(value);
|
|
1459
|
+
if (!parsed) {
|
|
1460
|
+
throw new RangeError(`Invalid TPS date string: ${value}`);
|
|
1461
|
+
}
|
|
1462
|
+
this.internal = parsed;
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
this.internal = new Date(value);
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
|
|
1469
|
+
this.internal = new Date(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
|
|
1470
|
+
}
|
|
1471
|
+
static looksLikeTPS(input) {
|
|
1472
|
+
const s = input.trim();
|
|
1473
|
+
return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
|
|
1474
|
+
}
|
|
1475
|
+
static now() {
|
|
1476
|
+
return Date.now();
|
|
1477
|
+
}
|
|
1478
|
+
static parse(input) {
|
|
1479
|
+
if (this.looksLikeTPS(input)) {
|
|
1480
|
+
const d = TPS.toDate(input);
|
|
1481
|
+
return d ? d.getTime() : Number.NaN;
|
|
1482
|
+
}
|
|
1483
|
+
return Date.parse(input);
|
|
1484
|
+
}
|
|
1485
|
+
static UTC(year, monthIndex, day, hours, minutes, seconds, ms) {
|
|
1486
|
+
return Date.UTC(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
|
|
1487
|
+
}
|
|
1488
|
+
static fromTPS(tps) {
|
|
1489
|
+
return new TpsDate(tps);
|
|
1490
|
+
}
|
|
1491
|
+
toGregorianDate() {
|
|
1492
|
+
return new Date(this.internal.getTime());
|
|
1493
|
+
}
|
|
1494
|
+
toDate() {
|
|
1495
|
+
return this.toGregorianDate();
|
|
1496
|
+
}
|
|
1497
|
+
toTPS(calendar = exports.DefaultCalendars.TPS, opts) {
|
|
1498
|
+
return TPS.fromDate(this.internal, calendar, opts);
|
|
1499
|
+
}
|
|
1500
|
+
toTPSURI(calendar = exports.DefaultCalendars.TPS, opts) {
|
|
1501
|
+
const time = this.toTPS(calendar, { order: opts?.order });
|
|
1502
|
+
const comp = TPS.parse(time);
|
|
1503
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
1504
|
+
comp.latitude = opts.latitude;
|
|
1505
|
+
comp.longitude = opts.longitude;
|
|
1506
|
+
if (opts.altitude !== undefined)
|
|
1507
|
+
comp.altitude = opts.altitude;
|
|
1508
|
+
}
|
|
1509
|
+
else if (opts?.isHiddenLocation) {
|
|
1510
|
+
comp.isHiddenLocation = true;
|
|
1511
|
+
}
|
|
1512
|
+
else if (opts?.isRedactedLocation) {
|
|
1513
|
+
comp.isRedactedLocation = true;
|
|
1514
|
+
}
|
|
1515
|
+
else {
|
|
1516
|
+
comp.isUnknownLocation = true;
|
|
1517
|
+
}
|
|
1518
|
+
return TPS.toURI(comp);
|
|
1519
|
+
}
|
|
1520
|
+
getTime() {
|
|
1521
|
+
return this.internal.getTime();
|
|
1522
|
+
}
|
|
1523
|
+
valueOf() {
|
|
1524
|
+
return this.internal.valueOf();
|
|
1525
|
+
}
|
|
1526
|
+
toString() {
|
|
1527
|
+
return this.toTPS(exports.DefaultCalendars.TPS);
|
|
1528
|
+
}
|
|
1529
|
+
toISOString() {
|
|
1530
|
+
return this.internal.toISOString();
|
|
1531
|
+
}
|
|
1532
|
+
toUTCString() {
|
|
1533
|
+
return this.internal.toUTCString();
|
|
1534
|
+
}
|
|
1535
|
+
toJSON() {
|
|
1536
|
+
return this.internal.toJSON();
|
|
1537
|
+
}
|
|
1538
|
+
getFullYear() {
|
|
1539
|
+
return this.getTpsFullYear();
|
|
1540
|
+
}
|
|
1541
|
+
getUTCFullYear() {
|
|
1542
|
+
return this.getTpsFullYear();
|
|
1543
|
+
}
|
|
1544
|
+
getMonth() {
|
|
1545
|
+
return this.getTpsComponents().month - 1;
|
|
1546
|
+
}
|
|
1547
|
+
getUTCMonth() {
|
|
1548
|
+
return this.getTpsComponents().month - 1;
|
|
1549
|
+
}
|
|
1550
|
+
getDate() {
|
|
1551
|
+
return this.getTpsComponents().day;
|
|
1552
|
+
}
|
|
1553
|
+
getUTCDate() {
|
|
1554
|
+
return this.getTpsComponents().day;
|
|
1555
|
+
}
|
|
1556
|
+
getHours() {
|
|
1557
|
+
return this.getTpsComponents().hour;
|
|
1558
|
+
}
|
|
1559
|
+
getUTCHours() {
|
|
1560
|
+
return this.getTpsComponents().hour;
|
|
1561
|
+
}
|
|
1562
|
+
getMinutes() {
|
|
1563
|
+
return this.getTpsComponents().minute;
|
|
1564
|
+
}
|
|
1565
|
+
getUTCMinutes() {
|
|
1566
|
+
return this.getTpsComponents().minute;
|
|
1567
|
+
}
|
|
1568
|
+
getSeconds() {
|
|
1569
|
+
return this.getTpsComponents().second;
|
|
1570
|
+
}
|
|
1571
|
+
getUTCSeconds() {
|
|
1572
|
+
return this.getTpsComponents().second;
|
|
1573
|
+
}
|
|
1574
|
+
getMilliseconds() {
|
|
1575
|
+
return this.getTpsComponents().millisecond;
|
|
1576
|
+
}
|
|
1577
|
+
getUTCMilliseconds() {
|
|
1578
|
+
return this.getTpsComponents().millisecond;
|
|
1579
|
+
}
|
|
1580
|
+
[Symbol.toPrimitive](hint) {
|
|
1581
|
+
if (hint === "number")
|
|
1582
|
+
return this.valueOf();
|
|
1583
|
+
return this.toString();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
exports.TpsDate = TpsDate;
|
|
1587
|
+
//# sourceMappingURL=index.js.map
|