@takagaki/cortex-decisions-viewer 0.4.75 → 0.4.77

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
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.readFleetSources = readFleetSources;
40
+ exports.findBacklogDir = findBacklogDir;
40
41
  exports.build = build;
41
42
  exports.createRefResolver = createRefResolver;
42
43
  const node_fs_1 = require("node:fs");
@@ -146,8 +147,31 @@ async function readFleetSources(repoRoot) {
146
147
  // 自動導出されたものは登録ファイルから消しても翌日また現れるので、
147
148
  // explicit のときだけ削除ボタンを出す。
148
149
  origin: s.origin != null ? String(s.origin) : undefined,
150
+ // どの Slack ワークスペースのチャンネルか(#104)。**列挙し忘れると静かに消える**
151
+ // (このマッピングは素通しではなく明示の許可リスト)。落とすと、同じIDの
152
+ // チャンネルを2つのワークスペースに持つ案件で Gold トグルが別の行を撃つ
153
+ workspace: s.workspace != null ? String(s.workspace) : undefined,
149
154
  }))
150
155
  : undefined;
156
+ // 接続済み Slack ワークスペース(#104)。**空配列と undefined を区別して通す**——
157
+ // 空配列は「まだ1つも繋いでいない」(接続の導線を出してよい)、undefined は
158
+ // 「この項目を知らない古い生成物」(何も言えないので出さない)。
159
+ // ここで `|| undefined` に潰すと、その2つが同じ表示になる
160
+ const slackWorkspaces = Array.isArray(obj.slackWorkspaces)
161
+ ? obj.slackWorkspaces
162
+ .filter((w) => w != null && typeof w === "object")
163
+ .map((w) => ({
164
+ teamId: String(w.teamId ?? ""),
165
+ // 表示名が無ければ team_id。**空欄にしない**(どれのことか分からない行になる)
166
+ name: String(w.name ?? "") || String(w.teamId ?? ""),
167
+ domain: w.domain != null ? String(w.domain) : undefined,
168
+ connectedAt: w.connectedAt != null ? String(w.connectedAt) : undefined,
169
+ channels: typeof w.channels === "number" ? w.channels : undefined,
170
+ // ok / missing / revoked / unknown。**潰さない**(次の一手が違う)
171
+ tokenState: w.tokenState != null ? String(w.tokenState) : undefined,
172
+ }))
173
+ .filter((w) => w.teamId !== "")
174
+ : undefined;
151
175
  // セットアップの充足チェック。**missing だけを通す。**
152
176
  // na(使わないと宣言した能力)を通すと「設定する必要が無いものに設定を促す」偽の警告になり、
153
177
  // #101 で潰した穴が復活する。unknown は AWS/gh へ到達できなかった回に出るので、
@@ -288,23 +312,42 @@ async function readFleetSources(repoRoot) {
288
312
  // **画面に現在値を出すために要る。** 「接続先を変更」フォームが入力欄だけだったため、
289
313
  // 実機で「何もセットされていない」と読まれ、同じURLが貼り直された(#127)。
290
314
  //
291
- // **domain / projectKey しか通さない。** 生成側は configPath / sections / strays /
292
- // features も持っているが、画面が使うのは接続先の2つだけ。素通しにすると、engine が
315
+ // **接続先そのものしか通さない。** 生成側は configPath / sections / strays /
316
+ // features も持っているが、画面が使うのは接続先だけ。素通しにすると、engine が
293
317
  // このオブジェクトに足したものが顧客の画面データへ黙って流れる(Lambda 側の
294
318
  // FLEET_PUBLIC_KEYS と同じ考え方で、こちらも許可リストにする)。
319
+ // 配列の要素も同じ2つに絞る——要素だけ素通しにすると、許可リストを通したつもりで
320
+ // 中身が漏れる(externalSources で実際にやった間違い)。
321
+ //
322
+ // **接続先は複数ありうる(#127)。** 1案件が複数の Backlog プロジェクトを同期できる
323
+ // ようになったので、`targets` があればそれを一覧の正本にする。**単数の2つは残す**
324
+ // (先頭と同じ値)——`targets` を載せない古いスナップショットでも現在値を出せるように、
325
+ // 読み手の両対応と同じ約束を画面データでも守る。
295
326
  //
296
327
  // **値が無い形は落とす。** 正本もミラーも持たない案件では `{ source: "secrets" }` だけが
297
328
  // 来る。空の器を通すと、画面は「接続先の情報がある」と判断して空文字を並べることになる
298
329
  const bt = obj.backlogTarget != null && typeof obj.backlogTarget === "object" && !Array.isArray(obj.backlogTarget)
299
330
  ? obj.backlogTarget
300
331
  : null;
301
- const backlogTarget = bt && typeof bt.domain === "string" && bt.domain && typeof bt.projectKey === "string" && bt.projectKey
302
- ? { domain: bt.domain, projectKey: bt.projectKey }
332
+ const oneTarget = (v) => {
333
+ if (v == null || typeof v !== "object")
334
+ return null;
335
+ const o = v;
336
+ return typeof o.domain === "string" && o.domain && typeof o.projectKey === "string" && o.projectKey
337
+ ? { domain: o.domain, projectKey: o.projectKey }
338
+ : null;
339
+ };
340
+ const btTargets = Array.isArray(bt?.targets)
341
+ ? bt.targets.map(oneTarget).filter((t) => t !== null)
342
+ : [];
343
+ const btHead = oneTarget(bt) ?? btTargets[0] ?? null;
344
+ const backlogTarget = btHead
345
+ ? { ...btHead, ...(btTargets.length ? { targets: btTargets } : {}) }
303
346
  : undefined;
304
347
  const secretSources = obj.secretSources && typeof obj.secretSources === "object"
305
348
  ? obj.secretSources
306
349
  : undefined;
307
- return { externalSources, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, checks, githubOwners, backlogTarget };
350
+ return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, checks, githubOwners, backlogTarget };
308
351
  }
309
352
  /** パスがディレクトリとして存在するか */
310
353
  async function isDirectory(dirAbs) {
@@ -482,9 +525,65 @@ async function findDirByMarker(repoRoot, marker, fallback) {
482
525
  }
483
526
  return fallback;
484
527
  }
