openbriefing 0.2.0 → 0.3.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/dist/obs.js CHANGED
@@ -183,13 +183,22 @@ function build(bag, ctx, extraWarnings = []) {
183
183
  warnings.push(`obs:version ${n} is newer than this reader (v1); unknown tags are ignored`);
184
184
  }
185
185
  }
186
- // obs:modified (required)
187
- const modified = first(bag, "obs:modified", warnings)?.trim();
188
- if (modified === undefined || modified === "") {
189
- errors.push("obs:modified is missing; it is required (§3)");
186
+ // The effective modified time (§13): obs:modified, else the carrier's own date, else none.
187
+ let modified;
188
+ let modifiedFrom = "none";
189
+ const rawModified = first(bag, "obs:modified", warnings)?.trim();
190
+ if (rawModified !== undefined && rawModified !== "") {
191
+ if (validModified(rawModified)) {
192
+ modified = rawModified;
193
+ modifiedFrom = "obs";
194
+ }
195
+ else {
196
+ warnings.push(`obs:modified: "${rawModified}" is not an ISO 8601 date and time; it is ignored (§13)`);
197
+ }
190
198
  }
191
- else if (!validModified(modified)) {
192
- errors.push(`obs:modified: "${modified}" is not an ISO 8601 date and time (§3)`);
199
+ if (modified === undefined && ctx.alt !== undefined && validModified(ctx.alt.value)) {
200
+ modified = ctx.alt.value;
201
+ modifiedFrom = ctx.alt.from;
193
202
  }
194
203
  // Summaries (§4)
195
204
  const tiered = new Map();
@@ -283,7 +292,8 @@ function build(bag, ctx, extraWarnings = []) {
283
292
  }
284
293
  const brief = {
285
294
  version,
286
- modified: modified,
295
+ ...(modified !== undefined ? { modified } : {}),
296
+ modifiedFrom,
287
297
  describesThisPage,
288
298
  summaries,
289
299
  keep,
@@ -341,6 +351,17 @@ function guard(fn) {
341
351
  }
342
352
  // ---------------------------------------------------------------------------
343
353
  // Pages (§3.1)
354
+ function metaContent(doc, key) {
355
+ for (const meta of Array.from(doc.getElementsByTagName("meta"))) {
356
+ const k = (meta.getAttribute("property") ?? meta.getAttribute("name") ?? "").trim().toLowerCase();
357
+ if (k !== key)
358
+ continue;
359
+ const v = meta.getAttribute("content")?.trim();
360
+ if (v)
361
+ return v;
362
+ }
363
+ return undefined;
364
+ }
344
365
  /**
345
366
  * Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
346
367
  * from; the page's <link rel="canonical"> takes precedence for §11.
@@ -361,6 +382,10 @@ export function parseObs(doc, pageUrl) {
361
382
  continue;
362
383
  if (!inBody && doc.body && doc.body.contains(meta))
363
384
  inBody = true;
385
+ // An unescaped double quote ends the attribute early; the rest becomes attributes with a quote in their names.
386
+ if (Array.from(meta.attributes).some((a) => a.name.includes('"'))) {
387
+ warnings.push(`${key}: the value is cut short at a double quote; write a double quote inside a value as &quot; (§9)`);
388
+ }
364
389
  collect(bag, key, content, warnings);
365
390
  }
366
391
  if (isEmpty(bag))
@@ -375,6 +400,9 @@ export function parseObs(doc, pageUrl) {
375
400
  ctx.canonical = canonical;
376
401
  if (lang !== undefined)
377
402
  ctx.lang = lang;
403
+ const articleModified = metaContent(doc, "article:modified_time");
404
+ if (articleModified !== undefined)
405
+ ctx.alt = { value: articleModified, from: "article" };
378
406
  const result = build(bag, ctx, warnings);
379
407
  if (result.brief)
380
408
  checkContent(result.brief, doc.body?.textContent ?? undefined, result.warnings);
@@ -413,6 +441,24 @@ function itemLink(item) {
413
441
  }
414
442
  return undefined;
415
443
  }
444
+ /** The item's own date (§13): Atom <updated>, or RSS <pubDate> turned into ISO 8601. */
445
+ function itemDate(item) {
446
+ for (const el of Array.from(item.children)) {
447
+ if (el.namespaceURI === OBS_NS)
448
+ continue;
449
+ const text = el.textContent?.trim();
450
+ if (!text)
451
+ continue;
452
+ if (el.localName === "updated" && validModified(text))
453
+ return text;
454
+ if (el.localName === "pubDate") {
455
+ const ms = Date.parse(text);
456
+ if (!Number.isNaN(ms))
457
+ return new Date(ms).toISOString();
458
+ }
459
+ }
460
+ return undefined;
461
+ }
416
462
  /**
417
463
  * Reads the OBS elements of one RSS <item> or Atom <entry>. `itemUrl`
418
464
  * overrides the item's own link when given.
@@ -460,6 +506,9 @@ export function parseFeedObs(item, itemUrl) {
460
506
  const ctx = {};
461
507
  if (canonical !== undefined)
462
508
  ctx.canonical = canonical;
509
+ const date = itemDate(item);
510
+ if (date !== undefined)
511
+ ctx.alt = { value: date, from: "feed" };
463
512
  return build(bag, ctx, warnings);
464
513
  });
465
514
  }
@@ -482,7 +531,14 @@ export function validateObs(input) {
482
531
  else if (v !== undefined && v !== null)
483
532
  warnings.push(`${key} is not a string; it is ignored`);
484
533
  };
485
- set("obs:modified", o.modified);
534
+ // A modified time that came from the carrier is carried through with its source.
535
+ const ctx = {};
536
+ if ((o.modifiedFrom === "article" || o.modifiedFrom === "feed") && typeof o.modified === "string") {
537
+ ctx.alt = { value: o.modified, from: o.modifiedFrom };
538
+ }
539
+ else {
540
+ set("obs:modified", o.modified);
541
+ }
486
542
  if (typeof o.version === "number")
487
543
  put(bag, "obs:version", String(o.version));
488
544
  set("obs:url", o.url);
@@ -540,7 +596,9 @@ export function validateObs(input) {
540
596
  if (typeof d.combine === "boolean")
541
597
  put(bag, "obs:derive:combine", d.combine ? "yes" : "no");
542
598
  }
543
- const result = build(bag, {}, warnings);
599
+ if (isEmpty(bag))
600
+ return { errors: ["No summary: obs:summary or a tiered summary is required (§3)"], warnings };
601
+ const result = build(bag, ctx, warnings);
544
602
  if (result.brief && typeof o.describesThisPage === "boolean") {
545
603
  result.brief.describesThisPage = o.describesThisPage;
546
604
  }
@@ -549,11 +607,121 @@ export function validateObs(input) {
549
607
  }
550
608
  // ---------------------------------------------------------------------------
551
609
  // Feed against page (§14)
552
- /** The later obs:modified stands; on a tie, the page stands. */
610
+ /**
611
+ * The later effective modified time stands. When the two are equal, or either
612
+ * side has none, the page stands.
613
+ */
553
614
  export function mergeObs(page, feed) {
554
615
  if (!page)
555
616
  return feed;
556
617
  if (!feed)
557
618
  return page;
619
+ if (page.modified === undefined || feed.modified === undefined)
620
+ return page;
558
621
  return Date.parse(feed.modified) > Date.parse(page.modified) ? feed : page;
559
622
  }
623
+ // ---------------------------------------------------------------------------
624
+ // Change detection (§13)
625
+ /**
626
+ * A stable hash of what a brief says: every text value and every setting, in a
627
+ * fixed order. Two briefs with the same fingerprint present the same. It leaves
628
+ * out the modified time, and everything that depends on where the tags were
629
+ * read rather than on what they say (the language fallback, describesThisPage,
630
+ * a highlight's inContent), so a page and its feed item agree. Readers compare
631
+ * fingerprints to tell a change, above all when there is no modified time.
632
+ *
633
+ * SHA-256 of the canonical JSON below, in hex, first 16 characters.
634
+ */
635
+ export function fingerprint(brief) {
636
+ const canonical = JSON.stringify([
637
+ "obs1",
638
+ brief.version,
639
+ brief.url ?? null,
640
+ brief.title ?? null,
641
+ brief.headline ?? null,
642
+ brief.summaries.map((s) => [s.tier, s.text]),
643
+ brief.keep,
644
+ brief.highlights.map((h) => [h.text, h.label ?? null]),
645
+ [brief.derive.mode, brief.derive.translate, brief.derive.combine],
646
+ brief.attribution ?? null,
647
+ brief.preference,
648
+ ]);
649
+ return sha256Hex(utf8(canonical)).slice(0, 16);
650
+ }
651
+ /** UTF-8 bytes, without TextEncoder so the module needs no platform API. */
652
+ function utf8(text) {
653
+ const out = [];
654
+ for (const ch of text) {
655
+ const c = ch.codePointAt(0);
656
+ if (c < 0x80)
657
+ out.push(c);
658
+ else if (c < 0x800)
659
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
660
+ else if (c < 0x10000)
661
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
662
+ else
663
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
664
+ }
665
+ return out;
666
+ }
667
+ const SHA_K = [
668
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
669
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
670
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
671
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
672
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
673
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
674
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
675
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
676
+ ];
677
+ /** SHA-256 (FIPS 180-4), synchronous, so fingerprint() is too. */
678
+ function sha256Hex(bytes) {
679
+ const h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
680
+ const bits = bytes.length * 8;
681
+ const msg = bytes.slice();
682
+ msg.push(0x80);
683
+ while (msg.length % 64 !== 56)
684
+ msg.push(0);
685
+ const hi = Math.floor(bits / 0x100000000);
686
+ const lo = bits >>> 0;
687
+ msg.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255, (lo >>> 24) & 255, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255);
688
+ const w = new Array(64).fill(0);
689
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
690
+ for (let i = 0; i < msg.length; i += 64) {
691
+ for (let t = 0; t < 16; t++)
692
+ w[t] = ((msg[i + t * 4] << 24) | (msg[i + t * 4 + 1] << 16) | (msg[i + t * 4 + 2] << 8) | msg[i + t * 4 + 3]) >>> 0;
693
+ for (let t = 16; t < 64; t++) {
694
+ const a = w[t - 15];
695
+ const b = w[t - 2];
696
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
697
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
698
+ w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
699
+ }
700
+ let [a, b, c, d, e, f, g, k] = h;
701
+ for (let t = 0; t < 64; t++) {
702
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
703
+ const ch = (e & f) ^ (~e & g);
704
+ const t1 = (k + S1 + ch + SHA_K[t] + w[t]) >>> 0;
705
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
706
+ const maj = (a & b) ^ (a & c) ^ (b & c);
707
+ const t2 = (S0 + maj) >>> 0;
708
+ k = g;
709
+ g = f;
710
+ f = e;
711
+ e = (d + t1) >>> 0;
712
+ d = c;
713
+ c = b;
714
+ b = a;
715
+ a = (t1 + t2) >>> 0;
716
+ }
717
+ h[0] = (h[0] + a) >>> 0;
718
+ h[1] = (h[1] + b) >>> 0;
719
+ h[2] = (h[2] + c) >>> 0;
720
+ h[3] = (h[3] + d) >>> 0;
721
+ h[4] = (h[4] + e) >>> 0;
722
+ h[5] = (h[5] + f) >>> 0;
723
+ h[6] = (h[6] + g) >>> 0;
724
+ h[7] = (h[7] + k) >>> 0;
725
+ }
726
+ return h.map((x) => x.toString(16).padStart(8, "0")).join("");
727
+ }