@blamejs/core 0.11.40 → 0.11.42
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 +4 -0
- package/lib/calendar.js +261 -9
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.42 (2026-05-22) — **`b.calendar` JSCalendar Group objects (RFC 8984 §1.4.4).** Closes the v0.11.31 deferral on JSCalendar Group. `b.calendar.validate` now recognises `@type: "Group"` as a container envelope for multiple Event / Task / Note entries that share a logical name + categories. `b.calendar.toIcal` emits a single VCALENDAR wrap containing every entry's component in declared order (VEVENT for Event, VTODO for Task, VJOURNAL for Note). Group is JSCalendar-only — iCalendar has no envelope-level metadata for Group.name / Group.categories, so a Group's metadata is dropped on the iCal-side of a round-trip; operators preserving Group state across systems use the JSON-native surface. **Added:** *`@type: "Group"` envelope (RFC 8984 §1.4.4)* — A Group carries `uid`, `updated`, `entries` (required non-empty array), and optional `name`, `description`, `categories` (String-keyed Boolean set), and `source` (URI string). Entries are validated recursively — each entry must be a valid Event / Task / Note per its own type rules. Groups nesting Groups is refused (RFC 8984 does not define a nesting semantic). · *`b.calendar.toIcal` emits single VCALENDAR for Group* — When `@type === "Group"`, toIcal emits one BEGIN:VCALENDAR / END:VCALENDAR envelope containing every entry's component in declared order. The Group's own uid / updated / name / description / categories are NOT round-tripped to iCalendar (RFC 5545 has no envelope-level metadata for these); operators preserving Group state across a round-trip use the JSON-native JSCalendar surface. · *Refusal vocabulary* — New structured `CalendarError` codes: `calendar/bad-entries` (entries missing, empty, non-array, or any entry malformed / Group-typed), `calendar/bad-group` (entry-specific field like start / duration / due / progress / recurrenceRules set on the Group envelope), `calendar/bad-categories` (non-object categories or non-`true` value), `calendar/bad-source` (non-string source). · *`JSCAL_TYPES.Group` export* — `b.calendar.JSCAL_TYPES.Group === "Group"` exposes the discriminator string for operator-side type-dispatch. **References:** [RFC 8984 §1.4.4 (JSCalendar Group)](https://www.rfc-editor.org/rfc/rfc8984.html#section-1.4.4) · [RFC 5545 §3.4 (VCALENDAR envelope)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.4)
|
|
12
|
+
|
|
13
|
+
- v0.11.41 (2026-05-21) — **`b.calendar.expandRecurrence` picks up BYSETPOS (RFC 5545 §3.3.10).** Closes the v0.11.31 deferral on BYSETPOS — the recurrence filter that picks the Nth candidate from a BY*-filtered set within a FREQ interval. Common operator patterns now work directly: `FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1` for last Friday of each month, `FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2` for second Tuesday, `FREQ=YEARLY;BYMONTH=10;BYDAY=SU;BYSETPOS=1` for first Sunday of October (the DST-end announcement pattern). Supported for `FREQ=MONTHLY` / `YEARLY` / `WEEKLY` at day-granularity (time-of-day inherited from `start`). `FREQ=DAILY` + BYSETPOS is refused with `calendar/bad-recurrence` since the semantics aren't meaningful at sub-day frequency. **Added:** *`bySetPos` filter on RecurrenceRule* — `recurrenceRules[i].bySetPos: [1, -1, 2]` picks the listed positions from the BY*-filtered candidate set within each FREQ interval. Positive values are 1-indexed from the start of the period; negative values count from the end. Multiple positions emit per period (e.g. `[1, -1]` emits both the first and last matching day of each month). Out-of-range positions silently drop per RFC 5545's tolerant grammar. · *MONTHLY / YEARLY / WEEKLY support* — BYSETPOS expands within month / year / WKST-aligned-week boundaries. For each period, all day-level candidates that pass the existing BY* filters (byDay / byMonth / byMonthDay / byWeekNo / byYearDay) are enumerated, sorted ascending, then indexed by `bySetPos`. The expand loop's step budget (`MAX_EXPAND_INSTANCES * 366`) is shared across periods so the BYSETPOS path can't outrun the v0.11.31 DoS bound. · *DAILY frequency refused* — `FREQ=DAILY` + BYSETPOS throws `calendar/bad-recurrence` at expand time. The combination has no meaningful per-period set semantics (a day has no sub-day BY* set to pick from in the v1 day-granularity model). Operators with a sub-day BYSETPOS use case use `FREQ=HOURLY` semantics directly without BYSETPOS, or open an issue with the use case. **Security:** *Step budget shared with non-BYSETPOS path* — BYSETPOS enumeration decrements the same `MAX_EXPAND_INSTANCES * 366` step budget the non-BYSETPOS path uses, and the per-period day-loop has a hard 400-iteration safety cap (covers max-366-days in a leap year + slack). Adversarial events combining many rules + sparse BY* filters + BYSETPOS can't outpace the original single-rule DoS bound. **References:** [RFC 5545 §3.3.10 (RRULE — BYSETPOS)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.10) · [RFC 8984 §4.3.2 (JSCalendar RecurrenceRule)](https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.2)
|
|
14
|
+
|
|
11
15
|
- v0.11.40 (2026-05-21) — **Wiki Docker default port moves from 8080 → 3008.** The `examples/wiki` Docker stack now defaults to port 3008 throughout — `WIKI_PORT`, the Dockerfile `EXPOSE` directive, the in-container HEALTHCHECK URL, the dev `docker-compose.yml` host mapping, the Caddy reverse-proxy upstream, and the `server.js` + `lib/build-app.js` code defaults. Production deployments (`docker-compose.prod.yml`) are operator-invisible since Caddy fronts the container on 80/443 — the upstream port is never exposed to the host. Dev users hitting the wiki container directly now use `http://localhost:3008`. Operators who set `WIKI_PORT` explicitly in their `.env` keep working unchanged. **Changed:** *Wiki default port 8080 → 3008* — Default `WIKI_PORT` moves from 8080 (HTTP-alt, frequently collides with Tomcat / Jenkins / Confluence / Spring-Boot defaults) to 3008 (IANA-unassigned, no service-convention collision). The new default applies consistently across: `examples/wiki/Dockerfile` (`ENV WIKI_PORT`, `EXPOSE`, HEALTHCHECK URL), `examples/wiki/server.js`, `examples/wiki/lib/build-app.js`, `examples/wiki/docker-compose.yml` (host mapping `3008:3008`), `examples/wiki/docker-compose.prod.yml` (internal-network expose), `examples/wiki/Caddyfile` (upstream resolver), `examples/wiki/README.md`, `examples/wiki/DEPLOY.md`. · *Operator action* — Dev users with `localhost:8080` bookmarks, curl scripts, host-firewall allowlists, or IDE port forwarders pointed at the wiki need to switch to `localhost:3008`. Operators who set `WIKI_PORT=8080` explicitly in their `.env` keep working unchanged. Production deployments behind Caddy see no change — the upstream port is internal-network only. **References:** [IANA Service Name and Port Number Registry](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml)
|
|
12
16
|
|
|
13
17
|
- v0.11.39 (2026-05-21) — **`b.calendar.expandRecurrence` honors multiple `recurrenceRules` (RFC 8984 §4.3.2).** Closes the v0.11.31 deferral on the multi-rule path. `expandRecurrence` now walks every entry in `event.recurrenceRules`, expands each rule independently against the same `start` anchor, and UNIONs the resulting instances (dedup + sorted ascending). Per-rule `count` caps apply per-rule per RFC 8984 §4.3.2; the global `max` / `MAX_EXPAND_INSTANCES` cap applies to the unioned set. The step budget (`MAX_EXPAND_INSTANCES * 366`) is shared across all rules in the same expand call so an N-rule fan-out can't amplify the worst-case loop past the single-rule bound. **Added:** *Multi-rule expansion + UNION* — `event.recurrenceRules` of length > 1 now expands every rule, not just the first. Each rule's stepping (frequency / interval / count / until / BY* filters) operates independently; the resulting ISO 8601 UTC instant strings dedupe via object-keyed set semantics, then sort ascending. The single-rule case is structurally unchanged — same step loop, same per-rule cap behaviour. · *Per-rule `count` applies per-rule (RFC 8984 §4.3.2)* — Two rules with `count: 3` and `count: 2` produce up to 5 instances in the unioned output (minus any timestamps that dedupe across rules), not 5 instances total across the rules. Matches RFC 8984 §4.3.2 phrasing: each Recurrence Rule's count is applied per rule. · *Global step budget shared across rules* — The `MAX_EXPAND_INSTANCES * 366` step budget (introduced in v0.11.31) now tracks across rules in the same expand call. A 4-rule event with sparse BY* filters can't accumulate 4× the single-rule worst-case work; the budget is depleted across the full rule set. **Security:** *DoS amplification across N rules bounded by the same step budget* — The step budget is shared (not per-rule), so adversarial multi-rule events can't bypass the v0.11.31 BY*-filter cap by stacking N rules with sparse filters. Sparse-filter rules still complete within the original 10-year `MAX_EXPAND_SPAN_MS` window cap. **Detectors:** *No new detector — covered by existing recurrence-test surface* — Multi-rule UNION + dedup + global step budget are exercised by three new tests in `test/layer-0-primitives/calendar.test.js` (multi-rule union, same-rule dedup, global max applied to union). No detector needed — the bug class is testable end-to-end and the test file IS the canonical regression surface. **References:** [RFC 8984 §4.3.2 (JSCalendar RecurrenceRule — multi-rule expansion)](https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.2) · [RFC 5545 §3.8.5.3 (iCalendar RRULE — semantics under composition)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.5.3)
|
package/lib/calendar.js
CHANGED
|
@@ -123,9 +123,9 @@ function validate(jsCal) {
|
|
|
123
123
|
"b.calendar.validate: input must be a JSCalendar object");
|
|
124
124
|
}
|
|
125
125
|
var t = jsCal["@type"];
|
|
126
|
-
if (t !== JSCAL_TYPES.Event && t !== JSCAL_TYPES.Task && t !== JSCAL_TYPES.Note) {
|
|
126
|
+
if (t !== JSCAL_TYPES.Event && t !== JSCAL_TYPES.Task && t !== JSCAL_TYPES.Note && t !== JSCAL_TYPES.Group) {
|
|
127
127
|
throw new CalendarError("calendar/bad-type",
|
|
128
|
-
"b.calendar.validate: @type must be 'Event'
|
|
128
|
+
"b.calendar.validate: @type must be 'Event', 'Task', 'Note' or 'Group' (got " + JSON.stringify(t) + ")");
|
|
129
129
|
}
|
|
130
130
|
if (typeof jsCal.uid !== "string" || jsCal.uid.length === 0) {
|
|
131
131
|
throw new CalendarError("calendar/no-uid",
|
|
@@ -224,7 +224,68 @@ function validate(jsCal) {
|
|
|
224
224
|
Object.keys(JSCAL_NOTE_STATUS).join(" | ") + " (RFC 5545 §3.8.1.11 VJOURNAL STATUS)");
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
-
if (
|
|
227
|
+
if (t === JSCAL_TYPES.Group) {
|
|
228
|
+
// RFC 8984 §1.4.4 — a Group is a container envelope for multiple
|
|
229
|
+
// Event / Task / Note entries that share a logical name +
|
|
230
|
+
// categories. Group itself does not carry start / duration / due
|
|
231
|
+
// / progress — those live on the entries. `entries` MUST be a
|
|
232
|
+
// non-empty array; every entry MUST be a valid Event / Task /
|
|
233
|
+
// Note (Groups nesting Groups is refused — the spec does not
|
|
234
|
+
// define a nesting recursion semantic).
|
|
235
|
+
if (!Array.isArray(jsCal.entries) || jsCal.entries.length === 0) {
|
|
236
|
+
throw new CalendarError("calendar/bad-entries",
|
|
237
|
+
"b.calendar.validate: Group.entries MUST be a non-empty array (RFC 8984 §1.4.4)");
|
|
238
|
+
}
|
|
239
|
+
for (var gei = 0; gei < jsCal.entries.length; gei += 1) {
|
|
240
|
+
var entry = jsCal.entries[gei];
|
|
241
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
242
|
+
throw new CalendarError("calendar/bad-entries",
|
|
243
|
+
"b.calendar.validate: Group.entries[" + gei + "] MUST be an object");
|
|
244
|
+
}
|
|
245
|
+
var et = entry["@type"];
|
|
246
|
+
if (et !== JSCAL_TYPES.Event && et !== JSCAL_TYPES.Task && et !== JSCAL_TYPES.Note) {
|
|
247
|
+
throw new CalendarError("calendar/bad-entries",
|
|
248
|
+
"b.calendar.validate: Group.entries[" + gei + "].@type MUST be 'Event', 'Task' or 'Note' " +
|
|
249
|
+
"(got " + JSON.stringify(et) + ") — Groups do not nest");
|
|
250
|
+
}
|
|
251
|
+
// Recurse into each entry so per-type shape rules apply.
|
|
252
|
+
validate(entry);
|
|
253
|
+
}
|
|
254
|
+
if (jsCal.source !== undefined && typeof jsCal.source !== "string") {
|
|
255
|
+
throw new CalendarError("calendar/bad-source",
|
|
256
|
+
"b.calendar.validate: Group.source MUST be a string URI when present (RFC 8984 §1.4.4)");
|
|
257
|
+
}
|
|
258
|
+
if (jsCal.categories !== undefined) {
|
|
259
|
+
// Codex P1 — `typeof null === "object"` would let `categories:
|
|
260
|
+
// null` through this check, and the subsequent Object.keys
|
|
261
|
+
// throws a raw TypeError instead of a structured CalendarError.
|
|
262
|
+
// Refuse null explicitly so callers depending on the
|
|
263
|
+
// `calendar/bad-categories` refusal code stay stable.
|
|
264
|
+
if (jsCal.categories === null || typeof jsCal.categories !== "object" ||
|
|
265
|
+
Array.isArray(jsCal.categories)) {
|
|
266
|
+
throw new CalendarError("calendar/bad-categories",
|
|
267
|
+
"b.calendar.validate: Group.categories MUST be a String-keyed Boolean object (RFC 8984 §1.4.4)");
|
|
268
|
+
}
|
|
269
|
+
var catKeys = Object.keys(jsCal.categories);
|
|
270
|
+
for (var ci = 0; ci < catKeys.length; ci += 1) {
|
|
271
|
+
if (jsCal.categories[catKeys[ci]] !== true) {
|
|
272
|
+
throw new CalendarError("calendar/bad-categories",
|
|
273
|
+
"b.calendar.validate: Group.categories['" + catKeys[ci] + "'] MUST be `true` (boolean set per RFC 8984 §1.4.4)");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Group itself MUST NOT carry the entry-specific fields. Refuse
|
|
278
|
+
// explicit setting so operators don't accidentally model entry
|
|
279
|
+
// state on the envelope.
|
|
280
|
+
if (jsCal.start !== undefined || jsCal.duration !== undefined || jsCal.due !== undefined ||
|
|
281
|
+
jsCal.progress !== undefined || jsCal.percentComplete !== undefined ||
|
|
282
|
+
jsCal.progressUpdated !== undefined || jsCal.recurrenceRules !== undefined) {
|
|
283
|
+
throw new CalendarError("calendar/bad-group",
|
|
284
|
+
"b.calendar.validate: Group MUST NOT carry start / duration / due / progress / " +
|
|
285
|
+
"percentComplete / progressUpdated / recurrenceRules — those live on the entries");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (t !== JSCAL_TYPES.Group && jsCal.recurrenceRules !== undefined) {
|
|
228
289
|
if (!Array.isArray(jsCal.recurrenceRules)) {
|
|
229
290
|
throw new CalendarError("calendar/bad-recurrence",
|
|
230
291
|
"b.calendar.validate: recurrenceRules MUST be an array of RecurrenceRule");
|
|
@@ -243,7 +304,7 @@ function validate(jsCal) {
|
|
|
243
304
|
}
|
|
244
305
|
}
|
|
245
306
|
if (jsCal.alerts !== undefined) {
|
|
246
|
-
if (typeof jsCal.alerts !== "object" || Array.isArray(jsCal.alerts)) {
|
|
307
|
+
if (jsCal.alerts === null || typeof jsCal.alerts !== "object" || Array.isArray(jsCal.alerts)) {
|
|
247
308
|
throw new CalendarError("calendar/bad-alerts",
|
|
248
309
|
"b.calendar.validate: alerts MUST be an object map keyed by alert-id");
|
|
249
310
|
}
|
|
@@ -328,6 +389,33 @@ function fromIcal(text, opts) {
|
|
|
328
389
|
function toIcal(jsCal, opts) {
|
|
329
390
|
validate(jsCal);
|
|
330
391
|
var prodid = (opts && opts.prodid) || "-//blamejs//Calendar//EN";
|
|
392
|
+
// RFC 8984 §1.4.4 — a Group emits a single VCALENDAR envelope
|
|
393
|
+
// containing every entry's component in declared order. The Group's
|
|
394
|
+
// own uid + updated + name + description are NOT round-tripped to
|
|
395
|
+
// iCalendar (RFC 5545 has no envelope-level metadata for these);
|
|
396
|
+
// operators preserving Group metadata across a round-trip use the
|
|
397
|
+
// JSON-native JSCalendar surface.
|
|
398
|
+
if (jsCal["@type"] === "Group") {
|
|
399
|
+
var groupLines = [
|
|
400
|
+
"BEGIN:VCALENDAR",
|
|
401
|
+
"VERSION:2.0",
|
|
402
|
+
"PRODID:" + prodid,
|
|
403
|
+
];
|
|
404
|
+
for (var gei2 = 0; gei2 < jsCal.entries.length; gei2 += 1) {
|
|
405
|
+
var entryIcal = toIcal(jsCal.entries[gei2], { prodid: prodid });
|
|
406
|
+
// Strip outer VCALENDAR envelope; keep inner component lines.
|
|
407
|
+
var entryLines = entryIcal.split("\r\n");
|
|
408
|
+
for (var eli = 0; eli < entryLines.length; eli += 1) {
|
|
409
|
+
var line = entryLines[eli];
|
|
410
|
+
if (line && line !== "BEGIN:VCALENDAR" && line !== "VERSION:2.0" &&
|
|
411
|
+
line.indexOf("PRODID:") !== 0 && line !== "END:VCALENDAR") {
|
|
412
|
+
groupLines.push(line);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
groupLines.push("END:VCALENDAR");
|
|
417
|
+
return groupLines.join("\r\n") + "\r\n";
|
|
418
|
+
}
|
|
331
419
|
// RFC 8984 §6 — JSCalendar Task maps to RFC 5545 §3.6.2 VTODO; Event
|
|
332
420
|
// maps to VEVENT. The wrapper + most properties are identical; the
|
|
333
421
|
// wrapping component tag + Task-specific fields (DUE / STATUS /
|
|
@@ -425,13 +513,14 @@ function toIcal(jsCal, opts) {
|
|
|
425
513
|
*
|
|
426
514
|
* v1 supports FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
|
|
427
515
|
* UNTIL. BYDAY / BYMONTH / BYMONTHDAY / BYWEEKNO / BYYEARDAY /
|
|
428
|
-
* BYHOUR / BYMINUTE / BYSECOND refine the base frequency.
|
|
516
|
+
* BYHOUR / BYMINUTE / BYSECOND refine the base frequency. BYSETPOS
|
|
517
|
+
* picks the Nth candidate from the BY*-filtered set within a FREQ
|
|
518
|
+
* interval (positive = 1-indexed from start, negative = from end);
|
|
519
|
+
* supported for FREQ=MONTHLY / YEARLY / WEEKLY with day-granularity
|
|
520
|
+
* candidates (time-of-day inherited from start). Multiple
|
|
429
521
|
* `recurrenceRules` are expanded independently and UNIONed; per
|
|
430
522
|
* RFC 8984 §4.3.2 each rule's `count` cap applies per-rule, not to
|
|
431
|
-
* the combined set.
|
|
432
|
-
* (requires expanding ALL candidates within a FREQ interval and
|
|
433
|
-
* picking the Nth — a structural restructure of the step loop;
|
|
434
|
-
* RFC 7529 non-Gregorian calendars not in scope either).
|
|
523
|
+
* the combined set. (RFC 7529 non-Gregorian calendars not in scope.)
|
|
435
524
|
*
|
|
436
525
|
* @opts
|
|
437
526
|
* from: string, // ISO 8601 UTC timestamp — lower bound of expansion window
|
|
@@ -645,6 +734,36 @@ function _expandSingleRule(rule, startMs, ctx) {
|
|
|
645
734
|
if (bySecondSet && !bySecondSet[d.getUTCSeconds()]) return false;
|
|
646
735
|
return true;
|
|
647
736
|
}
|
|
737
|
+
// RFC 5545 §3.3.10 BYSETPOS — picks the Nth candidate from the
|
|
738
|
+
// BY*-filtered set within a FREQ interval. Positive = 1-indexed
|
|
739
|
+
// from start; negative = from end. Operators reach for this most
|
|
740
|
+
// often with "last Friday of month" (FREQ=MONTHLY;BYDAY=FR;
|
|
741
|
+
// BYSETPOS=-1) or "second Tuesday of month" (BYSETPOS=2).
|
|
742
|
+
//
|
|
743
|
+
// v1 supports BYSETPOS for FREQ=MONTHLY / YEARLY / WEEKLY at
|
|
744
|
+
// day-granularity — candidates are days within the period at the
|
|
745
|
+
// start's time-of-day. Sub-day BY* filters (byHour/byMinute/
|
|
746
|
+
// bySecond) are ignored under BYSETPOS for v1; the rare combo
|
|
747
|
+
// (BYSETPOS + byHour) reverts to the standard non-bysetpos step
|
|
748
|
+
// path when applicable.
|
|
749
|
+
var bySetPosArr = _bySetPosArray(rule.bySetPos);
|
|
750
|
+
if (bySetPosArr) {
|
|
751
|
+
return _expandWithBysetpos({
|
|
752
|
+
rule: rule,
|
|
753
|
+
startMs: startMs,
|
|
754
|
+
freq: freq,
|
|
755
|
+
interval: interval,
|
|
756
|
+
count: count,
|
|
757
|
+
untilMs: untilMs,
|
|
758
|
+
fromMs: fromMs,
|
|
759
|
+
toMs: toMs,
|
|
760
|
+
maxCount: maxCount,
|
|
761
|
+
matchesBy: _matchesBy,
|
|
762
|
+
bySetPos: bySetPosArr,
|
|
763
|
+
stepBudgetRef: ctx.stepBudgetRef,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
648
767
|
var t = startMs;
|
|
649
768
|
// Safety cap on the step loop: at most MAX_EXPAND_INSTANCES * 366
|
|
650
769
|
// iterations so BY* filters that match sparsely (e.g. FREQ=DAILY;
|
|
@@ -670,6 +789,139 @@ function _expandSingleRule(rule, startMs, ctx) {
|
|
|
670
789
|
return { instances: out, stepBudgetRemaining: ctx.stepBudgetRef.remaining };
|
|
671
790
|
}
|
|
672
791
|
|
|
792
|
+
// Parse + validate rule.bySetPos. Returns null when absent / empty;
|
|
793
|
+
// otherwise an array of integers in [-366, -1] U [1, 366] (RFC 5545
|
|
794
|
+
// grammar). Zero values + out-of-range values are silently dropped.
|
|
795
|
+
function _bySetPosArray(raw) {
|
|
796
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
797
|
+
var out = [];
|
|
798
|
+
for (var i = 0; i < raw.length; i += 1) {
|
|
799
|
+
var n = parseInt(raw[i], 10);
|
|
800
|
+
if (isFinite(n) && n !== 0 && n >= -366 && n <= 366) out.push(n); // allow:raw-byte-literal — RFC 5545 §3.3.10 bysetpos range
|
|
801
|
+
}
|
|
802
|
+
return out.length > 0 ? out : null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// BYSETPOS expander. Iterates by FREQ interval; for each period,
|
|
806
|
+
// enumerates day-level candidates within the period; applies the
|
|
807
|
+
// caller's matchesBy filter; sorts ascending; picks the position(s)
|
|
808
|
+
// per bySetPos. Time-of-day per candidate matches the rule's start.
|
|
809
|
+
function _expandWithBysetpos(ctx) {
|
|
810
|
+
var startMs = ctx.startMs;
|
|
811
|
+
var freq = ctx.freq;
|
|
812
|
+
var interval = ctx.interval;
|
|
813
|
+
var count = ctx.count;
|
|
814
|
+
var untilMs = ctx.untilMs;
|
|
815
|
+
var fromMs = ctx.fromMs;
|
|
816
|
+
var toMs = ctx.toMs;
|
|
817
|
+
var maxCount = ctx.maxCount;
|
|
818
|
+
var matchesBy = ctx.matchesBy;
|
|
819
|
+
var bySetPos = ctx.bySetPos;
|
|
820
|
+
var stepBudgetRef = ctx.stepBudgetRef;
|
|
821
|
+
|
|
822
|
+
if (freq !== "monthly" && freq !== "yearly" && freq !== "weekly") {
|
|
823
|
+
throw new CalendarError("calendar/bad-recurrence",
|
|
824
|
+
"b.calendar.expandRecurrence: BYSETPOS supported only with FREQ=MONTHLY / YEARLY / WEEKLY (got '" + freq + "')");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
var startDate = new Date(startMs);
|
|
828
|
+
var hh = startDate.getUTCHours();
|
|
829
|
+
var mm = startDate.getUTCMinutes();
|
|
830
|
+
var ss = startDate.getUTCSeconds();
|
|
831
|
+
var ms = startDate.getUTCMilliseconds();
|
|
832
|
+
|
|
833
|
+
var out = [];
|
|
834
|
+
// Period anchor (period 0 = start's period).
|
|
835
|
+
var periodIndex = 0;
|
|
836
|
+
|
|
837
|
+
while (out.length < count && out.length < maxCount && stepBudgetRef.remaining > 0) {
|
|
838
|
+
var period = _periodForIndex(freq, startDate, periodIndex * interval);
|
|
839
|
+
periodIndex += 1;
|
|
840
|
+
// Out-of-window early exit. Window-uppper applies once the period
|
|
841
|
+
// start crosses toMs; until applies once period-start crosses untilMs.
|
|
842
|
+
if (period.startMs > untilMs) break;
|
|
843
|
+
if (toMs !== null && period.startMs > toMs) break;
|
|
844
|
+
|
|
845
|
+
// Enumerate day-level candidates within the period at start's
|
|
846
|
+
// time-of-day. The budget decrements per candidate so adversarial
|
|
847
|
+
// periods (e.g. YEARLY = 366 days) can't loop forever.
|
|
848
|
+
var candidates = [];
|
|
849
|
+
var dayMs = period.startMs;
|
|
850
|
+
var safety = 400; // allow:raw-byte-literal — period day cap (covers leap year 366 + slack)
|
|
851
|
+
while (dayMs <= period.endMs && safety-- > 0 && stepBudgetRef.remaining > 0) {
|
|
852
|
+
stepBudgetRef.remaining -= 1;
|
|
853
|
+
var candidate = _withTimeOfDay(dayMs, hh, mm, ss, ms);
|
|
854
|
+
if (matchesBy(candidate)) candidates.push(candidate);
|
|
855
|
+
dayMs += 86400000; // allow:raw-time-literal — 86400000 ms/day step // allow:raw-byte-literal — same constant in ms/day form
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Sort + apply BYSETPOS. Positive index 1-based from start;
|
|
859
|
+
// negative from end. Out-of-range positions silently drop.
|
|
860
|
+
candidates.sort(function (a, b) { return a - b; });
|
|
861
|
+
var picked = Object.create(null);
|
|
862
|
+
for (var pi = 0; pi < bySetPos.length; pi += 1) {
|
|
863
|
+
var pos = bySetPos[pi];
|
|
864
|
+
var idx = pos > 0 ? pos - 1 : candidates.length + pos;
|
|
865
|
+
if (idx >= 0 && idx < candidates.length) picked[candidates[idx]] = true;
|
|
866
|
+
}
|
|
867
|
+
// Emit picked candidates in ascending order, gated by window +
|
|
868
|
+
// untilMs + per-rule count cap.
|
|
869
|
+
//
|
|
870
|
+
// Codex P1 — recurrence instances MUST NOT precede DTSTART (per
|
|
871
|
+
// RFC 5545 §3.8.5.3). The period-boundary enumeration above
|
|
872
|
+
// includes candidates BEFORE startMs when the period containing
|
|
873
|
+
// startMs has earlier BY*-matching days (e.g. start = May 20
|
|
874
|
+
// Friday, BYDAY=FR;BYSETPOS=1 → enumeration would pick May 1).
|
|
875
|
+
// Refusing pre-start candidates here both fixes the semantics
|
|
876
|
+
// AND avoids consuming the per-rule COUNT cap on instances the
|
|
877
|
+
// operator never asked for.
|
|
878
|
+
var pickedKeys = Object.keys(picked).map(Number).sort(function (a, b) { return a - b; });
|
|
879
|
+
for (var ki = 0; ki < pickedKeys.length; ki += 1) {
|
|
880
|
+
var pickedMs = pickedKeys[ki];
|
|
881
|
+
if (pickedMs < startMs) continue;
|
|
882
|
+
if (pickedMs > untilMs) { count = out.length; break; }
|
|
883
|
+
if (toMs !== null && pickedMs > toMs) { count = out.length; break; }
|
|
884
|
+
if (fromMs !== null && pickedMs < fromMs) continue;
|
|
885
|
+
if (out.length >= count || out.length >= maxCount) break;
|
|
886
|
+
out.push(_msToIsoZ(pickedMs));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return { instances: out, stepBudgetRemaining: stepBudgetRef.remaining };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Compute period [startMs, endMs] given a base start date + interval
|
|
893
|
+
// offset. Period START is anchored at the first second of the period
|
|
894
|
+
// (Jan 1 for YEARLY, day 1 for MONTHLY, WKST-Monday for WEEKLY) so
|
|
895
|
+
// the day-enumeration loop strides whole-day from there.
|
|
896
|
+
function _periodForIndex(freq, startDate, offset) {
|
|
897
|
+
if (freq === "yearly") {
|
|
898
|
+
var year = startDate.getUTCFullYear() + offset;
|
|
899
|
+
var ys = Date.UTC(year, 0, 1, 0, 0, 0, 0);
|
|
900
|
+
var ye = Date.UTC(year + 1, 0, 1, 0, 0, 0, 0) - 1;
|
|
901
|
+
return { startMs: ys, endMs: ye };
|
|
902
|
+
}
|
|
903
|
+
if (freq === "monthly") {
|
|
904
|
+
var bm = startDate.getUTCMonth() + offset;
|
|
905
|
+
var by = startDate.getUTCFullYear() + Math.floor(bm / 12); // allow:raw-byte-literal — months/year
|
|
906
|
+
var mm = ((bm % 12) + 12) % 12; // allow:raw-byte-literal — months/year
|
|
907
|
+
var ms = Date.UTC(by, mm, 1, 0, 0, 0, 0);
|
|
908
|
+
var me = Date.UTC(by, mm + 1, 1, 0, 0, 0, 0) - 1;
|
|
909
|
+
return { startMs: ms, endMs: me };
|
|
910
|
+
}
|
|
911
|
+
// weekly — align to WKST=Monday (RFC 5545 default WKST).
|
|
912
|
+
var anchor = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate(), 0, 0, 0, 0));
|
|
913
|
+
var dow = anchor.getUTCDay() || 7;
|
|
914
|
+
anchor.setUTCDate(anchor.getUTCDate() - (dow - 1) + offset * 7); // allow:raw-byte-literal — days/week
|
|
915
|
+
var ws = anchor.getTime();
|
|
916
|
+
var we = ws + 7 * 86400000 - 1; // allow:raw-byte-literal + allow:raw-time-literal — 7-day window
|
|
917
|
+
return { startMs: ws, endMs: we };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function _withTimeOfDay(dayMs, hh, mm, ss, ms) {
|
|
921
|
+
var d = new Date(dayMs);
|
|
922
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hh, mm, ss, ms);
|
|
923
|
+
}
|
|
924
|
+
|
|
673
925
|
// ---- Internal helpers ----------------------------------------------------
|
|
674
926
|
|
|
675
927
|
function _veventToJsCalEvent(ve) {
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:7f569328-85b0-4395-b846-ba8cf2a95b3b",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-22T02:30:35.680Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.11.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.42",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.42",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.11.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.11.42",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.11.
|
|
57
|
+
"ref": "@blamejs/core@0.11.42",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|