528
+ /** exporter が作る区画名(engine の `BACKLOG_SECTIONS` と同じ固定の名前) */
529
+ const BACKLOG_SECTIONS = ["issues", "documents", "wiki"];
530
+ /**
531
+ * 課題管理の能力ディレクトリ(`<ここ>/issues` に課題ミラーが入る)を探す。
532
+ *
533
+ * **汎用の `findDirByMarker` では足りない。** `backlog-settings.json` は能力ディレクトリの
534
+ * 直下ではなく**区画の中**(`<課題管理>/issues/` 等)にあり、しかも接続先が複数になると
535
+ * さらに1階層落ちる(`<課題管理>/<キー>/{issues,…}/`・#127)。汎用版は最上位の
536
+ * ディレクトリ名しか返さないので、入れ子形の案件では `<課題管理>/issues` という
537
+ * 実在しないパスを見に行き、**課題キー→Backlog URL の表が空になる**
538
+ * (グラフの課題ノードが全部リポ内パス止まりになり、Backlog へ飛べない)。
539
+ *
540
+ * 規則の出どころは cortex-engine の `plugin/scripts/issues-config.mjs`(`findBacklogDir`)。
541
+ * **あちらを変えたらここも変えること**(`viewer/test/backlog-nested-layout.test.mjs` が見張る)。
542
+ *
543
+ * 浅い順(ルート → 深さ1 → 深さ2)に見て最初に当たったものを採る。深い側は後ろなので、
544
+ * いまフラット形で見つかっている案件の答えは1つも動かない。
545
+ */
546
+ async function findBacklogDir(repoRoot, fallback) {
547
+ const dirsIn = async (rel) => {
548
+ try {
549
+ return (await node_fs_1.promises.readdir(path.join(repoRoot, rel), { withFileTypes: true }))
550
+ .filter((e) => e.isDirectory() && e.name !== "node_modules" && !e.name.startsWith("."))
551
+ .map((e) => e.name)
552
+ .sort();
553
+ }
554
+ catch {
555
+ return [];
556
+ }
557
+ };
558
+ const hasMarker = async (rel) => {
559
+ for (const s of BACKLOG_SECTIONS) {
560
+ try {
561
+ await node_fs_1.promises.stat(path.join(repoRoot, rel, s, "backlog-settings.json"));
562
+ return true;
563
+ }
564
+ catch {
565
+ // 次の区画へ
566
+ }
567
+ }
568
+ return false;
569
+ };
570
+ if (await hasMarker(""))
571
+ return fallback; // ルート直下に区画がある形(`""` を返すと絶対パスになる)
572
+ const dirs = await dirsIn("");
573
+ for (const d of dirs)
574
+ if (await hasMarker(d))
575
+ return d;
576
+ for (const d of dirs) {
577
+ for (const sub of await dirsIn(d)) {
578
+ if (await hasMarker(`${d}/${sub}`))
579
+ return `${d}/${sub}`;
580
+ }
581
+ }
582
+ return fallback;
583
+ }
485
584
  async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
486
585
  const map = new Map();
487
- const issuesDir = await findDirByMarker(repoRoot, "backlog-settings.json", "課題管理");
586
+ const issuesDir = await findBacklogDir(repoRoot, "課題管理");
488
587
  const designDir = await findDirByMarker(repoRoot, "figma.json", "デザイン");
489
588
  const meetingDir = await findDirByMarker(repoRoot, "ingest-config.json", "会議");
490
589
  const materialsDir = await findDirByMarker(repoRoot, "materials-config.json", "共有資料");
@@ -1180,6 +1279,7 @@ async function build(opts) {
1180
1279
  .map((s) => s.trim())
1181
1280
  .filter(Boolean),
1182
1281
  externalSources: fleet.externalSources,
1282
+ slackWorkspaces: fleet.slackWorkspaces,
1183
1283
  fleetGeneratedAt: fleet.fleetGeneratedAt,
1184
1284
  secretSources: fleet.secretSources,
1185
1285
  internalSources: fleet.internalSources,
package/dist/render.js CHANGED
@@ -1250,6 +1250,16 @@ a.ds-name:hover { text-decoration: underline; }
1250
1250
  /* 登録の由来(#118)。**薄く出す**——読み取りの可否や Gold 対象かより弱い情報で、
1251
1251
  ここが目立つと「除外」「未宣言」の判断のバッジと同じ強さに見えてしまう */
1252
1252
  .badge.ds-origin { background: transparent; color: var(--muted); border: 1px dashed var(--border); }
1253
+ /* どの Slack ワークスペースのチャンネルか(#104)。**由来のバッジと同じ強さにする**——
1254
+ 「Gold 対象か」より弱い、どれのことかを見分けるための情報。強くすると判断のバッジと競る */
1255
+ .badge.ds-workspace { background: transparent; color: var(--muted); border: 1px solid var(--border); }
1256
+ /* 接続済み Slack ワークスペースの一覧(#104)。追加フォームの中に置く */
1257
+ .ds-ws-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 10px; }
1258
+ .ds-ws { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: 12.5px; }
1259
+ .ds-ws-name { color: var(--text); font-weight: 600; word-break: break-all; }
1260
+ .ds-ws-count { color: var(--muted); flex: 0 0 auto; }
1261
+ /* 1つも繋いでいない状態。**空白にしない**——一覧が出ていないのか繋がっていないのかが読めない */
1262
+ .ds-ws-empty { color: var(--muted); font-size: 12.5px; }
1253
1263
  /* 全組織が接続済みのときに、トークン入力欄の代わりに出す1行(#118) */
1254
1264
  .ds-all-connected { color: var(--muted); }
1255
1265
  .ds-gate { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600; flex: 0 0 auto; }
@@ -1294,6 +1304,12 @@ a.ds-name:hover { text-decoration: underline; }
1294
1304
  .ds-current-label { color: var(--muted); flex: 0 0 auto; }
1295
1305
  .ds-current-value { color: var(--text); font-weight: 600; word-break: break-all; }
1296
1306
  .ds-current-value.is-unset { color: var(--muted); font-weight: 400; }
