@nextera.one/tps-standard 0.6.0 → 0.8.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 +20 -0
- package/README.md +40 -3
- package/dist/date.d.ts +5 -5
- package/dist/date.js +5 -1
- package/dist/date.js.map +1 -1
- package/dist/drivers/tps.d.ts +8 -17
- package/dist/drivers/tps.js +64 -84
- package/dist/drivers/tps.js.map +1 -1
- package/dist/esm/date.js +6 -2
- package/dist/esm/date.js.map +1 -1
- package/dist/esm/drivers/tps.js +64 -84
- package/dist/esm/drivers/tps.js.map +1 -1
- package/dist/esm/index.js +152 -14
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/uid.js +3 -0
- package/dist/esm/uid.js.map +1 -1
- package/dist/esm/utils/tps-native.js +249 -0
- package/dist/esm/utils/tps-native.js.map +1 -0
- package/dist/esm/utils/tps-string.js +58 -15
- package/dist/esm/utils/tps-string.js.map +1 -1
- package/dist/index.d.ts +25 -6
- package/dist/index.js +152 -14
- package/dist/index.js.map +1 -1
- package/dist/tps.min.js +3786 -0
- package/dist/types.d.ts +11 -0
- package/dist/uid.js +3 -0
- package/dist/uid.js.map +1 -1
- package/dist/utils/tps-native.d.ts +32 -0
- package/dist/utils/tps-native.js +265 -0
- package/dist/utils/tps-native.js.map +1 -0
- package/dist/utils/tps-string.d.ts +2 -2
- package/dist/utils/tps-string.js +57 -14
- package/dist/utils/tps-string.js.map +1 -1
- package/package.json +3 -2
- package/src/date.ts +14 -4
- package/src/drivers/tps.ts +83 -104
- package/src/index.ts +213 -15
- package/src/types.ts +13 -0
- package/src/uid.ts +3 -0
- package/src/utils/tps-native.ts +346 -0
- package/src/utils/tps-string.ts +82 -15
package/dist/tps.min.js
ADDED
|
@@ -0,0 +1,3786 @@
|
|
|
1
|
+
/*! TPS v0.8.0 | Apache-2.0 */
|
|
2
|
+
var TPS = (function (exports) {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TPS: Temporal Positioning System
|
|
7
|
+
* Shared types and interfaces.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Calendar codes are plain strings to allow arbitrary user-defined
|
|
11
|
+
* calendars. The library still exports constants for the built-in values.
|
|
12
|
+
*/
|
|
13
|
+
const DefaultCalendars = {
|
|
14
|
+
TPS: "tps",
|
|
15
|
+
GREG: "greg",
|
|
16
|
+
HIJ: "hij",
|
|
17
|
+
PER: "per",
|
|
18
|
+
JUL: "jul",
|
|
19
|
+
HOLO: "holo",
|
|
20
|
+
UNIX: "unix",
|
|
21
|
+
CHIN: "chin",
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Specifies the direction of the time-component hierarchy when serializing or
|
|
25
|
+
* deserializing a TPS string. The default is 'descending'.
|
|
26
|
+
*/
|
|
27
|
+
exports.TimeOrder = void 0;
|
|
28
|
+
(function (TimeOrder) {
|
|
29
|
+
TimeOrder["DESC"] = "desc";
|
|
30
|
+
TimeOrder["ASC"] = "asc";
|
|
31
|
+
})(exports.TimeOrder || (exports.TimeOrder = {}));
|
|
32
|
+
|
|
33
|
+
const TPS_DAY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const TPS_DAYS_PER_WEEK = 7;
|
|
35
|
+
const TPS_WEEKS_PER_MONTH = 4;
|
|
36
|
+
const TPS_MONTHS_PER_YEAR = 12;
|
|
37
|
+
const TPS_DAYS_PER_MONTH = TPS_DAYS_PER_WEEK * TPS_WEEKS_PER_MONTH;
|
|
38
|
+
const TPS_DAYS_PER_YEAR = TPS_DAYS_PER_MONTH * TPS_MONTHS_PER_YEAR;
|
|
39
|
+
const TPS_EPOCH_START_MS = Date.UTC(1999, 7, 11, 7, 0, 0, 0);
|
|
40
|
+
function floorDiv(value, divisor) {
|
|
41
|
+
return Math.floor(value / divisor);
|
|
42
|
+
}
|
|
43
|
+
function mod(value, divisor) {
|
|
44
|
+
return ((value % divisor) + divisor) % divisor;
|
|
45
|
+
}
|
|
46
|
+
function getFractionalMilliseconds(second) {
|
|
47
|
+
if (second === undefined)
|
|
48
|
+
return 0;
|
|
49
|
+
const fractional = second - Math.floor(second);
|
|
50
|
+
return Math.round(fractional * 1000);
|
|
51
|
+
}
|
|
52
|
+
function normalizeIndexedParts(dayIndex, dayFraction) {
|
|
53
|
+
const roundedSubDayMilliseconds = Math.round(dayFraction * TPS_DAY_MS);
|
|
54
|
+
const dayCarry = floorDiv(roundedSubDayMilliseconds, TPS_DAY_MS);
|
|
55
|
+
const subDayMilliseconds = mod(roundedSubDayMilliseconds, TPS_DAY_MS);
|
|
56
|
+
const normalizedDayIndex = dayIndex + dayCarry;
|
|
57
|
+
return {
|
|
58
|
+
dayIndex: normalizedDayIndex,
|
|
59
|
+
dayFraction: subDayMilliseconds / TPS_DAY_MS,
|
|
60
|
+
subDayMilliseconds,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function splitTpsFullYear(fullYear) {
|
|
64
|
+
const millennium = floorDiv(fullYear, 1000) + 1;
|
|
65
|
+
const withinMillennium = mod(fullYear, 1000);
|
|
66
|
+
const century = floorDiv(withinMillennium, 100) + 1;
|
|
67
|
+
const year = mod(fullYear, 100);
|
|
68
|
+
return { millennium, century, year };
|
|
69
|
+
}
|
|
70
|
+
function getTpsFullYear(components) {
|
|
71
|
+
if (components.millennium !== undefined ||
|
|
72
|
+
components.century !== undefined) {
|
|
73
|
+
return (((components.millennium ?? 1) - 1) * 1000 +
|
|
74
|
+
((components.century ?? 1) - 1) * 100 +
|
|
75
|
+
(components.year ?? 0));
|
|
76
|
+
}
|
|
77
|
+
return components.year ?? 0;
|
|
78
|
+
}
|
|
79
|
+
function getTpsDayOfMonth(components) {
|
|
80
|
+
const day = components.day ?? 1;
|
|
81
|
+
const week = components.week;
|
|
82
|
+
if (week !== undefined && day >= 1 && day <= TPS_DAYS_PER_WEEK) {
|
|
83
|
+
return (week - 1) * TPS_DAYS_PER_WEEK + day;
|
|
84
|
+
}
|
|
85
|
+
return day;
|
|
86
|
+
}
|
|
87
|
+
function getTpsSubDayMilliseconds(input) {
|
|
88
|
+
if (input instanceof Date) {
|
|
89
|
+
const indexed = getTpsIndexedFromDate(input);
|
|
90
|
+
return indexed.subDayMilliseconds;
|
|
91
|
+
}
|
|
92
|
+
if (input.subDayMilliseconds !== undefined) {
|
|
93
|
+
return input.subDayMilliseconds;
|
|
94
|
+
}
|
|
95
|
+
const hour = input.hour ?? 0;
|
|
96
|
+
const minute = input.minute ?? 0;
|
|
97
|
+
const second = Math.floor(input.second ?? 0);
|
|
98
|
+
const millisecond = input.millisecond ?? getFractionalMilliseconds(input.second);
|
|
99
|
+
return (hour * 60 * 60 * 1000 +
|
|
100
|
+
minute * 60 * 1000 +
|
|
101
|
+
second * 1000 +
|
|
102
|
+
millisecond);
|
|
103
|
+
}
|
|
104
|
+
function getTpsIndexedFromDate(date) {
|
|
105
|
+
const deltaMs = date.getTime() - TPS_EPOCH_START_MS;
|
|
106
|
+
const dayIndex = floorDiv(deltaMs, TPS_DAY_MS);
|
|
107
|
+
const subDayMilliseconds = mod(deltaMs, TPS_DAY_MS);
|
|
108
|
+
return {
|
|
109
|
+
dayIndex,
|
|
110
|
+
dayFraction: subDayMilliseconds / TPS_DAY_MS,
|
|
111
|
+
subDayMilliseconds,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function buildTpsComponentsFromDayIndex(dayIndex, dayFraction = 0) {
|
|
115
|
+
const indexed = normalizeIndexedParts(dayIndex, dayFraction);
|
|
116
|
+
const fullYear = floorDiv(indexed.dayIndex, TPS_DAYS_PER_YEAR);
|
|
117
|
+
const dayOfYear = mod(indexed.dayIndex, TPS_DAYS_PER_YEAR);
|
|
118
|
+
const month = floorDiv(dayOfYear, TPS_DAYS_PER_MONTH) + 1;
|
|
119
|
+
const dayOfMonth = mod(dayOfYear, TPS_DAYS_PER_MONTH) + 1;
|
|
120
|
+
const week = floorDiv(dayOfMonth - 1, TPS_DAYS_PER_WEEK) + 1;
|
|
121
|
+
let remainder = indexed.subDayMilliseconds;
|
|
122
|
+
const hour = floorDiv(remainder, 60 * 60 * 1000);
|
|
123
|
+
remainder = mod(remainder, 60 * 60 * 1000);
|
|
124
|
+
const minute = floorDiv(remainder, 60 * 1000);
|
|
125
|
+
remainder = mod(remainder, 60 * 1000);
|
|
126
|
+
const second = floorDiv(remainder, 1000);
|
|
127
|
+
const millisecond = mod(remainder, 1000);
|
|
128
|
+
return {
|
|
129
|
+
calendar: "tps",
|
|
130
|
+
...splitTpsFullYear(fullYear),
|
|
131
|
+
month,
|
|
132
|
+
week,
|
|
133
|
+
day: dayOfMonth,
|
|
134
|
+
hour,
|
|
135
|
+
minute,
|
|
136
|
+
second,
|
|
137
|
+
millisecond,
|
|
138
|
+
dayIndex: indexed.dayIndex,
|
|
139
|
+
dayFraction: indexed.dayFraction,
|
|
140
|
+
subDayMilliseconds: indexed.subDayMilliseconds,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function normalizeTpsComponents(components) {
|
|
144
|
+
if (components.dayIndex !== undefined) {
|
|
145
|
+
const dayFraction = components.dayFraction ??
|
|
146
|
+
(getTpsSubDayMilliseconds(components) / TPS_DAY_MS);
|
|
147
|
+
const normalized = buildTpsComponentsFromDayIndex(components.dayIndex, dayFraction);
|
|
148
|
+
return {
|
|
149
|
+
...components,
|
|
150
|
+
...normalized,
|
|
151
|
+
calendar: "tps",
|
|
152
|
+
fractionPrecision: components.fractionPrecision,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const fullYear = getTpsFullYear(components);
|
|
156
|
+
const month = components.month ?? 1;
|
|
157
|
+
const dayOfMonth = getTpsDayOfMonth(components);
|
|
158
|
+
const subDayMilliseconds = getTpsSubDayMilliseconds(components);
|
|
159
|
+
const week = components.week ?? floorDiv(dayOfMonth - 1, TPS_DAYS_PER_WEEK) + 1;
|
|
160
|
+
const timeParts = buildTpsComponentsFromDayIndex(fullYear * TPS_DAYS_PER_YEAR +
|
|
161
|
+
(month - 1) * TPS_DAYS_PER_MONTH +
|
|
162
|
+
(dayOfMonth - 1), subDayMilliseconds / TPS_DAY_MS);
|
|
163
|
+
return {
|
|
164
|
+
...components,
|
|
165
|
+
...splitTpsFullYear(fullYear),
|
|
166
|
+
...timeParts,
|
|
167
|
+
calendar: "tps",
|
|
168
|
+
month,
|
|
169
|
+
week,
|
|
170
|
+
day: dayOfMonth,
|
|
171
|
+
dayIndex: fullYear * TPS_DAYS_PER_YEAR +
|
|
172
|
+
(month - 1) * TPS_DAYS_PER_MONTH +
|
|
173
|
+
(dayOfMonth - 1),
|
|
174
|
+
dayFraction: subDayMilliseconds / TPS_DAY_MS,
|
|
175
|
+
subDayMilliseconds,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function getTpsDayIndex(input) {
|
|
179
|
+
if (input instanceof Date) {
|
|
180
|
+
return getTpsIndexedFromDate(input).dayIndex;
|
|
181
|
+
}
|
|
182
|
+
if (input.dayIndex !== undefined) {
|
|
183
|
+
return input.dayIndex;
|
|
184
|
+
}
|
|
185
|
+
const fullYear = getTpsFullYear(input);
|
|
186
|
+
const month = input.month ?? 1;
|
|
187
|
+
const dayOfMonth = getTpsDayOfMonth(input);
|
|
188
|
+
return (fullYear * TPS_DAYS_PER_YEAR +
|
|
189
|
+
(month - 1) * TPS_DAYS_PER_MONTH +
|
|
190
|
+
(dayOfMonth - 1));
|
|
191
|
+
}
|
|
192
|
+
function getTpsDayFraction(input) {
|
|
193
|
+
if (input instanceof Date) {
|
|
194
|
+
return getTpsIndexedFromDate(input).dayFraction;
|
|
195
|
+
}
|
|
196
|
+
if (input.dayFraction !== undefined) {
|
|
197
|
+
return input.dayFraction;
|
|
198
|
+
}
|
|
199
|
+
return getTpsSubDayMilliseconds(input) / TPS_DAY_MS;
|
|
200
|
+
}
|
|
201
|
+
function parseTpsIndexedToken(token) {
|
|
202
|
+
const match = token.trim().match(/^i(\d+)(?:\.(\d+))?$/i);
|
|
203
|
+
if (!match)
|
|
204
|
+
return null;
|
|
205
|
+
const dayIndex = Number(match[1]);
|
|
206
|
+
if (!Number.isSafeInteger(dayIndex) || dayIndex < 0) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const digits = match[2];
|
|
210
|
+
if (digits && digits.endsWith("0")) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const dayFraction = digits ? Number(`0.${digits}`) : 0;
|
|
214
|
+
if (!Number.isFinite(dayFraction) || dayFraction < 0 || dayFraction >= 1) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const normalized = normalizeIndexedParts(dayIndex, dayFraction);
|
|
218
|
+
return {
|
|
219
|
+
...normalized,
|
|
220
|
+
fractionPrecision: digits?.length,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function formatTpsIndexedToken(components, precision) {
|
|
224
|
+
const normalized = normalizeTpsComponents(components);
|
|
225
|
+
const dayIndex = normalized.dayIndex ?? 0;
|
|
226
|
+
const dayFraction = normalized.dayFraction ?? 0;
|
|
227
|
+
const effectivePrecision = precision ?? normalized.fractionPrecision ?? 9;
|
|
228
|
+
let fraction = "";
|
|
229
|
+
if (dayFraction > 0 && effectivePrecision > 0) {
|
|
230
|
+
fraction = dayFraction
|
|
231
|
+
.toFixed(effectivePrecision)
|
|
232
|
+
.slice(2)
|
|
233
|
+
.replace(/0+$/g, "");
|
|
234
|
+
}
|
|
235
|
+
return fraction ? `i${dayIndex}.${fraction}` : `i${dayIndex}`;
|
|
236
|
+
}
|
|
237
|
+
function isTpsIndexedToken(token) {
|
|
238
|
+
return /^i\d+(?:\.\d+)?$/i.test(token.trim());
|
|
239
|
+
}
|
|
240
|
+
function validateTpsComponents(components) {
|
|
241
|
+
const month = components.month ?? 1;
|
|
242
|
+
const day = components.day ?? 1;
|
|
243
|
+
const week = components.week;
|
|
244
|
+
const hour = components.hour ?? 0;
|
|
245
|
+
const minute = components.minute ?? 0;
|
|
246
|
+
const second = components.second ?? 0;
|
|
247
|
+
const millisecond = components.millisecond ?? getFractionalMilliseconds(components.second);
|
|
248
|
+
if (components.dayIndex !== undefined &&
|
|
249
|
+
(!Number.isSafeInteger(components.dayIndex) || components.dayIndex < 0)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
if (components.dayFraction !== undefined &&
|
|
253
|
+
(!Number.isFinite(components.dayFraction) ||
|
|
254
|
+
components.dayFraction < 0 ||
|
|
255
|
+
components.dayFraction >= 1)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (month < 1 || month > TPS_MONTHS_PER_YEAR)
|
|
259
|
+
return false;
|
|
260
|
+
if (day < 1 || day > TPS_DAYS_PER_MONTH)
|
|
261
|
+
return false;
|
|
262
|
+
if (week !== undefined && (week < 1 || week > TPS_WEEKS_PER_MONTH)) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
if (week !== undefined && day > TPS_DAYS_PER_WEEK) {
|
|
266
|
+
const expectedWeek = floorDiv(day - 1, TPS_DAYS_PER_WEEK) + 1;
|
|
267
|
+
if (expectedWeek !== week)
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
if (hour < 0 || hour > 23)
|
|
271
|
+
return false;
|
|
272
|
+
if (minute < 0 || minute > 59)
|
|
273
|
+
return false;
|
|
274
|
+
if (second < 0 || second >= 60)
|
|
275
|
+
return false;
|
|
276
|
+
if (millisecond < 0 || millisecond >= 1000)
|
|
277
|
+
return false;
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Generate the canonical `T:` time string for a set of components.
|
|
283
|
+
*/
|
|
284
|
+
function buildTimePart(comp, options) {
|
|
285
|
+
const calendar = (comp.calendar || "").toLowerCase();
|
|
286
|
+
if (!/^[a-z]{3,4}$/.test(calendar)) {
|
|
287
|
+
throw new Error(`Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`);
|
|
288
|
+
}
|
|
289
|
+
let time = `T:${calendar}`;
|
|
290
|
+
if (calendar === DefaultCalendars.UNIX) {
|
|
291
|
+
if (comp.unixSeconds !== undefined) {
|
|
292
|
+
time += `.s${comp.unixSeconds}`;
|
|
293
|
+
}
|
|
294
|
+
return time;
|
|
295
|
+
}
|
|
296
|
+
if (calendar === DefaultCalendars.TPS && options?.timeMode === "indexed-fraction") {
|
|
297
|
+
time += `.${formatTpsIndexedToken(comp, options.indexedPrecision)}`;
|
|
298
|
+
if (comp.signature) {
|
|
299
|
+
time += `!${comp.signature}`;
|
|
300
|
+
}
|
|
301
|
+
return time;
|
|
302
|
+
}
|
|
303
|
+
const source = calendar === DefaultCalendars.TPS ? normalizeTpsComponents(comp) : comp;
|
|
304
|
+
const tokens = [
|
|
305
|
+
["m", source.millennium, 8],
|
|
306
|
+
["c", source.century, 7],
|
|
307
|
+
["y", source.year, 6],
|
|
308
|
+
["m", source.month, 5],
|
|
309
|
+
...(calendar === DefaultCalendars.TPS && source.week !== undefined
|
|
310
|
+
? [["w", source.week, 4.5]]
|
|
311
|
+
: []),
|
|
312
|
+
["d", source.day, 4],
|
|
313
|
+
["h", source.hour, 3],
|
|
314
|
+
["m", source.minute, 2],
|
|
315
|
+
["s", source.second, 1],
|
|
316
|
+
["m", source.millisecond, 0],
|
|
317
|
+
];
|
|
318
|
+
const order = options?.order || source.order || exports.TimeOrder.DESC;
|
|
319
|
+
const activeTokens = order === exports.TimeOrder.ASC ? [...tokens].reverse() : tokens;
|
|
320
|
+
for (const [pref, val] of activeTokens) {
|
|
321
|
+
if (val !== undefined) {
|
|
322
|
+
time += `.${pref}${val}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (source.signature) {
|
|
326
|
+
time += `!${source.signature}`;
|
|
327
|
+
}
|
|
328
|
+
return time;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Parse the time portion of a TPS string into components.
|
|
332
|
+
*/
|
|
333
|
+
function parseTimeString(input) {
|
|
334
|
+
let s = input.trim();
|
|
335
|
+
s = s.split(/[!;?#]/)[0];
|
|
336
|
+
if (s.startsWith("T:"))
|
|
337
|
+
s = s.slice(2);
|
|
338
|
+
const firstDot = s.indexOf(".");
|
|
339
|
+
const calendar = firstDot === -1 ? s : s.slice(0, firstDot);
|
|
340
|
+
const rawTokenString = firstDot === -1 ? "" : s.slice(firstDot + 1);
|
|
341
|
+
if (calendar === DefaultCalendars.TPS && isTpsIndexedToken(rawTokenString)) {
|
|
342
|
+
const indexed = parseTpsIndexedToken(rawTokenString);
|
|
343
|
+
if (!indexed)
|
|
344
|
+
return null;
|
|
345
|
+
return {
|
|
346
|
+
components: normalizeTpsComponents({
|
|
347
|
+
calendar,
|
|
348
|
+
...indexed,
|
|
349
|
+
}),
|
|
350
|
+
order: exports.TimeOrder.DESC,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (calendar === DefaultCalendars.TPS && /^i/i.test(rawTokenString)) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
const parts = s.split(".");
|
|
357
|
+
if (parts.length === 0)
|
|
358
|
+
return null;
|
|
359
|
+
const comp = { calendar };
|
|
360
|
+
const fixedRankMap = {
|
|
361
|
+
c: 7,
|
|
362
|
+
y: 6,
|
|
363
|
+
w: 4.5,
|
|
364
|
+
d: 4,
|
|
365
|
+
h: 3,
|
|
366
|
+
s: 1,
|
|
367
|
+
};
|
|
368
|
+
let initialOrder = exports.TimeOrder.DESC;
|
|
369
|
+
if (calendar !== DefaultCalendars.UNIX) {
|
|
370
|
+
const nonMRanks = [];
|
|
371
|
+
for (let i = 1; i < parts.length; i++) {
|
|
372
|
+
const pr = parts[i]?.charAt(0);
|
|
373
|
+
if (pr && pr in fixedRankMap)
|
|
374
|
+
nonMRanks.push(fixedRankMap[pr]);
|
|
375
|
+
}
|
|
376
|
+
if (nonMRanks.length >= 2) {
|
|
377
|
+
const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
378
|
+
if (isAsc)
|
|
379
|
+
initialOrder = exports.TimeOrder.ASC;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const assignMRank = (lastRank, ord) => {
|
|
383
|
+
if (ord === exports.TimeOrder.DESC) {
|
|
384
|
+
if (lastRank === null)
|
|
385
|
+
return 8;
|
|
386
|
+
if (lastRank > 5)
|
|
387
|
+
return 5;
|
|
388
|
+
if (lastRank > 2)
|
|
389
|
+
return 2;
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
if (lastRank === null)
|
|
394
|
+
return 0;
|
|
395
|
+
if (lastRank < 2)
|
|
396
|
+
return 2;
|
|
397
|
+
if (lastRank < 5)
|
|
398
|
+
return 5;
|
|
399
|
+
return 8;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const ranks = [];
|
|
403
|
+
let lastAssignedRank = null;
|
|
404
|
+
for (let i = 1; i < parts.length; i++) {
|
|
405
|
+
const token = parts[i];
|
|
406
|
+
if (!token)
|
|
407
|
+
continue;
|
|
408
|
+
const prefix = token.charAt(0);
|
|
409
|
+
const value = token.slice(1);
|
|
410
|
+
if (calendar === DefaultCalendars.UNIX && prefix === "s") {
|
|
411
|
+
comp.unixSeconds = parseFloat(value);
|
|
412
|
+
ranks.push(9);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (prefix === "m") {
|
|
416
|
+
const rank = assignMRank(lastAssignedRank, initialOrder);
|
|
417
|
+
switch (rank) {
|
|
418
|
+
case 8:
|
|
419
|
+
comp.millennium = parseInt(value, 10);
|
|
420
|
+
break;
|
|
421
|
+
case 5:
|
|
422
|
+
comp.month = parseInt(value, 10);
|
|
423
|
+
break;
|
|
424
|
+
case 2:
|
|
425
|
+
comp.minute = parseInt(value, 10);
|
|
426
|
+
break;
|
|
427
|
+
case 0:
|
|
428
|
+
comp.millisecond = parseInt(value, 10);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
ranks.push(rank);
|
|
432
|
+
lastAssignedRank = rank;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const rank = fixedRankMap[prefix];
|
|
436
|
+
if (rank !== undefined) {
|
|
437
|
+
switch (prefix) {
|
|
438
|
+
case "c":
|
|
439
|
+
comp.century = parseInt(value, 10);
|
|
440
|
+
break;
|
|
441
|
+
case "y":
|
|
442
|
+
comp.year = parseInt(value, 10);
|
|
443
|
+
break;
|
|
444
|
+
case "w":
|
|
445
|
+
comp.week = parseInt(value, 10);
|
|
446
|
+
break;
|
|
447
|
+
case "d":
|
|
448
|
+
comp.day = parseInt(value, 10);
|
|
449
|
+
break;
|
|
450
|
+
case "h":
|
|
451
|
+
comp.hour = parseInt(value, 10);
|
|
452
|
+
break;
|
|
453
|
+
case "s":
|
|
454
|
+
comp.second = parseFloat(value);
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
ranks.push(rank);
|
|
458
|
+
lastAssignedRank = rank;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
let order = exports.TimeOrder.DESC;
|
|
463
|
+
if (ranks.length > 1) {
|
|
464
|
+
const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
465
|
+
const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
|
|
466
|
+
if (isAsc && !isDesc)
|
|
467
|
+
order = exports.TimeOrder.ASC;
|
|
468
|
+
}
|
|
469
|
+
if (calendar === DefaultCalendars.TPS &&
|
|
470
|
+
comp.month !== undefined &&
|
|
471
|
+
comp.day !== undefined &&
|
|
472
|
+
comp.month >= 1 &&
|
|
473
|
+
comp.day >= 1) {
|
|
474
|
+
return {
|
|
475
|
+
components: normalizeTpsComponents(comp),
|
|
476
|
+
order,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return { components: comp, order };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Gregorian calendar driver.
|
|
484
|
+
* Supports robust validation and canonical Date conversions.
|
|
485
|
+
*/
|
|
486
|
+
class GregorianDriver {
|
|
487
|
+
constructor() {
|
|
488
|
+
this.code = "greg";
|
|
489
|
+
}
|
|
490
|
+
getComponentsFromDate(date) {
|
|
491
|
+
const fullYear = date.getUTCFullYear();
|
|
492
|
+
return {
|
|
493
|
+
calendar: this.code,
|
|
494
|
+
millennium: Math.floor(fullYear / 1000) + 1,
|
|
495
|
+
century: Math.floor((fullYear % 1000) / 100) + 1,
|
|
496
|
+
year: fullYear % 100,
|
|
497
|
+
month: date.getUTCMonth() + 1,
|
|
498
|
+
day: date.getUTCDate(),
|
|
499
|
+
hour: date.getUTCHours(),
|
|
500
|
+
minute: date.getUTCMinutes(),
|
|
501
|
+
second: date.getUTCSeconds(),
|
|
502
|
+
millisecond: date.getUTCMilliseconds(),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
getDateFromComponents(components) {
|
|
506
|
+
const m = components.millennium ?? 0;
|
|
507
|
+
const c = components.century ?? 1;
|
|
508
|
+
const y = components.year ?? 0;
|
|
509
|
+
const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
510
|
+
return new Date(Date.UTC(fullYear, (components.month || 1) - 1, components.day || 1, components.hour || 0, components.minute || 0, Math.floor(components.second || 0), components.millisecond ??
|
|
511
|
+
Math.round(((components.second || 0) % 1) * 1000)));
|
|
512
|
+
}
|
|
513
|
+
getFromDate(date) {
|
|
514
|
+
const comp = this.getComponentsFromDate(date);
|
|
515
|
+
return buildTimePart(comp);
|
|
516
|
+
}
|
|
517
|
+
// --- optional helpers --------------------------------------------------
|
|
518
|
+
parseDate(input, format) {
|
|
519
|
+
const s = input.trim();
|
|
520
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
|
|
521
|
+
if (!m) {
|
|
522
|
+
throw new Error(`GregorianDriver.parseDate: unsupported format "${input}"`);
|
|
523
|
+
}
|
|
524
|
+
const year = parseInt(m[1], 10);
|
|
525
|
+
const month = parseInt(m[2], 10);
|
|
526
|
+
const day = parseInt(m[3], 10);
|
|
527
|
+
const hour = m[4] !== undefined ? parseInt(m[4], 10) : undefined;
|
|
528
|
+
const minute = m[5] !== undefined ? parseInt(m[5], 10) : undefined;
|
|
529
|
+
const second = m[6] !== undefined ? parseInt(m[6], 10) : undefined;
|
|
530
|
+
const millisecond = m[7] !== undefined ? parseInt((m[7] + "000").slice(0, 3), 10) : undefined;
|
|
531
|
+
const comp = {
|
|
532
|
+
calendar: this.code,
|
|
533
|
+
year,
|
|
534
|
+
month,
|
|
535
|
+
day,
|
|
536
|
+
};
|
|
537
|
+
if (hour !== undefined)
|
|
538
|
+
comp.hour = hour;
|
|
539
|
+
if (minute !== undefined)
|
|
540
|
+
comp.minute = minute;
|
|
541
|
+
if (second !== undefined)
|
|
542
|
+
comp.second = second;
|
|
543
|
+
if (millisecond !== undefined)
|
|
544
|
+
comp.millisecond = millisecond;
|
|
545
|
+
return comp;
|
|
546
|
+
}
|
|
547
|
+
format(components, format) {
|
|
548
|
+
const y = components.year !== undefined
|
|
549
|
+
? String(components.year).padStart(4, "0")
|
|
550
|
+
: "0000";
|
|
551
|
+
const mo = components.month !== undefined
|
|
552
|
+
? String(components.month).padStart(2, "0")
|
|
553
|
+
: "01";
|
|
554
|
+
const d = components.day !== undefined
|
|
555
|
+
? String(components.day).padStart(2, "0")
|
|
556
|
+
: "01";
|
|
557
|
+
let out = `${y}-${mo}-${d}`;
|
|
558
|
+
if (components.hour !== undefined ||
|
|
559
|
+
components.minute !== undefined ||
|
|
560
|
+
components.second !== undefined ||
|
|
561
|
+
components.millisecond !== undefined) {
|
|
562
|
+
const h = components.hour !== undefined
|
|
563
|
+
? String(components.hour).padStart(2, "0")
|
|
564
|
+
: "00";
|
|
565
|
+
const mi = components.minute !== undefined
|
|
566
|
+
? String(components.minute).padStart(2, "0")
|
|
567
|
+
: "00";
|
|
568
|
+
const s = components.second !== undefined
|
|
569
|
+
? String(Math.floor(components.second)).padStart(2, "0")
|
|
570
|
+
: "00";
|
|
571
|
+
const ms = components.millisecond !== undefined
|
|
572
|
+
? String(components.millisecond).padStart(3, "0")
|
|
573
|
+
: "000";
|
|
574
|
+
out += `T${h}:${mi}:${s}.${ms}`;
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
isLeap(y) {
|
|
579
|
+
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
580
|
+
}
|
|
581
|
+
validate(input) {
|
|
582
|
+
if (typeof input === "string") {
|
|
583
|
+
return /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)?$/.test(input.trim());
|
|
584
|
+
}
|
|
585
|
+
if (typeof input === "object") {
|
|
586
|
+
const y = input.year ?? 0;
|
|
587
|
+
const m = input.month ?? 1;
|
|
588
|
+
const d = input.day ?? 1;
|
|
589
|
+
if (y < 0 || m < 1 || m > 12 || d < 1)
|
|
590
|
+
return false;
|
|
591
|
+
const daysInMonth = [
|
|
592
|
+
31,
|
|
593
|
+
this.isLeap(y) ? 29 : 28,
|
|
594
|
+
31,
|
|
595
|
+
30,
|
|
596
|
+
31,
|
|
597
|
+
30,
|
|
598
|
+
31,
|
|
599
|
+
31,
|
|
600
|
+
30,
|
|
601
|
+
31,
|
|
602
|
+
30,
|
|
603
|
+
31,
|
|
604
|
+
];
|
|
605
|
+
return d <= daysInMonth[m - 1];
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
getMetadata() {
|
|
610
|
+
return {
|
|
611
|
+
name: "Gregorian",
|
|
612
|
+
monthNames: [
|
|
613
|
+
"January",
|
|
614
|
+
"February",
|
|
615
|
+
"March",
|
|
616
|
+
"April",
|
|
617
|
+
"May",
|
|
618
|
+
"June",
|
|
619
|
+
"July",
|
|
620
|
+
"August",
|
|
621
|
+
"September",
|
|
622
|
+
"October",
|
|
623
|
+
"November",
|
|
624
|
+
"December",
|
|
625
|
+
],
|
|
626
|
+
dayNames: [
|
|
627
|
+
"Sunday",
|
|
628
|
+
"Monday",
|
|
629
|
+
"Tuesday",
|
|
630
|
+
"Wednesday",
|
|
631
|
+
"Thursday",
|
|
632
|
+
"Friday",
|
|
633
|
+
"Saturday",
|
|
634
|
+
],
|
|
635
|
+
monthsPerYear: 12,
|
|
636
|
+
epochYear: 1,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Unix calendar driver. Represents the epoch timestamp in seconds with
|
|
643
|
+
* fractional milliseconds.
|
|
644
|
+
*/
|
|
645
|
+
class UnixDriver {
|
|
646
|
+
constructor() {
|
|
647
|
+
this.code = "unix";
|
|
648
|
+
}
|
|
649
|
+
getComponentsFromDate(date) {
|
|
650
|
+
const s = (date.getTime() / 1000).toFixed(3);
|
|
651
|
+
return { calendar: this.code, unixSeconds: parseFloat(s) };
|
|
652
|
+
}
|
|
653
|
+
getDateFromComponents(components) {
|
|
654
|
+
if (components.unixSeconds !== undefined) {
|
|
655
|
+
return new Date(components.unixSeconds * 1000);
|
|
656
|
+
}
|
|
657
|
+
return new Date(0);
|
|
658
|
+
}
|
|
659
|
+
getFromDate(date) {
|
|
660
|
+
const comp = this.getComponentsFromDate(date);
|
|
661
|
+
return buildTimePart(comp);
|
|
662
|
+
}
|
|
663
|
+
parseDate(input, _format) {
|
|
664
|
+
const s = input.trim();
|
|
665
|
+
if (!/^[0-9]+(?:\.[0-9]+)?$/.test(s)) {
|
|
666
|
+
throw new Error(`UnixDriver.parseDate: unsupported format "${input}"`);
|
|
667
|
+
}
|
|
668
|
+
return { calendar: this.code, unixSeconds: parseFloat(s) };
|
|
669
|
+
}
|
|
670
|
+
format(components, _format) {
|
|
671
|
+
if (components.unixSeconds === undefined)
|
|
672
|
+
throw new Error("UnixDriver.format: missing unixSeconds");
|
|
673
|
+
return new Date(components.unixSeconds * 1000).toISOString();
|
|
674
|
+
}
|
|
675
|
+
validate(input) {
|
|
676
|
+
if (typeof input === "string")
|
|
677
|
+
return /^[0-9]+(?:\.[0-9]+)?$/.test(input.trim());
|
|
678
|
+
if (typeof input === "object")
|
|
679
|
+
return typeof input.unixSeconds === "number" && !isNaN(input.unixSeconds);
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
getMetadata() {
|
|
683
|
+
return {
|
|
684
|
+
name: "Unix Epoch",
|
|
685
|
+
monthsPerYear: 0,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* TPS calendar driver for canonical TPS time strings.
|
|
692
|
+
*
|
|
693
|
+
* TPS Calendar characteristics:
|
|
694
|
+
* - Epoch anchor: 1999-08-11T07:00:00.000Z
|
|
695
|
+
* - Day boundary: 07:00 Gregorian / UTC
|
|
696
|
+
* - Year shape: 12 months × 4 weeks × 7 days = 336 days
|
|
697
|
+
*/
|
|
698
|
+
class TpsDriver {
|
|
699
|
+
constructor() {
|
|
700
|
+
this.code = "tps";
|
|
701
|
+
this.name = "TPS Indexed";
|
|
702
|
+
}
|
|
703
|
+
getComponentsFromDate(date) {
|
|
704
|
+
const deltaMs = date.getTime() - TPS_EPOCH_START_MS;
|
|
705
|
+
const dayIndex = Math.floor(deltaMs / TPS_DAY_MS);
|
|
706
|
+
const dayFraction = ((deltaMs % TPS_DAY_MS) + TPS_DAY_MS) % TPS_DAY_MS / TPS_DAY_MS;
|
|
707
|
+
return buildTpsComponentsFromDayIndex(dayIndex, dayFraction);
|
|
708
|
+
}
|
|
709
|
+
getDateFromComponents(components) {
|
|
710
|
+
const normalized = normalizeTpsComponents({
|
|
711
|
+
...components,
|
|
712
|
+
calendar: this.code,
|
|
713
|
+
});
|
|
714
|
+
const dayIndex = normalized.dayIndex ?? 0;
|
|
715
|
+
const subDayMilliseconds = normalized.subDayMilliseconds ?? 0;
|
|
716
|
+
return new Date(TPS_EPOCH_START_MS + dayIndex * TPS_DAY_MS + subDayMilliseconds);
|
|
717
|
+
}
|
|
718
|
+
getFromDate(date) {
|
|
719
|
+
const comp = this.getComponentsFromDate(date);
|
|
720
|
+
return buildTimePart(comp);
|
|
721
|
+
}
|
|
722
|
+
parseDate(input, _format) {
|
|
723
|
+
const s = input.trim();
|
|
724
|
+
const indexed = parseTpsIndexedToken(s);
|
|
725
|
+
if (indexed) {
|
|
726
|
+
return normalizeTpsComponents({
|
|
727
|
+
calendar: this.code,
|
|
728
|
+
...indexed,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const m = s.match(/^(-?\d{1,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
|
|
732
|
+
if (!m)
|
|
733
|
+
throw new Error(`TpsDriver.parseDate: unsupported format "${input}"`);
|
|
734
|
+
const year = parseInt(m[1], 10);
|
|
735
|
+
const month = parseInt(m[2], 10);
|
|
736
|
+
const day = parseInt(m[3], 10);
|
|
737
|
+
if (month < 1 || month > TPS_MONTHS_PER_YEAR) {
|
|
738
|
+
throw new Error(`TpsDriver.parseDate: invalid TPS month ${month} (expected 1-12)`);
|
|
739
|
+
}
|
|
740
|
+
if (day < 1 || day > TPS_DAYS_PER_MONTH) {
|
|
741
|
+
throw new Error(`TpsDriver.parseDate: invalid TPS day ${day} (expected 1-28)`);
|
|
742
|
+
}
|
|
743
|
+
const hour = m[4] !== undefined ? parseInt(m[4], 10) : undefined;
|
|
744
|
+
const minute = m[5] !== undefined ? parseInt(m[5], 10) : undefined;
|
|
745
|
+
const second = m[6] !== undefined ? parseInt(m[6], 10) : undefined;
|
|
746
|
+
const millisecond = m[7] !== undefined ? parseInt((m[7] + "000").slice(0, 3), 10) : undefined;
|
|
747
|
+
const comp = {
|
|
748
|
+
calendar: this.code,
|
|
749
|
+
year,
|
|
750
|
+
month,
|
|
751
|
+
day,
|
|
752
|
+
};
|
|
753
|
+
if (hour !== undefined)
|
|
754
|
+
comp.hour = hour;
|
|
755
|
+
if (minute !== undefined)
|
|
756
|
+
comp.minute = minute;
|
|
757
|
+
if (second !== undefined)
|
|
758
|
+
comp.second = second;
|
|
759
|
+
if (millisecond !== undefined)
|
|
760
|
+
comp.millisecond = millisecond;
|
|
761
|
+
return normalizeTpsComponents(comp);
|
|
762
|
+
}
|
|
763
|
+
format(components, _format) {
|
|
764
|
+
const normalized = normalizeTpsComponents({
|
|
765
|
+
...components,
|
|
766
|
+
calendar: this.code,
|
|
767
|
+
});
|
|
768
|
+
const fullYear = getTpsFullYear(normalized);
|
|
769
|
+
const y = normalized.year !== undefined
|
|
770
|
+
? String(fullYear).padStart(4, "0")
|
|
771
|
+
: "0000";
|
|
772
|
+
const mo = normalized.month !== undefined
|
|
773
|
+
? String(normalized.month).padStart(2, "0")
|
|
774
|
+
: "01";
|
|
775
|
+
const d = normalized.day !== undefined
|
|
776
|
+
? String(normalized.day).padStart(2, "0")
|
|
777
|
+
: "01";
|
|
778
|
+
let out = `${y}-${mo}-${d}`;
|
|
779
|
+
if (normalized.hour !== undefined ||
|
|
780
|
+
normalized.minute !== undefined ||
|
|
781
|
+
normalized.second !== undefined ||
|
|
782
|
+
normalized.millisecond !== undefined) {
|
|
783
|
+
const h = normalized.hour !== undefined
|
|
784
|
+
? String(normalized.hour).padStart(2, "0")
|
|
785
|
+
: "00";
|
|
786
|
+
const mi = normalized.minute !== undefined
|
|
787
|
+
? String(normalized.minute).padStart(2, "0")
|
|
788
|
+
: "00";
|
|
789
|
+
const s = normalized.second !== undefined
|
|
790
|
+
? String(Math.floor(normalized.second)).padStart(2, "0")
|
|
791
|
+
: "00";
|
|
792
|
+
const ms = normalized.millisecond !== undefined
|
|
793
|
+
? String(normalized.millisecond).padStart(3, "0")
|
|
794
|
+
: "000";
|
|
795
|
+
out += `T${h}:${mi}:${s}.${ms}`;
|
|
796
|
+
}
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
validate(input) {
|
|
800
|
+
if (typeof input === "string") {
|
|
801
|
+
if (parseTpsIndexedToken(input.trim())) {
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
return validateTpsComponents(this.parseDate(input));
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (typeof input === "object") {
|
|
812
|
+
return validateTpsComponents(input);
|
|
813
|
+
}
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
getMetadata() {
|
|
817
|
+
return {
|
|
818
|
+
name: "TPS Native (epoch-based 12x4x7)",
|
|
819
|
+
monthNames: [
|
|
820
|
+
"Month 1",
|
|
821
|
+
"Month 2",
|
|
822
|
+
"Month 3",
|
|
823
|
+
"Month 4",
|
|
824
|
+
"Month 5",
|
|
825
|
+
"Month 6",
|
|
826
|
+
"Month 7",
|
|
827
|
+
"Month 8",
|
|
828
|
+
"Month 9",
|
|
829
|
+
"Month 10",
|
|
830
|
+
"Month 11",
|
|
831
|
+
"Month 12",
|
|
832
|
+
],
|
|
833
|
+
dayNames: [
|
|
834
|
+
"Day 1",
|
|
835
|
+
"Day 2",
|
|
836
|
+
"Day 3",
|
|
837
|
+
"Day 4",
|
|
838
|
+
"Day 5",
|
|
839
|
+
"Day 6",
|
|
840
|
+
"Day 7",
|
|
841
|
+
],
|
|
842
|
+
monthsPerYear: TPS_MONTHS_PER_YEAR,
|
|
843
|
+
epochYear: 1999,
|
|
844
|
+
isLunar: false,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Shared Calendar Math Utilities
|
|
851
|
+
*/
|
|
852
|
+
/**
|
|
853
|
+
* Gregorian -> Julian Day Number
|
|
854
|
+
*/
|
|
855
|
+
function gregorianToJdn(gy, gm, gd) {
|
|
856
|
+
const a = Math.floor((14 - gm) / 12);
|
|
857
|
+
const y = gy + 4800 - a;
|
|
858
|
+
const m = gm + 12 * a - 3;
|
|
859
|
+
return (gd +
|
|
860
|
+
Math.floor((153 * m + 2) / 5) +
|
|
861
|
+
365 * y +
|
|
862
|
+
Math.floor(y / 4) -
|
|
863
|
+
Math.floor(y / 100) +
|
|
864
|
+
Math.floor(y / 400) -
|
|
865
|
+
32045);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Julian Day Number -> Gregorian
|
|
869
|
+
*/
|
|
870
|
+
function jdnToGregorian(jdn) {
|
|
871
|
+
const a = jdn + 32044;
|
|
872
|
+
const b = Math.floor((4 * a + 3) / 146097);
|
|
873
|
+
const c = a - Math.floor((146097 * b) / 4);
|
|
874
|
+
const d = Math.floor((4 * c + 3) / 1461);
|
|
875
|
+
const e = c - Math.floor((1461 * d) / 4);
|
|
876
|
+
const m = Math.floor((5 * e + 2) / 153);
|
|
877
|
+
const gd = e - Math.floor((153 * m + 2) / 5) + 1;
|
|
878
|
+
const gm = m + 3 - 12 * Math.floor(m / 10);
|
|
879
|
+
const gy = 100 * b + d - 4800 + Math.floor(m / 10);
|
|
880
|
+
return { gy, gm, gd };
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Julian -> Julian Day Number
|
|
884
|
+
*/
|
|
885
|
+
function julianToJdn(jy, jm, jd) {
|
|
886
|
+
const a = Math.floor((14 - jm) / 12);
|
|
887
|
+
const y = jy + 4800 - a;
|
|
888
|
+
const m = jm + 12 * a - 3;
|
|
889
|
+
return (jd + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Julian Day Number -> Julian
|
|
893
|
+
*/
|
|
894
|
+
function jdnToJulian(jdn) {
|
|
895
|
+
const c = jdn + 32082;
|
|
896
|
+
const d = Math.floor((4 * c + 3) / 1461);
|
|
897
|
+
const e = c - Math.floor((1461 * d) / 4);
|
|
898
|
+
const m = Math.floor((5 * e + 2) / 153);
|
|
899
|
+
const jd = e - Math.floor((153 * m + 2) / 5) + 1;
|
|
900
|
+
const jm = m + 3 - 12 * Math.floor(m / 10);
|
|
901
|
+
const jy = d - 4800 + Math.floor(m / 10);
|
|
902
|
+
return { jy, jm, jd };
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Persian -> Julian Day Number
|
|
906
|
+
*/
|
|
907
|
+
function persianToJdn(jy, jm, jd) {
|
|
908
|
+
const EPOCH = 1948320;
|
|
909
|
+
const epbase = jy - (jy >= 0 ? 474 : 473);
|
|
910
|
+
const epyear = 474 + (epbase % 2820);
|
|
911
|
+
return (jd +
|
|
912
|
+
(jm <= 7 ? (jm - 1) * 31 : (jm - 1) * 30 + 6) +
|
|
913
|
+
Math.floor((epyear * 682 - 110) / 2816) +
|
|
914
|
+
(epyear - 1) * 365 +
|
|
915
|
+
Math.floor(epbase / 2820) * 1029983 +
|
|
916
|
+
EPOCH -
|
|
917
|
+
1);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Julian Day Number -> Persian
|
|
921
|
+
*/
|
|
922
|
+
function jdnToPersian(jdn) {
|
|
923
|
+
const depoch = jdn - persianToJdn(475, 1, 1);
|
|
924
|
+
const cycle = Math.floor(depoch / 1029983);
|
|
925
|
+
const cyear = depoch % 1029983;
|
|
926
|
+
let ycycle;
|
|
927
|
+
if (cyear === 1029982) {
|
|
928
|
+
ycycle = 2820;
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
const aux1 = Math.floor(cyear / 366);
|
|
932
|
+
const aux2 = cyear % 366;
|
|
933
|
+
ycycle =
|
|
934
|
+
Math.floor((2134 * aux1 + 2816 * aux2 + 2815) / 1028522) + aux1 + 1;
|
|
935
|
+
}
|
|
936
|
+
const jy = ycycle + 2820 * cycle + 474;
|
|
937
|
+
const yday = jdn - persianToJdn(jy, 1, 1) + 1;
|
|
938
|
+
const jm = yday <= 186 ? Math.ceil(yday / 31) : Math.ceil((yday - 6) / 30);
|
|
939
|
+
const jd = jdn - persianToJdn(jy, jm, 1) + 1;
|
|
940
|
+
return { jy: jy <= 0 ? jy - 1 : jy, jm, jd };
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Convert Gregorian to Hijri (Tabular Islamic Calendar).
|
|
944
|
+
*/
|
|
945
|
+
function gregorianToHijri(gy, gm, gd) {
|
|
946
|
+
const jdn = gregorianToJdn(gy, gm, gd);
|
|
947
|
+
const L = jdn - 1948440 + 10632;
|
|
948
|
+
const N = Math.floor((L - 1) / 10631);
|
|
949
|
+
const L2 = L - 10631 * N + 354;
|
|
950
|
+
const J = Math.floor((10985 - L2) / 5316) * Math.floor((50 * L2) / 17719) +
|
|
951
|
+
Math.floor(L2 / 5670) * Math.floor((43 * L2) / 15238);
|
|
952
|
+
const L3 = L2 -
|
|
953
|
+
Math.floor((30 - J) / 15) * Math.floor((17719 * J) / 50) -
|
|
954
|
+
Math.floor(J / 16) * Math.floor((15238 * J) / 43) +
|
|
955
|
+
29;
|
|
956
|
+
const hm = Math.floor((24 * L3) / 709);
|
|
957
|
+
const hd = L3 - Math.floor((709 * hm) / 24);
|
|
958
|
+
const hy = 30 * N + J - 30;
|
|
959
|
+
return { hy, hm, hd };
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Convert Hijri to Gregorian.
|
|
963
|
+
*/
|
|
964
|
+
function hijriToGregorian(hy, hm, hd) {
|
|
965
|
+
const jdn = Math.floor((11 * hy + 3) / 30) +
|
|
966
|
+
354 * hy +
|
|
967
|
+
30 * hm -
|
|
968
|
+
Math.floor((hm - 1) / 2) +
|
|
969
|
+
hd +
|
|
970
|
+
1948440 -
|
|
971
|
+
385;
|
|
972
|
+
return jdnToGregorian(jdn);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
class PersianDriver {
|
|
976
|
+
constructor() {
|
|
977
|
+
this.code = "per";
|
|
978
|
+
this.name = "Persian (Jalali/Solar Hijri)";
|
|
979
|
+
this.MONTH_NAMES = [
|
|
980
|
+
"Farvardin",
|
|
981
|
+
"Ordibehesht",
|
|
982
|
+
"Khordad",
|
|
983
|
+
"Tir",
|
|
984
|
+
"Mordad",
|
|
985
|
+
"Shahrivar",
|
|
986
|
+
"Mehr",
|
|
987
|
+
"Aban",
|
|
988
|
+
"Azar",
|
|
989
|
+
"Dey",
|
|
990
|
+
"Bahman",
|
|
991
|
+
"Esfand",
|
|
992
|
+
];
|
|
993
|
+
this.MONTH_NAMES_SHORT = [
|
|
994
|
+
"Far",
|
|
995
|
+
"Ord",
|
|
996
|
+
"Kho",
|
|
997
|
+
"Tir",
|
|
998
|
+
"Mor",
|
|
999
|
+
"Sha",
|
|
1000
|
+
"Meh",
|
|
1001
|
+
"Aba",
|
|
1002
|
+
"Aza",
|
|
1003
|
+
"Dey",
|
|
1004
|
+
"Bah",
|
|
1005
|
+
"Esf",
|
|
1006
|
+
];
|
|
1007
|
+
this.DAY_NAMES = [
|
|
1008
|
+
"Yekshanbeh",
|
|
1009
|
+
"Doshanbeh",
|
|
1010
|
+
"Seshanbeh",
|
|
1011
|
+
"Chaharshanbeh",
|
|
1012
|
+
"Panjshanbeh",
|
|
1013
|
+
"Jomeh",
|
|
1014
|
+
"Shanbeh",
|
|
1015
|
+
];
|
|
1016
|
+
/** Days per month (non-leap): 6×31 + 5×30 + 1×29 = 365 */
|
|
1017
|
+
this.DAYS_IN_MONTH = [
|
|
1018
|
+
31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29,
|
|
1019
|
+
];
|
|
1020
|
+
}
|
|
1021
|
+
getComponentsFromDate(date) {
|
|
1022
|
+
const jdn = gregorianToJdn(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
|
|
1023
|
+
const { jy, jm, jd } = jdnToPersian(jdn);
|
|
1024
|
+
return {
|
|
1025
|
+
calendar: this.code,
|
|
1026
|
+
millennium: Math.floor(jy / 1000) + 1,
|
|
1027
|
+
century: Math.floor((jy % 1000) / 100) + 1,
|
|
1028
|
+
year: jy % 100,
|
|
1029
|
+
month: jm,
|
|
1030
|
+
day: jd,
|
|
1031
|
+
hour: date.getUTCHours(),
|
|
1032
|
+
minute: date.getUTCMinutes(),
|
|
1033
|
+
second: date.getUTCSeconds(),
|
|
1034
|
+
millisecond: date.getUTCMilliseconds(),
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
getDateFromComponents(components) {
|
|
1038
|
+
let jy;
|
|
1039
|
+
if (components.millennium !== undefined) {
|
|
1040
|
+
const m = components.millennium ?? 0;
|
|
1041
|
+
const c = components.century ?? 1;
|
|
1042
|
+
const y = components.year ?? 0;
|
|
1043
|
+
jy = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
jy = components.year ?? 1;
|
|
1047
|
+
}
|
|
1048
|
+
const jm = components.month ?? 1;
|
|
1049
|
+
const jd = components.day ?? 1;
|
|
1050
|
+
const jdn = persianToJdn(jy, jm, jd);
|
|
1051
|
+
const { gy, gm, gd } = jdnToGregorian(jdn);
|
|
1052
|
+
return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
|
|
1053
|
+
}
|
|
1054
|
+
getFromDate(date) {
|
|
1055
|
+
const comp = this.getComponentsFromDate(date);
|
|
1056
|
+
return buildTimePart(comp);
|
|
1057
|
+
}
|
|
1058
|
+
parseDate(input, format) {
|
|
1059
|
+
const trimmed = input.trim();
|
|
1060
|
+
if (format === "short" ||
|
|
1061
|
+
(trimmed.includes("/") && trimmed.split("/")[0].length <= 2)) {
|
|
1062
|
+
const parts = trimmed.split("/").map(Number);
|
|
1063
|
+
let fullYear, month, day;
|
|
1064
|
+
if (parts[0] > 31) {
|
|
1065
|
+
[fullYear, month, day] = parts;
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
[day, month, fullYear] = parts;
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
calendar: this.code,
|
|
1072
|
+
millennium: Math.floor(fullYear / 1000) + 1,
|
|
1073
|
+
century: Math.floor((fullYear % 1000) / 100) + 1,
|
|
1074
|
+
year: fullYear % 100,
|
|
1075
|
+
month,
|
|
1076
|
+
day,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
const segments = trimmed.split(/[\s,T]+/);
|
|
1080
|
+
const [parsedYear, month, day] = segments[0].split(/[-/]/).map(Number);
|
|
1081
|
+
const result = { calendar: this.code };
|
|
1082
|
+
const fullYear = parsedYear;
|
|
1083
|
+
result.millennium = Math.floor(fullYear / 1000) + 1;
|
|
1084
|
+
result.century = Math.floor((fullYear % 1000) / 100) + 1;
|
|
1085
|
+
result.year = fullYear % 100;
|
|
1086
|
+
result.month = month;
|
|
1087
|
+
result.day = day;
|
|
1088
|
+
if (segments[1]) {
|
|
1089
|
+
const [h, m, s] = segments[1].split(":").map(Number);
|
|
1090
|
+
result.hour = h ?? 0;
|
|
1091
|
+
result.minute = m ?? 0;
|
|
1092
|
+
result.second = s ?? 0;
|
|
1093
|
+
}
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
format(components, format) {
|
|
1097
|
+
const pad = (n) => String(n ?? 0).padStart(2, "0");
|
|
1098
|
+
let fullYear;
|
|
1099
|
+
if (components.millennium !== undefined) {
|
|
1100
|
+
const m = components.millennium ?? 0;
|
|
1101
|
+
const c = components.century ?? 1;
|
|
1102
|
+
const y = components.year ?? 0;
|
|
1103
|
+
fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
fullYear = components.year ?? 0;
|
|
1107
|
+
}
|
|
1108
|
+
if (format === "short")
|
|
1109
|
+
return `${components.day}/${pad(components.month)}/${fullYear}`;
|
|
1110
|
+
if (format === "long") {
|
|
1111
|
+
const mn = this.MONTH_NAMES[(components.month ?? 1) - 1];
|
|
1112
|
+
return `${components.day} ${mn} ${fullYear}`;
|
|
1113
|
+
}
|
|
1114
|
+
let out = `${fullYear}-${pad(components.month)}-${pad(components.day)}`;
|
|
1115
|
+
if (components.hour !== undefined) {
|
|
1116
|
+
out += ` ${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
|
|
1117
|
+
}
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
validate(input) {
|
|
1121
|
+
let comp;
|
|
1122
|
+
if (typeof input === "string") {
|
|
1123
|
+
try {
|
|
1124
|
+
comp = this.parseDate(input);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
comp = input;
|
|
1132
|
+
}
|
|
1133
|
+
const { year, month, day } = comp;
|
|
1134
|
+
if (year === undefined || year < 1)
|
|
1135
|
+
return false;
|
|
1136
|
+
if (month === undefined || month < 1 || month > 12)
|
|
1137
|
+
return false;
|
|
1138
|
+
if (day === undefined || day < 1)
|
|
1139
|
+
return false;
|
|
1140
|
+
// leap check
|
|
1141
|
+
const leapYears = [1, 5, 9, 13, 17, 22, 26, 30];
|
|
1142
|
+
const cycle = ((year - 1) % 33) + 1;
|
|
1143
|
+
const isLeap = leapYears.includes(cycle);
|
|
1144
|
+
let max = this.DAYS_IN_MONTH[month - 1];
|
|
1145
|
+
if (month === 12 && isLeap)
|
|
1146
|
+
max = 30;
|
|
1147
|
+
return day <= max;
|
|
1148
|
+
}
|
|
1149
|
+
getMetadata() {
|
|
1150
|
+
return {
|
|
1151
|
+
name: "Persian (Jalali/Solar Hijri)",
|
|
1152
|
+
monthNames: this.MONTH_NAMES,
|
|
1153
|
+
monthNamesShort: this.MONTH_NAMES_SHORT,
|
|
1154
|
+
dayNames: this.DAY_NAMES,
|
|
1155
|
+
dayNamesShort: this.DAY_NAMES.map((d) => d.slice(0, 3)),
|
|
1156
|
+
isLunar: false,
|
|
1157
|
+
monthsPerYear: 12,
|
|
1158
|
+
epochYear: 622,
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
class HijriDriver {
|
|
1164
|
+
constructor() {
|
|
1165
|
+
this.code = "hij";
|
|
1166
|
+
this.name = "Hijri (Islamic Calendar)";
|
|
1167
|
+
this.MONTH_NAMES = [
|
|
1168
|
+
"Muharram",
|
|
1169
|
+
"Safar",
|
|
1170
|
+
"Rabi' al-Awwal",
|
|
1171
|
+
"Rabi' al-Thani",
|
|
1172
|
+
"Jumada al-Ula",
|
|
1173
|
+
"Jumada al-Thani",
|
|
1174
|
+
"Rajab",
|
|
1175
|
+
"Sha'ban",
|
|
1176
|
+
"Ramadan",
|
|
1177
|
+
"Shawwal",
|
|
1178
|
+
"Dhu al-Qi'dah",
|
|
1179
|
+
"Dhu al-Hijjah",
|
|
1180
|
+
];
|
|
1181
|
+
this.MONTH_NAMES_SHORT = [
|
|
1182
|
+
"Muh",
|
|
1183
|
+
"Saf",
|
|
1184
|
+
"Rab I",
|
|
1185
|
+
"Rab II",
|
|
1186
|
+
"Jum I",
|
|
1187
|
+
"Jum II",
|
|
1188
|
+
"Raj",
|
|
1189
|
+
"Sha",
|
|
1190
|
+
"Ram",
|
|
1191
|
+
"Shaw",
|
|
1192
|
+
"Dhu Q",
|
|
1193
|
+
"Dhu H",
|
|
1194
|
+
];
|
|
1195
|
+
this.DAY_NAMES = [
|
|
1196
|
+
"al-Ahad",
|
|
1197
|
+
"al-Ithnayn",
|
|
1198
|
+
"ath-Thulatha",
|
|
1199
|
+
"al-Arbi'a",
|
|
1200
|
+
"al-Khamis",
|
|
1201
|
+
"al-Jumu'ah",
|
|
1202
|
+
"as-Sabt",
|
|
1203
|
+
];
|
|
1204
|
+
}
|
|
1205
|
+
getComponentsFromDate(date) {
|
|
1206
|
+
const { hy, hm, hd } = gregorianToHijri(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
|
|
1207
|
+
return {
|
|
1208
|
+
calendar: this.code,
|
|
1209
|
+
millennium: Math.floor(hy / 1000) + 1,
|
|
1210
|
+
century: Math.floor((hy % 1000) / 100) + 1,
|
|
1211
|
+
year: hy % 100,
|
|
1212
|
+
month: hm,
|
|
1213
|
+
day: hd,
|
|
1214
|
+
hour: date.getUTCHours(),
|
|
1215
|
+
minute: date.getUTCMinutes(),
|
|
1216
|
+
second: date.getUTCSeconds(),
|
|
1217
|
+
millisecond: date.getUTCMilliseconds(),
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
getDateFromComponents(components) {
|
|
1221
|
+
let hy;
|
|
1222
|
+
if (components.millennium !== undefined) {
|
|
1223
|
+
const m = components.millennium ?? 0;
|
|
1224
|
+
const c = components.century ?? 1;
|
|
1225
|
+
const y = components.year ?? 0;
|
|
1226
|
+
hy = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
hy = components.year ?? 1;
|
|
1230
|
+
}
|
|
1231
|
+
const hm = components.month ?? 1;
|
|
1232
|
+
const hd = components.day ?? 1;
|
|
1233
|
+
const { gy, gm, gd } = hijriToGregorian(hy, hm, hd);
|
|
1234
|
+
return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
|
|
1235
|
+
}
|
|
1236
|
+
getFromDate(date) {
|
|
1237
|
+
const comp = this.getComponentsFromDate(date);
|
|
1238
|
+
return buildTimePart(comp);
|
|
1239
|
+
}
|
|
1240
|
+
parseDate(input, format) {
|
|
1241
|
+
const trimmed = input.trim();
|
|
1242
|
+
if (format === "short" ||
|
|
1243
|
+
(trimmed.includes("/") && trimmed.split("/")[0].length <= 2)) {
|
|
1244
|
+
const [day, month, year] = trimmed.split("/").map(Number);
|
|
1245
|
+
return {
|
|
1246
|
+
calendar: this.code,
|
|
1247
|
+
millennium: Math.floor(year / 1000) + 1,
|
|
1248
|
+
century: Math.floor((year % 1000) / 100) + 1,
|
|
1249
|
+
year: year % 100,
|
|
1250
|
+
month,
|
|
1251
|
+
day,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const segments = trimmed.split(/[\s,T]+/);
|
|
1255
|
+
const [fullYear, month, day] = segments[0].split("-").map(Number);
|
|
1256
|
+
const result = {
|
|
1257
|
+
calendar: this.code,
|
|
1258
|
+
millennium: Math.floor(fullYear / 1000) + 1,
|
|
1259
|
+
century: Math.floor((fullYear % 1000) / 100) + 1,
|
|
1260
|
+
year: fullYear % 100,
|
|
1261
|
+
month,
|
|
1262
|
+
day,
|
|
1263
|
+
};
|
|
1264
|
+
if (segments[1]) {
|
|
1265
|
+
const [h, m, s] = segments[1].split(":").map(Number);
|
|
1266
|
+
result.hour = h ?? 0;
|
|
1267
|
+
result.minute = m ?? 0;
|
|
1268
|
+
result.second = s ?? 0;
|
|
1269
|
+
}
|
|
1270
|
+
return result;
|
|
1271
|
+
}
|
|
1272
|
+
format(components, format) {
|
|
1273
|
+
const pad = (n) => String(n ?? 0).padStart(2, "0");
|
|
1274
|
+
let fullYear;
|
|
1275
|
+
if (components.millennium !== undefined) {
|
|
1276
|
+
const m = components.millennium ?? 0;
|
|
1277
|
+
const c = components.century ?? 1;
|
|
1278
|
+
const y = components.year ?? 0;
|
|
1279
|
+
fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
fullYear = components.year ?? 0;
|
|
1283
|
+
}
|
|
1284
|
+
if (format === "short")
|
|
1285
|
+
return `${components.day}/${pad(components.month)}/${fullYear}`;
|
|
1286
|
+
if (format === "long") {
|
|
1287
|
+
const mn = this.MONTH_NAMES[(components.month ?? 1) - 1];
|
|
1288
|
+
return `${components.day} ${mn} ${fullYear}`;
|
|
1289
|
+
}
|
|
1290
|
+
let out = `${fullYear}-${pad(components.month)}-${pad(components.day)}`;
|
|
1291
|
+
if (components.hour !== undefined) {
|
|
1292
|
+
out += ` ${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
|
|
1293
|
+
}
|
|
1294
|
+
return out;
|
|
1295
|
+
}
|
|
1296
|
+
validate(input) {
|
|
1297
|
+
let comp;
|
|
1298
|
+
if (typeof input === "string") {
|
|
1299
|
+
try {
|
|
1300
|
+
comp = this.parseDate(input);
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
else {
|
|
1307
|
+
comp = input;
|
|
1308
|
+
}
|
|
1309
|
+
const { year, month, day } = comp;
|
|
1310
|
+
let fullYear;
|
|
1311
|
+
if (comp.millennium !== undefined) {
|
|
1312
|
+
fullYear =
|
|
1313
|
+
((comp.millennium ?? 0) - 1) * 1000 +
|
|
1314
|
+
((comp.century ?? 1) - 1) * 100 +
|
|
1315
|
+
(year ?? 0);
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
fullYear = year ?? 0;
|
|
1319
|
+
}
|
|
1320
|
+
if (fullYear < 1)
|
|
1321
|
+
return false;
|
|
1322
|
+
if (!month || month < 1 || month > 12)
|
|
1323
|
+
return false;
|
|
1324
|
+
if (!day || day < 1)
|
|
1325
|
+
return false;
|
|
1326
|
+
// leap check (cycle of 30 years)
|
|
1327
|
+
const isLeap = new Set([2, 5, 7, 10, 13, 16, 18, 21, 24, 26, 29]).has(((fullYear - 1) % 30) + 1);
|
|
1328
|
+
const maxDays = month === 12 && isLeap ? 30 : month % 2 === 1 ? 30 : 29;
|
|
1329
|
+
return day <= maxDays;
|
|
1330
|
+
}
|
|
1331
|
+
getMetadata() {
|
|
1332
|
+
return {
|
|
1333
|
+
name: "Hijri (Islamic Calendar)",
|
|
1334
|
+
monthNames: this.MONTH_NAMES,
|
|
1335
|
+
monthNamesShort: this.MONTH_NAMES_SHORT,
|
|
1336
|
+
dayNames: this.DAY_NAMES,
|
|
1337
|
+
dayNamesShort: this.DAY_NAMES.map((d) => d.slice(0, 3)),
|
|
1338
|
+
isLunar: true,
|
|
1339
|
+
monthsPerYear: 12,
|
|
1340
|
+
epochYear: 1,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
class JulianDriver {
|
|
1346
|
+
constructor() {
|
|
1347
|
+
this.code = "jul";
|
|
1348
|
+
this.name = "Julian Calendar";
|
|
1349
|
+
this.MONTH_NAMES = [
|
|
1350
|
+
"Januarius",
|
|
1351
|
+
"Februarius",
|
|
1352
|
+
"Martius",
|
|
1353
|
+
"Aprilis",
|
|
1354
|
+
"Maius",
|
|
1355
|
+
"Junius",
|
|
1356
|
+
"Julius",
|
|
1357
|
+
"Augustus",
|
|
1358
|
+
"September",
|
|
1359
|
+
"October",
|
|
1360
|
+
"November",
|
|
1361
|
+
"December",
|
|
1362
|
+
];
|
|
1363
|
+
this.MONTH_NAMES_SHORT = [
|
|
1364
|
+
"Jan",
|
|
1365
|
+
"Feb",
|
|
1366
|
+
"Mar",
|
|
1367
|
+
"Apr",
|
|
1368
|
+
"May",
|
|
1369
|
+
"Jun",
|
|
1370
|
+
"Jul",
|
|
1371
|
+
"Aug",
|
|
1372
|
+
"Sep",
|
|
1373
|
+
"Oct",
|
|
1374
|
+
"Nov",
|
|
1375
|
+
"Dec",
|
|
1376
|
+
];
|
|
1377
|
+
this.DAYS_IN_MONTH = [
|
|
1378
|
+
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
|
1379
|
+
];
|
|
1380
|
+
}
|
|
1381
|
+
getComponentsFromDate(date) {
|
|
1382
|
+
const jdn = gregorianToJdn(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
|
|
1383
|
+
const { jy, jm, jd } = jdnToJulian(jdn);
|
|
1384
|
+
return {
|
|
1385
|
+
calendar: this.code,
|
|
1386
|
+
millennium: Math.floor(jy / 1000) + 1,
|
|
1387
|
+
century: Math.floor((jy % 1000) / 100) + 1,
|
|
1388
|
+
year: jy % 100,
|
|
1389
|
+
month: jm,
|
|
1390
|
+
day: jd,
|
|
1391
|
+
hour: date.getUTCHours(),
|
|
1392
|
+
minute: date.getUTCMinutes(),
|
|
1393
|
+
second: date.getUTCSeconds(),
|
|
1394
|
+
millisecond: date.getUTCMilliseconds(),
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
getDateFromComponents(components) {
|
|
1398
|
+
let fullYear;
|
|
1399
|
+
if (components.millennium !== undefined) {
|
|
1400
|
+
const m = components.millennium ?? 0;
|
|
1401
|
+
const c = components.century ?? 1;
|
|
1402
|
+
const y = components.year ?? 0;
|
|
1403
|
+
fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1404
|
+
}
|
|
1405
|
+
else {
|
|
1406
|
+
fullYear = components.year ?? 1;
|
|
1407
|
+
}
|
|
1408
|
+
const jm = components.month ?? 1;
|
|
1409
|
+
const jd = components.day ?? 1;
|
|
1410
|
+
const jdn = julianToJdn(fullYear, jm, jd);
|
|
1411
|
+
const { gy, gm, gd } = jdnToGregorian(jdn);
|
|
1412
|
+
return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
|
|
1413
|
+
}
|
|
1414
|
+
getFromDate(date) {
|
|
1415
|
+
const comp = this.getComponentsFromDate(date);
|
|
1416
|
+
return buildTimePart(comp);
|
|
1417
|
+
}
|
|
1418
|
+
parseDate(input, _format) {
|
|
1419
|
+
const trimmed = input.trim();
|
|
1420
|
+
const m = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
|
|
1421
|
+
if (!m)
|
|
1422
|
+
throw new Error(`JulianDriver.parseDate: unsupported format "${input}"`);
|
|
1423
|
+
const fullYear = parseInt(m[1], 10);
|
|
1424
|
+
const result = {
|
|
1425
|
+
calendar: this.code,
|
|
1426
|
+
millennium: Math.floor(fullYear / 1000) + 1,
|
|
1427
|
+
century: Math.floor((fullYear % 1000) / 100) + 1,
|
|
1428
|
+
year: fullYear % 100,
|
|
1429
|
+
month: parseInt(m[2], 10),
|
|
1430
|
+
day: parseInt(m[3], 10),
|
|
1431
|
+
};
|
|
1432
|
+
if (m[4] !== undefined)
|
|
1433
|
+
result.hour = parseInt(m[4], 10);
|
|
1434
|
+
if (m[5] !== undefined)
|
|
1435
|
+
result.minute = parseInt(m[5], 10);
|
|
1436
|
+
if (m[6] !== undefined)
|
|
1437
|
+
result.second = parseInt(m[6], 10);
|
|
1438
|
+
if (m[7] !== undefined)
|
|
1439
|
+
result.millisecond = parseInt((m[7] + "000").slice(0, 3), 10);
|
|
1440
|
+
return result;
|
|
1441
|
+
}
|
|
1442
|
+
format(components, _format) {
|
|
1443
|
+
const pad = (n, w = 2) => String(n ?? 0).padStart(w, "0");
|
|
1444
|
+
let fullYear;
|
|
1445
|
+
if (components.millennium !== undefined) {
|
|
1446
|
+
const m = components.millennium ?? 0;
|
|
1447
|
+
const c = components.century ?? 1;
|
|
1448
|
+
const y = components.year ?? 0;
|
|
1449
|
+
fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
fullYear = components.year ?? 0;
|
|
1453
|
+
}
|
|
1454
|
+
let out = `${pad(fullYear, 4)}-${pad(components.month)}-${pad(components.day)}`;
|
|
1455
|
+
if (components.hour !== undefined ||
|
|
1456
|
+
components.minute !== undefined ||
|
|
1457
|
+
components.second !== undefined ||
|
|
1458
|
+
components.millisecond !== undefined) {
|
|
1459
|
+
out += `T${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
|
|
1460
|
+
if (components.millisecond !== undefined)
|
|
1461
|
+
out += `.${pad(components.millisecond, 3)}`;
|
|
1462
|
+
}
|
|
1463
|
+
return out;
|
|
1464
|
+
}
|
|
1465
|
+
validate(input) {
|
|
1466
|
+
if (typeof input === "string") {
|
|
1467
|
+
return /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)?$/.test(input.trim());
|
|
1468
|
+
}
|
|
1469
|
+
if (typeof input === "object") {
|
|
1470
|
+
let fullYear;
|
|
1471
|
+
if (input.millennium !== undefined) {
|
|
1472
|
+
fullYear =
|
|
1473
|
+
((input.millennium ?? 0) - 1) * 1000 +
|
|
1474
|
+
((input.century ?? 1) - 1) * 100 +
|
|
1475
|
+
(input.year ?? 0);
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
fullYear = input.year ?? 0;
|
|
1479
|
+
}
|
|
1480
|
+
const { month, day } = input;
|
|
1481
|
+
if (month === undefined || day === undefined)
|
|
1482
|
+
return false;
|
|
1483
|
+
if (month < 1 || month > 12 || day < 1)
|
|
1484
|
+
return false;
|
|
1485
|
+
let maxDay = this.DAYS_IN_MONTH[month - 1];
|
|
1486
|
+
if (month === 2 && fullYear % 4 === 0)
|
|
1487
|
+
maxDay = 29;
|
|
1488
|
+
return day <= maxDay;
|
|
1489
|
+
}
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
getMetadata() {
|
|
1493
|
+
return {
|
|
1494
|
+
name: "Julian Calendar",
|
|
1495
|
+
monthNames: this.MONTH_NAMES,
|
|
1496
|
+
monthNamesShort: this.MONTH_NAMES_SHORT,
|
|
1497
|
+
isLunar: false,
|
|
1498
|
+
monthsPerYear: 12,
|
|
1499
|
+
epochYear: 1,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Holocene (Human Era) Calendar Driver
|
|
1506
|
+
*/
|
|
1507
|
+
class HoloceneDriver {
|
|
1508
|
+
constructor() {
|
|
1509
|
+
this.code = "holo";
|
|
1510
|
+
this.name = "Holocene (Human Era)";
|
|
1511
|
+
this.gregorian = new GregorianDriver();
|
|
1512
|
+
this.YEAR_OFFSET = 10000;
|
|
1513
|
+
}
|
|
1514
|
+
getComponentsFromDate(date) {
|
|
1515
|
+
const greg = this.gregorian.getComponentsFromDate(date);
|
|
1516
|
+
const fullYear = date.getUTCFullYear() + this.YEAR_OFFSET;
|
|
1517
|
+
return {
|
|
1518
|
+
...greg,
|
|
1519
|
+
calendar: this.code,
|
|
1520
|
+
millennium: Math.floor(fullYear / 1000) + 1,
|
|
1521
|
+
century: Math.floor((fullYear % 1000) / 100) + 1,
|
|
1522
|
+
year: fullYear % 100,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
getDateFromComponents(components) {
|
|
1526
|
+
const m = components.millennium ?? 0;
|
|
1527
|
+
const c = components.century ?? 1;
|
|
1528
|
+
const y = components.year ?? 0;
|
|
1529
|
+
const holoYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1530
|
+
const gregYear = holoYear - this.YEAR_OFFSET;
|
|
1531
|
+
return new Date(Date.UTC(gregYear, (components.month ?? 1) - 1, components.day ?? 1, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ??
|
|
1532
|
+
Math.round(((components.second ?? 0) % 1) * 1000)));
|
|
1533
|
+
}
|
|
1534
|
+
getFromDate(date) {
|
|
1535
|
+
const comp = this.getComponentsFromDate(date);
|
|
1536
|
+
return buildTimePart(comp);
|
|
1537
|
+
}
|
|
1538
|
+
parseDate(input, _format) {
|
|
1539
|
+
const m = input
|
|
1540
|
+
.trim()
|
|
1541
|
+
.match(/^(\d{4,5})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
|
|
1542
|
+
if (!m)
|
|
1543
|
+
throw new Error(`HoloceneDriver.parseDate: unsupported format "${input}"`);
|
|
1544
|
+
const result = {
|
|
1545
|
+
calendar: this.code,
|
|
1546
|
+
year: parseInt(m[1], 10),
|
|
1547
|
+
month: parseInt(m[2], 10),
|
|
1548
|
+
day: parseInt(m[3], 10),
|
|
1549
|
+
};
|
|
1550
|
+
if (m[4] !== undefined)
|
|
1551
|
+
result.hour = parseInt(m[4], 10);
|
|
1552
|
+
if (m[5] !== undefined)
|
|
1553
|
+
result.minute = parseInt(m[5], 10);
|
|
1554
|
+
if (m[6] !== undefined)
|
|
1555
|
+
result.second = parseInt(m[6], 10);
|
|
1556
|
+
if (m[7] !== undefined)
|
|
1557
|
+
result.millisecond = parseInt((m[7] + "000").slice(0, 3), 10);
|
|
1558
|
+
return result;
|
|
1559
|
+
}
|
|
1560
|
+
format(components, _format) {
|
|
1561
|
+
const pad = (n, w = 2) => String(n ?? 0).padStart(w, "0");
|
|
1562
|
+
let holoYear;
|
|
1563
|
+
if (components.millennium !== undefined) {
|
|
1564
|
+
const m = components.millennium ?? 0;
|
|
1565
|
+
const c = components.century ?? 1;
|
|
1566
|
+
const y = components.year ?? 0;
|
|
1567
|
+
holoYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
1568
|
+
}
|
|
1569
|
+
else {
|
|
1570
|
+
holoYear = components.year ?? 0;
|
|
1571
|
+
}
|
|
1572
|
+
let out = `${String(holoYear).padStart(5, "0")}-${pad(components.month)}-${pad(components.day)}`;
|
|
1573
|
+
if (components.hour !== undefined ||
|
|
1574
|
+
components.minute !== undefined ||
|
|
1575
|
+
components.second !== undefined) {
|
|
1576
|
+
out += `T${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
|
|
1577
|
+
}
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
validate(input) {
|
|
1581
|
+
if (typeof input === "string") {
|
|
1582
|
+
return /^\d{4,5}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)?$/.test(input.trim());
|
|
1583
|
+
}
|
|
1584
|
+
if (typeof input === "object") {
|
|
1585
|
+
return this.gregorian.validate({
|
|
1586
|
+
year: input.year,
|
|
1587
|
+
month: input.month,
|
|
1588
|
+
day: input.day,
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
return false;
|
|
1592
|
+
}
|
|
1593
|
+
getMetadata() {
|
|
1594
|
+
return {
|
|
1595
|
+
name: "Holocene (Human Era)",
|
|
1596
|
+
monthNames: [
|
|
1597
|
+
"January",
|
|
1598
|
+
"February",
|
|
1599
|
+
"March",
|
|
1600
|
+
"April",
|
|
1601
|
+
"May",
|
|
1602
|
+
"June",
|
|
1603
|
+
"July",
|
|
1604
|
+
"August",
|
|
1605
|
+
"September",
|
|
1606
|
+
"October",
|
|
1607
|
+
"November",
|
|
1608
|
+
"December",
|
|
1609
|
+
],
|
|
1610
|
+
isLunar: false,
|
|
1611
|
+
monthsPerYear: 12,
|
|
1612
|
+
epochYear: -1e4,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Chinese Lunisolar Calendar Driver
|
|
1619
|
+
*
|
|
1620
|
+
* Calendar characteristics:
|
|
1621
|
+
* - Traditional lunisolar calendar (月 months follow lunar phases, years follow solar)
|
|
1622
|
+
* - Year expressed as Sexagenary (干支 Ganzhi) cycle: 60-year repeating pattern
|
|
1623
|
+
* - Also expressed relative to the legendary emperor Huangdi (epoch ~2698 BCE)
|
|
1624
|
+
* - Months: 12 or 13 (leap month / 闰月 rùnyuè in some years)
|
|
1625
|
+
* - This implementation uses a simplified tabular algorithm accurate from ~1900–2100
|
|
1626
|
+
*
|
|
1627
|
+
* Data source: Pre-computed month start Julian Day Numbers for 1900–2100
|
|
1628
|
+
* based on the Hong Kong Observatory almanac algorithm.
|
|
1629
|
+
*/
|
|
1630
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1631
|
+
// Core Chinese Calendar Arithmetic
|
|
1632
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1633
|
+
/**
|
|
1634
|
+
* Approximate start of Chinese Lunar Month 1 (正月) for each Gregorian year.
|
|
1635
|
+
* Derived from the New Moon nearest to 'rain water' (雨水, around Feb 19).
|
|
1636
|
+
* Each entry is [month, day] in Gregorian for the start of that year's month 1.
|
|
1637
|
+
*
|
|
1638
|
+
* For simplicity we use the Gregorian Spring Festival date as month-1 start,
|
|
1639
|
+
* which is accurate to within ±1 day for the intended display purpose.
|
|
1640
|
+
*/
|
|
1641
|
+
const SPRING_FESTIVAL = {
|
|
1642
|
+
1900: [1, 31],
|
|
1643
|
+
1901: [2, 19],
|
|
1644
|
+
1902: [2, 8],
|
|
1645
|
+
1903: [1, 29],
|
|
1646
|
+
1904: [2, 16],
|
|
1647
|
+
1905: [2, 4],
|
|
1648
|
+
1906: [1, 25],
|
|
1649
|
+
1907: [2, 13],
|
|
1650
|
+
1908: [2, 2],
|
|
1651
|
+
1909: [1, 22],
|
|
1652
|
+
1910: [2, 10],
|
|
1653
|
+
1911: [1, 30],
|
|
1654
|
+
1912: [2, 18],
|
|
1655
|
+
1913: [2, 6],
|
|
1656
|
+
1914: [1, 26],
|
|
1657
|
+
1915: [2, 14],
|
|
1658
|
+
1916: [2, 3],
|
|
1659
|
+
1917: [1, 23],
|
|
1660
|
+
1918: [2, 11],
|
|
1661
|
+
1919: [2, 1],
|
|
1662
|
+
1920: [2, 20],
|
|
1663
|
+
1921: [2, 8],
|
|
1664
|
+
1922: [1, 28],
|
|
1665
|
+
1923: [2, 16],
|
|
1666
|
+
1924: [2, 5],
|
|
1667
|
+
1925: [1, 25],
|
|
1668
|
+
1926: [2, 13],
|
|
1669
|
+
1927: [2, 2],
|
|
1670
|
+
1928: [1, 23],
|
|
1671
|
+
1929: [2, 10],
|
|
1672
|
+
1930: [1, 30],
|
|
1673
|
+
1931: [2, 17],
|
|
1674
|
+
1932: [2, 6],
|
|
1675
|
+
1933: [1, 26],
|
|
1676
|
+
1934: [2, 14],
|
|
1677
|
+
1935: [2, 4],
|
|
1678
|
+
1936: [1, 24],
|
|
1679
|
+
1937: [2, 11],
|
|
1680
|
+
1938: [1, 31],
|
|
1681
|
+
1939: [2, 19],
|
|
1682
|
+
1940: [2, 8],
|
|
1683
|
+
1941: [1, 27],
|
|
1684
|
+
1942: [2, 15],
|
|
1685
|
+
1943: [2, 5],
|
|
1686
|
+
1944: [1, 25],
|
|
1687
|
+
1945: [2, 13],
|
|
1688
|
+
1946: [2, 2],
|
|
1689
|
+
1947: [1, 22],
|
|
1690
|
+
1948: [2, 10],
|
|
1691
|
+
1949: [1, 29],
|
|
1692
|
+
1950: [2, 17],
|
|
1693
|
+
1951: [2, 6],
|
|
1694
|
+
1952: [1, 27],
|
|
1695
|
+
1953: [2, 14],
|
|
1696
|
+
1954: [2, 3],
|
|
1697
|
+
1955: [1, 24],
|
|
1698
|
+
1956: [2, 12],
|
|
1699
|
+
1957: [1, 31],
|
|
1700
|
+
1958: [2, 18],
|
|
1701
|
+
1959: [2, 8],
|
|
1702
|
+
1960: [1, 28],
|
|
1703
|
+
1961: [2, 15],
|
|
1704
|
+
1962: [2, 5],
|
|
1705
|
+
1963: [1, 25],
|
|
1706
|
+
1964: [2, 13],
|
|
1707
|
+
1965: [2, 2],
|
|
1708
|
+
1966: [1, 21],
|
|
1709
|
+
1967: [2, 9],
|
|
1710
|
+
1968: [1, 30],
|
|
1711
|
+
1969: [2, 17],
|
|
1712
|
+
1970: [2, 6],
|
|
1713
|
+
1971: [1, 27],
|
|
1714
|
+
1972: [2, 15],
|
|
1715
|
+
1973: [2, 3],
|
|
1716
|
+
1974: [1, 23],
|
|
1717
|
+
1975: [2, 11],
|
|
1718
|
+
1976: [1, 31],
|
|
1719
|
+
1977: [2, 18],
|
|
1720
|
+
1978: [2, 7],
|
|
1721
|
+
1979: [1, 28],
|
|
1722
|
+
1980: [2, 16],
|
|
1723
|
+
1981: [2, 5],
|
|
1724
|
+
1982: [1, 25],
|
|
1725
|
+
1983: [2, 13],
|
|
1726
|
+
1984: [2, 2],
|
|
1727
|
+
1985: [2, 20],
|
|
1728
|
+
1986: [2, 9],
|
|
1729
|
+
1987: [1, 29],
|
|
1730
|
+
1988: [2, 17],
|
|
1731
|
+
1989: [2, 6],
|
|
1732
|
+
1990: [1, 27],
|
|
1733
|
+
1991: [2, 15],
|
|
1734
|
+
1992: [2, 4],
|
|
1735
|
+
1993: [1, 23],
|
|
1736
|
+
1994: [2, 10],
|
|
1737
|
+
1995: [1, 31],
|
|
1738
|
+
1996: [2, 19],
|
|
1739
|
+
1997: [2, 7],
|
|
1740
|
+
1998: [1, 28],
|
|
1741
|
+
1999: [2, 16],
|
|
1742
|
+
2000: [2, 5],
|
|
1743
|
+
2001: [1, 24],
|
|
1744
|
+
2002: [2, 12],
|
|
1745
|
+
2003: [2, 1],
|
|
1746
|
+
2004: [1, 22],
|
|
1747
|
+
2005: [2, 9],
|
|
1748
|
+
2006: [1, 29],
|
|
1749
|
+
2007: [2, 18],
|
|
1750
|
+
2008: [2, 7],
|
|
1751
|
+
2009: [1, 26],
|
|
1752
|
+
2010: [2, 14],
|
|
1753
|
+
2011: [2, 3],
|
|
1754
|
+
2012: [1, 23],
|
|
1755
|
+
2013: [2, 10],
|
|
1756
|
+
2014: [1, 31],
|
|
1757
|
+
2015: [2, 19],
|
|
1758
|
+
2016: [2, 8],
|
|
1759
|
+
2017: [1, 28],
|
|
1760
|
+
2018: [2, 16],
|
|
1761
|
+
2019: [2, 5],
|
|
1762
|
+
2020: [1, 25],
|
|
1763
|
+
2021: [2, 12],
|
|
1764
|
+
2022: [2, 1],
|
|
1765
|
+
2023: [1, 22],
|
|
1766
|
+
2024: [2, 10],
|
|
1767
|
+
2025: [1, 29],
|
|
1768
|
+
2026: [2, 17],
|
|
1769
|
+
2027: [2, 6],
|
|
1770
|
+
2028: [1, 26],
|
|
1771
|
+
2029: [2, 13],
|
|
1772
|
+
2030: [2, 3],
|
|
1773
|
+
2031: [1, 23],
|
|
1774
|
+
2032: [2, 11],
|
|
1775
|
+
2033: [1, 31],
|
|
1776
|
+
2034: [2, 19],
|
|
1777
|
+
2035: [2, 8],
|
|
1778
|
+
2036: [1, 28],
|
|
1779
|
+
2037: [2, 15],
|
|
1780
|
+
2038: [2, 4],
|
|
1781
|
+
2039: [1, 24],
|
|
1782
|
+
2040: [2, 12],
|
|
1783
|
+
2041: [2, 1],
|
|
1784
|
+
2042: [1, 22],
|
|
1785
|
+
2043: [2, 10],
|
|
1786
|
+
2044: [1, 30],
|
|
1787
|
+
2045: [2, 17],
|
|
1788
|
+
2046: [2, 6],
|
|
1789
|
+
2047: [1, 26],
|
|
1790
|
+
2048: [2, 14],
|
|
1791
|
+
2049: [2, 2],
|
|
1792
|
+
2050: [1, 23],
|
|
1793
|
+
2051: [2, 11],
|
|
1794
|
+
2052: [2, 1],
|
|
1795
|
+
2053: [2, 19],
|
|
1796
|
+
2054: [2, 8],
|
|
1797
|
+
2055: [1, 28],
|
|
1798
|
+
2056: [2, 15],
|
|
1799
|
+
2057: [2, 4],
|
|
1800
|
+
2058: [1, 24],
|
|
1801
|
+
2059: [2, 12],
|
|
1802
|
+
2060: [2, 2],
|
|
1803
|
+
2061: [1, 21],
|
|
1804
|
+
2062: [2, 9],
|
|
1805
|
+
2063: [1, 29],
|
|
1806
|
+
2064: [2, 17],
|
|
1807
|
+
2065: [2, 5],
|
|
1808
|
+
2066: [1, 26],
|
|
1809
|
+
2067: [2, 14],
|
|
1810
|
+
2068: [2, 3],
|
|
1811
|
+
2069: [1, 23],
|
|
1812
|
+
2070: [2, 11],
|
|
1813
|
+
2071: [2, 1],
|
|
1814
|
+
2072: [1, 21],
|
|
1815
|
+
2073: [2, 8],
|
|
1816
|
+
2074: [1, 28],
|
|
1817
|
+
2075: [2, 15],
|
|
1818
|
+
2076: [2, 5],
|
|
1819
|
+
2077: [1, 24],
|
|
1820
|
+
2078: [2, 12],
|
|
1821
|
+
2079: [2, 2],
|
|
1822
|
+
2080: [1, 22],
|
|
1823
|
+
2081: [2, 9],
|
|
1824
|
+
2082: [1, 29],
|
|
1825
|
+
2083: [2, 17],
|
|
1826
|
+
2084: [2, 6],
|
|
1827
|
+
2085: [1, 26],
|
|
1828
|
+
2086: [2, 14],
|
|
1829
|
+
2087: [2, 3],
|
|
1830
|
+
2088: [1, 24],
|
|
1831
|
+
2089: [2, 10],
|
|
1832
|
+
2090: [1, 30],
|
|
1833
|
+
2091: [2, 17],
|
|
1834
|
+
2092: [2, 7],
|
|
1835
|
+
2093: [1, 27],
|
|
1836
|
+
2094: [2, 15],
|
|
1837
|
+
2095: [2, 4],
|
|
1838
|
+
2096: [1, 25],
|
|
1839
|
+
2097: [2, 12],
|
|
1840
|
+
2098: [2, 1],
|
|
1841
|
+
2099: [1, 21],
|
|
1842
|
+
2100: [2, 9],
|
|
1843
|
+
};
|
|
1844
|
+
/** Chinese Heavenly Stems (天干 Tiāngān) */
|
|
1845
|
+
const HEAVENLY_STEMS = [
|
|
1846
|
+
"甲",
|
|
1847
|
+
"乙",
|
|
1848
|
+
"丙",
|
|
1849
|
+
"丁",
|
|
1850
|
+
"戊",
|
|
1851
|
+
"己",
|
|
1852
|
+
"庚",
|
|
1853
|
+
"辛",
|
|
1854
|
+
"壬",
|
|
1855
|
+
"癸",
|
|
1856
|
+
];
|
|
1857
|
+
const HEAVENLY_STEMS_ROMAN = [
|
|
1858
|
+
"Jiǎ",
|
|
1859
|
+
"Yǐ",
|
|
1860
|
+
"Bǐng",
|
|
1861
|
+
"Dīng",
|
|
1862
|
+
"Wù",
|
|
1863
|
+
"Jǐ",
|
|
1864
|
+
"Gēng",
|
|
1865
|
+
"Xīn",
|
|
1866
|
+
"Rén",
|
|
1867
|
+
"Guǐ",
|
|
1868
|
+
];
|
|
1869
|
+
/** Chinese Earthly Branches (地支 Dìzhī) — zodiac animals */
|
|
1870
|
+
const EARTHLY_BRANCHES = [
|
|
1871
|
+
"子",
|
|
1872
|
+
"丑",
|
|
1873
|
+
"寅",
|
|
1874
|
+
"卯",
|
|
1875
|
+
"辰",
|
|
1876
|
+
"巳",
|
|
1877
|
+
"午",
|
|
1878
|
+
"未",
|
|
1879
|
+
"申",
|
|
1880
|
+
"酉",
|
|
1881
|
+
"戌",
|
|
1882
|
+
"亥",
|
|
1883
|
+
];
|
|
1884
|
+
const ZODIAC_EN = [
|
|
1885
|
+
"Rat",
|
|
1886
|
+
"Ox",
|
|
1887
|
+
"Tiger",
|
|
1888
|
+
"Rabbit",
|
|
1889
|
+
"Dragon",
|
|
1890
|
+
"Snake",
|
|
1891
|
+
"Horse",
|
|
1892
|
+
"Goat",
|
|
1893
|
+
"Monkey",
|
|
1894
|
+
"Rooster",
|
|
1895
|
+
"Dog",
|
|
1896
|
+
"Pig",
|
|
1897
|
+
];
|
|
1898
|
+
/** Chinese month names */
|
|
1899
|
+
const MONTH_NAMES_ZH = [
|
|
1900
|
+
"正月",
|
|
1901
|
+
"二月",
|
|
1902
|
+
"三月",
|
|
1903
|
+
"四月",
|
|
1904
|
+
"五月",
|
|
1905
|
+
"六月",
|
|
1906
|
+
"七月",
|
|
1907
|
+
"八月",
|
|
1908
|
+
"九月",
|
|
1909
|
+
"十月",
|
|
1910
|
+
"十一月",
|
|
1911
|
+
"十二月",
|
|
1912
|
+
];
|
|
1913
|
+
const MONTH_NAMES_EN = [
|
|
1914
|
+
"Zhēngyuè",
|
|
1915
|
+
"Èryuè",
|
|
1916
|
+
"Sānyuè",
|
|
1917
|
+
"Sìyuè",
|
|
1918
|
+
"Wǔyuè",
|
|
1919
|
+
"Liùyuè",
|
|
1920
|
+
"Qīyuè",
|
|
1921
|
+
"Bāyuè",
|
|
1922
|
+
"Jiǔyuè",
|
|
1923
|
+
"Shíyuè",
|
|
1924
|
+
"Shíyīyuè",
|
|
1925
|
+
"Shíèryuè",
|
|
1926
|
+
];
|
|
1927
|
+
/** Huangdi Epoch: Year 2698 BCE is year 1 of the Chinese Calendar */
|
|
1928
|
+
const HUANGDI_OFFSET = 2698;
|
|
1929
|
+
/**
|
|
1930
|
+
* Convert a Gregorian date to Chinese lunar date components.
|
|
1931
|
+
* Returns { chineseYear, month, day, heavenlyStem, earthlyBranch, zodiac }
|
|
1932
|
+
*/
|
|
1933
|
+
function gregorianToChinese(gy, gm, gd) {
|
|
1934
|
+
// Locate the Chinese year whose Spring Festival (month 1, day 1) is on or before the given date
|
|
1935
|
+
const inputJdn = gregorianToJdn(gy, gm, gd);
|
|
1936
|
+
let chineseYear = gy; // The Chinese New Year typically starts in the same Gregorian year
|
|
1937
|
+
// Check if input is before this year's Spring Festival → use previous year's cycle
|
|
1938
|
+
let sf = SPRING_FESTIVAL[chineseYear];
|
|
1939
|
+
if (!sf) {
|
|
1940
|
+
// Clamp to data range
|
|
1941
|
+
chineseYear = Math.max(1900, Math.min(2100, chineseYear));
|
|
1942
|
+
sf = SPRING_FESTIVAL[chineseYear] ?? [2, 1];
|
|
1943
|
+
}
|
|
1944
|
+
const sfJdn = gregorianToJdn(chineseYear, sf[0], sf[1]);
|
|
1945
|
+
if (inputJdn < sfJdn) {
|
|
1946
|
+
chineseYear -= 1;
|
|
1947
|
+
sf = SPRING_FESTIVAL[chineseYear] ?? [2, 1];
|
|
1948
|
+
}
|
|
1949
|
+
const yearStartJdn = gregorianToJdn(chineseYear, sf[0], sf[1]);
|
|
1950
|
+
const dayOfYear = inputJdn - yearStartJdn; // 0-indexed
|
|
1951
|
+
// Approximate month and day. Chinese months alternate 29/30 days.
|
|
1952
|
+
// Average lunar month ≈ 29.53 days
|
|
1953
|
+
const approxMonth = Math.floor(dayOfYear / 29.53);
|
|
1954
|
+
const month = Math.min(approxMonth + 1, 12); // 1-indexed, cap at 12
|
|
1955
|
+
const monthStartDay = Math.round(approxMonth * 29.53);
|
|
1956
|
+
const day = dayOfYear - monthStartDay + 1;
|
|
1957
|
+
// Sexagenary cycle (干支 gānzhī) — 60-year cycle, epoch 4 BCE = year 1
|
|
1958
|
+
const cycleYear = (((chineseYear - 4) % 60) + 60) % 60;
|
|
1959
|
+
const heavenlyStem = HEAVENLY_STEMS_ROMAN[cycleYear % 10];
|
|
1960
|
+
const earthlyBranch = ZODIAC_EN[cycleYear % 12];
|
|
1961
|
+
const zodiac = earthlyBranch;
|
|
1962
|
+
const huangdiYear = chineseYear + HUANGDI_OFFSET;
|
|
1963
|
+
return {
|
|
1964
|
+
chineseYear: huangdiYear,
|
|
1965
|
+
month: Math.max(1, month),
|
|
1966
|
+
day: Math.max(1, day),
|
|
1967
|
+
heavenlyStem,
|
|
1968
|
+
earthlyBranch,
|
|
1969
|
+
zodiac,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Convert a Chinese Huangdi year + month + day back to an approximate Gregorian date.
|
|
1974
|
+
*/
|
|
1975
|
+
function chineseToGregorian(huangdiYear, month, day) {
|
|
1976
|
+
const chineseYear = huangdiYear - HUANGDI_OFFSET;
|
|
1977
|
+
const yearClamped = Math.max(1900, Math.min(2100, chineseYear));
|
|
1978
|
+
const sf = SPRING_FESTIVAL[yearClamped] ?? [2, 1];
|
|
1979
|
+
// Start JDN of Chinese year 1st month
|
|
1980
|
+
const yearStartJdn = gregorianToJdn(yearClamped, sf[0], sf[1]);
|
|
1981
|
+
// Approx JDN for given month/day
|
|
1982
|
+
const approxJdn = yearStartJdn + Math.round((month - 1) * 29.53) + (day - 1);
|
|
1983
|
+
return jdnToGregorian(approxJdn);
|
|
1984
|
+
}
|
|
1985
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1986
|
+
// Driver Class
|
|
1987
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1988
|
+
class ChineseDriver {
|
|
1989
|
+
constructor() {
|
|
1990
|
+
this.code = "chin";
|
|
1991
|
+
this.name = "Chinese Lunisolar";
|
|
1992
|
+
}
|
|
1993
|
+
getComponentsFromDate(date) {
|
|
1994
|
+
const gy = date.getUTCFullYear();
|
|
1995
|
+
const gm = date.getUTCMonth() + 1;
|
|
1996
|
+
const gd = date.getUTCDate();
|
|
1997
|
+
const { chineseYear, month, day } = gregorianToChinese(gy, gm, gd);
|
|
1998
|
+
// Store as TPS year field (full Huangdi year); millennium/century/year decomposition
|
|
1999
|
+
const millennium = Math.floor(chineseYear / 1000) + 1;
|
|
2000
|
+
const century = Math.floor((chineseYear % 1000) / 100) + 1;
|
|
2001
|
+
const year = chineseYear % 100;
|
|
2002
|
+
return {
|
|
2003
|
+
calendar: this.code,
|
|
2004
|
+
millennium,
|
|
2005
|
+
century,
|
|
2006
|
+
year,
|
|
2007
|
+
month,
|
|
2008
|
+
day,
|
|
2009
|
+
hour: date.getUTCHours(),
|
|
2010
|
+
minute: date.getUTCMinutes(),
|
|
2011
|
+
second: date.getUTCSeconds(),
|
|
2012
|
+
millisecond: date.getUTCMilliseconds(),
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
getDateFromComponents(components) {
|
|
2016
|
+
// Reconstruct full Huangdi year
|
|
2017
|
+
const millennium = components.millennium ?? 5;
|
|
2018
|
+
const century = components.century ?? 1;
|
|
2019
|
+
const year = components.year ?? 0;
|
|
2020
|
+
const huangdiYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
2021
|
+
const { gy, gm, gd } = chineseToGregorian(huangdiYear, components.month ?? 1, components.day ?? 1);
|
|
2022
|
+
return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
|
|
2023
|
+
}
|
|
2024
|
+
getFromDate(date) {
|
|
2025
|
+
const comp = this.getComponentsFromDate(date);
|
|
2026
|
+
return buildTimePart(comp);
|
|
2027
|
+
}
|
|
2028
|
+
parseDate(input, _format) {
|
|
2029
|
+
const trimmed = input.trim();
|
|
2030
|
+
// Accept ISO-like: "4722-03-15" or "4722/03/15"
|
|
2031
|
+
const parts = trimmed.split(/[-/]/).map(Number);
|
|
2032
|
+
if (parts.length < 2)
|
|
2033
|
+
return { calendar: this.code };
|
|
2034
|
+
const [huangdiYear, month, day = 1] = parts;
|
|
2035
|
+
const millennium = Math.floor(huangdiYear / 1000) + 1;
|
|
2036
|
+
const century = Math.floor((huangdiYear % 1000) / 100) + 1;
|
|
2037
|
+
const year = huangdiYear % 100;
|
|
2038
|
+
return { calendar: this.code, millennium, century, year, month, day };
|
|
2039
|
+
}
|
|
2040
|
+
format(components, format) {
|
|
2041
|
+
const millennium = components.millennium ?? 5;
|
|
2042
|
+
const century = components.century ?? 1;
|
|
2043
|
+
const year = components.year ?? 0;
|
|
2044
|
+
const huangdiYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
2045
|
+
const month = components.month ?? 1;
|
|
2046
|
+
const day = components.day ?? 1;
|
|
2047
|
+
if (format === "zh") {
|
|
2048
|
+
const monthName = MONTH_NAMES_ZH[month - 1] ?? `${month}月`;
|
|
2049
|
+
return `${huangdiYear}年${monthName}${day}日`;
|
|
2050
|
+
}
|
|
2051
|
+
if (format === "ganzhi") {
|
|
2052
|
+
const chineseYear = huangdiYear - HUANGDI_OFFSET;
|
|
2053
|
+
const cycleYear = (((chineseYear - 4) % 60) + 60) % 60;
|
|
2054
|
+
const stem = HEAVENLY_STEMS[cycleYear % 10];
|
|
2055
|
+
const branch = EARTHLY_BRANCHES[cycleYear % 12];
|
|
2056
|
+
const zodiac = ZODIAC_EN[cycleYear % 12];
|
|
2057
|
+
return `${stem}${branch} (${zodiac}) Year, Month ${month}, Day ${day}`;
|
|
2058
|
+
}
|
|
2059
|
+
// Default: ISO-like with Huangdi year
|
|
2060
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2061
|
+
return `${huangdiYear}-${pad(month)}-${pad(day)}`;
|
|
2062
|
+
}
|
|
2063
|
+
validate(input) {
|
|
2064
|
+
let comp;
|
|
2065
|
+
if (typeof input === "string") {
|
|
2066
|
+
try {
|
|
2067
|
+
comp = this.parseDate(input);
|
|
2068
|
+
}
|
|
2069
|
+
catch {
|
|
2070
|
+
return false;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
comp = input;
|
|
2075
|
+
}
|
|
2076
|
+
const { month, day } = comp;
|
|
2077
|
+
if (!month || month < 1 || month > 12)
|
|
2078
|
+
return false;
|
|
2079
|
+
if (!day || day < 1 || day > 30)
|
|
2080
|
+
return false;
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
2083
|
+
getMetadata() {
|
|
2084
|
+
return {
|
|
2085
|
+
name: "Chinese Lunisolar",
|
|
2086
|
+
monthNames: MONTH_NAMES_EN,
|
|
2087
|
+
monthNamesShort: MONTH_NAMES_EN.map((n) => n.slice(0, 3)),
|
|
2088
|
+
dayNames: ["Rì", "Yuè", "Huǒ", "Shuǐ", "Mù", "Jīn", "Tǔ"], // Sun/Mon/Tue…Sat equivalents
|
|
2089
|
+
isLunar: true,
|
|
2090
|
+
monthsPerYear: 12,
|
|
2091
|
+
epochYear: -2698, // 2698 BCE
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/**
|
|
2097
|
+
* Environment & Compatibility Layer
|
|
2098
|
+
* Abstracts Node.js vs Browser differences.
|
|
2099
|
+
*/
|
|
2100
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
2101
|
+
const isNode = typeof require !== "undefined";
|
|
2102
|
+
/**
|
|
2103
|
+
* Node.js crypto module (conditional)
|
|
2104
|
+
*/
|
|
2105
|
+
const crypto = isNode ? require("crypto") : null;
|
|
2106
|
+
/**
|
|
2107
|
+
* Node.js zlib module (conditional)
|
|
2108
|
+
*/
|
|
2109
|
+
const zlib = isNode ? require("zlib") : null;
|
|
2110
|
+
const Env = {
|
|
2111
|
+
isNode,
|
|
2112
|
+
randomBytes(length) {
|
|
2113
|
+
if (isNode && crypto) {
|
|
2114
|
+
return new Uint8Array(crypto.randomBytes(length));
|
|
2115
|
+
}
|
|
2116
|
+
if (typeof window !== "undefined" && window.crypto) {
|
|
2117
|
+
return window.crypto.getRandomValues(new Uint8Array(length));
|
|
2118
|
+
}
|
|
2119
|
+
throw new Error("TPS: randomBytes not available in this environment");
|
|
2120
|
+
},
|
|
2121
|
+
deflate(data) {
|
|
2122
|
+
if (isNode && zlib) {
|
|
2123
|
+
return new Uint8Array(zlib.deflateRawSync(data));
|
|
2124
|
+
}
|
|
2125
|
+
throw new Error("TPS: deflate not available in this environment");
|
|
2126
|
+
},
|
|
2127
|
+
inflate(data) {
|
|
2128
|
+
if (isNode && zlib) {
|
|
2129
|
+
return new Uint8Array(zlib.inflateRawSync(data));
|
|
2130
|
+
}
|
|
2131
|
+
throw new Error("TPS: inflate not available in this environment");
|
|
2132
|
+
},
|
|
2133
|
+
signEd25519(data, privateKey) {
|
|
2134
|
+
if (isNode && crypto) {
|
|
2135
|
+
let key;
|
|
2136
|
+
if (typeof privateKey === "string") {
|
|
2137
|
+
if (privateKey.includes("PRIVATE KEY")) {
|
|
2138
|
+
key = privateKey;
|
|
2139
|
+
}
|
|
2140
|
+
else {
|
|
2141
|
+
key = crypto.createPrivateKey({
|
|
2142
|
+
key: Buffer.from(privateKey, "hex"),
|
|
2143
|
+
format: "der",
|
|
2144
|
+
type: "pkcs8",
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
else if (typeof privateKey === "object" &&
|
|
2149
|
+
privateKey !== null &&
|
|
2150
|
+
"asymmetricKeyType" in privateKey) {
|
|
2151
|
+
key = privateKey;
|
|
2152
|
+
}
|
|
2153
|
+
else {
|
|
2154
|
+
key = crypto.createPrivateKey({
|
|
2155
|
+
key: Buffer.from(privateKey),
|
|
2156
|
+
format: "der",
|
|
2157
|
+
type: "pkcs8",
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
return new Uint8Array(crypto.sign(null, data, key));
|
|
2161
|
+
}
|
|
2162
|
+
throw new Error("TPS: signEd25519 not available in this environment");
|
|
2163
|
+
},
|
|
2164
|
+
verifyEd25519(data, signature, publicKey) {
|
|
2165
|
+
if (isNode && crypto) {
|
|
2166
|
+
return crypto.verify(null, data, publicKey, signature);
|
|
2167
|
+
}
|
|
2168
|
+
throw new Error("TPS: verifyEd25519 not available in this environment");
|
|
2169
|
+
},
|
|
2170
|
+
};
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
|
|
2174
|
+
*/
|
|
2175
|
+
class TPSUID7RB {
|
|
2176
|
+
static encodeBinary(tps, opts = {}) {
|
|
2177
|
+
tps = TPS.expandIndexedTime(tps) ?? tps;
|
|
2178
|
+
const compress = opts.compress ?? false;
|
|
2179
|
+
const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
|
|
2180
|
+
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
2181
|
+
throw new Error("TPSUID7RB: Invalid epochMs (must be 48-bit non-negative integer)");
|
|
2182
|
+
}
|
|
2183
|
+
const flags = compress ? 0x01 : 0x00;
|
|
2184
|
+
const nonceBuf = Env.randomBytes(4);
|
|
2185
|
+
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
2186
|
+
const payload = compress ? Env.deflate(tpsUtf8) : tpsUtf8;
|
|
2187
|
+
const lenVar = this.uvarintEncode(payload.length);
|
|
2188
|
+
const out = new Uint8Array(4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length);
|
|
2189
|
+
let offset = 0;
|
|
2190
|
+
out.set(this.MAGIC, offset);
|
|
2191
|
+
offset += 4;
|
|
2192
|
+
out[offset++] = this.VER;
|
|
2193
|
+
out[offset++] = flags;
|
|
2194
|
+
out.set(this.writeU48(epochMs), offset);
|
|
2195
|
+
offset += 6;
|
|
2196
|
+
out.set(nonceBuf, offset);
|
|
2197
|
+
offset += 4;
|
|
2198
|
+
out.set(lenVar, offset);
|
|
2199
|
+
offset += lenVar.length;
|
|
2200
|
+
out.set(payload, offset);
|
|
2201
|
+
return out;
|
|
2202
|
+
}
|
|
2203
|
+
static decodeBinary(bytes) {
|
|
2204
|
+
if (bytes.length < 17)
|
|
2205
|
+
throw new Error("TPSUID7RB: too short");
|
|
2206
|
+
if (bytes[0] !== 0x54 ||
|
|
2207
|
+
bytes[1] !== 0x50 ||
|
|
2208
|
+
bytes[2] !== 0x55 ||
|
|
2209
|
+
bytes[3] !== 0x37) {
|
|
2210
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
2211
|
+
}
|
|
2212
|
+
const ver = bytes[4];
|
|
2213
|
+
if (ver !== this.VER)
|
|
2214
|
+
throw new Error(`TPSUID7RB: unsupported version ${ver}`);
|
|
2215
|
+
const flags = bytes[5];
|
|
2216
|
+
const compressed = (flags & 0x01) === 0x01;
|
|
2217
|
+
const epochMs = this.readU48(bytes, 6);
|
|
2218
|
+
const nonce = ((bytes[12] << 24) >>> 0) +
|
|
2219
|
+
((bytes[13] << 16) >>> 0) +
|
|
2220
|
+
((bytes[14] << 8) >>> 0) +
|
|
2221
|
+
bytes[15];
|
|
2222
|
+
let offset = 16;
|
|
2223
|
+
const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
|
|
2224
|
+
offset += bytesRead;
|
|
2225
|
+
if (offset + tpsLen > bytes.length)
|
|
2226
|
+
throw new Error("TPSUID7RB: length overflow");
|
|
2227
|
+
const payload = bytes.slice(offset, offset + tpsLen);
|
|
2228
|
+
const tpsUtf8 = compressed ? Env.inflate(payload) : payload;
|
|
2229
|
+
const tps = new TextDecoder().decode(tpsUtf8);
|
|
2230
|
+
return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
|
|
2231
|
+
}
|
|
2232
|
+
static encodeBinaryB64(tps, opts) {
|
|
2233
|
+
const bytes = this.encodeBinary(tps, opts ?? {});
|
|
2234
|
+
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
2235
|
+
}
|
|
2236
|
+
static decodeBinaryB64(id) {
|
|
2237
|
+
const s = id.trim();
|
|
2238
|
+
if (!s.startsWith(this.PREFIX))
|
|
2239
|
+
throw new Error("TPSUID7RB: missing prefix");
|
|
2240
|
+
return this.decodeBinary(this.base64UrlDecode(s.slice(this.PREFIX.length)));
|
|
2241
|
+
}
|
|
2242
|
+
static validateBinaryB64(id) {
|
|
2243
|
+
return this.REGEX.test(id.trim());
|
|
2244
|
+
}
|
|
2245
|
+
static generate(opts) {
|
|
2246
|
+
const now = new Date();
|
|
2247
|
+
const time = TPS.fromDate(now, DefaultCalendars.TPS, {
|
|
2248
|
+
order: opts?.order,
|
|
2249
|
+
});
|
|
2250
|
+
let space = "unknown";
|
|
2251
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
2252
|
+
space = `${opts.latitude},${opts.longitude}`;
|
|
2253
|
+
if (opts.altitude !== undefined)
|
|
2254
|
+
space += `,${opts.altitude}m`;
|
|
2255
|
+
}
|
|
2256
|
+
const tps = `tps://${space}@${time}`;
|
|
2257
|
+
return this.encodeBinaryB64(tps, {
|
|
2258
|
+
compress: opts?.compress,
|
|
2259
|
+
epochMs: now.getTime(),
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
static seal(tps, privateKey, opts) {
|
|
2263
|
+
tps = TPS.expandIndexedTime(tps) ?? tps;
|
|
2264
|
+
const compress = opts?.compress ?? false;
|
|
2265
|
+
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
2266
|
+
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
2267
|
+
throw new Error("TPSUID7RB: Invalid epochMs");
|
|
2268
|
+
}
|
|
2269
|
+
const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
|
|
2270
|
+
const nonceBuf = Env.randomBytes(4);
|
|
2271
|
+
const tpsUtf8 = new TextEncoder().encode(tps);
|
|
2272
|
+
const payload = compress ? Env.deflate(tpsUtf8) : tpsUtf8;
|
|
2273
|
+
const lenVar = this.uvarintEncode(payload.length);
|
|
2274
|
+
const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
|
|
2275
|
+
const content = new Uint8Array(contentLen);
|
|
2276
|
+
let offset = 0;
|
|
2277
|
+
content.set(this.MAGIC, offset);
|
|
2278
|
+
offset += 4;
|
|
2279
|
+
content[offset++] = this.VER;
|
|
2280
|
+
content[offset++] = flags;
|
|
2281
|
+
content.set(this.writeU48(epochMs), offset);
|
|
2282
|
+
offset += 6;
|
|
2283
|
+
content.set(nonceBuf, offset);
|
|
2284
|
+
offset += 4;
|
|
2285
|
+
content.set(lenVar, offset);
|
|
2286
|
+
offset += lenVar.length;
|
|
2287
|
+
content.set(payload, offset);
|
|
2288
|
+
const signature = Env.signEd25519(content, privateKey);
|
|
2289
|
+
const final = new Uint8Array(contentLen + 1 + signature.length);
|
|
2290
|
+
final.set(content, 0);
|
|
2291
|
+
final.set([0x01], contentLen); // Ed25519 type
|
|
2292
|
+
final.set(signature, contentLen + 1);
|
|
2293
|
+
return final;
|
|
2294
|
+
}
|
|
2295
|
+
static verifyAndDecode(sealedBytes, publicKey) {
|
|
2296
|
+
if (sealedBytes.length < 18)
|
|
2297
|
+
throw new Error("TPSUID7RB: too short");
|
|
2298
|
+
if (sealedBytes[5] & 0x02 ? false : true)
|
|
2299
|
+
throw new Error("TPSUID7RB: not sealed");
|
|
2300
|
+
let offset = 16;
|
|
2301
|
+
const { value: tpsLen, bytesRead } = this.uvarintDecode(sealedBytes, offset);
|
|
2302
|
+
const payloadEnd = offset + bytesRead + tpsLen;
|
|
2303
|
+
const content = sealedBytes.slice(0, payloadEnd);
|
|
2304
|
+
const signature = sealedBytes.slice(payloadEnd + 1);
|
|
2305
|
+
if (!Env.verifyEd25519(content, signature, publicKey)) {
|
|
2306
|
+
throw new Error("TPSUID7RB: verification failed");
|
|
2307
|
+
}
|
|
2308
|
+
return this.decodeBinary(content);
|
|
2309
|
+
}
|
|
2310
|
+
static epochMsFromTPSString(tps) {
|
|
2311
|
+
tps = TPS.expandIndexedTime(tps) ?? tps;
|
|
2312
|
+
const date = TPS.toDate(tps);
|
|
2313
|
+
if (date)
|
|
2314
|
+
return date.getTime();
|
|
2315
|
+
const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
|
|
2316
|
+
const retryDate = TPS.toDate(stripped);
|
|
2317
|
+
if (!retryDate)
|
|
2318
|
+
throw new Error("TPS: unable to parse date for epoch");
|
|
2319
|
+
return retryDate.getTime();
|
|
2320
|
+
}
|
|
2321
|
+
static writeU48(epochMs) {
|
|
2322
|
+
const b = new Uint8Array(6);
|
|
2323
|
+
const v = BigInt(epochMs);
|
|
2324
|
+
b[0] = Number((v >> 40n) & 0xffn);
|
|
2325
|
+
b[1] = Number((v >> 32n) & 0xffn);
|
|
2326
|
+
b[2] = Number((v >> 24n) & 0xffn);
|
|
2327
|
+
b[3] = Number((v >> 16n) & 0xffn);
|
|
2328
|
+
b[4] = Number((v >> 8n) & 0xffn);
|
|
2329
|
+
b[5] = Number(v & 0xffn);
|
|
2330
|
+
return b;
|
|
2331
|
+
}
|
|
2332
|
+
static readU48(bytes, offset) {
|
|
2333
|
+
const v = (BigInt(bytes[offset]) << 40n) +
|
|
2334
|
+
(BigInt(bytes[offset + 1]) << 32n) +
|
|
2335
|
+
(BigInt(bytes[offset + 2]) << 24n) +
|
|
2336
|
+
(BigInt(bytes[offset + 3]) << 16n) +
|
|
2337
|
+
(BigInt(bytes[offset + 4]) << 8n) +
|
|
2338
|
+
BigInt(bytes[offset + 5]);
|
|
2339
|
+
return Number(v);
|
|
2340
|
+
}
|
|
2341
|
+
static uvarintEncode(n) {
|
|
2342
|
+
const out = [];
|
|
2343
|
+
let x = n >>> 0;
|
|
2344
|
+
while (x >= 0x80) {
|
|
2345
|
+
out.push((x & 0x7f) | 0x80);
|
|
2346
|
+
x >>>= 7;
|
|
2347
|
+
}
|
|
2348
|
+
out.push(x);
|
|
2349
|
+
return new Uint8Array(out);
|
|
2350
|
+
}
|
|
2351
|
+
static uvarintDecode(bytes, offset) {
|
|
2352
|
+
let x = 0, s = 0, i = 0;
|
|
2353
|
+
while (true) {
|
|
2354
|
+
const b = bytes[offset + i];
|
|
2355
|
+
if (b < 0x80) {
|
|
2356
|
+
x |= b << s;
|
|
2357
|
+
return { value: x >>> 0, bytesRead: i + 1 };
|
|
2358
|
+
}
|
|
2359
|
+
x |= (b & 0x7f) << s;
|
|
2360
|
+
s += 7;
|
|
2361
|
+
i++;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
static base64UrlEncode(bytes) {
|
|
2365
|
+
if (typeof Buffer !== "undefined") {
|
|
2366
|
+
return Buffer.from(bytes)
|
|
2367
|
+
.toString("base64")
|
|
2368
|
+
.replace(/\+/g, "-")
|
|
2369
|
+
.replace(/\//g, "_")
|
|
2370
|
+
.replace(/=+$/g, "");
|
|
2371
|
+
}
|
|
2372
|
+
return btoa(String.fromCharCode(...bytes))
|
|
2373
|
+
.replace(/\+/g, "-")
|
|
2374
|
+
.replace(/\//g, "_")
|
|
2375
|
+
.replace(/=+$/g, "");
|
|
2376
|
+
}
|
|
2377
|
+
static base64UrlDecode(b64url) {
|
|
2378
|
+
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
2379
|
+
const b64 = (b64url + "=".repeat(padLen))
|
|
2380
|
+
.replace(/-/g, "+")
|
|
2381
|
+
.replace(/_/g, "/");
|
|
2382
|
+
if (typeof Buffer !== "undefined")
|
|
2383
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
2384
|
+
const binary = atob(b64);
|
|
2385
|
+
return new Uint8Array(Array.from(binary).map((c) => c.charCodeAt(0)));
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
TPSUID7RB.MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
|
|
2389
|
+
TPSUID7RB.VER = 0x01;
|
|
2390
|
+
TPSUID7RB.PREFIX = "tpsuid7rb_";
|
|
2391
|
+
TPSUID7RB.REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* TpsDate Date-like wrapper with native TPS conversion helpers.
|
|
2395
|
+
*/
|
|
2396
|
+
class TpsDate {
|
|
2397
|
+
constructor(...args) {
|
|
2398
|
+
this._cachedComponents = null;
|
|
2399
|
+
this._cachedTps = null;
|
|
2400
|
+
if (args.length === 0) {
|
|
2401
|
+
this.internal = new Date();
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
if (args.length === 1) {
|
|
2405
|
+
const value = args[0];
|
|
2406
|
+
if (value instanceof TpsDate) {
|
|
2407
|
+
this.internal = new Date(value.getTime());
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
if (value instanceof Date) {
|
|
2411
|
+
this.internal = new Date(value.getTime());
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
|
|
2415
|
+
const parsed = TPS.toDate(value);
|
|
2416
|
+
if (!parsed) {
|
|
2417
|
+
throw new RangeError(`Invalid TPS date string: ${value}`);
|
|
2418
|
+
}
|
|
2419
|
+
this.internal = parsed;
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
this.internal = new Date(value);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
|
|
2426
|
+
this.internal = new Date(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
|
|
2427
|
+
}
|
|
2428
|
+
static looksLikeTPS(input) {
|
|
2429
|
+
const s = input.trim();
|
|
2430
|
+
return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
|
|
2431
|
+
}
|
|
2432
|
+
getTpsComponents() {
|
|
2433
|
+
const currentTps = this.toTPS(DefaultCalendars.TPS);
|
|
2434
|
+
if (this._cachedTps === currentTps && this._cachedComponents) {
|
|
2435
|
+
return this._cachedComponents;
|
|
2436
|
+
}
|
|
2437
|
+
const parsed = TPS.parse(currentTps);
|
|
2438
|
+
if (!parsed) {
|
|
2439
|
+
throw new Error("TpsDate: failed to derive TPS components");
|
|
2440
|
+
}
|
|
2441
|
+
this._cachedTps = currentTps;
|
|
2442
|
+
this._cachedComponents = parsed;
|
|
2443
|
+
return parsed;
|
|
2444
|
+
}
|
|
2445
|
+
getTpsFullYear() {
|
|
2446
|
+
const comp = this.getTpsComponents();
|
|
2447
|
+
return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
|
|
2448
|
+
}
|
|
2449
|
+
static now() {
|
|
2450
|
+
return Date.now();
|
|
2451
|
+
}
|
|
2452
|
+
static parse(input) {
|
|
2453
|
+
if (this.looksLikeTPS(input)) {
|
|
2454
|
+
const d = TPS.toDate(input);
|
|
2455
|
+
return d ? d.getTime() : Number.NaN;
|
|
2456
|
+
}
|
|
2457
|
+
return Date.parse(input);
|
|
2458
|
+
}
|
|
2459
|
+
static UTC(year, monthIndex, day, hours, minutes, seconds, ms) {
|
|
2460
|
+
return Date.UTC(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
|
|
2461
|
+
}
|
|
2462
|
+
static fromTPS(tps) {
|
|
2463
|
+
return new TpsDate(tps);
|
|
2464
|
+
}
|
|
2465
|
+
toGregorianDate() {
|
|
2466
|
+
return new Date(this.internal.getTime());
|
|
2467
|
+
}
|
|
2468
|
+
toDate() {
|
|
2469
|
+
return this.toGregorianDate();
|
|
2470
|
+
}
|
|
2471
|
+
toTPS(calendar = DefaultCalendars.TPS, opts) {
|
|
2472
|
+
return TPS.fromDate(this.internal, calendar, opts);
|
|
2473
|
+
}
|
|
2474
|
+
toTPSURI(calendar = DefaultCalendars.TPS, opts) {
|
|
2475
|
+
const time = this.toTPS(calendar, {
|
|
2476
|
+
order: opts?.order,
|
|
2477
|
+
timeMode: opts?.timeMode,
|
|
2478
|
+
indexedPrecision: opts?.indexedPrecision,
|
|
2479
|
+
});
|
|
2480
|
+
const comp = TPS.parse(time);
|
|
2481
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
2482
|
+
comp.latitude = opts.latitude;
|
|
2483
|
+
comp.longitude = opts.longitude;
|
|
2484
|
+
if (opts.altitude !== undefined)
|
|
2485
|
+
comp.altitude = opts.altitude;
|
|
2486
|
+
}
|
|
2487
|
+
else if (opts?.isHiddenLocation) {
|
|
2488
|
+
comp.isHiddenLocation = true;
|
|
2489
|
+
}
|
|
2490
|
+
else if (opts?.isRedactedLocation) {
|
|
2491
|
+
comp.isRedactedLocation = true;
|
|
2492
|
+
}
|
|
2493
|
+
else {
|
|
2494
|
+
comp.isUnknownLocation = true;
|
|
2495
|
+
}
|
|
2496
|
+
return TPS.toURI(comp);
|
|
2497
|
+
}
|
|
2498
|
+
getTime() {
|
|
2499
|
+
return this.internal.getTime();
|
|
2500
|
+
}
|
|
2501
|
+
valueOf() {
|
|
2502
|
+
return this.internal.valueOf();
|
|
2503
|
+
}
|
|
2504
|
+
toString() {
|
|
2505
|
+
return this.toTPS(DefaultCalendars.TPS);
|
|
2506
|
+
}
|
|
2507
|
+
toISOString() {
|
|
2508
|
+
return this.internal.toISOString();
|
|
2509
|
+
}
|
|
2510
|
+
toUTCString() {
|
|
2511
|
+
return this.internal.toUTCString();
|
|
2512
|
+
}
|
|
2513
|
+
toJSON() {
|
|
2514
|
+
return this.internal.toJSON();
|
|
2515
|
+
}
|
|
2516
|
+
getFullYear() {
|
|
2517
|
+
return this.getTpsFullYear();
|
|
2518
|
+
}
|
|
2519
|
+
getUTCFullYear() {
|
|
2520
|
+
return this.getTpsFullYear();
|
|
2521
|
+
}
|
|
2522
|
+
getMonth() {
|
|
2523
|
+
return this.getTpsComponents().month - 1;
|
|
2524
|
+
}
|
|
2525
|
+
getUTCMonth() {
|
|
2526
|
+
return this.getTpsComponents().month - 1;
|
|
2527
|
+
}
|
|
2528
|
+
getDate() {
|
|
2529
|
+
return this.getTpsComponents().day;
|
|
2530
|
+
}
|
|
2531
|
+
getUTCDate() {
|
|
2532
|
+
return this.getTpsComponents().day;
|
|
2533
|
+
}
|
|
2534
|
+
getHours() {
|
|
2535
|
+
return this.getTpsComponents().hour;
|
|
2536
|
+
}
|
|
2537
|
+
getUTCHours() {
|
|
2538
|
+
return this.getTpsComponents().hour;
|
|
2539
|
+
}
|
|
2540
|
+
getMinutes() {
|
|
2541
|
+
return this.getTpsComponents().minute;
|
|
2542
|
+
}
|
|
2543
|
+
getUTCMinutes() {
|
|
2544
|
+
return this.getTpsComponents().minute;
|
|
2545
|
+
}
|
|
2546
|
+
getSeconds() {
|
|
2547
|
+
return this.getTpsComponents().second;
|
|
2548
|
+
}
|
|
2549
|
+
getUTCSeconds() {
|
|
2550
|
+
return this.getTpsComponents().second;
|
|
2551
|
+
}
|
|
2552
|
+
getMilliseconds() {
|
|
2553
|
+
return this.getTpsComponents().millisecond;
|
|
2554
|
+
}
|
|
2555
|
+
getUTCMilliseconds() {
|
|
2556
|
+
return this.getTpsComponents().millisecond;
|
|
2557
|
+
}
|
|
2558
|
+
[Symbol.toPrimitive](hint) {
|
|
2559
|
+
if (hint === "number")
|
|
2560
|
+
return this.valueOf();
|
|
2561
|
+
return this.toString();
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/**
|
|
2566
|
+
* TPS DriverManager
|
|
2567
|
+
* Manages the registry of calendar driver plugins.
|
|
2568
|
+
*/
|
|
2569
|
+
/**
|
|
2570
|
+
* A dedicated registry for TPS calendar driver plugins.
|
|
2571
|
+
* The global `TPS` class exposes a shared singleton instance (`TPS.driverManager`).
|
|
2572
|
+
*/
|
|
2573
|
+
class DriverManager {
|
|
2574
|
+
constructor() {
|
|
2575
|
+
this.registry = new Map();
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Registers a calendar driver.
|
|
2579
|
+
* Overwrites any previously registered driver with the same code.
|
|
2580
|
+
*/
|
|
2581
|
+
register(driver) {
|
|
2582
|
+
if (!driver || !driver.code) {
|
|
2583
|
+
throw new Error("DriverManager: driver must have a valid `code` string.");
|
|
2584
|
+
}
|
|
2585
|
+
this.registry.set(driver.code, driver);
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Returns the driver registered for the given calendar code, or `undefined`.
|
|
2589
|
+
*/
|
|
2590
|
+
get(code) {
|
|
2591
|
+
return this.registry.get(code);
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Returns `true` if a driver with the given code has been registered.
|
|
2595
|
+
*/
|
|
2596
|
+
has(code) {
|
|
2597
|
+
return this.registry.has(code);
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Returns an array of all registered calendar codes.
|
|
2601
|
+
*/
|
|
2602
|
+
list() {
|
|
2603
|
+
return Array.from(this.registry.keys());
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* Removes a driver from the registry.
|
|
2607
|
+
* Returns `true` if the driver existed and was removed.
|
|
2608
|
+
*/
|
|
2609
|
+
unregister(code) {
|
|
2610
|
+
return this.registry.delete(code);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
/**
|
|
2615
|
+
* Timezone Utilities
|
|
2616
|
+
*
|
|
2617
|
+
* Provides lightweight, zero-dependency helpers for converting between
|
|
2618
|
+
* UTC timestamps and local timestamps in a given IANA timezone or
|
|
2619
|
+
* fixed UTC offset.
|
|
2620
|
+
*
|
|
2621
|
+
* Used by `TPS.toDate()` when the parsed extensions include a `tz` key.
|
|
2622
|
+
*
|
|
2623
|
+
* Examples:
|
|
2624
|
+
* tz=Asia/Tehran → IANA timezone name
|
|
2625
|
+
* tz=+03:30 → fixed UTC offset
|
|
2626
|
+
* tz=IRST → abbreviated names (best-effort, common ones mapped)
|
|
2627
|
+
*/
|
|
2628
|
+
/** Map of common abbreviated timezone names to IANA names. */
|
|
2629
|
+
const TZ_ABBR = {
|
|
2630
|
+
// Middle East
|
|
2631
|
+
IRST: "Asia/Tehran",
|
|
2632
|
+
IRDT: "Asia/Tehran",
|
|
2633
|
+
AST: "Asia/Riyadh",
|
|
2634
|
+
AST3: "Asia/Riyadh",
|
|
2635
|
+
// Europe
|
|
2636
|
+
CET: "Europe/Paris",
|
|
2637
|
+
CEST: "Europe/Paris",
|
|
2638
|
+
EET: "Europe/Athens",
|
|
2639
|
+
EEST: "Europe/Athens",
|
|
2640
|
+
WET: "Europe/Lisbon",
|
|
2641
|
+
WEST: "Europe/Lisbon",
|
|
2642
|
+
// Americas
|
|
2643
|
+
EST: "America/New_York",
|
|
2644
|
+
EDT: "America/New_York",
|
|
2645
|
+
CST: "America/Chicago",
|
|
2646
|
+
CDT: "America/Chicago",
|
|
2647
|
+
MST: "America/Denver",
|
|
2648
|
+
MDT: "America/Denver",
|
|
2649
|
+
PST: "America/Los_Angeles",
|
|
2650
|
+
PDT: "America/Los_Angeles",
|
|
2651
|
+
// Asia Pacific
|
|
2652
|
+
JST: "Asia/Tokyo",
|
|
2653
|
+
KST: "Asia/Seoul",
|
|
2654
|
+
IST: "Asia/Kolkata",
|
|
2655
|
+
CST8: "Asia/Shanghai",
|
|
2656
|
+
AEST: "Australia/Sydney",
|
|
2657
|
+
AEDT: "Australia/Sydney",
|
|
2658
|
+
// UTC variants
|
|
2659
|
+
UTC: "UTC",
|
|
2660
|
+
GMT: "UTC",
|
|
2661
|
+
Z: "UTC",
|
|
2662
|
+
};
|
|
2663
|
+
/**
|
|
2664
|
+
* Parse a `tz` string into an IANA timezone name, or return null if unrecognized.
|
|
2665
|
+
*/
|
|
2666
|
+
function resolveIANA(tz) {
|
|
2667
|
+
if (!tz)
|
|
2668
|
+
return null;
|
|
2669
|
+
// Direct IANA name (e.g. "Asia/Tehran")
|
|
2670
|
+
if (tz.includes("/"))
|
|
2671
|
+
return tz;
|
|
2672
|
+
// Common abbreviation lookup
|
|
2673
|
+
const abbr = TZ_ABBR[tz.toUpperCase()];
|
|
2674
|
+
if (abbr)
|
|
2675
|
+
return abbr;
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Parses a fixed offset string like "+03:30", "-05:00", "+0530" into ms.
|
|
2680
|
+
* Returns NaN if the string is not a valid offset.
|
|
2681
|
+
*/
|
|
2682
|
+
function parseFixedOffset(tz) {
|
|
2683
|
+
const m = tz.match(/^([+-])(\d{1,2}):?(\d{2})$/);
|
|
2684
|
+
if (!m)
|
|
2685
|
+
return NaN;
|
|
2686
|
+
const sign = m[1] === "+" ? 1 : -1;
|
|
2687
|
+
const h = parseInt(m[2], 10);
|
|
2688
|
+
const min = parseInt(m[3], 10);
|
|
2689
|
+
return sign * (h * 60 + min) * 60000;
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Returns the UTC offset in milliseconds for a given IANA timezone at a
|
|
2693
|
+
* specific UTC moment. Uses `Intl.DateTimeFormat` for correctness.
|
|
2694
|
+
*
|
|
2695
|
+
* Returns 0 (UTC) if the timezone is unrecognised by the runtime.
|
|
2696
|
+
*/
|
|
2697
|
+
function getIANAOffsetMs(ianaName, atUtcMs) {
|
|
2698
|
+
try {
|
|
2699
|
+
// Build a formatter that reports both UTC and local time parts
|
|
2700
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
2701
|
+
timeZone: ianaName,
|
|
2702
|
+
year: "numeric",
|
|
2703
|
+
month: "2-digit",
|
|
2704
|
+
day: "2-digit",
|
|
2705
|
+
hour: "2-digit",
|
|
2706
|
+
minute: "2-digit",
|
|
2707
|
+
second: "2-digit",
|
|
2708
|
+
hour12: false,
|
|
2709
|
+
});
|
|
2710
|
+
const parts = Object.fromEntries(fmt.formatToParts(new Date(atUtcMs)).map((p) => [p.type, p.value]));
|
|
2711
|
+
// Reconstruct the local time as UTC
|
|
2712
|
+
const localMs = Date.UTC(parseInt(parts.year), parseInt(parts.month) - 1, parseInt(parts.day), parseInt(parts.hour === "24" ? "0" : parts.hour), parseInt(parts.minute), parseInt(parts.second));
|
|
2713
|
+
return localMs - atUtcMs;
|
|
2714
|
+
}
|
|
2715
|
+
catch {
|
|
2716
|
+
return 0;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* Given a UTC timestamp (ms), returns the equivalent *local* millisecond
|
|
2721
|
+
* value for the given `tz` string.
|
|
2722
|
+
*
|
|
2723
|
+
* This is useful for display: `utcMs + offsetMs` gives local wall-clock time.
|
|
2724
|
+
*/
|
|
2725
|
+
function utcToLocal(utcMs, tz) {
|
|
2726
|
+
// Try fixed offset first (e.g. "+03:30")
|
|
2727
|
+
const fixed = parseFixedOffset(tz);
|
|
2728
|
+
if (!isNaN(fixed))
|
|
2729
|
+
return utcMs + fixed;
|
|
2730
|
+
const iana = resolveIANA(tz);
|
|
2731
|
+
if (!iana)
|
|
2732
|
+
return utcMs; // Unknown tz — return UTC unchanged
|
|
2733
|
+
return utcMs + getIANAOffsetMs(iana, utcMs);
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Given a *local* timestamp (ms) in `tz`, returns the equivalent UTC ms.
|
|
2737
|
+
*
|
|
2738
|
+
* Used when converting a calendar date expressed in local time back to UTC.
|
|
2739
|
+
*/
|
|
2740
|
+
function localToUtc(localMs, tz) {
|
|
2741
|
+
// Try fixed offset first
|
|
2742
|
+
const fixed = parseFixedOffset(tz);
|
|
2743
|
+
if (!isNaN(fixed))
|
|
2744
|
+
return localMs - fixed;
|
|
2745
|
+
const iana = resolveIANA(tz);
|
|
2746
|
+
if (!iana)
|
|
2747
|
+
return localMs;
|
|
2748
|
+
// Approximation: compute the offset at the approximate UTC moment
|
|
2749
|
+
// (offset iteration is overkill for a scheduling library)
|
|
2750
|
+
const approxUtcMs = localMs - getIANAOffsetMs(iana, localMs);
|
|
2751
|
+
// One correction pass for DST edge-cases
|
|
2752
|
+
const correctedOffset = getIANAOffsetMs(iana, approxUtcMs);
|
|
2753
|
+
return localMs - correctedOffset;
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Returns the UTC offset string (e.g. "+03:30") for a given IANA timezone
|
|
2757
|
+
* at the current moment. Useful for formatting and display.
|
|
2758
|
+
*/
|
|
2759
|
+
function getOffsetString(tz, atUtcMs = Date.now()) {
|
|
2760
|
+
// Fast-path for UTC/GMT/Z
|
|
2761
|
+
const upper = tz.toUpperCase();
|
|
2762
|
+
if (upper === "UTC" || upper === "GMT" || upper === "Z")
|
|
2763
|
+
return "+00:00";
|
|
2764
|
+
const fixed = parseFixedOffset(tz);
|
|
2765
|
+
if (!isNaN(fixed))
|
|
2766
|
+
return tz;
|
|
2767
|
+
const iana = resolveIANA(tz);
|
|
2768
|
+
if (!iana)
|
|
2769
|
+
return "+00:00";
|
|
2770
|
+
const offsetMs = getIANAOffsetMs(iana, atUtcMs);
|
|
2771
|
+
const sign = offsetMs >= 0 ? "+" : "-";
|
|
2772
|
+
const abs = Math.abs(offsetMs);
|
|
2773
|
+
const h = Math.floor(abs / 3600000)
|
|
2774
|
+
.toString()
|
|
2775
|
+
.padStart(2, "0");
|
|
2776
|
+
const m = Math.floor((abs % 3600000) / 60000)
|
|
2777
|
+
.toString()
|
|
2778
|
+
.padStart(2, "0");
|
|
2779
|
+
return `${sign}${h}:${m}`;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
/**
|
|
2783
|
+
* TPS: Temporal Positioning System
|
|
2784
|
+
* The Universal Protocol for Space-Time Coordinates.
|
|
2785
|
+
* @packageDocumentation
|
|
2786
|
+
* @version 0.8.0
|
|
2787
|
+
* @license Apache-2.0
|
|
2788
|
+
* @copyright 2026 TPS Standards Working Group
|
|
2789
|
+
*
|
|
2790
|
+
* v0.5.35 Changes:
|
|
2791
|
+
* - Added TPS.now(), TPS.diff(), TPS.add() convenience methods
|
|
2792
|
+
* - Added Chinese Lunisolar (chin) calendar driver
|
|
2793
|
+
* - Added DriverManager (driver registry separated from TPS class)
|
|
2794
|
+
* - Added timezone utility (src/utils/timezone.ts) with IANA + offset support
|
|
2795
|
+
* - TPS.toDate() now respects ;tz= extensions when present
|
|
2796
|
+
* - ESM dual-mode exports + browser IIFE bundle
|
|
2797
|
+
*
|
|
2798
|
+
* v0.5.0 Changes:
|
|
2799
|
+
* - Added Actor anchor (A:) for provenance tracking
|
|
2800
|
+
* - Added Signature (!) for cryptographic verification
|
|
2801
|
+
* - Added structural anchors (bldg, floor, room, zone)
|
|
2802
|
+
* - Added geospatial cell systems (S2, H3, Plus Code, what3words)
|
|
2803
|
+
*/
|
|
2804
|
+
// built-in drivers are registered automatically; importing them here
|
|
2805
|
+
// ensures they are included when the library bundler/tree-shaker runs.
|
|
2806
|
+
class TPS {
|
|
2807
|
+
/**
|
|
2808
|
+
* Registers a calendar driver plugin.
|
|
2809
|
+
* @param driver - The driver instance to register.
|
|
2810
|
+
*/
|
|
2811
|
+
static registerDriver(driver) {
|
|
2812
|
+
this.driverManager.register(driver);
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* Gets a registered calendar driver.
|
|
2816
|
+
* @param code - The calendar code.
|
|
2817
|
+
* @returns The driver or undefined.
|
|
2818
|
+
*/
|
|
2819
|
+
static getDriver(code) {
|
|
2820
|
+
return this.driverManager.get(code);
|
|
2821
|
+
}
|
|
2822
|
+
// --- CORE METHODS ---
|
|
2823
|
+
/**
|
|
2824
|
+
* SANITIZER: Normalises a raw TPS input string before validation.
|
|
2825
|
+
*
|
|
2826
|
+
* Pure string-based — no parsing into components, no regex beyond simple
|
|
2827
|
+
* character checks, no re-serialisation via buildTimePart / toURI.
|
|
2828
|
+
*
|
|
2829
|
+
* Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
2830
|
+
*/
|
|
2831
|
+
static sanitizeTimeInput(input) {
|
|
2832
|
+
// ── 1. Whitespace ────────────────────────────────────────────────────────
|
|
2833
|
+
let s = input.trim().replace(/\s+/g, "");
|
|
2834
|
+
if (!s)
|
|
2835
|
+
return s;
|
|
2836
|
+
// ── 1.2 Compact scheme normalization (v0.6.0) ──────────────────────────
|
|
2837
|
+
// TPS:... → tps://... (generic compact)
|
|
2838
|
+
// NIP4:x → tps://net:ip4:x (IPv4 shorthand)
|
|
2839
|
+
// NIP6:x → tps://net:ip6:x (IPv6 shorthand)
|
|
2840
|
+
// NODE:x → tps://node:x (logical node shorthand)
|
|
2841
|
+
if (/^TPS:/i.test(s) && !s.toLowerCase().startsWith("tps://")) {
|
|
2842
|
+
// TPS:L:... or TPS:lat,lon... → tps://...
|
|
2843
|
+
s = "tps://" + s.slice(4); // strip 'TPS:'
|
|
2844
|
+
}
|
|
2845
|
+
else if (/^NIP4:/i.test(s)) {
|
|
2846
|
+
s = "tps://net:ip4:" + s.slice(5);
|
|
2847
|
+
}
|
|
2848
|
+
else if (/^NIP6:/i.test(s)) {
|
|
2849
|
+
s = "tps://net:ip6:" + s.slice(5);
|
|
2850
|
+
}
|
|
2851
|
+
else if (/^NODE:/i.test(s)) {
|
|
2852
|
+
s = "tps://node:" + s.slice(5);
|
|
2853
|
+
}
|
|
2854
|
+
// ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
|
|
2855
|
+
// The input may contain "/T:" from older versions; we normalise early so
|
|
2856
|
+
// subsequent logic can assume only the '@' form.
|
|
2857
|
+
if (s.includes("/T:")) {
|
|
2858
|
+
s = s.replace(/\/T:/g, "@T:");
|
|
2859
|
+
}
|
|
2860
|
+
// ── 2. Scheme casing ─────────────────────────────────────────────────────
|
|
2861
|
+
if (s.slice(0, 6).toLowerCase() === "tps://") {
|
|
2862
|
+
s = "tps://" + s.slice(6);
|
|
2863
|
+
}
|
|
2864
|
+
// ── 3. T: prefix casing (time-only strings) ──────────────────────────────
|
|
2865
|
+
if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
|
|
2866
|
+
s = "T:" + s.slice(2);
|
|
2867
|
+
}
|
|
2868
|
+
// ── 4. Locate T: section ─────────────────────────────────────────────────
|
|
2869
|
+
let tStart = -1;
|
|
2870
|
+
if (s.startsWith("T:")) {
|
|
2871
|
+
tStart = 0;
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
const atT = s.indexOf("@T:");
|
|
2875
|
+
if (atT !== -1)
|
|
2876
|
+
tStart = atT + 1;
|
|
2877
|
+
}
|
|
2878
|
+
if (tStart === -1)
|
|
2879
|
+
return s; // no T: section — return as-is
|
|
2880
|
+
const beforeT = s.slice(0, tStart); // URI prefix or empty
|
|
2881
|
+
const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
|
|
2882
|
+
// Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
|
|
2883
|
+
const suffixIdx = timeAndRest.search(/[!;?#]/);
|
|
2884
|
+
const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
|
|
2885
|
+
const timePart = suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
|
|
2886
|
+
// timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
|
|
2887
|
+
// Split off calendar code
|
|
2888
|
+
const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
|
|
2889
|
+
const firstDot = afterColon.indexOf(".");
|
|
2890
|
+
const cal = (firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon).toLowerCase();
|
|
2891
|
+
const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
|
|
2892
|
+
// If no calendar code was provided at all (e.g. "T:"), bail out early
|
|
2893
|
+
// rather than inventing a default calendar. The string will remain
|
|
2894
|
+
// unparsable so validation can report it as invalid.
|
|
2895
|
+
if (!cal) {
|
|
2896
|
+
return s;
|
|
2897
|
+
}
|
|
2898
|
+
// No tokens at all — fill every slot with 0 and return
|
|
2899
|
+
if (!tokenStr) {
|
|
2900
|
+
return `${beforeT}T:${cal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
|
|
2901
|
+
}
|
|
2902
|
+
if (/^i/i.test(tokenStr)) {
|
|
2903
|
+
return `${beforeT}T:${cal}.${tokenStr}${timeSuffix}`;
|
|
2904
|
+
}
|
|
2905
|
+
const tokens = tokenStr
|
|
2906
|
+
.split(".")
|
|
2907
|
+
.filter((t) => t.length >= 2 && /^[a-z]/.test(t))
|
|
2908
|
+
.map((t) => ({ p: t[0], v: t.slice(1) }));
|
|
2909
|
+
// ── 6. Detect order from non-m tokens (c=7, y=6, w=4.5, d=4, h=3, s=1) ──
|
|
2910
|
+
const nonMRank = {
|
|
2911
|
+
c: 7,
|
|
2912
|
+
y: 6,
|
|
2913
|
+
w: 4.5,
|
|
2914
|
+
d: 4,
|
|
2915
|
+
h: 3,
|
|
2916
|
+
s: 1,
|
|
2917
|
+
};
|
|
2918
|
+
const nonMSeq = tokens
|
|
2919
|
+
.filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
|
|
2920
|
+
.map((t) => nonMRank[t.p]);
|
|
2921
|
+
let isAsc = false;
|
|
2922
|
+
if (nonMSeq.length >= 2) {
|
|
2923
|
+
// ascending when every consecutive rank-diff is positive
|
|
2924
|
+
isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
|
|
2925
|
+
}
|
|
2926
|
+
// ── 7. Reverse tokens if ascending ───────────────────────────────────────
|
|
2927
|
+
if (isAsc)
|
|
2928
|
+
tokens.reverse();
|
|
2929
|
+
// ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
|
|
2930
|
+
// DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
|
|
2931
|
+
const mDescRanks = [8, 5, 2, 0];
|
|
2932
|
+
const byRank = new Map();
|
|
2933
|
+
let mIdx = 0;
|
|
2934
|
+
for (const tok of tokens) {
|
|
2935
|
+
if (tok.p === "m") {
|
|
2936
|
+
if (mIdx < mDescRanks.length)
|
|
2937
|
+
byRank.set(mDescRanks[mIdx++], tok.v);
|
|
2938
|
+
}
|
|
2939
|
+
else {
|
|
2940
|
+
const r = nonMRank[tok.p];
|
|
2941
|
+
if (r !== undefined)
|
|
2942
|
+
byRank.set(r, tok.v);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
// ── 9. Build complete DESC token string, filling gaps with '0' ───────────
|
|
2946
|
+
const descSlots = [
|
|
2947
|
+
["m", 8],
|
|
2948
|
+
["c", 7],
|
|
2949
|
+
["y", 6],
|
|
2950
|
+
["m", 5],
|
|
2951
|
+
...(tokens.some((t) => t.p === "w") ? [["w", 4.5]] : []),
|
|
2952
|
+
["d", 4],
|
|
2953
|
+
["h", 3],
|
|
2954
|
+
["m", 2],
|
|
2955
|
+
["s", 1],
|
|
2956
|
+
["m", 0],
|
|
2957
|
+
];
|
|
2958
|
+
const finalTokenStr = descSlots
|
|
2959
|
+
.map(([p, r]) => p + (byRank.get(r) ?? "0"))
|
|
2960
|
+
.join(".");
|
|
2961
|
+
return `${beforeT}T:${cal}.${finalTokenStr}${timeSuffix}`;
|
|
2962
|
+
}
|
|
2963
|
+
static validate(input) {
|
|
2964
|
+
const sanitized = this.sanitizeTimeInput(input);
|
|
2965
|
+
const matchesRegex = sanitized.startsWith("tps://")
|
|
2966
|
+
? this.REGEX_URI.test(sanitized)
|
|
2967
|
+
: this.REGEX_TIME.test(sanitized);
|
|
2968
|
+
if (!matchesRegex)
|
|
2969
|
+
return false;
|
|
2970
|
+
const indexedMatch = sanitized.match(/(?:^T:|@T:)([a-z]{3,4})\.(i[^!;?#]+)/i);
|
|
2971
|
+
if (indexedMatch) {
|
|
2972
|
+
if (indexedMatch[1].toLowerCase() !== DefaultCalendars.TPS) {
|
|
2973
|
+
return false;
|
|
2974
|
+
}
|
|
2975
|
+
if (!isTpsIndexedToken(indexedMatch[2])) {
|
|
2976
|
+
return this.parse(sanitized) !== null;
|
|
2977
|
+
}
|
|
2978
|
+
return this.parse(sanitized) !== null;
|
|
2979
|
+
}
|
|
2980
|
+
return true;
|
|
2981
|
+
}
|
|
2982
|
+
static parse(input) {
|
|
2983
|
+
// Always sanitize first so we operate on the canonical form. This also
|
|
2984
|
+
// rewrites any legacy "/T:" separators to "@T:" so the regex below can
|
|
2985
|
+
// remain strict.
|
|
2986
|
+
input = this.sanitizeTimeInput(input);
|
|
2987
|
+
// quick fail via regex to rule out obviously bad strings
|
|
2988
|
+
if (input.startsWith("tps://")) {
|
|
2989
|
+
const match = this.REGEX_URI.exec(input);
|
|
2990
|
+
if (!match || !match.groups)
|
|
2991
|
+
return null;
|
|
2992
|
+
const comp = this._mapGroupsToComponents(match.groups);
|
|
2993
|
+
// extract the raw time portion and parse it separately
|
|
2994
|
+
const atIdx = input.indexOf("@T:");
|
|
2995
|
+
let timeStr = "";
|
|
2996
|
+
let signature;
|
|
2997
|
+
if (atIdx !== -1) {
|
|
2998
|
+
timeStr = input.slice(atIdx + 1); // include the leading 'T:'
|
|
2999
|
+
// if there's a signature, capture it first
|
|
3000
|
+
const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
|
|
3001
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
3002
|
+
signature = sigMatch.groups.sig;
|
|
3003
|
+
}
|
|
3004
|
+
// cut off signature, extensions, query, or fragment
|
|
3005
|
+
timeStr = timeStr.split(/[!;?#]/)[0];
|
|
3006
|
+
}
|
|
3007
|
+
if (timeStr) {
|
|
3008
|
+
const parsed = parseTimeString(timeStr);
|
|
3009
|
+
if (!parsed)
|
|
3010
|
+
return null;
|
|
3011
|
+
Object.assign(comp, parsed.components);
|
|
3012
|
+
comp.order = parsed.order;
|
|
3013
|
+
}
|
|
3014
|
+
if (signature) {
|
|
3015
|
+
comp.signature = signature;
|
|
3016
|
+
}
|
|
3017
|
+
return comp;
|
|
3018
|
+
}
|
|
3019
|
+
// time-only string
|
|
3020
|
+
const match = this.REGEX_TIME.exec(input);
|
|
3021
|
+
if (!match || !match.groups)
|
|
3022
|
+
return null;
|
|
3023
|
+
// isolate signature if present
|
|
3024
|
+
let timeOnly = input;
|
|
3025
|
+
let signature;
|
|
3026
|
+
const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
|
|
3027
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
3028
|
+
signature = sigMatch.groups.sig;
|
|
3029
|
+
timeOnly = input.split(/[!;?#]/)[0];
|
|
3030
|
+
}
|
|
3031
|
+
else {
|
|
3032
|
+
// Strip extension/query/fragment suffix so parseTimeString sees only tokens
|
|
3033
|
+
timeOnly = input.split(/[;?#]/)[0];
|
|
3034
|
+
}
|
|
3035
|
+
const parsed = parseTimeString(timeOnly);
|
|
3036
|
+
if (!parsed)
|
|
3037
|
+
return null;
|
|
3038
|
+
const comp = parsed.components;
|
|
3039
|
+
if (signature)
|
|
3040
|
+
comp.signature = signature;
|
|
3041
|
+
comp.order = parsed.order;
|
|
3042
|
+
// Route through the same group mapper used by REGEX_URI for consistency
|
|
3043
|
+
// (handles extensions ;KEY:val and context #C:key=val)
|
|
3044
|
+
const syntheticGroups = {
|
|
3045
|
+
calendar: match.groups.calendar ?? "",
|
|
3046
|
+
signature: match.groups.signature ?? "",
|
|
3047
|
+
extensions: match.groups.extensions ?? "",
|
|
3048
|
+
context: match.groups.context ?? "",
|
|
3049
|
+
location: "", // no location in time-only string
|
|
3050
|
+
actor: "",
|
|
3051
|
+
};
|
|
3052
|
+
const mappedComp = this._mapGroupsToComponents(syntheticGroups);
|
|
3053
|
+
// Merge temporal components from parseTimeString with mapped metadata
|
|
3054
|
+
Object.assign(comp, {
|
|
3055
|
+
signature: mappedComp.signature || comp.signature,
|
|
3056
|
+
extensions: mappedComp.extensions || comp.extensions,
|
|
3057
|
+
context: mappedComp.context,
|
|
3058
|
+
});
|
|
3059
|
+
return comp;
|
|
3060
|
+
}
|
|
3061
|
+
static buildTimeString(comp, opts) {
|
|
3062
|
+
let time = buildTimePart(comp, opts);
|
|
3063
|
+
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
3064
|
+
const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
|
|
3065
|
+
return `${k.toUpperCase()}:${v}`;
|
|
3066
|
+
});
|
|
3067
|
+
time += `;${extStrings.join(";")}`;
|
|
3068
|
+
}
|
|
3069
|
+
if (comp.context && Object.keys(comp.context).length > 0) {
|
|
3070
|
+
const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
|
|
3071
|
+
time += `#C:${ctxStrings.join(";")}`;
|
|
3072
|
+
}
|
|
3073
|
+
return time;
|
|
3074
|
+
}
|
|
3075
|
+
static toTpsNativeComponents(input) {
|
|
3076
|
+
if (input instanceof Date) {
|
|
3077
|
+
return normalizeTpsComponents({
|
|
3078
|
+
calendar: DefaultCalendars.TPS,
|
|
3079
|
+
...buildTpsComponentsFromDayIndex(getTpsDayIndex(input), getTpsDayFraction(input)),
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
if (typeof input === "string") {
|
|
3083
|
+
const parsed = this.parse(input);
|
|
3084
|
+
if (!parsed || parsed.calendar !== DefaultCalendars.TPS)
|
|
3085
|
+
return null;
|
|
3086
|
+
return normalizeTpsComponents(parsed);
|
|
3087
|
+
}
|
|
3088
|
+
if ((input.calendar ?? DefaultCalendars.TPS) !== DefaultCalendars.TPS) {
|
|
3089
|
+
return null;
|
|
3090
|
+
}
|
|
3091
|
+
return normalizeTpsComponents({
|
|
3092
|
+
...input,
|
|
3093
|
+
calendar: DefaultCalendars.TPS,
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
static renderTpsLikeInput(originalInput, comp, opts) {
|
|
3097
|
+
const sanitized = this.sanitizeTimeInput(originalInput);
|
|
3098
|
+
return sanitized.startsWith("tps://")
|
|
3099
|
+
? this.toURI(comp, opts)
|
|
3100
|
+
: this.buildTimeString(comp, opts);
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* SERIALIZER: Converts a components object into a full TPS URI.
|
|
3104
|
+
* @param comp - The TPS components.
|
|
3105
|
+
* @returns Full URI string (e.g. "tps://...").
|
|
3106
|
+
*/
|
|
3107
|
+
static toURI(comp, opts) {
|
|
3108
|
+
// ── 1. Location layers (v0.6.0) ──────────────────────────────────────────
|
|
3109
|
+
// Build an ordered list of location layer strings, then join with ";"
|
|
3110
|
+
const layers = [];
|
|
3111
|
+
// Privacy shorthand takes priority
|
|
3112
|
+
if (comp.isHiddenLocation) {
|
|
3113
|
+
layers.push("L:~");
|
|
3114
|
+
}
|
|
3115
|
+
else if (comp.isRedactedLocation) {
|
|
3116
|
+
layers.push("L:redacted");
|
|
3117
|
+
}
|
|
3118
|
+
else if (comp.isUnknownLocation) {
|
|
3119
|
+
layers.push("L:-");
|
|
3120
|
+
}
|
|
3121
|
+
else if (comp.spaceAnchor) {
|
|
3122
|
+
// Generic / legacy anchor (adm:, planet:, etc.)
|
|
3123
|
+
layers.push(comp.spaceAnchor);
|
|
3124
|
+
}
|
|
3125
|
+
else if (comp.ipv4) {
|
|
3126
|
+
layers.push(`net:ip4:${comp.ipv4}`);
|
|
3127
|
+
}
|
|
3128
|
+
else if (comp.ipv6) {
|
|
3129
|
+
layers.push(`net:ip6:${comp.ipv6}`);
|
|
3130
|
+
}
|
|
3131
|
+
else if (comp.nodeName) {
|
|
3132
|
+
layers.push(`node:${comp.nodeName}`);
|
|
3133
|
+
}
|
|
3134
|
+
else if (comp.s2Cell) {
|
|
3135
|
+
layers.push(`S2:${comp.s2Cell}`);
|
|
3136
|
+
}
|
|
3137
|
+
else if (comp.h3Cell) {
|
|
3138
|
+
layers.push(`H3:${comp.h3Cell}`);
|
|
3139
|
+
}
|
|
3140
|
+
else if (comp.what3words) {
|
|
3141
|
+
layers.push(`3W:${comp.what3words}`);
|
|
3142
|
+
}
|
|
3143
|
+
else if (comp.plusCode) {
|
|
3144
|
+
layers.push(`plus:${comp.plusCode}`);
|
|
3145
|
+
}
|
|
3146
|
+
else if (comp.building) {
|
|
3147
|
+
layers.push(`bldg:${comp.building}`);
|
|
3148
|
+
if (comp.floor)
|
|
3149
|
+
layers.push(`floor:${comp.floor}`);
|
|
3150
|
+
if (comp.room)
|
|
3151
|
+
layers.push(`room:${comp.room}`);
|
|
3152
|
+
if (comp.door)
|
|
3153
|
+
layers.push(`door:${comp.door}`);
|
|
3154
|
+
if (comp.zone)
|
|
3155
|
+
layers.push(`zone:${comp.zone}`);
|
|
3156
|
+
}
|
|
3157
|
+
else if (comp.latitude !== undefined && comp.longitude !== undefined) {
|
|
3158
|
+
let gps = `L:${comp.latitude},${comp.longitude}`;
|
|
3159
|
+
if (comp.altitude !== undefined)
|
|
3160
|
+
gps += `,${comp.altitude}m`;
|
|
3161
|
+
layers.push(gps);
|
|
3162
|
+
}
|
|
3163
|
+
else {
|
|
3164
|
+
layers.push("L:-"); // unknown fallback
|
|
3165
|
+
}
|
|
3166
|
+
// Place layer (P:) — appended after primary location
|
|
3167
|
+
if (comp.placeCountryCode || comp.placeCountryName ||
|
|
3168
|
+
comp.placeCityCode || comp.placeCityName) {
|
|
3169
|
+
const pParts = [];
|
|
3170
|
+
if (comp.placeCountryCode)
|
|
3171
|
+
pParts.push(`cc=${comp.placeCountryCode}`);
|
|
3172
|
+
if (comp.placeCountryName)
|
|
3173
|
+
pParts.push(`cn=${comp.placeCountryName}`);
|
|
3174
|
+
if (comp.placeCityCode)
|
|
3175
|
+
pParts.push(`ci=${comp.placeCityCode}`);
|
|
3176
|
+
if (comp.placeCityName)
|
|
3177
|
+
pParts.push(`ct=${comp.placeCityName}`);
|
|
3178
|
+
layers.push(`P:${pParts.join(",")}`);
|
|
3179
|
+
}
|
|
3180
|
+
const locationStr = layers.join(";");
|
|
3181
|
+
// ── 2. Actor (/A:...) ─────────────────────────────────────────────────────
|
|
3182
|
+
const actorPart = comp.actor ? `/A:${comp.actor}` : "";
|
|
3183
|
+
// ── 3. Time (mandatory 9 tokens) ─────────────────────────────────────────
|
|
3184
|
+
const timePart = buildTimePart(comp, opts);
|
|
3185
|
+
// ── 4. Extensions (;KEY:val;...) ─────────────────────────────────────────
|
|
3186
|
+
let extPart = "";
|
|
3187
|
+
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
3188
|
+
const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
|
|
3189
|
+
// Emit as KEY:val (preferred v0.6.0 style)
|
|
3190
|
+
return `${k.toUpperCase()}:${v}`;
|
|
3191
|
+
});
|
|
3192
|
+
extPart = `;${extStrings.join(";")}`;
|
|
3193
|
+
}
|
|
3194
|
+
// ── 5. Context (#C:key=val;...) ──────────────────────────────────────────
|
|
3195
|
+
let contextPart = "";
|
|
3196
|
+
if (comp.context && Object.keys(comp.context).length > 0) {
|
|
3197
|
+
const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
|
|
3198
|
+
contextPart = `#C:${ctxStrings.join(";")}`;
|
|
3199
|
+
}
|
|
3200
|
+
return `tps://${locationStr}${actorPart}@${timePart}${extPart}${contextPart}`;
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
|
|
3204
|
+
* Supports plugin drivers for non-Gregorian calendars.
|
|
3205
|
+
* @param date - The JS Date object (defaults to Now).
|
|
3206
|
+
* @param calendar - The target calendar driver (default `"tps"`).
|
|
3207
|
+
* @param opts - Optional parameters; for built-in calendars the only
|
|
3208
|
+
* supported key is `order` which may be `'ascending'` or `'descending'`.
|
|
3209
|
+
* @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
|
|
3210
|
+
*/
|
|
3211
|
+
static fromDate(date = new Date(), calendar = DefaultCalendars.TPS, opts) {
|
|
3212
|
+
const normalizedCalendar = calendar.toLowerCase();
|
|
3213
|
+
const driver = this.driverManager.get(normalizedCalendar);
|
|
3214
|
+
if (driver) {
|
|
3215
|
+
// when caller requested an explicit order we can bypass the driver's
|
|
3216
|
+
// `fromDate` helper and instead generate components ourselves so that
|
|
3217
|
+
// order is honoured even if the driver doesn't know about it. This
|
|
3218
|
+
// keeps behaviour identical to the old built-in implementation.
|
|
3219
|
+
if (opts?.order || opts?.timeMode || opts?.indexedPrecision !== undefined) {
|
|
3220
|
+
const comp = driver.getComponentsFromDate(date);
|
|
3221
|
+
comp.calendar = normalizedCalendar;
|
|
3222
|
+
if (opts?.order)
|
|
3223
|
+
comp.order = opts.order;
|
|
3224
|
+
return buildTimePart(comp, opts);
|
|
3225
|
+
}
|
|
3226
|
+
return driver.getFromDate(date);
|
|
3227
|
+
}
|
|
3228
|
+
// Fallback for old built-in calendars (shouldn't happen once drivers are
|
|
3229
|
+
// registered, but kept for backwards compatibility).
|
|
3230
|
+
const comp = { calendar: normalizedCalendar };
|
|
3231
|
+
if (normalizedCalendar === DefaultCalendars.UNIX) {
|
|
3232
|
+
const s = (date.getTime() / 1000).toFixed(3);
|
|
3233
|
+
comp.unixSeconds = parseFloat(s);
|
|
3234
|
+
if (opts?.order)
|
|
3235
|
+
comp.order = opts.order;
|
|
3236
|
+
return buildTimePart(comp, opts);
|
|
3237
|
+
}
|
|
3238
|
+
if (normalizedCalendar === DefaultCalendars.GREG) {
|
|
3239
|
+
const fullYear = date.getUTCFullYear();
|
|
3240
|
+
comp.millennium = Math.floor(fullYear / 1000) + 1;
|
|
3241
|
+
comp.century = Math.floor((fullYear % 1000) / 100) + 1;
|
|
3242
|
+
comp.year = fullYear % 100;
|
|
3243
|
+
comp.month = date.getUTCMonth() + 1;
|
|
3244
|
+
comp.day = date.getUTCDate();
|
|
3245
|
+
comp.hour = date.getUTCHours();
|
|
3246
|
+
comp.minute = date.getUTCMinutes();
|
|
3247
|
+
comp.second = date.getUTCSeconds();
|
|
3248
|
+
comp.millisecond = date.getUTCMilliseconds();
|
|
3249
|
+
if (opts?.order)
|
|
3250
|
+
comp.order = opts.order;
|
|
3251
|
+
return buildTimePart(comp, opts);
|
|
3252
|
+
}
|
|
3253
|
+
throw new Error(`Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`);
|
|
3254
|
+
}
|
|
3255
|
+
/**
|
|
3256
|
+
* CONVERTER: Converts a TPS string to a Date in a target calendar format.
|
|
3257
|
+
* Uses plugin drivers for cross-calendar conversion.
|
|
3258
|
+
* @param tpsString - The source TPS string (any calendar).
|
|
3259
|
+
* @param targetCalendar - The target calendar code (e.g., 'hij').
|
|
3260
|
+
* @returns A TPS string in the target calendar, or null if invalid.
|
|
3261
|
+
*/
|
|
3262
|
+
static to(targetCalendar, tpsString) {
|
|
3263
|
+
// 1. Parse to components and convert to Gregorian Date
|
|
3264
|
+
const gregDate = this.toDate(tpsString);
|
|
3265
|
+
if (!gregDate)
|
|
3266
|
+
return null;
|
|
3267
|
+
// 2. Convert Gregorian to target calendar using driver
|
|
3268
|
+
return this.fromDate(gregDate, targetCalendar);
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* CONVERTER: Reconstructs a JavaScript Date object from a TPS string.
|
|
3272
|
+
* Supports plugin drivers for non-Gregorian calendars.
|
|
3273
|
+
* @param tpsString - The TPS string.
|
|
3274
|
+
* @returns JS Date object or `null` if invalid.
|
|
3275
|
+
*/
|
|
3276
|
+
static toDate(tpsString) {
|
|
3277
|
+
const parsed = this.parse(tpsString);
|
|
3278
|
+
if (!parsed)
|
|
3279
|
+
return null;
|
|
3280
|
+
const cal = parsed.calendar || DefaultCalendars.TPS;
|
|
3281
|
+
const driver = this.driverManager.get(cal);
|
|
3282
|
+
if (!driver) {
|
|
3283
|
+
console.error(`Calendar driver '${cal}' not registered.`);
|
|
3284
|
+
return null;
|
|
3285
|
+
}
|
|
3286
|
+
const date = driver.getDateFromComponents(parsed);
|
|
3287
|
+
// If the URI has a ;tz= extension, the calendar date was expressed in local
|
|
3288
|
+
// time. Convert from local → UTC using the timezone utility.
|
|
3289
|
+
const tz = parsed.extensions?.["tz"];
|
|
3290
|
+
if (tz && date) {
|
|
3291
|
+
const localMs = date.getTime();
|
|
3292
|
+
const utcMs = localToUtc(localMs, tz);
|
|
3293
|
+
return new Date(utcMs);
|
|
3294
|
+
}
|
|
3295
|
+
return date;
|
|
3296
|
+
}
|
|
3297
|
+
static toDayIndex(input) {
|
|
3298
|
+
const comp = this.toTpsNativeComponents(input);
|
|
3299
|
+
return comp ? getTpsDayIndex(comp) : null;
|
|
3300
|
+
}
|
|
3301
|
+
static fromDayIndex(dayIndex, dayFraction = 0, opts) {
|
|
3302
|
+
if (!Number.isSafeInteger(dayIndex) || dayIndex < 0) {
|
|
3303
|
+
throw new Error("TPS.fromDayIndex: dayIndex must be a non-negative integer");
|
|
3304
|
+
}
|
|
3305
|
+
if (!Number.isFinite(dayFraction) || dayFraction < 0 || dayFraction >= 1) {
|
|
3306
|
+
throw new Error("TPS.fromDayIndex: dayFraction must be in [0, 1)");
|
|
3307
|
+
}
|
|
3308
|
+
const comp = buildTpsComponentsFromDayIndex(dayIndex, dayFraction);
|
|
3309
|
+
if (opts?.order)
|
|
3310
|
+
comp.order = opts.order;
|
|
3311
|
+
return buildTimePart(comp, opts);
|
|
3312
|
+
}
|
|
3313
|
+
static getDayFraction(input) {
|
|
3314
|
+
const comp = this.toTpsNativeComponents(input);
|
|
3315
|
+
return comp ? getTpsDayFraction(comp) : null;
|
|
3316
|
+
}
|
|
3317
|
+
static getSubDayMilliseconds(input) {
|
|
3318
|
+
const comp = this.toTpsNativeComponents(input);
|
|
3319
|
+
return comp ? getTpsSubDayMilliseconds(comp) : null;
|
|
3320
|
+
}
|
|
3321
|
+
static expandIndexedTime(input) {
|
|
3322
|
+
const sanitized = this.sanitizeTimeInput(input);
|
|
3323
|
+
const indexedMatch = sanitized.match(/(?:^T:|@T:)([a-z]{3,4})\.(i[^!;?#]+)/i);
|
|
3324
|
+
if (!indexedMatch ||
|
|
3325
|
+
indexedMatch[1].toLowerCase() !== DefaultCalendars.TPS ||
|
|
3326
|
+
!isTpsIndexedToken(indexedMatch[2])) {
|
|
3327
|
+
const parsed = this.parse(sanitized);
|
|
3328
|
+
if (!parsed || parsed.calendar !== DefaultCalendars.TPS)
|
|
3329
|
+
return null;
|
|
3330
|
+
return input.trim();
|
|
3331
|
+
}
|
|
3332
|
+
const comp = this.toTpsNativeComponents(sanitized);
|
|
3333
|
+
if (!comp)
|
|
3334
|
+
return null;
|
|
3335
|
+
return this.renderTpsLikeInput(sanitized, comp);
|
|
3336
|
+
}
|
|
3337
|
+
static expandIndex(input) {
|
|
3338
|
+
return this.expandIndexedTime(input);
|
|
3339
|
+
}
|
|
3340
|
+
static compactIndexedTime(input, opts) {
|
|
3341
|
+
const comp = this.toTpsNativeComponents(input);
|
|
3342
|
+
if (!comp)
|
|
3343
|
+
return null;
|
|
3344
|
+
return this.renderTpsLikeInput(input, comp, {
|
|
3345
|
+
timeMode: "indexed-fraction",
|
|
3346
|
+
indexedPrecision: opts?.precision,
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
static compact(input, opts) {
|
|
3350
|
+
return this.compactIndexedTime(input, opts);
|
|
3351
|
+
}
|
|
3352
|
+
static toIndexedTime(input, opts) {
|
|
3353
|
+
const comp = this.toTpsNativeComponents(input);
|
|
3354
|
+
if (!comp)
|
|
3355
|
+
return null;
|
|
3356
|
+
return this.buildTimeString(comp, {
|
|
3357
|
+
timeMode: "indexed-fraction",
|
|
3358
|
+
indexedPrecision: opts?.precision,
|
|
3359
|
+
});
|
|
3360
|
+
}
|
|
3361
|
+
static toIndexedURI(input, opts) {
|
|
3362
|
+
const comp = this.toTpsNativeComponents(input);
|
|
3363
|
+
if (!comp)
|
|
3364
|
+
return null;
|
|
3365
|
+
return this.toURI(comp, {
|
|
3366
|
+
timeMode: "indexed-fraction",
|
|
3367
|
+
indexedPrecision: opts?.precision,
|
|
3368
|
+
});
|
|
3369
|
+
}
|
|
3370
|
+
// --- DRIVER CONVENIENCE METHODS ---
|
|
3371
|
+
/**
|
|
3372
|
+
* Parse a calendar-specific date string into TPS components.
|
|
3373
|
+
* Requires the driver to implement `parseDate`.
|
|
3374
|
+
*
|
|
3375
|
+
* @param calendar - The calendar code (e.g., 'hij')
|
|
3376
|
+
* @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
|
|
3377
|
+
* @param format - Optional format string (driver-specific)
|
|
3378
|
+
* @returns TPS components or null if parsing fails
|
|
3379
|
+
*
|
|
3380
|
+
* @example
|
|
3381
|
+
* ```ts
|
|
3382
|
+
* const components = TPS.parseCalendarDate('hij', '1447-07-21');
|
|
3383
|
+
* // { calendar: 'hij', year: 1447, month: 7, day: 21 }
|
|
3384
|
+
*
|
|
3385
|
+
* const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
|
|
3386
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
3387
|
+
* ```
|
|
3388
|
+
*/
|
|
3389
|
+
static parseCalendarDate(calendar, dateString, format) {
|
|
3390
|
+
const driver = this.driverManager.get(calendar);
|
|
3391
|
+
if (!driver) {
|
|
3392
|
+
throw new Error(`Calendar driver '${calendar}' not found. Register a driver first.`);
|
|
3393
|
+
}
|
|
3394
|
+
// parseDate is guaranteed by the interface, so we can call it directly.
|
|
3395
|
+
return driver.parseDate(dateString, format);
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* Convert a calendar-specific date string directly to a TPS URI.
|
|
3399
|
+
* This is a convenience method that combines parseDate + toURI.
|
|
3400
|
+
*
|
|
3401
|
+
* @param calendar - The calendar code (e.g., 'hij')
|
|
3402
|
+
* @param dateString - Date string in calendar-native format
|
|
3403
|
+
* @param location - Optional location (lat/lon/alt or privacy flag)
|
|
3404
|
+
* @returns Full TPS URI string
|
|
3405
|
+
*
|
|
3406
|
+
* @example
|
|
3407
|
+
* ```ts
|
|
3408
|
+
* // With coordinates
|
|
3409
|
+
* TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
|
|
3410
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
3411
|
+
*
|
|
3412
|
+
* // With privacy flag
|
|
3413
|
+
* TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
|
|
3414
|
+
* // "tps://hidden@T:hij.y1447.m07.d21"
|
|
3415
|
+
*
|
|
3416
|
+
* // Without location
|
|
3417
|
+
* TPS.fromCalendarDate('hij', '1447-07-21');
|
|
3418
|
+
* // "tps://unknown@T:hij.y1447.m07.d21"
|
|
3419
|
+
* ```
|
|
3420
|
+
*/
|
|
3421
|
+
static fromCalendarDate(calendar, dateString, location) {
|
|
3422
|
+
const components = this.parseCalendarDate(calendar, dateString);
|
|
3423
|
+
if (!components) {
|
|
3424
|
+
throw new Error(`Failed to parse date string: ${dateString}`);
|
|
3425
|
+
}
|
|
3426
|
+
// Merge with location
|
|
3427
|
+
const fullComponents = {
|
|
3428
|
+
calendar: calendar,
|
|
3429
|
+
...components,
|
|
3430
|
+
...location,
|
|
3431
|
+
};
|
|
3432
|
+
return this.toURI(fullComponents);
|
|
3433
|
+
}
|
|
3434
|
+
/**
|
|
3435
|
+
* Format TPS components to a calendar-specific date string.
|
|
3436
|
+
* Requires the driver to implement `format`.
|
|
3437
|
+
*
|
|
3438
|
+
* @param calendar - The calendar code
|
|
3439
|
+
* @param components - TPS components to format
|
|
3440
|
+
* @param format - Optional format string (driver-specific)
|
|
3441
|
+
* @returns Formatted date string in calendar-native format
|
|
3442
|
+
*
|
|
3443
|
+
* @example
|
|
3444
|
+
* ```ts
|
|
3445
|
+
* const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
|
|
3446
|
+
* const formatted = TPS.formatCalendarDate('hij', tps);
|
|
3447
|
+
* // "1447-07-21"
|
|
3448
|
+
* ```
|
|
3449
|
+
*/
|
|
3450
|
+
static formatCalendarDate(calendar, components, format) {
|
|
3451
|
+
const driver = this.driverManager.get(calendar);
|
|
3452
|
+
if (!driver) {
|
|
3453
|
+
throw new Error(`Calendar driver '${calendar}' not found.`);
|
|
3454
|
+
}
|
|
3455
|
+
// format is guaranteed by the interface, so we can call it directly.
|
|
3456
|
+
return driver.format(components, format);
|
|
3457
|
+
}
|
|
3458
|
+
// --- CONVENIENCE METHODS ---
|
|
3459
|
+
/**
|
|
3460
|
+
* Returns a TPS time string for the current moment.
|
|
3461
|
+
* Shorthand for `TPS.fromDate(new Date(), calendar, opts)`.
|
|
3462
|
+
*
|
|
3463
|
+
* @param calendar - Calendar code. Defaults to 'greg'.
|
|
3464
|
+
* @param opts - Optional `order` (ASC/DESC) parameter.
|
|
3465
|
+
* @returns TPS time string.
|
|
3466
|
+
*
|
|
3467
|
+
* @example
|
|
3468
|
+
* ```ts
|
|
3469
|
+
* TPS.now(); // "T:greg.m3.c1.y26.m3.d4.h06.m30.s00.m0"
|
|
3470
|
+
* TPS.now('hij'); // "T:hij.y1447.m09.d05.h06.m30.s00"
|
|
3471
|
+
* ```
|
|
3472
|
+
*/
|
|
3473
|
+
static now(calendar = DefaultCalendars.GREG, opts) {
|
|
3474
|
+
return this.fromDate(new Date(), calendar, opts);
|
|
3475
|
+
}
|
|
3476
|
+
/**
|
|
3477
|
+
* Returns the difference in milliseconds between two TPS strings.
|
|
3478
|
+
* The result is `t2 - t1`; negative if t1 is after t2.
|
|
3479
|
+
*
|
|
3480
|
+
* @param t1 - First TPS string (subtracted from t2).
|
|
3481
|
+
* @param t2 - Second TPS string.
|
|
3482
|
+
* @returns Milliseconds between the two moments, or NaN on parse failure.
|
|
3483
|
+
*
|
|
3484
|
+
* @example
|
|
3485
|
+
* ```ts
|
|
3486
|
+
* const ms = TPS.diff('T:greg.m3.c1.y26.m1.d1.h0.m0.s0.m0',
|
|
3487
|
+
* 'T:greg.m3.c1.y26.m1.d2.h0.m0.s0.m0');
|
|
3488
|
+
* // 86_400_000 (one day)
|
|
3489
|
+
* ```
|
|
3490
|
+
*/
|
|
3491
|
+
static diff(t1, t2) {
|
|
3492
|
+
const d1 = this.toDate(t1);
|
|
3493
|
+
const d2 = this.toDate(t2);
|
|
3494
|
+
if (!d1 || !d2)
|
|
3495
|
+
return NaN;
|
|
3496
|
+
return d2.getTime() - d1.getTime();
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Returns a new TPS string shifted by the given duration.
|
|
3500
|
+
* The result is in the same calendar as the original string.
|
|
3501
|
+
*
|
|
3502
|
+
* @param tpsStr - Source TPS string.
|
|
3503
|
+
* @param duration - Object with optional `days`, `hours`, `minutes`, `seconds`, `milliseconds`.
|
|
3504
|
+
* @returns Shifted TPS string, or null if the input is invalid.
|
|
3505
|
+
*
|
|
3506
|
+
* @example
|
|
3507
|
+
* ```ts
|
|
3508
|
+
* const t = 'T:greg.m3.c1.y26.m1.d9.h14.m30.s25.m0';
|
|
3509
|
+
* TPS.add(t, { days: 7 }); // one week later
|
|
3510
|
+
* TPS.add(t, { hours: -2 }); // two hours earlier
|
|
3511
|
+
* ```
|
|
3512
|
+
*/
|
|
3513
|
+
static add(tpsStr, duration) {
|
|
3514
|
+
const date = this.toDate(tpsStr);
|
|
3515
|
+
if (!date)
|
|
3516
|
+
return null;
|
|
3517
|
+
const parsed = this.parse(tpsStr);
|
|
3518
|
+
const calendar = parsed?.calendar ?? DefaultCalendars.GREG;
|
|
3519
|
+
const order = parsed?.order;
|
|
3520
|
+
const deltaMs = (duration.days ?? 0) * 86400000 +
|
|
3521
|
+
(duration.hours ?? 0) * 3600000 +
|
|
3522
|
+
(duration.minutes ?? 0) * 60000 +
|
|
3523
|
+
(duration.seconds ?? 0) * 1000 +
|
|
3524
|
+
(duration.milliseconds ?? 0);
|
|
3525
|
+
const shifted = new Date(date.getTime() + deltaMs);
|
|
3526
|
+
return this.fromDate(shifted, calendar, order ? { order } : undefined);
|
|
3527
|
+
}
|
|
3528
|
+
// --- INTERNAL HELPERS ---
|
|
3529
|
+
static _mapGroupsToComponents(g) {
|
|
3530
|
+
const components = {};
|
|
3531
|
+
components.calendar = g.calendar;
|
|
3532
|
+
// ── Signature ────────────────────────────────────────────────────────────
|
|
3533
|
+
if (g.signature) {
|
|
3534
|
+
components.signature = g.signature;
|
|
3535
|
+
}
|
|
3536
|
+
// ── Actor (/A:...) ────────────────────────────────────────────────────────
|
|
3537
|
+
if (g.actor) {
|
|
3538
|
+
components.actor = g.actor.trim();
|
|
3539
|
+
}
|
|
3540
|
+
// ── Location layers (v0.6.0: multi-layer, ;-separated) ───────────────────
|
|
3541
|
+
if (g.location) {
|
|
3542
|
+
this._parseLocationLayers(g.location, components);
|
|
3543
|
+
}
|
|
3544
|
+
// ── Extensions (;KEY:val or ;key=val after T: tokens) ────────────────────
|
|
3545
|
+
if (g.extensions) {
|
|
3546
|
+
const extObj = {};
|
|
3547
|
+
g.extensions.split(";").forEach((part) => {
|
|
3548
|
+
part = part.trim();
|
|
3549
|
+
if (!part)
|
|
3550
|
+
return;
|
|
3551
|
+
const colonIdx = part.indexOf(":");
|
|
3552
|
+
const eqIdx = part.indexOf("=");
|
|
3553
|
+
if (colonIdx > 0 && (eqIdx < 0 || colonIdx < eqIdx)) {
|
|
3554
|
+
// KEY:val form (e.g. TZ:+03:00)
|
|
3555
|
+
const key = part.substring(0, colonIdx).toLowerCase();
|
|
3556
|
+
const val = part.substring(colonIdx + 1);
|
|
3557
|
+
if (key && val !== undefined)
|
|
3558
|
+
extObj[key] = val;
|
|
3559
|
+
}
|
|
3560
|
+
else if (eqIdx > 0) {
|
|
3561
|
+
// key=val form (e.g. tz=+03:00)
|
|
3562
|
+
const key = part.substring(0, eqIdx).toLowerCase();
|
|
3563
|
+
const val = part.substring(eqIdx + 1);
|
|
3564
|
+
if (key && val !== undefined)
|
|
3565
|
+
extObj[key] = val;
|
|
3566
|
+
}
|
|
3567
|
+
});
|
|
3568
|
+
if (Object.keys(extObj).length > 0)
|
|
3569
|
+
components.extensions = extObj;
|
|
3570
|
+
}
|
|
3571
|
+
// ── Context (#C:key=val;key=val) ─────────────────────────────────────────
|
|
3572
|
+
if (g.context) {
|
|
3573
|
+
const ctx = {};
|
|
3574
|
+
g.context.split(";").forEach((part) => {
|
|
3575
|
+
part = part.trim();
|
|
3576
|
+
if (!part)
|
|
3577
|
+
return;
|
|
3578
|
+
const eqIdx = part.indexOf("=");
|
|
3579
|
+
if (eqIdx > 0) {
|
|
3580
|
+
ctx[part.substring(0, eqIdx)] = part.substring(eqIdx + 1);
|
|
3581
|
+
}
|
|
3582
|
+
});
|
|
3583
|
+
if (Object.keys(ctx).length > 0)
|
|
3584
|
+
components.context = ctx;
|
|
3585
|
+
}
|
|
3586
|
+
return components;
|
|
3587
|
+
}
|
|
3588
|
+
/**
|
|
3589
|
+
* Parses a multi-layer location string (before @T:) into component fields.
|
|
3590
|
+
* Layers are `;`-separated. Each layer is identified by its prefix token.
|
|
3591
|
+
*
|
|
3592
|
+
* Supported layers:
|
|
3593
|
+
* L:lat,lon[,altm] — GPS
|
|
3594
|
+
* L:~|L:-|L:redacted — Privacy markers
|
|
3595
|
+
* P:cc=JO,ci=AMM,... — Place (country/city codes and names)
|
|
3596
|
+
* S2:token — S2 cell
|
|
3597
|
+
* H3:token — H3 cell
|
|
3598
|
+
* 3W:word.word.word — What3Words
|
|
3599
|
+
* plus:token — Plus Code
|
|
3600
|
+
* net:ip4:x.x.x.x — IPv4
|
|
3601
|
+
* net:ip6:x::x — IPv6
|
|
3602
|
+
* node:name — Logical node/host
|
|
3603
|
+
* bldg:name — Building
|
|
3604
|
+
* floor:x — Floor
|
|
3605
|
+
* room:x — Room
|
|
3606
|
+
* door:x — Door
|
|
3607
|
+
* zone:x — Zone
|
|
3608
|
+
*/
|
|
3609
|
+
static _parseLocationLayers(location, components) {
|
|
3610
|
+
const layers = location.trim().split(";");
|
|
3611
|
+
for (const layer of layers) {
|
|
3612
|
+
const l = layer.trim();
|
|
3613
|
+
if (!l)
|
|
3614
|
+
continue;
|
|
3615
|
+
// Privacy shorthand
|
|
3616
|
+
if (l === "L:~" || l === "L:hidden") {
|
|
3617
|
+
components.isHiddenLocation = true;
|
|
3618
|
+
continue;
|
|
3619
|
+
}
|
|
3620
|
+
if (l === "L:-" || l === "L:unknown") {
|
|
3621
|
+
components.isUnknownLocation = true;
|
|
3622
|
+
continue;
|
|
3623
|
+
}
|
|
3624
|
+
if (l === "L:redacted") {
|
|
3625
|
+
components.isRedactedLocation = true;
|
|
3626
|
+
continue;
|
|
3627
|
+
}
|
|
3628
|
+
// P: Place layer — P:cc=JO,ci=AMM,cn=Jordan,ct=Amman
|
|
3629
|
+
if (l.startsWith("P:")) {
|
|
3630
|
+
l.slice(2).split(",").forEach((pair) => {
|
|
3631
|
+
const eq = pair.indexOf("=");
|
|
3632
|
+
if (eq < 1)
|
|
3633
|
+
return;
|
|
3634
|
+
const k = pair.substring(0, eq).toLowerCase();
|
|
3635
|
+
const v = pair.substring(eq + 1);
|
|
3636
|
+
if (k === "cc")
|
|
3637
|
+
components.placeCountryCode = v;
|
|
3638
|
+
else if (k === "cn")
|
|
3639
|
+
components.placeCountryName = v;
|
|
3640
|
+
else if (k === "ci")
|
|
3641
|
+
components.placeCityCode = v;
|
|
3642
|
+
else if (k === "ct")
|
|
3643
|
+
components.placeCityName = v;
|
|
3644
|
+
});
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
// GPS coordinates (L:lat,lon[,alt])
|
|
3648
|
+
if (l.startsWith("L:")) {
|
|
3649
|
+
const coords = l.slice(2);
|
|
3650
|
+
const m = coords.match(/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(-?\d+(?:\.\d+)?)m?)?$/);
|
|
3651
|
+
if (m) {
|
|
3652
|
+
components.latitude = parseFloat(m[1]);
|
|
3653
|
+
components.longitude = parseFloat(m[2]);
|
|
3654
|
+
if (m[3])
|
|
3655
|
+
components.altitude = parseFloat(m[3]);
|
|
3656
|
+
}
|
|
3657
|
+
continue;
|
|
3658
|
+
}
|
|
3659
|
+
// Geospatial cells
|
|
3660
|
+
if (/^S2:/i.test(l)) {
|
|
3661
|
+
components.s2Cell = l.slice(3);
|
|
3662
|
+
continue;
|
|
3663
|
+
}
|
|
3664
|
+
if (/^H3:/i.test(l)) {
|
|
3665
|
+
components.h3Cell = l.slice(3);
|
|
3666
|
+
continue;
|
|
3667
|
+
}
|
|
3668
|
+
if (/^3W:/i.test(l)) {
|
|
3669
|
+
components.what3words = l.slice(3);
|
|
3670
|
+
continue;
|
|
3671
|
+
}
|
|
3672
|
+
if (/^plus:/i.test(l)) {
|
|
3673
|
+
components.plusCode = l.slice(5);
|
|
3674
|
+
continue;
|
|
3675
|
+
}
|
|
3676
|
+
// Network
|
|
3677
|
+
if (/^net:ip4:/i.test(l)) {
|
|
3678
|
+
components.ipv4 = l.slice(8);
|
|
3679
|
+
continue;
|
|
3680
|
+
}
|
|
3681
|
+
if (/^net:ip6:/i.test(l)) {
|
|
3682
|
+
components.ipv6 = l.slice(8);
|
|
3683
|
+
continue;
|
|
3684
|
+
}
|
|
3685
|
+
if (/^node:/i.test(l)) {
|
|
3686
|
+
components.nodeName = l.slice(5);
|
|
3687
|
+
continue;
|
|
3688
|
+
}
|
|
3689
|
+
// Structural
|
|
3690
|
+
if (/^bldg:/i.test(l)) {
|
|
3691
|
+
components.building = l.slice(5);
|
|
3692
|
+
continue;
|
|
3693
|
+
}
|
|
3694
|
+
if (/^floor:/i.test(l)) {
|
|
3695
|
+
components.floor = l.slice(6);
|
|
3696
|
+
continue;
|
|
3697
|
+
}
|
|
3698
|
+
if (/^room:/i.test(l)) {
|
|
3699
|
+
components.room = l.slice(5);
|
|
3700
|
+
continue;
|
|
3701
|
+
}
|
|
3702
|
+
if (/^door:/i.test(l)) {
|
|
3703
|
+
components.door = l.slice(5);
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
if (/^zone:/i.test(l)) {
|
|
3707
|
+
components.zone = l.slice(5);
|
|
3708
|
+
continue;
|
|
3709
|
+
}
|
|
3710
|
+
// Fallback: generic space anchor (adm:, planet:, legacy strings)
|
|
3711
|
+
if (l) {
|
|
3712
|
+
components.spaceAnchor = components.spaceAnchor
|
|
3713
|
+
? components.spaceAnchor + ";" + l
|
|
3714
|
+
: l;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
// --- PLUGIN REGISTRY ---
|
|
3720
|
+
/** Shared DriverManager instance — use TPS.driverManager for direct access. */
|
|
3721
|
+
TPS.driverManager = new DriverManager();
|
|
3722
|
+
// --- REGEX (v0.6.0) ---
|
|
3723
|
+
// The URI and time regexes are intentionally permissive in the location &
|
|
3724
|
+
// extension sections — detailed semantic parsing happens in
|
|
3725
|
+
// _mapGroupsToComponents() and the layer parsers below.
|
|
3726
|
+
//
|
|
3727
|
+
// Structure:
|
|
3728
|
+
// tps://[location]/A:[actor]@T:[cal].[tokens];[ext];...#C:[ctx];...
|
|
3729
|
+
//
|
|
3730
|
+
// The `;` separator is used consistently:
|
|
3731
|
+
// - between location layers (before @T:)
|
|
3732
|
+
// - between extensions (after T: tokens, before #)
|
|
3733
|
+
// - between context key=val pairs (after #C:)
|
|
3734
|
+
TPS.REGEX_URI = new RegExp("^tps://" +
|
|
3735
|
+
// Location: everything up to optional /A: actor and then @T:
|
|
3736
|
+
"(?<location>[^@]+?)" +
|
|
3737
|
+
// Optional actor overlay
|
|
3738
|
+
"(?:/A:(?<actor>[^@]+))?" +
|
|
3739
|
+
// Time section
|
|
3740
|
+
"@T:(?<calendar>[a-z]{3,4})" +
|
|
3741
|
+
"(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
|
|
3742
|
+
// Optional signature
|
|
3743
|
+
"(?:!(?<signature>[^;#]+))?" +
|
|
3744
|
+
// Optional extensions (;KEY:val;key=val;...)
|
|
3745
|
+
"(?:;(?<extensions>[^#]+))?" +
|
|
3746
|
+
// Optional context fragment (#C:key=val;...)
|
|
3747
|
+
"(?:#C:(?<context>.+))?$");
|
|
3748
|
+
TPS.REGEX_TIME = new RegExp("^T:(?<calendar>[a-z]{3,4})" +
|
|
3749
|
+
"(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
|
|
3750
|
+
"(?:!(?<signature>[^;#]+))?" +
|
|
3751
|
+
"(?:;(?<extensions>[^#]+))?" +
|
|
3752
|
+
"(?:#C:(?<context>.+))?$");
|
|
3753
|
+
// register built-in drivers and set default
|
|
3754
|
+
// (tps and gregorian provide canonical conversions before unix)
|
|
3755
|
+
TPS.registerDriver(new TpsDriver());
|
|
3756
|
+
TPS.registerDriver(new GregorianDriver());
|
|
3757
|
+
TPS.registerDriver(new UnixDriver());
|
|
3758
|
+
TPS.registerDriver(new PersianDriver());
|
|
3759
|
+
TPS.registerDriver(new HijriDriver());
|
|
3760
|
+
TPS.registerDriver(new JulianDriver());
|
|
3761
|
+
TPS.registerDriver(new HoloceneDriver());
|
|
3762
|
+
TPS.registerDriver(new ChineseDriver());
|
|
3763
|
+
/**
|
|
3764
|
+
* `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
|
|
3765
|
+
*
|
|
3766
|
+
* It mirrors common JavaScript `Date` construction patterns:
|
|
3767
|
+
* - `new TpsDate()`
|
|
3768
|
+
* - `new TpsDate(ms)`
|
|
3769
|
+
* - `new TpsDate(isoString)`
|
|
3770
|
+
* - `new TpsDate(tpsString)`
|
|
3771
|
+
* - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
|
|
3772
|
+
*/
|
|
3773
|
+
|
|
3774
|
+
exports.DefaultCalendars = DefaultCalendars;
|
|
3775
|
+
exports.DriverManager = DriverManager;
|
|
3776
|
+
exports.Env = Env;
|
|
3777
|
+
exports.TPS = TPS;
|
|
3778
|
+
exports.TPSUID7RB = TPSUID7RB;
|
|
3779
|
+
exports.TpsDate = TpsDate;
|
|
3780
|
+
exports.getOffsetString = getOffsetString;
|
|
3781
|
+
exports.localToUtc = localToUtc;
|
|
3782
|
+
exports.utcToLocal = utcToLocal;
|
|
3783
|
+
|
|
3784
|
+
return exports;
|
|
3785
|
+
|
|
3786
|
+
})({});
|