@takagaki/cortex-decisions-viewer 0.12.106 → 0.12.108

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/build.js CHANGED
@@ -60,6 +60,7 @@ const path = __importStar(require("node:path"));
60
60
  const gray_matter_1 = __importDefault(require("gray-matter"));
61
61
  const marked_1 = require("marked");
62
62
  const sanitize_html_1 = __importDefault(require("sanitize-html"));
63
+ const canonical_evidence_1 = require("./canonical-evidence");
63
64
  const repo_1 = require("./repo");
64
65
  const engagement_1 = require("./engagement");
65
66
  const story_1 = require("./story");
@@ -1577,10 +1578,54 @@ function extractBodyLinks(content) {
1577
1578
  }
1578
1579
  return [...out];
1579
1580
  }
1580
- /** Backlogの課題URL(…/view/KEY-123 や #comment付き)なら課題キーに正規化する */
1581
- function backlogViewKey(url) {
1582
- const m = url.match(/\/view\/([A-Z][A-Z0-9_]*-\d+)(?:[#?]|$)/);
1583
- return m ? m[1] : null;
1581
+ /**
1582
+ * 根拠の URL の種類(`canonicalEvidence` の `kind`)→ グラフの層(#968 tools 1)。
1583
+ *
1584
+ * 🔴 **全部 `external` に潰さない。** Backlog の課題キーは今までどおり `bronze`、Google
1585
+ * ドキュメント/ドライブ・Figma・Slack・Google Chat は資料・デザイン・チャットの層として
1586
+ * `silver` に寄せる——根拠が同期ミラーの ID から元ツールの URL に書き換わっていっても
1587
+ * (engine #968 便 3)、課題・資料・会議の層の色が消えないようにするため。`bronze`/`silver`/
1588
+ * `external` は今は同じ色・同じ凡例行(`GROUP_STYLE`)なので、見た目は変わらない
1589
+ */
1590
+ const EVIDENCE_GROUP = {
1591
+ backlog: "bronze",
1592
+ gdoc: "silver",
1593
+ drive: "silver",
1594
+ "drive-folder": "silver",
1595
+ figma: "silver",
1596
+ slack: "silver",
1597
+ gchat: "silver",
1598
+ };
1599
+ /** 元ツールが無い根拠(engine #968 設計v3・将来の語彙)。リンクにせず、機械の語(urn:…)を画面に出さない */
1600
+ const NO_ORIGINAL_TOOL_PREFIX = "urn:cortex:obs:";
1601
+ const NO_ORIGINAL_TOOL_LABEL = "元のツールが無い根拠";
1602
+ /**
1603
+ * `canonicalEvidence` の `kind` → 画面に出す読める名前(題名が引けない種類の既定ラベル)。
1604
+ *
1605
+ * 🔴 **`key` は常に畳んだ後の鍵(`canon.parentKey ?? canon.key`)。** コメント・返信の
1606
+ * `#comment-N` / `#issuecomment-N` は呼び出し側で既に外れているので、ここで二重に取り除かない
1607
+ * ——GitHub の `#12`(Issue 番号そのもの)まで一緒に削ってしまう事故になる
1608
+ */
1609
+ function readableEvidenceLabel(kind, key, fallback) {
1610
+ switch (kind) {
1611
+ case "backlog":
1612
+ return key.toUpperCase();
1613
+ case "github":
1614
+ return key.replace(/^gh:/, "");
1615
+ case "gdoc":
1616
+ return "Google ドキュメント";
1617
+ case "drive":
1618
+ case "drive-folder":
1619
+ return "Google ドライブの資料";
1620
+ case "figma":
1621
+ return "Figma のデザイン";
1622
+ case "slack":
1623
+ return "Slack のメッセージ";
1624
+ case "gchat":
1625
+ return "Google Chat のメッセージ";
1626
+ default:
1627
+ return fallback;
1628
+ }
1584
1629
  }
1585
1630
  /**
1586
1631
  * 全レコードのfrontmatter(relations / source / references)と本文リンクから関係グラフを構築する(engine #551・分類A 10/1)。
@@ -1640,6 +1685,34 @@ function buildGraph(decisions, home, sections, targetUrls, repoBaseUrl) {
1640
1685
  // (leaderの実測で77件)を無くすのが今回の決定の本題なので、ここも揃えて飛び先を外す
1641
1686
  // (`graph-gold-only.test.mjs` の「議事録を根拠にした決定」で固定)。
1642
1687
  const dropMirrorBlob = (url) => url == null || (repoBaseUrl != null && url.startsWith(`${repoBaseUrl}/blob/`)) ? undefined : url;
1688
+ // 課題・GitHub 等の鍵(`canonicalEvidence` の `key`、コメントは親の鍵)→ 先に作った点のID。
1689
+ // 同じ課題を「課題キー」「URL」「コメントのURL」のどの形で指していても、1つの点に畳むために使う
1690
+ // (#968 tools 1・設計v3「点を正規化の鍵で畳む」)
1691
+ const evidenceKeyToNodeId = new Map();
1692
+ /**
1693
+ * 根拠の URL を、`canonicalEvidence` の鍵で重複を畳んで点にする(#968 tools 1)。
1694
+ * 解けない(対応表でしか引けない・URLが2本以上混ざる等)URL は、今までどおり生のURLをIDにする。
1695
+ */
1696
+ const addEvidenceUrlNode = (rawUrl, fallbackLabel, preferFallbackLabel = false) => {
1697
+ const canon = (0, canonical_evidence_1.canonicalEvidence)(rawUrl);
1698
+ if (!canon)
1699
+ return addNode({ id: rawUrl, label: fallbackLabel, group: "external", url: dropMirrorBlob(rawUrl) });
1700
+ // コメント・返信は独立の点にせず、親(課題・Issue)の点に畳む
1701
+ const foldKey = canon.parentKey ?? canon.key;
1702
+ const existing = evidenceKeyToNodeId.get(foldKey);
1703
+ if (existing && nodes.has(existing))
1704
+ return existing;
1705
+ const group = canon.kind === "url" ? "external" : EVIDENCE_GROUP[canon.kind] ?? "external";
1706
+ // 対応表に無い種類(kind: "url")は、元から有効だった生URLに近い綴りをIDにする
1707
+ // (既存の点のURL・1番目に見つかった記録の飛び先を変えない)
1708
+ const id = canon.kind === "url" ? canon.url : foldKey;
1709
+ // fallbackLabel が markdown リンクの表示文字列等(既に読める名前)なら、それを使う
1710
+ // (例: 資料の題名)。生URLしか無いときだけ、根拠の種類から読める名前を補う
1711
+ const label = canon.kind === "url" || preferFallbackLabel ? fallbackLabel : readableEvidenceLabel(canon.kind, foldKey, fallbackLabel);
1712
+ const nodeId = addNode({ id, label, group, url: dropMirrorBlob(canon.url) });
1713
+ evidenceKeyToNodeId.set(foldKey, nodeId);
1714
+ return nodeId;
1715
+ };
1643
1716
  // 参照先IDから層を推定してノード化する
1644
1717
  const classifyTarget = (target) => {
1645
1718
  if (nodes.has(target))
@@ -1651,7 +1724,11 @@ function buildGraph(decisions, home, sections, targetUrls, repoBaseUrl) {
1651
1724
  return resolved;
1652
1725
  let group = "phantom";
1653
1726
  let label = target;
1654
- if (target.startsWith("minute:") ||
1727
+ if (target.startsWith(NO_ORIGINAL_TOOL_PREFIX)) {
1728
+ // 元ツールが無い根拠(engine #968 の将来の語彙)。リンクにせず、機械の語(urn:…)を出さない
1729
+ return addNode({ id: target, label: NO_ORIGINAL_TOOL_LABEL, group: "phantom" });
1730
+ }
1731
+ else if (target.startsWith("minute:") ||
1655
1732
  target.startsWith("material:") ||
1656
1733
  target.startsWith("design:") ||
1657
1734
  target.startsWith("figma-comment:")) {
@@ -1662,15 +1739,20 @@ function buildGraph(decisions, home, sections, targetUrls, repoBaseUrl) {
1662
1739
  return addNode({ id: target, label, group, url });
1663
1740
  }
1664
1741
  else if (/^[A-Z][A-Z0-9_]*-\d+$/.test(target)) {
1665
- group = "bronze"; // 課題キー → 同期mdから解決した実Backlog URLに飛べるようにする
1742
+ // 課題キー → 同期mdから解決した実Backlog URLに飛べるようにする(#968 便1までは1バイトも変えない表示)。
1743
+ // 同じ課題が先にURLの形で現れていれば、その点に畳む
1744
+ group = "bronze";
1745
+ const foldKey = target.toLowerCase();
1746
+ const existing = evidenceKeyToNodeId.get(foldKey);
1747
+ if (existing && nodes.has(existing))
1748
+ return existing;
1666
1749
  const url = dropMirrorBlob(targetUrls.get(target));
1667
- if (url)
1668
- return addNode({ id: target, label, group, url });
1750
+ const nodeId = addNode(url ? { id: target, label, group, url } : { id: target, label, group });
1751
+ evidenceKeyToNodeId.set(foldKey, nodeId);
1752
+ return nodeId;
1669
1753
  }
1670
1754
  else if (/^https?:\/\//.test(target)) {
1671
- group = "external";
1672
- label = target.replace(/^https?:\/\//, "").slice(0, 60);
1673
- return addNode({ id: target, label, group, url: dropMirrorBlob(target) });
1755
+ return addEvidenceUrlNode(target, target.replace(/^https?:\/\//, "").slice(0, 60));
1674
1756
  }
1675
1757
  else if (target.includes("/") || target.toLowerCase().endsWith(".md")) {
1676
1758
  // リポジトリ内ファイルへの生のパス参照(同期ミラーの議事録・資料など)。
@@ -1696,13 +1778,11 @@ function buildGraph(decisions, home, sections, targetUrls, repoBaseUrl) {
1696
1778
  const addReferenceEdges = (fromId, references) => {
1697
1779
  for (const ref of references) {
1698
1780
  if (ref.url) {
1699
- const url = dropMirrorBlob(ref.url);
1700
- const id = addNode({
1701
- id: ref.url,
1702
- label: ref.label.slice(0, 60),
1703
- group: url ? "external" : "silver",
1704
- url,
1705
- });
1781
+ // ref.label はマークダウンリンクの表示文字列(既に読める名前のことが多い。例: 資料の題名)。
1782
+ // 生URLそのものがラベルのとき(本文の裸URL等)だけ、根拠の種類から読める名前を補う
1783
+ const hasHumanLabel = ref.label !== ref.url;
1784
+ const fallback = hasHumanLabel ? ref.label.slice(0, 60) : ref.label.replace(/^https?:\/\//, "").slice(0, 60);
1785
+ const id = addEvidenceUrlNode(ref.url, fallback, hasHumanLabel);
1706
1786
  edges.push({ from: fromId, to: id, rel: "reference" });
1707
1787
  }
1708
1788
  else {
@@ -1712,8 +1792,7 @@ function buildGraph(decisions, home, sections, targetUrls, repoBaseUrl) {
1712
1792
  };
1713
1793
  const addLinkEdges = (fromId, links) => {
1714
1794
  for (const link of links) {
1715
- const key = backlogViewKey(link);
1716
- edges.push({ from: fromId, to: classifyTarget(key ?? link), rel: "link" });
1795
+ edges.push({ from: fromId, to: classifyTarget(link), rel: "link" });
1717
1796
  }
1718
1797
  };
1719
1798
  for (const d of decisions) {
@@ -2445,6 +2524,9 @@ function createRefResolver(opts) {
2445
2524
  const trimmed = String(ref ?? "").trim();
2446
2525
  if (!trimmed)
2447
2526
  return { label: trimmed, url: null };
2527
+ // 元ツールが無い根拠(engine #968 の将来の語彙)。リンクにせず、機械の語(urn:…)を出さない
2528
+ if (trimmed.startsWith(NO_ORIGINAL_TOOL_PREFIX))
2529
+ return { label: NO_ORIGINAL_TOOL_LABEL, url: null };
2448
2530
  const mdLink = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
2449
2531
  if (mdLink) {
2450
2532
  // 🔴 http(s) 以外(`javascript:` `data:` 等)はリンクにしない。ラベルだけ残す。
@@ -2455,8 +2537,13 @@ function createRefResolver(opts) {
2455
2537
  return { label: mdLink[1], url: null };
2456
2538
  return { label: mdLink[1], url: mdLink[2] };
2457
2539
  }
2458
- if (/^https?:\/\//.test(trimmed))
2459
- return { label: trimmed, url: trimmed };
2540
+ if (/^https?:\/\//.test(trimmed)) {
2541
+ // 裸のURL(markdownリンクでラベルを添えていないもの)は、分かる種類なら読める名前にする
2542
+ // (#968 tools 1「URL → 読める名前」)。**url は変えない**(正規化で既存のリンクを壊さない)
2543
+ const canon = (0, canonical_evidence_1.canonicalEvidence)(trimmed);
2544
+ const label = canon && canon.kind !== "url" ? readableEvidenceLabel(canon.kind, canon.parentKey ?? canon.key, trimmed) : trimmed;
2545
+ return { label, url: trimmed };
2546
+ }
2460
2547
  // 正本のURL(課題キー・material:・design:・minute:)。**ここが「ツールへ飛ぶ」の本体**
2461
2548
  const known = targetUrls.get(trimmed);
2462
2549
  if (known)
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ /**
3
+ * **根拠の名前の正規化**(cortex-engine #968 設計 v3 §1.4)。
4
+ *
5
+ * 正本は cortex-engine `scripts/gold/ref-tokens.mjs#canonicalEvidence`。ここはその TypeScript の写し
6
+ * (ビューアは engine のコードを import できないので、ロジックごと複製する)。
7
+ *
8
+ * 🔴 **写しを直したら、`test/fixtures/evidence-canonical-cases.json`(両リポに同じバイトで置く)で
9
+ * 両方の実装が同じ答えを返すことを確かめる。** 手で見比べて直すと、どちらかの枝だけ直って
10
+ * 静かに食い違う。
11
+ *
12
+ * 比べるのは `key`、画面がリンク先として使うのは `url`。課題と GitHub の `key` は engine の
13
+ * 既存の印(`refTokens`)と同じ綴り(小文字の課題キー・`gh:o/r#N`)——ビューアの他の場所
14
+ * (課題キーをそのまま ID にする `classifyTarget` の旧経路)と当たり方をそろえるため。
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.githubRefToken = void 0;
18
+ exports.syntaxNormalizedUrl = syntaxNormalizedUrl;
19
+ exports.canonicalEvidence = canonicalEvidence;
20
+ /** 課題キーだけの値(`PJ_CORTEX-12`)。engine の `ISSUE_KEY_RE` と同じ形の全体一致 */
21
+ const WHOLE_ISSUE_KEY_RE = /^([A-Z][A-Z0-9_]{1,20})-(\d+)$/;
22
+ /** `owner/repo#N` だけの値 */
23
+ const WHOLE_GH_SHORT_RE = /^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)#(\d+)$/;
24
+ /** スキーム・ホスト・残り(パス・クエリ・断片) */
25
+ const URL_PARTS_RE = /^(https?):\/\/([^/?#\s]+)([^\s]*)$/i;
26
+ /** GitHub の issue / PR を指す 2 つの綴りを同じ印にする(engine `githubRefToken` と同じ) */
27
+ const githubRefToken = (owner, repo, num) => `gh:${String(owner).toLowerCase()}/${String(repo).toLowerCase()}#${num}`;
28
+ exports.githubRefToken = githubRefToken;
29
+ /**
30
+ * URL の構文の正規化(RFC 3986 §6.2.2)。スキームとホストを小文字・既定のポートを落とす・`%xx` を大文字・
31
+ * 末尾の句読点と末尾の `/` を落とす。日本語はエンコードしない。URL でなければ `null`。
32
+ */
33
+ function syntaxNormalizedUrl(raw) {
34
+ let t = String(raw ?? "").trim();
35
+ for (let prev = ""; prev !== t;) {
36
+ prev = t;
37
+ t = t.replace(/[).,;:、。]+$/, "").replace(/\/+$/, "");
38
+ }
39
+ const m = t.match(URL_PARTS_RE);
40
+ if (!m)
41
+ return null;
42
+ const scheme = m[1].toLowerCase();
43
+ let host = m[2].toLowerCase();
44
+ if ((scheme === "http" && host.endsWith(":80")) || (scheme === "https" && host.endsWith(":443"))) {
45
+ host = host.replace(/:\d+$/, "");
46
+ }
47
+ const rest = m[3].replace(/%[0-9a-f]{2}/gi, (x) => x.toUpperCase()).replace(/\/+(?=$|[?#])/, "");
48
+ return `${scheme}://${host}${rest}`;
49
+ }
50
+ /**
51
+ * 値全体が URL 1 本の形のときだけ、その URL を返す(素の URL・`[ラベル](URL)`・Slack の `<URL|ラベル>`)。
52
+ * パスに URL を添えた値・文の中に URL がある値は `null`。Markdown のエスケープ(`PJ\_CORTEX`)は外す
53
+ */
54
+ function wholeUrlOf(value) {
55
+ const v = value.trim();
56
+ const m = v.match(/^(https?:\/\/\S+)$/i) ||
57
+ v.match(/^\[[^\]\n]*\]\((https?:\/\/[^)\s]+)\)$/i) ||
58
+ v.match(/^<(https?:\/\/[^|>\s]+)(?:\|[^>]*)?>$/i);
59
+ return m ? m[1].replace(/\\_/g, "_") : null;
60
+ }
61
+ function toMap(v) {
62
+ if (v instanceof Map)
63
+ return v;
64
+ return new Map(Object.entries(v ?? {}));
65
+ }
66
+ const queryOf = (rest) => {
67
+ const q = rest.indexOf("?");
68
+ if (q < 0)
69
+ return new URLSearchParams();
70
+ const h = rest.indexOf("#", q);
71
+ return new URLSearchParams(rest.slice(q + 1, h < 0 ? undefined : h));
72
+ };
73
+ const pathOf = (rest) => rest.replace(/[?#].*$/, "");
74
+ const fragmentOf = (rest) => {
75
+ const h = rest.indexOf("#");
76
+ return h < 0 ? "" : rest.slice(h + 1);
77
+ };
78
+ const GDOC_KINDS = new Set(["document", "spreadsheets", "presentation", "forms"]);
79
+ const BACKLOG_HOST_RE = /\.(?:backlog\.jp|backlog\.com|backlogtool\.com)$/;
80
+ /** 種類ごとの解き方(URL を構文正規化した後)。当たらなければ汎用の `url` 種に落ちる */
81
+ function canonicalOfUrl(url, ctx) {
82
+ const m = url.match(URL_PARTS_RE);
83
+ const host = m[2];
84
+ const rest = m[3];
85
+ const p = pathOf(rest);
86
+ const q = queryOf(rest);
87
+ if (host === "docs.google.com") {
88
+ const g = p.match(/^\/([a-z]+)\/(?:u\/\d+\/)?d\/([A-Za-z0-9_-]{10,})/);
89
+ if (g && GDOC_KINDS.has(g[1])) {
90
+ return { kind: "gdoc", key: `gfile:${g[2]}`, url: `https://docs.google.com/${g[1]}/d/${g[2]}` };
91
+ }
92
+ const fd = p.match(/^\/file\/d\/([A-Za-z0-9_-]{10,})/);
93
+ if (fd)
94
+ return { kind: "drive", key: `gfile:${fd[1]}`, url: `https://drive.google.com/file/d/${fd[1]}/view` };
95
+ }
96
+ if (host === "drive.google.com") {
97
+ const f = p.match(/^\/file\/(?:u\/\d+\/)?d\/([A-Za-z0-9_-]{10,})/);
98
+ const id = f ? f[1] : /^\/(?:open|uc)$/.test(p) && /^[A-Za-z0-9_-]{10,}$/.test(q.get("id") || "") ? q.get("id") : "";
99
+ if (id)
100
+ return { kind: "drive", key: `gfile:${id}`, url: `https://drive.google.com/file/d/${id}/view` };
101
+ const d = p.match(/^\/drive\/(?:u\/\d+\/)?folders\/([A-Za-z0-9_-]{10,})/);
102
+ if (d)
103
+ return { kind: "drive-folder", key: `gfolder:${d[1]}`, url: `https://drive.google.com/drive/folders/${d[1]}` };
104
+ }
105
+ if (host.endsWith(".slack.com")) {
106
+ const s = p.match(/^\/archives\/([A-Z0-9]+)(?:\/p(\d+))?$/);
107
+ if (s) {
108
+ const hosts = toMap(ctx.slackHosts);
109
+ const h = String(hosts.get(s[1]) || "").trim().toLowerCase() || host;
110
+ const thread = s[2] && /^\d+\.\d+$/.test(q.get("thread_ts") || "") ? q.get("thread_ts") : "";
111
+ const key = `slack:${s[1]}${s[2] ? `:p${s[2]}` : ""}`;
112
+ const tail = `${s[2] ? `/p${s[2]}` : ""}${thread ? `?thread_ts=${thread}` : ""}`;
113
+ return { kind: "slack", key, url: `https://${h}/archives/${s[1]}${tail}` };
114
+ }
115
+ }
116
+ if (host === "chat.google.com" || host === "mail.google.com") {
117
+ const room = host === "chat.google.com" ? p.match(/^\/(?:u\/\d+\/)?room\/([A-Za-z0-9_-]+)/) : null;
118
+ const inMail = host === "mail.google.com" && /^\/chat(?:\/|$)/.test(p) ? fragmentOf(rest).match(/^chat\/space\/([A-Za-z0-9_-]+)/) : null;
119
+ const space = (room && room[1]) || (inMail && inMail[1]);
120
+ if (space)
121
+ return { kind: "gchat", key: `gchat:${space}`, url: `https://chat.google.com/room/${space}` };
122
+ }
123
+ if (BACKLOG_HOST_RE.test(host)) {
124
+ const v = p.match(/^\/view\/([A-Z][A-Z0-9_]{1,20}-\d+)$/);
125
+ if (v) {
126
+ const c = fragmentOf(rest).match(/^comment-(\d+)$/);
127
+ const issue = v[1].toLowerCase();
128
+ return {
129
+ kind: "backlog",
130
+ key: c ? `${issue}#comment-${c[1]}` : issue,
131
+ url: `https://${host}/view/${v[1]}${c ? `#comment-${c[1]}` : ""}`,
132
+ ...(c ? { parentKey: issue } : {}),
133
+ };
134
+ }
135
+ const w = p.match(/^\/alias\/wiki\/(\d+)$/);
136
+ if (w)
137
+ return { kind: "backlog", key: `backlog:${host}:wiki:${w[1]}`, url: `https://${host}/alias/wiki/${w[1]}` };
138
+ const d = p.match(/^\/document\/([^/]+)\/([0-9a-f]{32})$/i);
139
+ if (d)
140
+ return { kind: "backlog", key: `backlog:${host}:doc:${d[2].toLowerCase()}`, url: `https://${host}/document/${d[1]}/${d[2].toLowerCase()}` };
141
+ }
142
+ if (host === "figma.com" || host === "www.figma.com") {
143
+ const f = p.match(/^\/(design|file|proto|board)\/([A-Za-z0-9]{10,})(?:\/branch\/([A-Za-z0-9]{10,}))?(?:\/|$)/);
144
+ if (f) {
145
+ const fileKey = f[3] || f[2];
146
+ const node = String(q.get("node-id") || "").trim().replace(/%3A/gi, ":");
147
+ if (node && !/^\d+[-:]\d+$/.test(node))
148
+ return { kind: "url", key: `url:${url}`, url };
149
+ const n = node ? node.replace(":", "-") : "";
150
+ const seg = f[1] === "board" ? "board" : "design";
151
+ return { kind: "figma", key: `figma:${fileKey}${n ? `:${n.replace("-", ":")}` : ""}`, url: `https://www.figma.com/${seg}/${fileKey}${n ? `?node-id=${n}` : ""}` };
152
+ }
153
+ }
154
+ if (host === "github.com" || host === "www.github.com") {
155
+ const g = p.match(/^\/([^/]+)\/([^/]+)\/(?:issues|pull|discussions)\/(\d+)$/);
156
+ if (g) {
157
+ const [o, r] = [g[1].toLowerCase(), g[2].toLowerCase()];
158
+ const issue = (0, exports.githubRefToken)(o, r, g[3]);
159
+ const c = fragmentOf(rest).match(/^(issuecomment-\d+|discussioncomment-\d+|discussion_r\d+|pullrequestreview-\d+)$/);
160
+ return {
161
+ kind: "github",
162
+ key: c ? `${issue}#${c[1]}` : issue,
163
+ url: `https://github.com/${o}/${r}/issues/${g[3]}${c ? `#${c[1]}` : ""}`,
164
+ ...(c ? { parentKey: issue } : {}),
165
+ };
166
+ }
167
+ }
168
+ return { kind: "url", key: `url:${url}`, url };
169
+ }
170
+ /**
171
+ * 根拠の名前の正規化。解けない入力(Gold の ID・`minute:`・`material:`・`design:`・リポのパス・
172
+ * ホストの決まらない課題キー・URL が 2 本以上の値)は `null`。engine 版と 1:1 対応。
173
+ */
174
+ function canonicalEvidence(value, ctx = {}) {
175
+ const raw = String(value ?? "").trim();
176
+ if (!raw)
177
+ return null;
178
+ const key = raw.match(WHOLE_ISSUE_KEY_RE);
179
+ if (key) {
180
+ const domains = toMap(ctx.backlogDomains);
181
+ const host = String(domains.get(key[1]) || "")
182
+ .trim()
183
+ .toLowerCase()
184
+ .replace(/^https?:\/\//, "")
185
+ .replace(/\/+$/, "");
186
+ return host ? canonicalOfUrl(`https://${host}/view/${raw}`, ctx) : null;
187
+ }
188
+ const gh = raw.match(WHOLE_GH_SHORT_RE);
189
+ if (gh)
190
+ return canonicalOfUrl(`https://github.com/${gh[1]}/${gh[2]}/issues/${gh[3]}`, ctx);
191
+ const url = wholeUrlOf(raw);
192
+ if (!url)
193
+ return null;
194
+ const normalized = syntaxNormalizedUrl(url);
195
+ return normalized ? canonicalOfUrl(normalized, ctx) : null;
196
+ }
package/dist/render.js CHANGED
@@ -50,6 +50,12 @@ exports.ingestFacts = ingestFacts;
50
50
  exports.ingestSucceeded = ingestSucceeded;
51
51
  exports.ingestedAtOf = ingestedAtOf;
52
52
  exports.ingestedRowOf = ingestedRowOf;
53
+ exports.reflectedStateOf = reflectedStateOf;
54
+ exports.jstNightLabel = jstNightLabel;
55
+ exports.jstCalendarDay = jstCalendarDay;
56
+ exports.nextMemoryReflectGoldAtMs = nextMemoryReflectGoldAtMs;
57
+ exports.memoryReflectDoneAtMs = memoryReflectDoneAtMs;
58
+ exports.memoryReflectTextOf = memoryReflectTextOf;
53
59
  exports.runLogUrlOf = runLogUrlOf;
54
60
  exports.lastSyncRunUrlOf = lastSyncRunUrlOf;
55
61
  exports.runSourceWordOf = runSourceWordOf;
@@ -2591,6 +2597,115 @@ function ingestedRowOf(rows, capId, refs) {
2591
2597
  }
2592
2598
  return bestRow;
2593
2599
  }
2600
+ /**
2601
+ * `reflected` の行(能力ごとに 1 本・cortex-engine #968 条件 4・E3)を探す。ref は接続先の ID では
2602
+ * なく決まった語 `memory`(サーバの定数は `STATE_MEMORY_REF`。埋め込み関数は共有定数を参照できず
2603
+ * 綴りを写して持つ——`exemptFromRegistrySieve` と同じ判断。ずれは state-vocab-parity.test.mjs)。
2604
+ * 無ければ null(栓 `memory_reflect_row` が off の今の姿。T2 の行は何も出さない)。
2605
+ *
2606
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2607
+ */
2608
+ function reflectedStateOf(rows, capId) {
2609
+ if (!Array.isArray(rows) || !capId)
2610
+ return null;
2611
+ for (const r of rows) {
2612
+ if (r && r.cap === capId && r.question === "reflected" && r.ref === "memory")
2613
+ return r;
2614
+ }
2615
+ return null;
2616
+ }
2617
+ /**
2618
+ * ISO 時刻を「Gold の夜の日」の表示(`M/D`)にする(cortex-engine #968 設計 v1 §3)。
2619
+ *
2620
+ * **engine の `nightDayOf`(−12 時間)と同じ切り方。** Gold は JST 22:00 に始まり日をまたいで
2621
+ * 終わるので、素の日付だと同じ晩の記録が「9/28」と「9/29」に分かれる。12 時間引いてから
2622
+ * JST の日付を読むと、22:00 に始まった晩はいつも開始した日の日付になる。
2623
+ *
2624
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2625
+ * @returns `M/D`(ゼロ埋めなし)。読めない値は null
2626
+ */
2627
+ function jstNightLabel(iso) {
2628
+ const ms = typeof iso === "string" && iso ? Date.parse(iso) : NaN;
2629
+ if (!Number.isFinite(ms))
2630
+ return null;
2631
+ const JST_MS = 9 * 60 * 60 * 1000;
2632
+ const d = new Date(ms - 12 * 60 * 60 * 1000 + JST_MS);
2633
+ return (d.getUTCMonth() + 1) + "/" + d.getUTCDate();
2634
+ }
2635
+ /** JST の暦日(`YYYY-MM-DD`)。`jstNightLabel` と違い −12 時間をしない(素の日付の比較用) */
2636
+ function jstCalendarDay(ms) {
2637
+ const JST_MS = 9 * 60 * 60 * 1000;
2638
+ const d = new Date(ms + JST_MS);
2639
+ const p = (n) => (n < 10 ? "0" + n : String(n));
2640
+ return d.getUTCFullYear() + "-" + p(d.getUTCMonth() + 1) + "-" + p(d.getUTCDate());
2641
+ }
2642
+ /**
2643
+ * ある時刻(`ms`)の後、最初に来る JST 22:00(update-gold の cron・T2)。
2644
+ *
2645
+ * 🔴 **「22」はここ 1 か所だけに書く**(.github/workflows の `update-gold.yml` の cron
2646
+ * `0 13 * * *`(UTC)= JST 22:00 と番人(memory-reflect-line.test.mjs)が突き合わせる)。
2647
+ *
2648
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2649
+ */
2650
+ function nextMemoryReflectGoldAtMs(ms) {
2651
+ const GOLD_HOUR_JST = 22;
2652
+ const JST_MS = 9 * 60 * 60 * 1000;
2653
+ const jst = new Date(ms + JST_MS);
2654
+ const goldUtcMs = Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth(), jst.getUTCDate(), GOLD_HOUR_JST, 0, 0, 0) - JST_MS;
2655
+ return goldUtcMs > ms ? goldUtcMs : goldUtcMs + 24 * 60 * 60 * 1000;
2656
+ }
2657
+ /**
2658
+ * Gold の夜間 job が走り切っている(はずの)締切(T2)。
2659
+ *
2660
+ * 🔴 **「105」(Gold の最長実行時間・分)もここ 1 か所だけ。** 余白 30 分を足す
2661
+ * (番人が update-gold の実測の最長と突き合わせる)。
2662
+ *
2663
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2664
+ */
2665
+ function memoryReflectDoneAtMs(goldAtMs) {
2666
+ const GOLD_MAX_MINUTES = 105;
2667
+ const MARGIN_MINUTES = 30;
2668
+ return goldAtMs + (GOLD_MAX_MINUTES + MARGIN_MINUTES) * 60000;
2669
+ }
2670
+ /**
2671
+ * 「記憶に反映」の文言(接続先 1 件・cortex-engine #968 条件 4・T2)。発注書の下書き
2672
+ * (#968 -5925683579)の表のとおり。**R(`reflected` の行)が無ければ null**(栓 off の今の姿。
2673
+ * 行は何も出さない)。
2674
+ *
2675
+ * @param reflected `reflectedStateOf` の答え(R)
2676
+ * @param ingestedRow その能力の `ingested` の行(N=`lastNewAt` を読む。なければ比べない)
2677
+ * @param nowMs 時計。**呼び手が渡す**(ここで `Date.now()` を読まない。日付爆弾を避ける)
2678
+ *
2679
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2680
+ */
2681
+ function memoryReflectTextOf(reflected, ingestedRow, nowMs) {
2682
+ if (!reflected)
2683
+ return null;
2684
+ const lastOkAt = typeof reflected.lastOkAt === "string" ? reflected.lastOkAt : "";
2685
+ const night = jstNightLabel(lastOkAt);
2686
+ // R の読み口(`detail`)が bronze 以外(写し・API から作った)なら、N とは比べない
2687
+ // (写しと S3 の時刻を比べると嘘になるため)
2688
+ if (reflected.detail && reflected.detail !== "bronze") {
2689
+ return night ? "記憶の更新: " + night + " の夜" : null;
2690
+ }
2691
+ // 一度も反映していない(成功した lastOkAt がまだ無い)。言い切れる材料が無いので何も出さない
2692
+ if (!night)
2693
+ return null;
2694
+ const rMs = Date.parse(lastOkAt);
2695
+ // 門で外した接続先(`ingested: skipped`・書き手 gold)の `lastNewAt` は比べない(申し送り)
2696
+ const n = ingestedRow && ingestedRow.status !== "skipped" && typeof ingestedRow.lastNewAt === "string" ? ingestedRow.lastNewAt : "";
2697
+ const nMs = n ? Date.parse(n) : NaN;
2698
+ if (!n || !Number.isFinite(nMs) || nMs <= rMs)
2699
+ return "記憶に反映: " + night + " の夜まで";
2700
+ const goldAt = nextMemoryReflectGoldAtMs(nMs);
2701
+ if (nowMs < goldAt) {
2702
+ const tonight = jstCalendarDay(goldAt) === jstCalendarDay(nowMs);
2703
+ return "取り込み済み・" + (tonight ? "今夜" : "明日の夜") + " 22:00 に記憶へ反映";
2704
+ }
2705
+ if (nowMs < memoryReflectDoneAtMs(goldAt))
2706
+ return "取り込み済み・いま記憶へ反映しています";
2707
+ return "取り込み済みの原文がまだ記憶に入っていません → 次の定時に自動でやり直します";
2708
+ }
2594
2709
  /**
2595
2710
  * 行の横に出す「実行ログ」のリンク先(#560)。**無ければ空文字。**
2596
2711
  *
@@ -4798,14 +4913,21 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4798
4913
  }
4799
4914
  .card:hover { border-color: var(--accent); }
4800
4915
  .card-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12.5px; color: var(--muted); margin-bottom: 6px; }
4801
- .card-title { font-size: 16px; font-weight: 650; margin: 2px 0 6px; color: var(--text); }
4916
+ /* card-title はカードの見出し(#1 新しいタブでも開けるよう <a> にした)。見た目は元の <div> と揃える */
4917
+ .card-title { display: block; font-size: 16px; font-weight: 650; margin: 2px 0 6px; color: var(--text); }
4802
4918
  .card-summary { color: #3b424c; font-size: 14px; margin: 0; }
4803
4919
  .empty { color: var(--muted); text-align: center; padding: 48px 0; }
4804
4920
  .pager { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 20px 0 4px; }
4805
- .pager-btn { padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: inherit; cursor: pointer; font: inherit; }
4921
+ .pager-btn { display: inline-block; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: inherit; cursor: pointer; font: inherit; }
4806
4922
  .pager-btn:hover:not(:disabled) { border-color: var(--muted); }
4807
4923
  .pager-btn:disabled { opacity: .4; cursor: default; }
4924
+ /* ボタンの見た目の <a> に下線は要らない(.trace-btn 等、押しボタン型のリンクと同じ扱い) */
4925
+ a.pager-btn:hover { text-decoration: none; }
4926
+ /* 前へ/次へ(#2)の端は <a> ではなく <span>(キーボードでも踏めないように)。このクラスで見た目をそろえる */
4927
+ .pager-btn.is-disabled { opacity: .4; cursor: default; pointer-events: none; }
4808
4928
  .pager-ind { color: var(--muted); font-size: 13px; min-width: 72px; text-align: center; }
4929
+ /* 詳細の「前へ/次へ」は本文の上(戻るリンクのすぐ下)に置くので、一覧の下の余白は要らない */
4930
+ .detail-pager { justify-content: flex-start; padding: 0 0 16px; }
4809
4931
 
4810
4932
  /* ---- badges / chips ---- */
4811
4933
  .badge {
@@ -5601,6 +5723,10 @@ a.ds-tile:hover { text-decoration: none; }
5601
5723
  どちらが重いのか読めない) */
5602
5724
  .ds-ingest { font-size: 11px; color: var(--muted); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
5603
5725
  .ds-ingest-failed { color: #b45309; font-weight: 600; }
5726
+ /* 「記憶に反映」(cortex-engine #968 条件4・T2)。「取り込み」(.ds-ingest)の下に1行。
5727
+ **必ず自分の行を占める**(flex: 1 1 100%)——.ds-gate-help と同じ理由(チップの横に
5728
+ 押し込むと省略される)。強さは .ds-ingest と同じ弱さ(添え物として読ませる) */
5729
+ .ds-reflect { flex: 1 1 100%; margin: 2px 0 0; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
5604
5730
  .ds-gate::before { content: ""; width: 8px; height: 8px; border-radius: 999px; background: currentColor; }
5605
5731
  .ds-gate-ok { color: #1f7a45; }
5606
5732
  .ds-gate-warn { color: #b45309; }
@@ -6290,6 +6416,14 @@ ${ingestFacts.toString()}
6290
6416
  ${ingestSucceeded.toString()}
6291
6417
  ${ingestedAtOf.toString()}
6292
6418
  ${ingestedRowOf.toString()}
6419
+ // 「記憶に反映」(#968 条件 4・T2)。🔴 **ingestedEl より前に置く。** 落とすと行の描画が
6420
+ // 未定義を参照して画面のスクリプトが丸ごと死ぬ
6421
+ ${reflectedStateOf.toString()}
6422
+ ${jstNightLabel.toString()}
6423
+ ${jstCalendarDay.toString()}
6424
+ ${nextMemoryReflectGoldAtMs.toString()}
6425
+ ${memoryReflectDoneAtMs.toString()}
6426
+ ${memoryReflectTextOf.toString()}
6293
6427
  ${runLogUrlOf.toString()}
6294
6428
  ${lastSyncRunUrlOf.toString()}
6295
6429
  ${runSourceWordOf.toString()}
@@ -8450,11 +8584,37 @@ ${calendarAddUrl.toString()}
8450
8584
  });
8451
8585
  return a;
8452
8586
  }
8587
+ // 一覧 ⇄ 詳細のスクロール位置(#1 一覧→詳細→戻るで毎回スクロールが消える問題)。
8588
+ //
8589
+ // ⚠ ブラウザ既定の自動復元(scrollRestoration: "auto")に任せない。** この画面は
8590
+ // 一覧も詳細も同じ #app を毎回 innerHTML="" で作り直す静的サイトなので、戻ったときの
8591
+ // 高さが自動復元の計算と噛み合わず当てにできない。代わりに「一覧を離れた瞬間のY」を
8592
+ // 自分で1つだけ覚え、一覧へ戻るときに明示的に scrollTo する(分類B 2026-10-01)。
8593
+ //
8594
+ // ⚠ 詳細→詳細(後述の前へ/次へ)では更新しない。** 更新すると、何件か読み進めてから
8595
+ // 一覧へ戻ったときに「最後に見ていた詳細でのスクロール位置」に飛んでしまう。
8596
+ // 一覧に戻る行き先は常に「一覧を離れたときの位置」でなければならない
8597
+ var savedListScrollY = 0;
8598
+ // どのタブの一覧で覚えた位置か(実装レビュー②)。** route() は詳細を描くとき、その記録の
8599
+ // 種類で listState.tab を書き換える(例: 用語集の一覧からウィキリンクで決定の詳細へ移ると
8600
+ // listState.tab は "decisions" になる)。そこで「一覧へ戻る」を押すと、戻る先はその
8601
+ // 書き換わった後のタブになるので、離れたときのタブと戻る先のタブが違うことがある。
8602
+ // 違うタブに離れたときのYを当てはめない(合わなければ先頭へ)
8603
+ var savedListTab = null;
8604
+ // 直前に描いたのが詳細(not foundも含む)か一覧か。popstate(ブラウザの戻る/進む)は
8605
+ // go() を通らないので、この印で「詳細→一覧」の遷移だけを見分けて復元する
8606
+ var lastWasDetail = false;
8607
+ if ("scrollRestoration" in history) history.scrollRestoration = "manual";
8608
+
8453
8609
  function go(id) {
8610
+ // 一覧から詳細へ移るときだけ、今の一覧のスクロール位置とタブを覚える
8611
+ if (id && !lastWasDetail) { savedListScrollY = window.scrollY; savedListTab = listState.tab; }
8454
8612
  var url = id ? "?id=" + encodeURIComponent(id) : tabUrl(listState.tab);
8455
8613
  history.pushState({}, "", url);
8456
8614
  route();
8457
- window.scrollTo(0, 0);
8615
+ // 詳細は常に先頭から。一覧は、離れたときと同じタブに戻るときだけ元の位置へ
8616
+ if (id) window.scrollTo(0, 0);
8617
+ else window.scrollTo(0, listState.tab === savedListTab ? savedListScrollY : 0);
8458
8618
  }
8459
8619
 
8460
8620
  var listState = { tab: DEFAULT_TAB, q: "", category: "", month: "", status: "", page: 1 };
@@ -8482,6 +8642,38 @@ ${calendarAddUrl.toString()}
8482
8642
  return el("div", { class: "pager" }, [prev, ind, next]);
8483
8643
  }
8484
8644
 
8645
+ /** 新しいタブ・新しいウィンドウ等を求めるクリックではない(左クリック・修飾キー無し)か */
8646
+ function isPlainClick(ev) {
8647
+ return !ev.defaultPrevented && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey;
8648
+ }
8649
+
8650
+ /**
8651
+ * 一覧カードの見出し(#1)。普通の a href="?id=..." にして、中クリック・
8652
+ * Ctrl/Cmd クリックで新しいタブでも開けるようにする(opsTabLink と同じ型)。
8653
+ * 素のクリックは今までどおり SPA 内遷移(go)のまま——1 件ずつ確かめるときに
8654
+ * ページ全体の再読み込みを挟まない。
8655
+ */
8656
+ function cardTitleLink(rec, extraClass) {
8657
+ var a = el("a", {
8658
+ class: "card-title" + (extraClass || ""),
8659
+ href: "?id=" + encodeURIComponent(rec.id),
8660
+ text: humanTitle(rec.title),
8661
+ });
8662
+ a.addEventListener("click", function (ev) {
8663
+ if (ev.defaultPrevented) return;
8664
+ if (!isPlainClick(ev)) {
8665
+ // 新しいタブ・新しいウィンドウ等はブラウザの既定の動作に任せる。
8666
+ // ここで止めないと、外側のカードの click にも伝わって SPA 内遷移も重ねて走ってしまう
8667
+ ev.stopPropagation();
8668
+ return;
8669
+ }
8670
+ ev.preventDefault();
8671
+ ev.stopPropagation();
8672
+ go(rec.id);
8673
+ });
8674
+ return a;
8675
+ }
8676
+
8485
8677
  function monthLabel(m) {
8486
8678
  var p = m.split("-");
8487
8679
  return p[0] + "年" + parseInt(p[1], 10) + "月";
@@ -8550,6 +8742,7 @@ ${calendarAddUrl.toString()}
8550
8742
 
8551
8743
  // ---------- 一覧 ----------
8552
8744
  function renderList() {
8745
+ lastWasDetail = false;
8553
8746
  app.innerHTML = "";
8554
8747
  renderTabs();
8555
8748
  renderInfoBar();
@@ -14636,6 +14829,22 @@ ${calendarAddUrl.toString()}
14636
14829
  return frag([span].concat(runTrailNodes(r)));
14637
14830
  }
14638
14831
 
14832
+ /**
14833
+ * 「記憶に反映」の行(cortex-engine #968 条件 4・T2)。**「取り込み」の行の下に 1 行**。
14834
+ * 材料は「reflectedStateOf」(R)と、同じ能力の「ingested」の行(N=「lastNewAt」)。
14835
+ * **R が無ければ何も出さない**(栓「memory_reflect_row」が off の今の姿。「ingestedEl」 と同じ原則)。
14836
+ *
14837
+ * @param r その能力の「ingested」の行(「ingestedEl」に渡したのと同じ行。無くても R だけで判定する)
14838
+ * @param capId 接続状態ストアの能力名(issues / design 等。「ingestedEl」 の「cap」は画面の
14839
+ * ラベルなので使えない——「keyMissingText」用の別物)
14840
+ */
14841
+ function memoryReflectEl(r, capId) {
14842
+ var reflected = reflectedStateOf(LIVE_STATE, capId);
14843
+ var text = memoryReflectTextOf(reflected, r, Date.now());
14844
+ if (!text) return null;
14845
+ return el("p", { class: "ds-reflect", text: text });
14846
+ }
14847
+
14639
14848
  /**
14640
14849
  * その接続先の「届くか」の行(接続状態ストア・#523 S1)。**無ければ null。**
14641
14850
  *
@@ -15133,6 +15342,8 @@ ${calendarAddUrl.toString()}
15133
15342
  pendingBadgeEl(t),
15134
15343
  // この接続先の取り込みが最後にどうなったか(段C)。測っていなければ null
15135
15344
  ingestedEl(ingestedStateOf(t.domain, t.projectKey), "課題管理"),
15345
+ // 「記憶に反映」(cortex-engine #968 条件4・T2)。R が無ければ null(栓offの今の姿)
15346
+ memoryReflectEl(ingestedStateOf(t.domain, t.projectKey), "issues"),
15136
15347
  ]);
15137
15348
  // この接続先に届くか(#523 S1)。**「取り込み」とは別の問い**なので併記する
15138
15349
  // ——鍵はあるが 403 でプロジェクトが見えない、は取り込みの失敗より前の話。
@@ -15772,6 +15983,8 @@ ${calendarAddUrl.toString()}
15772
15983
  pendingBadgeEl(f),
15773
15984
  // このファイルの取り込みが最後にどうなったか(段C)。測っていなければ null
15774
15985
  ingestedEl(designIngestedStateOf(f.key), "デザイン"),
15986
+ // 「記憶に反映」(cortex-engine #968 条件4・T2)。R が無ければ null(栓offの今の姿)
15987
+ memoryReflectEl(designIngestedStateOf(f.key), "design"),
15775
15988
  ]);
15776
15989
  // このファイルに届くか(#523 S1)。書き手は engine の同期(#523 S4・engine #329)。
15777
15990
  // 行が無いファイル(まだ一度も同期していない)には何も出ない。
@@ -19030,11 +19243,16 @@ ${calendarAddUrl.toString()}
19030
19243
  return sel;
19031
19244
  }
19032
19245
 
19033
- function drawCards() {
19246
+ /**
19247
+ * 今の絞り込み(検索語・期間・カテゴリー・状態)を当てた決定の一覧(並びは
19248
+ * SITE.decisions の順=新しい順のまま)。drawCards と、詳細の「前へ/次へ」
19249
+ * (#2)の両方がここを通る——一覧の並びと前後移動の並びがずれないように、口を1つにする。
19250
+ */
19251
+ function filteredDecisions() {
19034
19252
  var q = listState.q.trim().toLowerCase();
19035
19253
  // 削除済みは絞り込みの手前で落とす(分母も一緒に減るので「12/13件」が残らない)
19036
19254
  var all = visibleRecords(SITE.decisions);
19037
- var items = all.filter(function (d) {
19255
+ return all.filter(function (d) {
19038
19256
  if (listState.month && d.date.slice(0, 7) !== listState.month) return false;
19039
19257
  if (listState.category && d.category !== listState.category) return false;
19040
19258
  if (!matchesStatusFilter(d, !!SITE.decisionFollowup)) return false;
@@ -19044,6 +19262,11 @@ ${calendarAddUrl.toString()}
19044
19262
  }
19045
19263
  return true;
19046
19264
  });
19265
+ }
19266
+
19267
+ function drawCards() {
19268
+ var all = visibleRecords(SITE.decisions);
19269
+ var items = filteredDecisions();
19047
19270
 
19048
19271
  var wrap = document.getElementById("card-list");
19049
19272
  wrap.innerHTML = "";
@@ -19077,7 +19300,7 @@ ${calendarAddUrl.toString()}
19077
19300
  var card = el("div", { class: "card", title: d.id, "data-record-id": d.id }, [
19078
19301
  meta,
19079
19302
  // 消したレコードは一覧でも取り消し線(次のビルドまで一覧には残る)
19080
- el("div", { class: "card-title" + (d.deleted ? " is-deleted" : ""), text: humanTitle(d.title) }),
19303
+ cardTitleLink(d, d.deleted ? " is-deleted" : ""),
19081
19304
  d.summary ? el("p", { class: "card-summary", text: d.summary }) : null,
19082
19305
  ]);
19083
19306
  card.addEventListener("click", function () { go(d.id); });
@@ -19090,8 +19313,7 @@ ${calendarAddUrl.toString()}
19090
19313
 
19091
19314
  // ---------- 汎用一覧(records/ を持つディレクトリ) ----------
19092
19315
  function renderGenericList() {
19093
- var tab = null;
19094
- TABS.forEach(function (t) { if (t.key === listState.tab) tab = t; });
19316
+ var tab = findTab(listState.tab);
19095
19317
  // 削除済みは描画の手前で落とす(件数の分母・絞り込みの選択肢もこれ1本で揃う)
19096
19318
  var items = visibleRecords(tab && tab.items ? tab.items : []);
19097
19319
 
@@ -19112,12 +19334,7 @@ ${calendarAddUrl.toString()}
19112
19334
  app.appendChild(listWrap);
19113
19335
 
19114
19336
  function draw() {
19115
- var q = listState.q.trim().toLowerCase();
19116
- var shown = items.filter(function (r) {
19117
- if (!matchesStatusFilter(r)) return false;
19118
- if (!q) return true;
19119
- return (r.title + " " + r.summary + " " + r.searchText).toLowerCase().indexOf(q) !== -1;
19120
- });
19337
+ var shown = filteredGenericItems(tab);
19121
19338
  listWrap.innerHTML = "";
19122
19339
  if (!shown.length) {
19123
19340
  countEl.textContent = "0 / " + items.length + " 件";
@@ -19146,7 +19363,7 @@ ${calendarAddUrl.toString()}
19146
19363
  meta.appendChild(traceR);
19147
19364
  var card = el("div", { class: "card", title: r.id, "data-record-id": r.id }, [
19148
19365
  meta,
19149
- el("div", { class: "card-title" + (r.deleted ? " is-deleted" : ""), text: humanTitle(r.title) }),
19366
+ cardTitleLink(r, r.deleted ? " is-deleted" : ""),
19150
19367
  r.summary ? el("p", { class: "card-summary", text: r.summary }) : null,
19151
19368
  ]);
19152
19369
  card.addEventListener("click", function () { go(r.id); });
@@ -19916,6 +20133,68 @@ ${calendarAddUrl.toString()}
19916
20133
  function visibleRecords(items) {
19917
20134
  return (items || []).filter(function (r) { return !r.deleted; });
19918
20135
  }
20136
+
20137
+ /** タブkeyからTABSの該当行を探す(見つからなければ null) */
20138
+ function findTab(key) {
20139
+ var found = null;
20140
+ TABS.forEach(function (t) { if (t.key === key) found = t; });
20141
+ return found;
20142
+ }
20143
+
20144
+ /**
20145
+ * 今の絞り込み(検索語・状態)を当てた汎用一覧(用語集・関係者・ルール等)。
20146
+ * renderGenericList の draw と、詳細の「前へ/次へ」(#2)の両方がここを通る
20147
+ */
20148
+ function filteredGenericItems(tab) {
20149
+ var q = listState.q.trim().toLowerCase();
20150
+ var items = visibleRecords(tab && tab.items ? tab.items : []);
20151
+ return items.filter(function (r) {
20152
+ if (!matchesStatusFilter(r)) return false;
20153
+ if (!q) return true;
20154
+ return (r.title + " " + r.summary + " " + r.searchText).toLowerCase().indexOf(q) !== -1;
20155
+ });
20156
+ }
20157
+
20158
+ /**
20159
+ * 絞り込んだ並び(filtered)に今の記録が無ければ、絞り込み前の既定の並び(fallback。
20160
+ * 削除済みを除いた新しい順)を使う(#2 一覧を経ずに開いたとき・絞り込みに
20161
+ * 合わない記録を開いたときは既定の順、の受け皿)。
20162
+ */
20163
+ function pagerOrder(filtered, fallback, id) {
20164
+ for (var i = 0; i < filtered.length; i++) { if (filtered[i].id === id) return filtered; }
20165
+ return fallback;
20166
+ }
20167
+
20168
+ /**
20169
+ * 詳細画面の「前へ/次へ」(#2)。ordered の中で id の前後の記録へ移る。
20170
+ * 端(先頭・末尾)では押せない。ordered に id が無ければ何も出さない
20171
+ * (削除済みを直リンクで開いた等、並びそのものが無いとき)
20172
+ */
20173
+ function buildDetailPager(ordered, id) {
20174
+ var idx = -1;
20175
+ for (var i = 0; i < ordered.length; i++) { if (ordered[i].id === id) { idx = i; break; } }
20176
+ if (idx === -1) return null;
20177
+ var prevRec = idx > 0 ? ordered[idx - 1] : null;
20178
+ var nextRec = idx < ordered.length - 1 ? ordered[idx + 1] : null;
20179
+ function navBtn(rec, cls, text) {
20180
+ // 端では押せない。<a href="#"> のままだとポインタは止められても、Tab→Enter では
20181
+ // 踏めてしまう(#へ飛んで popstate が走り、詳細を描き直してしまう)ので、
20182
+ // 端では href を持たない <span> にする(実装レビュー④)
20183
+ if (!rec) return el("span", { class: "pager-btn is-disabled " + cls, "aria-disabled": "true", text: text });
20184
+ var a = el("a", { class: "pager-btn " + cls, href: "?id=" + encodeURIComponent(rec.id), text: text });
20185
+ a.addEventListener("click", function (ev) {
20186
+ if (!isPlainClick(ev)) { ev.stopPropagation(); return; }
20187
+ ev.preventDefault();
20188
+ ev.stopPropagation();
20189
+ go(rec.id);
20190
+ });
20191
+ return a;
20192
+ }
20193
+ return el("div", { class: "pager detail-pager" }, [
20194
+ navBtn(prevRec, "detail-pager-prev", "← 前へ"),
20195
+ navBtn(nextRec, "detail-pager-next", "次へ →"),
20196
+ ]);
20197
+ }
19919
20198
  /**
19920
20199
  * 削除済みを除いたグラフ。**消費側で1行ずつ摘まむのではなく、ここ1箇所で作る**
19921
20200
  * ——グラフ・エクスプローラー・件数表示が別々に間引くと、片方だけ消え残る。
@@ -19940,6 +20219,7 @@ ${calendarAddUrl.toString()}
19940
20219
 
19941
20220
  // ---------- 汎用詳細 ----------
19942
20221
  function renderGenericDetail(r) {
20222
+ lastWasDetail = true;
19943
20223
  app.innerHTML = "";
19944
20224
  countEl.textContent = "";
19945
20225
 
@@ -19947,6 +20227,12 @@ ${calendarAddUrl.toString()}
19947
20227
  back.addEventListener("click", function (ev) { ev.preventDefault(); go(null); });
19948
20228
  app.appendChild(back);
19949
20229
 
20230
+ // 前へ/次へ(#2)。一覧の今の絞り込み・並びの順。一覧を経ずに開いたときは既定の順
20231
+ var pagerTabG = findTab("dir:" + r._section);
20232
+ var orderedG = pagerOrder(filteredGenericItems(pagerTabG), visibleRecords(pagerTabG ? pagerTabG.items : []), r.id);
20233
+ var detailPagerG = buildDetailPager(orderedG, r.id);
20234
+ if (detailPagerG) app.appendChild(detailPagerG);
20235
+
19950
20236
  var head = el("div", { class: "detail-head" });
19951
20237
  head.appendChild(el("div", {}, [ el("h1", { class: "detail-title", text: humanTitle(r.title) }) ]));
19952
20238
  var traceBtn2 = el("a", { class: "trace-btn", href: "?tab=graph", text: "🕸 つながり" });
@@ -20632,6 +20918,7 @@ ${calendarAddUrl.toString()}
20632
20918
 
20633
20919
  // ---------- Decision詳細 ----------
20634
20920
  function renderDetail(d) {
20921
+ lastWasDetail = true;
20635
20922
  app.innerHTML = "";
20636
20923
  countEl.textContent = "";
20637
20924
 
@@ -20639,6 +20926,11 @@ ${calendarAddUrl.toString()}
20639
20926
  back.addEventListener("click", function (ev) { ev.preventDefault(); go(null); });
20640
20927
  app.appendChild(back);
20641
20928
 
20929
+ // 前へ/次へ(#2)。一覧の今の絞り込み・並びの順。一覧を経ずに開いたときは既定の順(新しい順)
20930
+ var orderedD = pagerOrder(filteredDecisions(), visibleRecords(SITE.decisions), d.id);
20931
+ var detailPagerD = buildDetailPager(orderedD, d.id);
20932
+ if (detailPagerD) app.appendChild(detailPagerD);
20933
+
20642
20934
  var head = el("div", { class: "detail-head" });
20643
20935
  var headLeft = el("div", {}, [ el("h1", { class: "detail-title", text: humanTitle(d.title) }) ]);
20644
20936
  head.appendChild(headLeft);
@@ -20717,6 +21009,7 @@ ${calendarAddUrl.toString()}
20717
21009
  }
20718
21010
 
20719
21011
  function renderNotFound(id) {
21012
+ lastWasDetail = true;
20720
21013
  app.innerHTML = "";
20721
21014
  var back = el("a", { class: "detail-back", href: "?", text: "← 一覧へ戻る" });
20722
21015
  back.addEventListener("click", function (ev) { ev.preventDefault(); go(null); });
@@ -21201,7 +21494,24 @@ ${calendarAddUrl.toString()}
21201
21494
  }
21202
21495
  })();
21203
21496
 
21204
- window.addEventListener("popstate", route);
21497
+ // ブラウザの「戻る」「進む」。go() を通らないので、ここで一覧⇄詳細のスクロールだけ別に見る
21498
+ // (#1 ブラウザの戻るでも一覧のスクロール位置を保つ)
21499
+ window.addEventListener("popstate", function () {
21500
+ var goingToId = getParam("id");
21501
+ var cameFromDetail = lastWasDetail;
21502
+ // 「進む」で一覧→詳細に戻ることもある(戻る→スクロール→進む、で位置が古くなる実装レビュー③)。
21503
+ // go() と同じ条件で、そのときの一覧の位置とタブを覚え直す
21504
+ if (goingToId && !cameFromDetail) { savedListScrollY = window.scrollY; savedListTab = listState.tab; }
21505
+ route();
21506
+ if (!goingToId && cameFromDetail) {
21507
+ // 一覧へ戻る。離れたときと同じタブのときだけ元の位置へ(違えば先頭)
21508
+ window.scrollTo(0, listState.tab === savedListTab ? savedListScrollY : 0);
21509
+ } else if (goingToId) {
21510
+ // 詳細はブラウザの戻る/進むでも常に先頭から
21511
+ // (scrollRestoration を manual にしたので、ここで明示しないとブラウザがどこへも戻さない)
21512
+ window.scrollTo(0, 0);
21513
+ }
21514
+ });
21205
21515
  /**
21206
21516
  * ヘッダーの案件名(#379)。**ポータル配下(/p/<key>/ の下)で開いているときだけ**、
21207
21517
  * ポータルの案件一覧(同一オリジンの /)へ戻るリンクにする。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.106",
3
+ "version": "0.12.108",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,