1307
+ /* 接続先の一覧(#127)。**1件のときは従来どおりラベルの右に1行**——単数の案件で
1308
+ 見た目が変わると、複数対応と関係のない全案件に「何か変わった」と読ませる */
1309
+ .ds-target-list { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
1310
+ .ds-target-row { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
1311
+ /* 行の中のボタンは行に揃える(.ds-toggle の縦マージンは節と節の間隔なので効かせない) */
1312
+ .ds-target-row .ds-toggle-btn { flex: 0 0 auto; }
1297
1313
  /* 入力欄のすぐ下。貼る直前に読める位置に置く */
1298
1314
  .sec-note { color: var(--muted); font-size: 12px; line-height: 1.7; margin: 6px 0 0; }
1299
1315
  .sec-how p { margin: 0; }
@@ -2326,6 +2342,9 @@ ${capabilityState.toString()}
2326
2342
  // **追加フォームはグループの外に置く。** グループは0件だと描かれず、4件以上だと
2327
2343
  // 畳まれる(既定は閉)。中に入れると**新規案件(0件)=一番追加したい場面**で消える。
2328
2344
  var adders = renderExternalAdders();
2345
+ // **接続の入口はチャンネル追加より前に置く。** 顧客ワークスペースを繋いでいない状態で
2346
+ // 先に追加フォームを見せると、貼れるのに読まれないチャンネルを登録することになる
2347
+ pushCap("チャット", slackConnectBox());
2329
2348
  pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
2330
2349
  // **組織ごとの接続状況を、開発の一番上に置く。** トークンは組織ごとにしか
2331
2350
  // 効かない(Fine-grained PAT も GitHub App のインストールトークンも1組織分)ので、
@@ -2833,7 +2852,12 @@ ${capabilityState.toString()}
2833
2852
  "有効にしますか?",
2834
2853
  ],
2835
2854
  build: function (next) {
2836
- return { op: "set-channel-gold", ref: s.ref, value: next };
2855
+ // **どのワークスペースのチャンネルかを必ず送る(#104)。** 落とすと、サーバ側は
2856
+ // 既定ワークスペースの同じIDのエントリを探しに行き、「対象チャンネルが
2857
+ // 見つかりません」か、最悪**別のチャンネルを Gold に上げる**
2858
+ var payload = { op: "set-channel-gold", ref: s.ref, value: next };
2859
+ if (s.workspace) payload.workspace = s.workspace;
2860
+ return payload;
2837
2861
  },
2838
2862
  // 保存できた直後に、この行のバッジだけを「反映中」に差し替える。
2839
2863
  // 一覧そのものは触らない(結果メッセージや他行の状態を巻き込まないため)。
@@ -2842,8 +2866,9 @@ ${capabilityState.toString()}
2842
2866
  // 画面が出している値と一致するので待つものが無い。ここで消さないと、
2843
2867
  // 何も保留していないのに (反映中) が残り、リロードで消えて不可解に見える。
2844
2868
  var back = next === (s.gold === true);
2845
- if (back) clearPendingGold(s.ref);
2846
- else markPendingGold(s.ref, next);
2869
+ // 控えの鍵もワークスペース込み(#104。pendingKeyOf の doc)
2870
+ if (back) clearPendingGold(pendingKeyOf(s));
2871
+ else markPendingGold(pendingKeyOf(s), next);
2847
2872
  var card = toggleEl && toggleEl.parentNode;
2848
2873
  var badges = card && card.querySelector(".ds-badges");
2849
2874
  if (!badges) return;
@@ -2856,31 +2881,200 @@ ${capabilityState.toString()}
2856
2881
  }
2857
2882
 
2858
2883
  /**
2859
- * いま同期している Backlog の接続先(#127)。
2884
+ * いま同期している Backlog の接続先の一覧(#127)。**1件でも配列で返す。**
2860
2885
  *
2861
- * **2つの経路から引く。** 正本は fleet の backlogTarget(engine が正本
2862
- * issues-config.json ミラーの順で解決した値)だが、**このキーを載せているのは
2863
- * 新しい engine だけ**で、艦隊の多くは @v1 のまま持っていない。無いときは
2864
- * 内部ソースの url(同じ値から機械導出されたプロジェクトURL)を解析して補う。
2886
+ * **3つの経路から引く。** 正本は fleet の backlogTarget で、接続先が複数ある案件では
2887
+ * targets に全件が入る。targets を載せていないスナップショット(複数対応より前に
2888
+ * 生成されたもの)では単数の2つを1件として読み、それも無ければ内部ソースの url
2889
+ * (同じ値から機械導出されたプロジェクトURL)を解析して補う。
2865
2890
  * ここを backlogTarget だけにすると、**設定済みの案件に「未設定」と出す**
2866
- * ——今回直している誤読(何もセットされていないと読んで貼り直す)を、
2867
- * 画面の側からもう一度作ることになる。
2891
+ * ——直したい誤読(何もセットされていないと読んで貼り直す)を画面の側から作ることになる。
2868
2892
  *
2869
- * **解析できないときは黙らない。** 「未設定」と明示する。行ごと消すと、
2870
- * 「表示が無い=まだ何も無い」なのか「出し忘れ」なのかが読み手に区別できない。
2893
+ * **半端な要素は落とす。** ドメインかキーの片方しか無い接続先は行として読めない
2894
+ * (「cm1.backlog.jp / 」が並ぶ)。空になったら「未設定」に倒す。
2871
2895
  */
2872
- function currentBacklogTargetLine(s) {
2896
+ function backlogTargetsNow(s) {
2873
2897
  var t = fleetVal("backlogTarget");
2874
- if (!t || !t.domain || !t.projectKey) t = (s && s.url && backlogTargetOf(s.url)) || null;
2875
- var p = el("p", { class: "ds-current" });
2876
- p.appendChild(el("span", { class: "ds-current-label", text: "現在の接続先" }));
2877
- p.appendChild(
2878
- el("span", {
2879
- class: t ? "ds-current-value" : "ds-current-value is-unset",
2880
- text: t ? t.domain + " / " + t.projectKey : "未設定",
2881
- }),
2882
- );
2883
- return p;
2898
+ var raw = t && Array.isArray(t.targets) ? t.targets : t ? [t] : [];
2899
+ var out = [];
2900
+ for (var i = 0; i < raw.length; i++) {
2901
+ var x = raw[i];
2902
+ if (x && x.domain && x.projectKey) out.push({ domain: String(x.domain), projectKey: String(x.projectKey) });
2903
+ }
2904
+ if (out.length) return out;
2905
+ var u = (s && s.url && backlogTargetOf(s.url)) || null;
2906
+ return u ? [u] : [];
2907
+ }
2908
+
2909
+ /**
2910
+ * 接続先の一覧を1つの文字列にする(**サーバへ送る「いま画面が見ている世代」**・#127)。
2911
+ *
2912
+ * 追加・削除は一覧に対する操作なので、後勝ちで上書きすると**他の人が足した接続先が
2913
+ * 黙って消える**。サーバは受け取った世代といまの正本を突き合わせ、違えば 409 で断る。
2914
+ *
2915
+ * **並びと大小は問わない**(サーバ側の targetsGeneration と同じ畳み方にすること)。
2916
+ * 片方だけが大文字化していると、正しい操作が毎回 409 になって**再読み込みしても直らない**
2917
+ * ——食い違わないことは backlog-targets-ui.test.mjs が実物どうしで突き合わせている。
2918
+ *
2919
+ * **クロージャを持たない純関数に保つこと**(テストが配信物から切り出して直接呼ぶ)。
2920
+ */
2921
+ function targetsGenerationOf(list) {
2922
+ return (list || [])
2923
+ .filter(function (t) { return t && t.domain && t.projectKey; })
2924
+ .map(function (t) { return String(t.domain).toLowerCase() + "/" + String(t.projectKey).toUpperCase(); })
2925
+ .sort()
2926
+ .join(",");
2927
+ }
2928
+
2929
+ /**
2930
+ * 他の人が先に一覧を変えていたときの文面(#127)。
2931
+ *
2932
+ * **サーバの文面をそのまま出さない。** サーバは「画面を再読み込みして…」と案内するが、
2933
+ * 画面は応答に添えられた一覧でその場で追いついている。再読み込みを促すと、
2934
+ * 済んでいることをやらせる(そして再読み込みしても同じ表示になる=堂々巡りに見える)。
2935
+ */
2936
+ var TARGETS_RACE_NOTE =
2937
+ "他の人が先に接続先を変更しました。最新の一覧に更新しましたので、内容を確かめてもう一度お試しください。";
2938
+
2939
+ /**
2940
+ * 「課題管理」カードの接続先(一覧+追加+削除・#127)。
2941
+ *
2942
+ * **一覧は応答で描き直す。** 追加・削除の応答には必ずいまの一覧が添えられている
2943
+ * (成功も、重複で断られたときも、世代がずれていたときも)。夜間スナップショットの
2944
+ * 到着を待つと、押した直後は何も変わらないので「効いていない」と読まれ、もう一度
2945
+ * 押して「すでに登録されています」になる——他の追加フォームで実際に起きたこと。
2946
+ *
2947
+ * **結果の表示は一覧の外に置く。** 行の中に置くと、一覧を描き直した瞬間に
2948
+ * 押した結果が消える(Gold の先出しバッジで踏んだのと同じ罠)。
2949
+ */
2950
+ function backlogTargetsSection(s) {
2951
+ var targets = backlogTargetsNow(s);
2952
+ // 書き込み先(正本の置き場)が決まらない案件では操作を出さない。判定は呼び出し側と
2953
+ // 同じ s.url(同期ミラーが1つでもあるか)で、フォームを出さない条件と揃える
2954
+ var editable = !!(s && s.url);
2955
+
2956
+ var listEl = el("div", { class: "ds-target-list" });
2957
+ var current = el("div", { class: "ds-current" }, [
2958
+ el("span", { class: "ds-current-label", text: "現在の接続先" }),
2959
+ listEl,
2960
+ ]);
2961
+ // 追加・削除の結果はここに1つだけ出す(行を描き直しても消えない位置)
2962
+ var msg = el("div", { class: "ds-toggle-msg" });
2963
+
2964
+ function removeRow(t) {
2965
+ var row = el("div", { class: "ds-target-row" }, [
2966
+ el("span", { class: "ds-current-value", text: t.domain + " / " + t.projectKey }),
2967
+ ]);
2968
+ if (!editable) return row;
2969
+ var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: "この接続先を外す" });
2970
+ btn.addEventListener("click", function () {
2971
+ // **最後の1件かどうかは「押した時点」で数える。** 描画時の件数で決めると、
2972
+ // 2件を1件ずつ外したとき最後の1件で確認が出ない(Figma で踏んだのと同じ)
2973
+ var last = targets.length === 1;
2974
+ if (!confirmDanger(removeTargetConfirmLines(t, last))) { msg.textContent = ""; return; }
2975
+ var payload = {
2976
+ op: "backlog-target-remove",
2977
+ // **projectKey では送らない。** body の projectKey は案件キー(cortex の
2978
+ // 案件識別子)に予約されていて、トークンから解決した案件と食い違えば 403 で断られる
2979
+ backlogProjectKey: t.projectKey,
2980
+ baseGeneration: targetsGenerationOf(targets),
2981
+ };
2982
+ // **常には送らない。** 最後の1件の重さを利用者に確かめてもらった証がこのフラグで、
2983
+ // 無条件に付けると「一覧から1行消すだけ」の見た目で同期が止まる
2984
+ if (last) payload.confirmLast = true;
2985
+ sendConfig(payload, msg, btn, null, targetsHooks());
2986
+ });
2987
+ row.appendChild(btn);
2988
+ return row;
2989
+ }
2990
+
2991
+ function paint() {
2992
+ while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
2993
+ if (!targets.length) {
2994
+ // **行ごと消さない。** 表示が無いと「まだ何も無い」のか「出し忘れ」なのか区別できない
2995
+ listEl.appendChild(el("span", { class: "ds-current-value is-unset", text: "未設定" }));
2996
+ return;
2997
+ }
2998
+ for (var i = 0; i < targets.length; i++) listEl.appendChild(removeRow(targets[i]));
2999
+ }
3000
+
3001
+ /**
3002
+ * 応答に添えられた一覧を取り込む。**画面が見ていた集合と違っていたら true**
3003
+ * (=他の人が先に変えていた。409 の文面をこちらの言葉に差し替える判断に使う)。
3004
+ */
3005
+ function adoptTargets(list) {
3006
+ var before = targetsGenerationOf(targets);
3007
+ var next = [];
3008
+ for (var i = 0; i < list.length; i++) {
3009
+ var x = list[i];
3010
+ if (x && x.domain && x.projectKey) next.push({ domain: String(x.domain), projectKey: String(x.projectKey) });
3011
+ }
3012
+ targets = next;
3013
+ paint();
3014
+ return targetsGenerationOf(targets) !== before;
3015
+ }
3016
+
3017
+ function targetsHooks() {
3018
+ return { onTargets: adoptTargets, staleNote: TARGETS_RACE_NOTE };
3019
+ }
3020
+
3021
+ paint();
3022
+ var box = el("div", {}, [current]);
3023
+ if (editable) {
3024
+ box.appendChild(addForm({
3025
+ // **「追加」に戻す(#127)。** 1件しか持てなかった頃は「保存」(置き換え)だったが、
3026
+ // 接続先は本当に足せるようになった。押した結果は一覧の行として出る
3027
+ title: "+ 接続先を追加",
3028
+ placeholder: "https://<スペース>.backlog.jp/projects/<プロジェクトキー>",
3029
+ requiredNote: "BacklogのURLを入力してください(プロジェクトキーが入った形。数字だけのURLは受け付けません)。",
3030
+ hint: "Backlog でこの案件のプロジェクトを開き、ブラウザのURLをそのまま貼ってください(課題の画面のURLでも構いません)。APIキーは下の「Backlog のAPIキー」から別に登録します。",
3031
+ confirmLines: function (v) {
3032
+ // **解析した結果を見せる。** 貼ったURLをそのまま出すと、どのプロジェクトを
3033
+ // 指すことになるかが確定前に分からない。実際 /projects/PJ-CORTEX は
3034
+ // ハイフンで切れて PJ と解釈される(黙って別プロジェクトを指す)
3035
+ var t = backlogTargetOf(v);
3036
+ return [
3037
+ "この案件が同期する Backlog プロジェクトを追加します。",
3038
+ "",
3039
+ t ? t.domain + " / " + t.projectKey : v,
3040
+ "",
3041
+ "このプロジェクトの課題・ドキュメント・Wiki が、次の同期から取り込まれます。",
3042
+ "貼ったURLがこの案件のものか、もう一度確認してください。",
3043
+ "",
3044
+ "※ 増えるのは同期先だけです。ビューアからの起票先は変わりません。リアルタイム同期を使う場合は、追加したプロジェクトの Backlog 側にも案件専用URLの登録が要ります。",
3045
+ "",
3046
+ "追加しますか?",
3047
+ ];
3048
+ },
3049
+ build: function (url) {
3050
+ return { op: "backlog-target-add", url: url, baseGeneration: targetsGenerationOf(targets) };
3051
+ },
3052
+ // 応答の一覧で本物の行が増えるので、フォーム側の控え(「追加済み・反映中」)は出さない
3053
+ hooks: targetsHooks,
3054
+ }));
3055
+ }
3056
+ box.appendChild(msg);
3057
+ return box;
3058
+ }
3059
+
3060
+ /** 接続先を外すときの確認。**最後の1件は、止まるものを名指しで伝える** */
3061
+ function removeTargetConfirmLines(t, last) {
3062
+ var lines = [
3063
+ "「" + t.domain + " / " + t.projectKey + "」を同期の対象から外します。",
3064
+ "",
3065
+ ];
3066
+ if (last) {
3067
+ // 一覧から1行消す見た目と、実際に起きることの重さが釣り合わないので必ず言う
3068
+ lines.push("これが最後の接続先です。この接続先を外すと課題の同期が止まります。よろしいですか?");
3069
+ } else {
3070
+ lines.push(
3071
+ "次回の同期から、このプロジェクトの課題・ドキュメント・Wiki は取り込まれなくなります。",
3072
+ "取り込み済みの内容はリポジトリに残ります(不要なら手で削除してください)。",
3073
+ "",
3074
+ "外しますか?",
3075
+ );
3076
+ }
3077
+ return lines;
2884
3078
  }
2885
3079
 
2886
3080
  /**
@@ -2950,46 +3144,21 @@ ${capabilityState.toString()}
2950
3144
  // 画面のどこにも出ていなかった。保存直後に出る楽観的なチップはリロードで消えるので、
2951
3145
  // **利用者は「何もセットされていない」と読み、同じURLを貼り直した**。
2952
3146
  // 設定を変える口の隣には、いまの値が必ず要る。
2953
- box.appendChild(currentBacklogTargetLine(s));
2954
- // **設定ファイルが無い案件にはフォームを出さない。** 書き込み先が無いので
2955
- // 貼っても 404 で終わる。三菱電機は tools が backlog なのに
3147
+ // **設定ファイルが無い案件には追加・削除を出さない。** 書き込み先が無いので
3148
+ // 押しても 404 で終わる。三菱電機は tools が backlog なのに
2956
3149
  // backlog-settings.json が1つも無い(固定IP経由の同期で、UIからの投入は
2957
3150
  // 鍵と同じく対応していない)。会議・共有資料は unset / broken を文言で
2958
- // 伝えてフォームを出さない形なので、課題管理だけ不揃いだった
3151
+ // 伝えてフォームを出さない形なので、課題管理だけ不揃いだった。
3152
+ // 一覧そのもの(現在の接続先)は**そういう案件にも出す**——読めているものを隠さない
2959
3153
  //
2960
3154
  // **接続先の正本は issues-config.json(#111)**だが、その置き場は
2961
3155
  // 同期ミラー(backlog-settings.json)の位置から決めている。ミラーが1つも
2962
3156
  // 無い案件では正本をどこへ置けばよいかも決められないので、条件は変わらない
3157
+ box.appendChild(backlogTargetsSection(s));
2963
3158
  if (!s.url) {
2964
3159
  box.appendChild(el("p", { class: "sec-how", text: "この案件はまだ一度も Backlog と同期していないため、この画面からは接続先を設定できません。先に初回の同期を実行するか、cortex の担当者にご連絡ください。" }));
2965
3160
  used = true;
2966
- } else box.appendChild(addForm({
2967
- title: "接続先を変更",
2968
- // **「追加」ではなく「保存」。** 接続先は1件しか持てない置き換えで、
2969
- // 「追加」だと2つ目を足せると読める(実機テストでの指摘・複数対応は #127)
2970
- submitLabel: "保存",
2971
- placeholder: "https://<スペース>.backlog.jp/projects/<プロジェクトキー>",
2972
- requiredNote: "BacklogのURLを入力してください(プロジェクトキーが入った形。数字だけのURLは受け付けません)。",
2973
- hint: "Backlog でこの案件のプロジェクトを開き、ブラウザのURLをそのまま貼ってください(課題の画面のURLでも構いません)。APIキーは下の「Backlog のAPIキー」から別に登録します。",
2974
- confirmLines: function (v) {
2975
- // **解析した結果を見せる。** 貼ったURLをそのまま出すと、どのプロジェクトを
2976
- // 指すことになるかが確定前に分からない。実際 /projects/PJ-CORTEX は
2977
- // ハイフンで切れて PJ と解釈される(黙って別プロジェクトを指す)
2978
- var t = backlogTargetOf(v);
2979
- return [
2980
- "この案件が同期する Backlog プロジェクトを設定します。",
2981
- "",
2982
- "別のプロジェクトを指すと、次の同期で別案件の課題が入ります。",
2983
- "",
2984
- t ? t.domain + " / " + t.projectKey : v,
2985
- "",
2986
- "※ 変わるのは同期先だけです。ビューアからの起票先と Webhook の配送先は別途の対応が要ります。",
2987
- "",
2988
- "設定しますか?",
2989
- ];
2990
- },
2991
- build: function (url) { return { op: "set-backlog-target", url: url }; },
2992
- }));
3161
+ }
2993
3162
  // **接続先の下に置く。** リアルタイム同期は「どのプロジェクトを見るか」が
2994
3163
  // 決まった後の話で、順序が逆だと、接続先を設定していない案件が先に
2995
3164
  // Webhook を貼りに行く(貼っても届かない)
@@ -3167,6 +3336,115 @@ ${capabilityState.toString()}
3167
3336
  return wrap;
3168
3337
  }
3169
3338
 
3339
+ /* ---------------------------------------------------------------------------
3340
+ * Slack ワークスペースの接続(#104)
3341
+ *
3342
+ * 顧客のワークスペースを読み始める日の入口。押す → サーバが署名つきの承認リンクを
3343
+ * 作って返す → そこへ遷移 → 顧客が Slack で承認 → 別の Lambda が受け取って棚に鍵を入れる。
3344
+ *
3345
+ * **リンクを静的に焼き込まない。** 承認リンクには「この案件へ書き込んでよい」という
3346
+ * 10分間有効な署名(state)が載っている。HTML に埋めると、配信物を読める人(=その案件の
3347
+ * Basic 認証を通れる人)が期限のないリンクを手に入れることになるし、
3348
+ * ビルド時点の state はどのみち期限切れで使えない。押した瞬間にサーバから貰う。
3349
+ *
3350
+ * **「トークン」という語を出さない。** 顧客に見せる画面なので、内部語ではなく
3351
+ * 「接続する/読み取りたいチャンネルに招待する」で通す(内部語排除の原則の資格情報版)。
3352
+ * ------------------------------------------------------------------------- */
3353
+ function slackConnectBox() {
3354
+ if (!SITE.intake) return null;
3355
+ // **この項目を知らない古い fleet-status では何も出さない。** 「0件」と「知らない」を
3356
+ // 同じ扱いにすると、engine が @v1 で止まっている案件に「まだ接続されていません」と
3357
+ // 嘘を出すことになる(実際には社内ワークスペースで動いている)
3358
+ var list = slackWorkspaces();
3359
+ if (!list) return null;
3360
+
3361
+ var box = el("div", { class: "ds-addform", "data-adder": "slack-workspace" }, [
3362
+ el("div", { class: "ds-addform-title", text: "Slack ワークスペースを接続" }),
3363
+ el("p", {
3364
+ class: "sec-how",
3365
+ text:
3366
+ "お客様の Slack ワークスペースのチャンネルを読み取り対象にするための接続です。" +
3367
+ "ボタンを押すと Slack の承認画面が開きます(承認するのはワークスペースの管理者です)。" +
3368
+ "お願いする権限は読み取りだけで、Cortex から投稿することはありません。" +
3369
+ "接続したあと、読み取りたいチャンネルにボットを招待し、この画面から追加してください。",
3370
+ }),
3371
+ ]);
3372
+
3373
+ // 接続済みの一覧。**0件でも「まだありません」と書く**——空白だと、
3374
+ // 一覧が出ていないのか繋がっていないのかが読めない
3375
+ var listBox = el("div", { class: "ds-ws-list" });
3376
+ if (!list.length) {
3377
+ listBox.appendChild(el("div", { class: "ds-ws-empty", text: "接続済みのワークスペースはまだありません。" }));
3378
+ } else {
3379
+ list.forEach(function (w) {
3380
+ var row = el("div", { class: "ds-ws" }, [
3381
+ el("span", { class: "ds-ws-name", text: w.name || w.teamId }),
3382
+ el("span", {
3383
+ class: "ds-ws-count",
3384
+ text: (w.channels || 0) + "チャンネル",
3385
+ }),
3386
+ ]);
3387
+ // **revoked と missing を分けて出す。** 前者は「接続し直してください」、
3388
+ // 後者は「まだ接続が完了していない」で、次の一手が違う。
3389
+ // unknown(確かめられなかった回)には何も出さない——繋がっているのに
3390
+ // 「接続し直して」と言う方が、黙っているより悪い。
3391
+ // 注: ここは埋め込みJSのテンプレートリテラル内。**コメントでもバッククォートを使わない**
3392
+ if (w.tokenState === "revoked") {
3393
+ row.appendChild(el("span", {
3394
+ class: "ds-gate ds-gate-warn",
3395
+ text: "⚠️ 接続が切れています",
3396
+ title: "Slack 側で Cortex のアプリが削除されたか、接続が無効になっています。もう一度接続してください。",
3397
+ }));
3398
+ } else if (w.tokenState === "missing") {
3399
+ row.appendChild(el("span", {
3400
+ class: "ds-gate ds-gate-warn",
3401
+ text: "⚠️ 未接続",
3402
+ title: "このワークスペースのチャンネルが登録されていますが、接続が見つかりません。もう一度接続してください。",
3403
+ }));
3404
+ } else if (w.tokenState === "ok") {
3405
+ row.appendChild(el("span", { class: "ds-gate ds-gate-ok", text: "● 接続OK" }));
3406
+ }
3407
+ listBox.appendChild(row);
3408
+ });
3409
+ }
3410
+ box.appendChild(listBox);
3411
+
3412
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "Slack ワークスペースを接続" });
3413
+ var msg = el("div", { class: "ds-toggle-msg" });
3414
+ btn.addEventListener("click", async function () {
3415
+ var intake = SITE.intake;
3416
+ if (!intake) return;
3417
+ btn.disabled = true;
3418
+ msg.className = "ds-toggle-msg";
3419
+ msg.textContent = "承認画面を準備しています…";
3420
+ try {
3421
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
3422
+ var res = await fetch(pfx + "op=slack-oauth-start&t=" + encodeURIComponent(intake.token), {
3423
+ method: "POST",
3424
+ headers: { "content-type": "application/json" },
3425
+ body: "{}",
3426
+ });
3427
+ var j = await res.json().catch(function () { return {}; });
3428
+ if (!res.ok || !j.url) {
3429
+ // サーバの文言をそのまま出す(未設定のときは「準備中です」が返る)
3430
+ msg.className = "ds-toggle-msg is-error";
3431
+ msg.textContent = "✗ " + (j.message || "接続を開始できませんでした。");
3432
+ btn.disabled = false;
3433
+ return;
3434
+ }
3435
+ // **同じタブで遷移する。** 別タブだと、承認後の「接続しました」を見た人が
3436
+ // 元のタブに戻る動線を自分で見つけることになる
3437
+ location.href = j.url;
3438
+ } catch (e) {
3439
+ msg.className = "ds-toggle-msg is-error";
3440
+ msg.textContent = "✗ 接続を開始できませんでした。時間をおいて試してください。";
3441
+ btn.disabled = false;
3442
+ }
3443
+ });
3444
+ box.appendChild(el("div", { class: "ds-toggle" }, [btn, msg]));
3445
+ return box;
3446
+ }
3447
+
3170
3448
  /**
3171
3449
  * 外部ソースの追加フォーム(Slack・GitHub)。
3172
3450
  *
@@ -3212,7 +3490,14 @@ ${capabilityState.toString()}
3212
3490
  "追加しますか?",
3213
3491
  ];
3214
3492
  },
3215
- build: function (url, name) { return { op: "add-channel", url: url, name: name }; },
3493
+ // **選んだワークスペースを送る(#104)。** 空文字=社内ワークスペースなのでキーごと落とす
3494
+ // (サーバ側は「未宣言・null・空」を区別しない約束で、無いことで既定を表す)
3495
+ build: function (url, name, workspace) {
3496
+ var payload = { op: "add-channel", url: url, name: name };
3497
+ if (workspace) payload.workspace = workspace;
3498
+ return payload;
3499
+ },
3500
+ workspacePicker: true,
3216
3501
  adder: "slack",
3217
3502
  }));
3218
3503
 
@@ -3256,8 +3541,15 @@ ${capabilityState.toString()}
3256
3541
  * 押した内容を「(反映中)」として先に出す。出してよいのは (1) 保存の成否がサーバで
3257
3542
  * 確定していて推測が入らない (2) **確定した表示と見た目で区別できる**、の両方を満たす
3258
3543
  * ときだけ。区別しないと、リロードで消えたときに「戻された」と読まれる。
3544
+ *
3545
+ * **もう1つの例外が接続先の一覧(#127)。** 追加・削除の応答にはいまの一覧が必ず
3546
+ * 添えられていて(成功も、重複で断られたときも、世代がずれていたときも)、これは
3547
+ * スナップショットではなく**サーバが書いた直後の確定値**なので、その場で描き直してよい。
3548
+ * @param hooks { onTargets(list) → 画面が見ていた集合と違ったか, staleNote }
3549
+ * onTargets … 応答に添えられた一覧を画面へ取り込む。**成否を問わず先に呼ぶ**——
3550
+ * 断られたときこそ、画面を正しい状態へ追いつかせないと堂々巡りになる
3259
3551
  */
3260
- async function sendConfig(payload, msg, btn, onDone) {
3552
+ async function sendConfig(payload, msg, btn, onDone, hooks) {
3261
3553
  var intake = SITE.intake;
3262
3554
  if (!intake) return;
3263
3555
  var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
@@ -3271,10 +3563,14 @@ ${capabilityState.toString()}
3271
3563
  body: JSON.stringify(payload),
3272
3564
  });
3273
3565
  var j = await res.json().catch(function () { return {}; });
3566
+ // 一覧が添えられていれば、断られていても先に画面を追いつかせる(#127)
3567
+ var raced = hooks && hooks.onTargets && Array.isArray(j.targets) ? hooks.onTargets(j.targets) : false;
3274
3568
  if (!res.ok) {
3275
- // サーバの文言をそのまま出す(貼り方の案内・取り違えの警告を含んでいる)
3569
+ // サーバの文言をそのまま出す(貼り方の案内・取り違えの警告を含んでいる)。
3570
+ // **例外は「他の人が先に変えていた」だけ**——サーバは再読み込みを案内するが、
3571
+ // 画面は既に追いついているので、済んでいることをやらせる案内になる(#127)
3276
3572
  msg.className = "ds-toggle-msg is-error";
3277
- msg.textContent = "✗ " + (j.message || "保存できませんでした。");
3573
+ msg.textContent = "✗ " + ((res.status === 409 && raced && hooks.staleNote) || j.message || "保存できませんでした。");
3278
3574
  btn.disabled = false;
3279
3575
  return;
3280
3576
  }
@@ -3385,18 +3681,38 @@ ${capabilityState.toString()}
3385
3681
  /**
3386
3682
  * 「+…を追加」のフォーム。URLをそのまま貼れるようにする(IDだけを要求すると転記ミスが増える)。
3387
3683
  *
3388
- * **ボタンの語は呼び出し側が決める(既定は「追加」)。** 課題管理の「接続先を変更」だけは
3389
- * 1件しか持てない設定の**置き換え**なのに、ボタンが「追加」だと**複数登録できる**と読める
3390
- * (実機テストでの指摘。複数接続先の対応は #127 の将来要件で、いまは持てない)。
3391
- * 押した結果の表示(+/追加済み)も同じ理由で語を揃える。
3684
+ * **ボタンの語は呼び出し側が決める(既定は「追加」)。** 置き換えの設定に「追加」と書くと
3685
+ * 2つ目を足せると読めるため、そういう口だけ submitLabel で語を変える。押した結果の
3686
+ * 表示(+/追加済み)も同じ理由で語を揃える。
3392
3687
  *
3393
- * @param opts { title, placeholder, hint, needsName, confirmLines, build(values), submitLabel }
3688
+ * @param opts { title, placeholder, hint, needsName, confirmLines, build(values), submitLabel, hooks }
3689
+ * hooks … sendConfig へ渡す追い付き処理を返す関数(接続先の一覧・#127)。
3690
+ * **これがある口では、その場の控え(「追加済み・反映中」)を出さない**——
3691
+ * 応答で本物の一覧が増えるので、控えを併記すると同じものが2回出る
3394
3692
  */
3395
3693
  function addForm(opts) {
3396
3694
  var main = el("input", { class: "sec-input", type: "text", placeholder: opts.placeholder, autocomplete: "off" });
3397
3695
  var name = opts.needsName
3398
3696
  ? el("input", { class: "sec-input", type: "text", placeholder: opts.namePlaceholder || "表示名", autocomplete: "off" })
3399
3697
  : null;
3698
+ // 接続先ワークスペースの選択(#104・Slackの追加だけ)。
3699
+ //
3700
+ // **接続済みのワークスペースが1つも無ければ出さない。** 選択肢が「社内」1つだけの
3701
+ // プルダウンは、選ぶものが無いのに1手増やすだけ。既存の全案件がこの状態から始まる。
3702
+ //
3703
+ // **チャンネルのURLから推測しない。** URL のホスト名(acme.slack.com)は
3704
+ // ワークスペースの改名で変わるし、共有チャンネルでは貼った人のホストになる。
3705
+ // 推測して間違えると「追加できたのに読まれない」チャンネルができ、
3706
+ // 翌朝の接続状況で初めて分かる。人に選ばせる。
3707
+ var wsList = opts.workspacePicker ? slackWorkspaces() || [] : [];
3708
+ var wsSelect = null;
3709
+ if (wsList.length) {
3710
+ wsSelect = el("select", { class: "sec-input" });
3711
+ wsSelect.appendChild(el("option", { value: "", text: "社内ワークスペース(クラスメソッド)" }));
3712
+ wsList.forEach(function (w) {
3713
+ wsSelect.appendChild(el("option", { value: w.teamId, text: w.name || w.teamId }));
3714
+ });
3715
+ }
3400
3716
  var submitLabel = opts.submitLabel || "追加";
3401
3717
  var btn = el("button", { class: "ds-toggle-btn", type: "button", text: submitLabel });
3402
3718
  var msg = el("div", { class: "ds-toggle-msg" });
@@ -3415,11 +3731,13 @@ ${capabilityState.toString()}
3415
3731
  if (opts.confirmLines && !confirmDanger(opts.confirmLines(main.value.trim()))) { msg.textContent = ""; return; }
3416
3732
  var added = main.value.trim();
3417
3733
  var addedName = name ? name.value.trim() : "";
3418
- var payload = opts.build(added, addedName);
3734
+ var payload = opts.build(added, addedName, wsSelect ? wsSelect.value : "");
3419
3735
  sendConfig(payload, msg, btn, function () {
3420
3736
  // 追加できたら入力欄を空にする(同じものを二度押ししないように)
3421
3737
  main.value = "";
3422
3738
  if (name) name.value = "";
3739
+ // 応答で本物の一覧が描き直される口では、控えを出さない(同じものが2回並ぶ)
3740
+ if (opts.hooks) return;
3423
3741
  // **追加したものをその場に出す。** 上の一覧は fleet-status.json(夜間スナップショット)
3424
3742
  // から描かれているので、追加してもその場では変わらない。何も変わらないと
3425
3743
  // 「追加できていない」と読まれ、もう一度押して「既に登録されています」になる
@@ -3431,7 +3749,7 @@ ${capabilityState.toString()}
3431
3749
  el("span", { class: "ds-pending-note", text: submitLabel + "済み・反映中" }),
3432
3750
  ]),
3433
3751
  );
3434
- });
3752
+ }, opts.hooks ? opts.hooks() : null);
3435
3753
  });
3436
3754
 
3437
3755
  // このセッションで追加したものを並べる場所(リロードすると消える=正式な一覧は上に出る)
@@ -3442,6 +3760,7 @@ ${capabilityState.toString()}
3442
3760
  opts.hint ? el("p", { class: "sec-how", text: opts.hint }) : null,
3443
3761
  main,
3444
3762
  name,
3763
+ wsSelect,
3445
3764
  el("div", { class: "ds-toggle" }, [btn, msg]),
3446
3765
  pending,
3447
3766
  ]);
@@ -3519,18 +3838,27 @@ ${capabilityState.toString()}
3519
3838
  function writePending(map) {
3520
3839
  try { sessionStorage.setItem(PENDING_KEY, JSON.stringify(map)); } catch (_) {}
3521
3840
  }
3522
- function clearPendingGold(ref) {
3523
- if (!ref) return;
3841
+ function clearPendingGold(key) {
3842
+ if (!key) return;
3524
3843
  var map = readPending();
3525
- if (!(ref in map)) return;
3526
- delete map[ref];
3844
+ if (!(key in map)) return;
3845
+ delete map[key];
3527
3846
  writePending(map);
3528
3847
  }
3848
+ /**
3849
+ * 控えの鍵。**チャンネルIDだけでは足りない(#104)。**
3850
+ * IDはワークスペースの中でしか一意でないので、同じIDのチャンネルを2つのワークスペースに
3851
+ * 持つ案件では、片方で押した「反映中」が**もう片方の行にも出る**。
3852
+ */
3853
+ function pendingKeyOf(s) {
3854
+ if (!s || !s.ref) return "";
3855
+ return s.workspace ? s.workspace + "\t" + s.ref : s.ref;
3856
+ }
3529
3857
  /** 押した内容を控える。fleet-status が追いつくまでの間だけ効く */
3530
- function markPendingGold(ref, value) {
3531
- if (!ref) return;
3858
+ function markPendingGold(key, value) {
3859
+ if (!key) return;
3532
3860
  var map = readPending();
3533
- map[ref] = { value: value === true, at: Date.now() };
3861
+ map[key] = { value: value === true, at: Date.now() };
3534
3862
  writePending(map);
3535
3863
  }
3536
3864
  /**
@@ -3538,9 +3866,10 @@ ${capabilityState.toString()}
3538
3866
  * 捨てないと、正しくなった後も (反映中) が残り続ける。
3539
3867
  */
3540
3868
  function pendingGoldFor(s) {
3541
- if (!s || !s.ref) return null;
3869
+ var key = pendingKeyOf(s);
3870
+ if (!key) return null;
3542
3871
  var map = readPending();
3543
- var e = map[s.ref];
3872
+ var e = map[key];
3544
3873
  if (!e) return null;
3545
3874
  // **「値が一致したら捨てる」だけでは足りない。** 別の人が直後に逆を押して、
3546
3875
  // その結果で追いついた場合、値が一致しないので控えが残り、TTL切れまで
@@ -3573,6 +3902,26 @@ ${capabilityState.toString()}
3573
3902
  * **「登録から削除」が出てはいけない行に出て**、押しても翌日また現れる。
3574
3903
  * 全案件の再生成が回りきったら、github 側のこの分岐を落とす。
3575
3904
  */
3905
+ /**
3906
+ * 接続済み Slack ワークスペース(#104)。**空配列と undefined を区別する**——
3907
+ * 空配列は「まだ1つも繋いでいない」(接続の導線を出してよい)、undefined は
3908
+ * 「この項目を知らない古い fleet-status」(何も言えないので導線も一覧も出さない)。
3909
+ * ライブ(op=state)があればそちら、無ければ静的ビルドの値。
3910
+ */
3911
+ function slackWorkspaces() {
3912
+ var v = fleetVal("slackWorkspaces");
3913
+ return Array.isArray(v) ? v : null;
3914
+ }
3915
+ /** team_id → 表示名。宣言はあるが一覧に無いワークスペースは team_id のまま出す */
3916
+ function workspaceLabel(teamId) {
3917
+ if (!teamId) return "";
3918
+ var list = slackWorkspaces() || [];
3919
+ for (var i = 0; i < list.length; i++) {
3920
+ if (list[i].teamId === teamId) return list[i].name || teamId;
3921
+ }
3922
+ return teamId;
3923
+ }
3924
+
3576
3925
  var ORIGIN_LABEL = { explicit: "明示登録", derived: "自動検出", both: "明示登録+自動検出" };
3577
3926
  var ORIGIN_HELP = {
3578
3927
  explicit: "開発の登録ファイル(dev-sources.json)に登録されているリポジトリです。ここから登録を削除できます。",
@@ -3593,6 +3942,16 @@ ${capabilityState.toString()}
3593
3942
  else if (s.goldState === "undeclared") badges.appendChild(el("span", { class: "badge ds-undeclared", text: "未宣言(対象外)" }));
3594
3943
  else if (s.gold === false) badges.appendChild(el("span", { class: "badge ds-excluded", text: "除外" }));
3595
3944
  if (s.notify === true) badges.appendChild(el("span", { class: "badge ds-notify", text: "通知先" }));
3945
+ // **どのワークスペースのチャンネルかを出す(#104)。** 同じチャンネル名が
3946
+ // 2つのワークスペースに並びうるので、これが無いと「どっちの #general か」が読めない。
3947
+ // 宣言なし(社内ワークスペース)には何も出さない——これまでどおりの見た目を保つ
3948
+ if (s.type === "slack" && s.workspace) {
3949
+ badges.appendChild(el("span", {
3950
+ class: "badge ds-workspace",
3951
+ text: workspaceLabel(s.workspace),
3952
+ title: "接続した Slack ワークスペースのチャンネルです(読み取り専用の接続なので、通知は届きません)。",
3953
+ }));
3954
+ }
3596
3955
  // 登録の由来(#118)。**「登録から削除」が出る/出ないの理由がこれ。** 導出されたリポは
3597
3956
  // 登録ファイルから消しても翌日また現れるので削除を出していないが、画面には
3598
3957
  // 「同じ見た目の行なのに、片方にだけ削除がある」としか映らず、毎回考え直すことになる。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.75",
3
+ "version": "0.4.77",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,