@takagaki/cortex-decisions-viewer 0.12.29 → 0.12.31
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/google-chat-guide-images.js +33 -0
- package/dist/meet-guide-images.js +6 -1
- package/dist/pending-ops.js +874 -0
- package/dist/render.js +398 -93
- package/package.json +1 -1
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// 操作タブの仮置き(pending ops・#388)
|
|
4
|
+
//
|
|
5
|
+
// 「ビルド待ちの操作はすべて即時反映」(決定ログ 2026-09-03)を、操作タブの全操作に
|
|
6
|
+
// 共通の 1 部品で当てる。**能力ごとに個別の仕組みを持たない**(Backlog だけ直して
|
|
7
|
+
// Figma に残した前例を繰り返さない)。
|
|
8
|
+
//
|
|
9
|
+
// 流れ: 保存が成功 → `pendingOpOf` でエントリを作り localStorage に置く →
|
|
10
|
+
// 描画は「本物の設定(4層合成)+仮置き」を `applyPendingOps` で合成して出す →
|
|
11
|
+
// 本物が追いついたら `prunePendingOps` が捨てる。
|
|
12
|
+
//
|
|
13
|
+
// **ここは純関数と定数だけを置く**(render.ts 冒頭の規約と同じ)。画面側(クライアントJS)は
|
|
14
|
+
// toString() で実体を流し込んで使い、テストは同じ実体を直接呼ぶ。
|
|
15
|
+
// **クロージャを持たないこと。** 参照してよいのは、クライアント側にも同名で注入している
|
|
16
|
+
// 定数(PENDING_OPS_* と PENDING_KINDS)だけ。
|
|
17
|
+
// ⚠ 定数そのものを export しない(CommonJS で `exports.X` に書き換わり、流し込んだ先で落ちる)。
|
|
18
|
+
// テストから読む値は下の PENDING_OPS_LIMITS(入れ物)経由。
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.PENDING_OPS_LIMITS = void 0;
|
|
22
|
+
exports.pendingKindsTable = pendingKindsTable;
|
|
23
|
+
exports.pendingOpsKey = pendingOpsKey;
|
|
24
|
+
exports.pendingSlackChannelId = pendingSlackChannelId;
|
|
25
|
+
exports.pendingIsSpaceRef = pendingIsSpaceRef;
|
|
26
|
+
exports.pendingGoogleChatSpaceRef = pendingGoogleChatSpaceRef;
|
|
27
|
+
exports.pendingRepo = pendingRepo;
|
|
28
|
+
exports.pendingFigmaKey = pendingFigmaKey;
|
|
29
|
+
exports.pendingDriveFolderId = pendingDriveFolderId;
|
|
30
|
+
exports.backlogTargetOf = backlogTargetOf;
|
|
31
|
+
exports.pendingIsSlackTeamId = pendingIsSlackTeamId;
|
|
32
|
+
exports.pendingIssuesRef = pendingIssuesRef;
|
|
33
|
+
exports.pendingLabel = pendingLabel;
|
|
34
|
+
exports.pendingOpOf = pendingOpOf;
|
|
35
|
+
exports.sanitizePendingOps = sanitizePendingOps;
|
|
36
|
+
exports.rememberPendingOp = rememberPendingOp;
|
|
37
|
+
exports.pendingSiteTimeMs = pendingSiteTimeMs;
|
|
38
|
+
exports.pendingStale = pendingStale;
|
|
39
|
+
exports.pendingOpBuildCaughtUp = pendingOpBuildCaughtUp;
|
|
40
|
+
exports.pendingLiveRowMatches = pendingLiveRowMatches;
|
|
41
|
+
exports.pendingOpLiveAgrees = pendingOpLiveAgrees;
|
|
42
|
+
exports.pendingCountDelta = pendingCountDelta;
|
|
43
|
+
exports.prunePendingOps = prunePendingOps;
|
|
44
|
+
exports.pendingExternalMatches = pendingExternalMatches;
|
|
45
|
+
exports.pendingExternalRow = pendingExternalRow;
|
|
46
|
+
exports.pendingInternalOf = pendingInternalOf;
|
|
47
|
+
exports.applyPendingOps = applyPendingOps;
|
|
48
|
+
exports.pendingCapsOf = pendingCapsOf;
|
|
49
|
+
exports.pendingRowNote = pendingRowNote;
|
|
50
|
+
/** 念のための自動失効。本物が追いついたことを検出できなくても、これ以上は残さない */
|
|
51
|
+
const PENDING_OPS_TTL_MS = 24 * 60 * 60 * 1000;
|
|
52
|
+
/**
|
|
53
|
+
* 「保存より後に始まったビルドか」を判断するときの余裕。
|
|
54
|
+
* **トゥームストーン(TOMBSTONE_MARGIN_MS)・アイコンの仮置きと同じ 30 分**——
|
|
55
|
+
* 同じ性質の待ちなので、破棄の条件を別に持つと片方だけ直したときに挙動が割れる。
|
|
56
|
+
*/
|
|
57
|
+
const PENDING_OPS_MARGIN_MS = 30 * 60 * 1000;
|
|
58
|
+
/** これだけ経っても live で確かめられない add の行には「反映を確認できていません」を出す */
|
|
59
|
+
const PENDING_OPS_UNCONFIRMED_MS = 30 * 60 * 1000;
|
|
60
|
+
/**
|
|
61
|
+
* live(op=state の registeredNow)の同意で仮置きを捨ててよくなるまでの余裕。
|
|
62
|
+
*
|
|
63
|
+
* 🔴 **保存直後の同意で捨ててはいけない。** op=state の据え置き(60 秒)は Lambda
|
|
64
|
+
* インスタンス単位のメモリで、保存を受けたインスタンスしか消せない。保存直後の取り直しが
|
|
65
|
+
* 同意しても、60 秒以内の再読み込み・取り直しが別インスタンスに当たると保存前の答えが返り、
|
|
66
|
+
* 仮置きを捨てていれば**消したはずの行が復活する**(Issue の症状そのもの・設計レビュー②の重大1)。
|
|
67
|
+
* 据え置き 60 秒に対して余裕を取り、保存から 5 分は同意しても残す(残っていても害は無い——
|
|
68
|
+
* remove は無い行を消すだけ・add は本物があれば足さない)。
|
|
69
|
+
*/
|
|
70
|
+
const PENDING_OPS_SETTLE_MS = 5 * 60 * 1000;
|
|
71
|
+
exports.PENDING_OPS_LIMITS = {
|
|
72
|
+
ttlMs: PENDING_OPS_TTL_MS,
|
|
73
|
+
marginMs: PENDING_OPS_MARGIN_MS,
|
|
74
|
+
unconfirmedMs: PENDING_OPS_UNCONFIRMED_MS,
|
|
75
|
+
settleMs: PENDING_OPS_SETTLE_MS,
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* 仮置きの種別(intake の plan.kind と同じ語)→ 画面の能力名・アダプタ・registeredNow のキー。
|
|
79
|
+
*
|
|
80
|
+
* `cap` / `adapter` はタイルの「確認中」の鍵(pendingCaps と同じ形)、`reg` は
|
|
81
|
+
* op=state の registeredNow のキー(live が追いついたかの判定に使う)。
|
|
82
|
+
* `internal` は同期ソース(internalSources)の kind(Figma / Drive だけ)。
|
|
83
|
+
*/
|
|
84
|
+
const PENDING_KINDS = {
|
|
85
|
+
slack: { cap: "チャット", adapter: "slack", reg: "chat" },
|
|
86
|
+
"google-chat": { cap: "チャット", adapter: "google-chat", reg: "google-chat" },
|
|
87
|
+
github: { cap: "開発", adapter: "github", reg: "dev" },
|
|
88
|
+
figma: { cap: "デザイン", adapter: "figma", reg: "design", internal: "デザイン" },
|
|
89
|
+
drive: { cap: "共有資料", adapter: "google-drive", reg: "materials", internal: "共有資料" },
|
|
90
|
+
backlog: { cap: "課題管理", adapter: "backlog", reg: "issues" },
|
|
91
|
+
};
|
|
92
|
+
/** 対応表の読み出し(定数そのものは export しない・上の注意) */
|
|
93
|
+
function pendingKindsTable() { return PENDING_KINDS; }
|
|
94
|
+
/** localStorage の鍵。案件別(同一ブラウザで複数案件を見ても混ざらない・アイコンと同じ切り方) */
|
|
95
|
+
function pendingOpsKey(projectKey) {
|
|
96
|
+
return projectKey ? "cortex-pending-ops:" + String(projectKey) : "";
|
|
97
|
+
}
|
|
98
|
+
// ---- URL の切り出し(サーバ logic.mjs の写し。ずれは parity テストが固定する) --------
|
|
99
|
+
//
|
|
100
|
+
// op=config の応答には正規化済みの ID が載らないので、画面側で切り出す。
|
|
101
|
+
// **切り出せなければ仮置きしない**(従来どおり live の読み直しに任せる・安全側)。
|
|
102
|
+
function pendingSlackChannelId(input) {
|
|
103
|
+
const s = String(input == null ? "" : input).trim();
|
|
104
|
+
if (/^[CG][A-Z0-9]{6,}$/.test(s))
|
|
105
|
+
return s;
|
|
106
|
+
const m = s.match(/\/archives\/([CG][A-Z0-9]{6,})/);
|
|
107
|
+
if (m)
|
|
108
|
+
return m[1];
|
|
109
|
+
// ブラウザのアドレスバーの形(#398)。**team_id は使わない**——どのワークスペースかは
|
|
110
|
+
// サーバが実測して決める。ここで取るのはチャンネルIDだけ
|
|
111
|
+
// (team_id の位置の形は shared/secret-names.mjs の isSlackTeamId と同じ。値は採らない)
|
|
112
|
+
const client = s.match(/^https?:\/\/app\.slack\.com\/client\/[TE][A-Z0-9]{6,20}\/([CG][A-Z0-9]{6,})/);
|
|
113
|
+
return client ? client[1] : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* スペース ref の形(サーバの `GOOGLE_CHAT_SPACE_REF` の写し)。ずれは parity テストが固定する。
|
|
117
|
+
*
|
|
118
|
+
* **写しがこのファイルに 2 つあったのを 1 つに寄せた(#398)。** ビューアは infra を
|
|
119
|
+
* import できないので写し自体は無くせないが、**同じ判定を 2 度書かない**。
|
|
120
|
+
*/
|
|
121
|
+
function pendingIsSpaceRef(v) {
|
|
122
|
+
return /^spaces\/[A-Za-z0-9_-]+$/.test(String(v == null ? "" : v));
|
|
123
|
+
}
|
|
124
|
+
function pendingGoogleChatSpaceRef(input) {
|
|
125
|
+
const s = String(input == null ? "" : input).trim();
|
|
126
|
+
if (!s || s.length > 500)
|
|
127
|
+
return null;
|
|
128
|
+
// 素の `spaces/<ID>`(#389)。URL ではないのでホスト検査より前に見る
|
|
129
|
+
if (pendingIsSpaceRef(s))
|
|
130
|
+
return s;
|
|
131
|
+
if (!/^https?:\/\/(?:chat|mail)\.google\.com\//i.test(s))
|
|
132
|
+
return null;
|
|
133
|
+
if (/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?(?:dm|app\/dm)\//i.test(s) ||
|
|
134
|
+
/^https?:\/\/mail\.google\.com\/[^#]*#chat\/dm\//i.test(s)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const m = s.match(/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?(?:room|app\/(?:chat|join|management))\/([^/?#]+)/i) ||
|
|
138
|
+
s.match(/^https?:\/\/mail\.google\.com\/(?:chat|mail)\/u\/\d+\/[^#]*#chat\/space\/([^/?#]+)/i);
|
|
139
|
+
if (!m)
|
|
140
|
+
return null;
|
|
141
|
+
const ref = "spaces/" + m[1];
|
|
142
|
+
return pendingIsSpaceRef(ref) ? ref : null;
|
|
143
|
+
}
|
|
144
|
+
function pendingRepo(input) {
|
|
145
|
+
let s = String(input == null ? "" : input).trim();
|
|
146
|
+
if (!s)
|
|
147
|
+
return null;
|
|
148
|
+
s = s.replace(/^git@github\.com:/, "").replace(/^https?:\/\/github\.com\//, "");
|
|
149
|
+
s = s.replace(/\.git$/, "").replace(/^\/+/, "");
|
|
150
|
+
const parts = s.split("/").filter(function (p) { return !!p; });
|
|
151
|
+
if (parts.length < 2)
|
|
152
|
+
return null;
|
|
153
|
+
const repo = (parts[0] + "/" + parts[1]).toLowerCase();
|
|
154
|
+
return /^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(repo) ? repo : null;
|
|
155
|
+
}
|
|
156
|
+
function pendingFigmaKey(input) {
|
|
157
|
+
const s = String(input == null ? "" : input).trim();
|
|
158
|
+
const m = s.match(/figma\.com\/(?:design|file|proto)\/([A-Za-z0-9_-]{8,})/);
|
|
159
|
+
if (m)
|
|
160
|
+
return m[1];
|
|
161
|
+
return /^[A-Za-z0-9_-]{8,}$/.test(s) ? s : null;
|
|
162
|
+
}
|
|
163
|
+
function pendingDriveFolderId(input) {
|
|
164
|
+
const s = String(input == null ? "" : input).trim();
|
|
165
|
+
const m = s.match(/\/drive\/(?:u\/\d+\/)?folders\/([A-Za-z0-9_-]+)/) ||
|
|
166
|
+
s.match(/[?&]id=([A-Za-z0-9_-]+)/);
|
|
167
|
+
if (m)
|
|
168
|
+
return m[1];
|
|
169
|
+
return /^[A-Za-z0-9_-]{10,}$/.test(s) ? s : null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Backlog の URL → 接続先(ドメインとプロジェクトキー)。サーバ(logic.mjs の backlogTarget)と同じ形。
|
|
173
|
+
*
|
|
174
|
+
* もともと render.ts にあった写し(画面の接続先の復元・保存の突き合わせに使う)を、
|
|
175
|
+
* 仮置きも同じものを使うようにここへ移した。**写しは 1 つだけ**(parity テストが見張る)。
|
|
176
|
+
*/
|
|
177
|
+
function backlogTargetOf(input) {
|
|
178
|
+
const s = String(input == null ? "" : input).trim();
|
|
179
|
+
const m = s.match(/^https?:\/\/([a-z0-9-]+\.(?:backlog\.jp|backlog\.com|backlogtool\.com))\/(.*)$/i);
|
|
180
|
+
if (!m)
|
|
181
|
+
return null;
|
|
182
|
+
const domain = m[1].toLowerCase();
|
|
183
|
+
const rest = m[2];
|
|
184
|
+
// **数値のプロジェクトIDは弾く。** 取り違えの検査(艦隊のキーとの照合)を
|
|
185
|
+
// 迂回できてしまう。Backlog はドキュメントのURLをこの形でも出す
|
|
186
|
+
const num = /^[0-9]+$/;
|
|
187
|
+
const head = rest.match(/^(?:projects|document|wiki|find|board|gantt|git)\/([A-Za-z0-9_]+)(?:[/?#]|$)/);
|
|
188
|
+
if (head)
|
|
189
|
+
return num.test(head[1]) ? null : { domain: domain, projectKey: head[1].toUpperCase() };
|
|
190
|
+
const view = rest.match(/^view\/([A-Za-z0-9_]+)-\d+/);
|
|
191
|
+
if (view)
|
|
192
|
+
return num.test(view[1]) ? null : { domain: domain, projectKey: view[1].toUpperCase() };
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Slack の team_id の形。**サーバの正本(shared/secret-names.mjs の `isSlackTeamId`)の写し**で、
|
|
197
|
+
* ずれは parity テストが固定する。
|
|
198
|
+
*
|
|
199
|
+
* 🔴 **狭くしないこと。** `T…` だけに絞ると **Enterprise Grid の `E…` を画面が弾き**、
|
|
200
|
+
* その組織のチャンネルだけ仮置きの行に札が出ない(サーバは受けているのに画面が捨てる形)。
|
|
201
|
+
*/
|
|
202
|
+
function pendingIsSlackTeamId(v) {
|
|
203
|
+
return /^[TE][A-Z0-9]{6,20}$/.test(String(v == null ? "" : v));
|
|
204
|
+
}
|
|
205
|
+
/** Backlog の接続先の鍵(render.ts の issuesRefOf と同形: ドメイン小文字 / キー大文字) */
|
|
206
|
+
function pendingIssuesRef(domain, projectKey) {
|
|
207
|
+
return String(domain == null ? "" : domain).toLowerCase() + "/" + String(projectKey == null ? "" : projectKey).toUpperCase();
|
|
208
|
+
}
|
|
209
|
+
// ---- エントリを作る(op → kind / action / targetId の対応表はここ 1 つ) ------------
|
|
210
|
+
/**
|
|
211
|
+
* 表示名の整形(サーバの cleanLabel の写し: 制御文字を落とし、連続空白を1つに畳み、
|
|
212
|
+
* 長すぎれば切る)。**空なら空文字**——サーバは null を返すが、画面側は `|| id` の
|
|
213
|
+
* 落とし先があるので文字列のまま扱う(parity テストは `|| null` にして突き合わせる)。
|
|
214
|
+
*
|
|
215
|
+
* 🔴 **連続空白の圧縮を落とさないこと(#391)。** サーバだけが畳むと、名前に連続空白を
|
|
216
|
+
* 含むチャンネルの update 仮置きが pendingOpLiveAgrees の名前比較で永久に同意せず、
|
|
217
|
+
* 24 時間(TTL)残り続ける。写しのずれはこの1行だけで起きる。
|
|
218
|
+
*/
|
|
219
|
+
function pendingLabel(s, max) {
|
|
220
|
+
const v = String(s == null ? "" : s)
|
|
221
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
222
|
+
.replace(/\s+/g, " ")
|
|
223
|
+
.trim();
|
|
224
|
+
const lim = max || 120;
|
|
225
|
+
return v.length > lim ? v.slice(0, lim) : v;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `op=config` に送った payload(と、必要なら応答)→ 仮置きのエントリ。作れなければ null。
|
|
229
|
+
*
|
|
230
|
+
* @param payload 送った中身(`payload.op` を見る)。refresh-channel-name は送っていない
|
|
231
|
+
* `name`(引けた名前)を呼び出し側が添える
|
|
232
|
+
* @param ctx { projectKey, now, generatedAt, targets? }
|
|
233
|
+
* targets は Backlog の解除で表示中の一覧(ドメインを補うため・無くてもよい)
|
|
234
|
+
*
|
|
235
|
+
* **応答に正規化済みの ref / name が載るようになったら、ここで `ctx.response` を先に読む**
|
|
236
|
+
* (intake 側 3 行の追加で Google Chat の名前も即時に出せる。今回は intake を触らない)。
|
|
237
|
+
*/
|
|
238
|
+
function pendingOpOf(payload, ctx) {
|
|
239
|
+
if (!payload || typeof payload !== "object" || !ctx || !ctx.projectKey)
|
|
240
|
+
return null;
|
|
241
|
+
const op = String(payload.op || "");
|
|
242
|
+
const make = (kind, action, targetId, data) => {
|
|
243
|
+
const id = targetId == null ? "" : String(targetId);
|
|
244
|
+
if (!id)
|
|
245
|
+
return null;
|
|
246
|
+
return {
|
|
247
|
+
v: 1,
|
|
248
|
+
projectKey: String(ctx.projectKey),
|
|
249
|
+
kind: kind,
|
|
250
|
+
targetId: id,
|
|
251
|
+
action: action,
|
|
252
|
+
payload: data || {},
|
|
253
|
+
committedAt: typeof ctx.now === "number" ? ctx.now : Date.now(),
|
|
254
|
+
baseBuildId: typeof ctx.generatedAt === "string" ? ctx.generatedAt : "",
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
switch (op) {
|
|
258
|
+
case "add-channel": {
|
|
259
|
+
const id = pendingSlackChannelId(payload.url);
|
|
260
|
+
const data = { name: pendingLabel(payload.name) || id };
|
|
261
|
+
if (payload.workspace)
|
|
262
|
+
data.workspace = String(payload.workspace);
|
|
263
|
+
// **どの組織のチャンネルかは、サーバの実測を応答から受け取る(#391)。**
|
|
264
|
+
// 画面はワークスペースを送らない(プルダウンは 2026-08-20 に廃止)ので、
|
|
265
|
+
// これが無いと仮置きの行にだけ組織の札が出ない=本物の行と見た目が食い違う。
|
|
266
|
+
// 🔴 **文字列 1 欄で運ぶ**("unknown" / "internal" / team_id)。真偽値を増やすと
|
|
267
|
+
// sanitizePendingOps の許可リストの例外が増え、篩が篩の意味を失う。
|
|
268
|
+
// 応答に無ければ載せない——載せないことが「まだ分からない」の表明になる
|
|
269
|
+
const org = ctx.response && typeof ctx.response.slackOrg === "string" ? ctx.response.slackOrg : "";
|
|
270
|
+
if (org)
|
|
271
|
+
data.slackOrg = org;
|
|
272
|
+
return make("slack", "add", id, data);
|
|
273
|
+
}
|
|
274
|
+
case "remove-channel":
|
|
275
|
+
return make("slack", "remove", payload.ref);
|
|
276
|
+
case "refresh-channel-name": {
|
|
277
|
+
const name = pendingLabel(payload.name);
|
|
278
|
+
return name ? make("slack", "update", payload.ref, { name: name }) : null;
|
|
279
|
+
}
|
|
280
|
+
case "set-notify":
|
|
281
|
+
return typeof payload.value === "boolean" ? make("slack", "update", payload.ref, { notify: payload.value }) : null;
|
|
282
|
+
case "add-google-chat-space": {
|
|
283
|
+
const ref = pendingGoogleChatSpaceRef(payload.url);
|
|
284
|
+
// 名前は画面に欄が無い(サーバが引く)。live が来るまでは ref で出す
|
|
285
|
+
return make("google-chat", "add", ref, { name: ref || "" });
|
|
286
|
+
}
|
|
287
|
+
case "remove-google-chat-space":
|
|
288
|
+
return make("google-chat", "remove", payload.ref);
|
|
289
|
+
case "add-github-source": {
|
|
290
|
+
const repo = pendingRepo(payload.repo);
|
|
291
|
+
return make("github", "add", repo, { name: repo || "", type: "github-issues" });
|
|
292
|
+
}
|
|
293
|
+
case "remove-github-source":
|
|
294
|
+
return make("github", "remove", pendingRepo(payload.repo));
|
|
295
|
+
case "add-figma-file": {
|
|
296
|
+
const key = pendingFigmaKey(payload.url);
|
|
297
|
+
return make("figma", "add", key, { name: pendingLabel(payload.name) || key || "" });
|
|
298
|
+
}
|
|
299
|
+
case "remove-figma-file":
|
|
300
|
+
return make("figma", "remove", pendingFigmaKey(payload.url) || payload.key);
|
|
301
|
+
case "add-drive-folder": {
|
|
302
|
+
const id = pendingDriveFolderId(payload.url);
|
|
303
|
+
return make("drive", "add", id, { name: pendingLabel(payload.name) });
|
|
304
|
+
}
|
|
305
|
+
case "remove-drive-folder":
|
|
306
|
+
return make("drive", "remove", pendingDriveFolderId(payload.url) || payload.id);
|
|
307
|
+
case "backlog-target-add": {
|
|
308
|
+
const t = backlogTargetOf(payload.url);
|
|
309
|
+
return t ? make("backlog", "add", pendingIssuesRef(t.domain, t.projectKey), { domain: t.domain, projectKey: t.projectKey }) : null;
|
|
310
|
+
}
|
|
311
|
+
case "backlog-target-remove": {
|
|
312
|
+
// **鍵はドメイン込み(issuesRefOf と同形)。** projectKey だけで丸めると、2 つのドメインに
|
|
313
|
+
// 同じキーがある案件で片方の解除が両方を消し、live の同意も成立せず 24 時間消えたままになる。
|
|
314
|
+
// 送る payload にはドメインが無いので、表示中の一覧(ctx.targets)から引く。引けなければ仮置きしない
|
|
315
|
+
const key = String(payload.backlogProjectKey || "").trim().toUpperCase();
|
|
316
|
+
if (!/^[A-Z0-9_]{1,64}$/.test(key))
|
|
317
|
+
return null;
|
|
318
|
+
let domain = "";
|
|
319
|
+
for (const t of (ctx.targets || [])) {
|
|
320
|
+
if (t && String(t.projectKey || "").toUpperCase() === key && t.domain) {
|
|
321
|
+
domain = String(t.domain);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return domain ? make("backlog", "remove", pendingIssuesRef(domain, key)) : null;
|
|
326
|
+
}
|
|
327
|
+
default:
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ---- localStorage の生値の検査 -------------------------------------------------
|
|
332
|
+
/**
|
|
333
|
+
* localStorage の生値 → 通ってよいエントリだけ。**通らなかったものは黙って落とす。**
|
|
334
|
+
* 形の版(v)と案件キーはアイコンの仮置きと同じ検査。
|
|
335
|
+
*/
|
|
336
|
+
function sanitizePendingOps(raw, projectKey) {
|
|
337
|
+
const out = [];
|
|
338
|
+
if (!Array.isArray(raw) || !projectKey)
|
|
339
|
+
return out;
|
|
340
|
+
for (const e of raw) {
|
|
341
|
+
if (!e || typeof e !== "object")
|
|
342
|
+
continue;
|
|
343
|
+
if (e.v !== 1 || e.projectKey !== String(projectKey))
|
|
344
|
+
continue;
|
|
345
|
+
if (!PENDING_KINDS[e.kind])
|
|
346
|
+
continue;
|
|
347
|
+
if (e.action !== "add" && e.action !== "remove" && e.action !== "update")
|
|
348
|
+
continue;
|
|
349
|
+
if (typeof e.targetId !== "string" || !e.targetId)
|
|
350
|
+
continue;
|
|
351
|
+
if (typeof e.committedAt !== "number" || !isFinite(e.committedAt))
|
|
352
|
+
continue;
|
|
353
|
+
const payload = {};
|
|
354
|
+
const src = e.payload && typeof e.payload === "object" ? e.payload : {};
|
|
355
|
+
// 通してよい payload の欄(葉は文字列か、notify だけ真偽値)。関数の中に置くのは、
|
|
356
|
+
// クライアントへ toString() で流し込んだ先でも同じ一覧を参照できるようにするため
|
|
357
|
+
const fields = ["name", "workspace", "notify", "domain", "projectKey", "type", "slackOrg"];
|
|
358
|
+
for (const f of fields) {
|
|
359
|
+
if (!Object.prototype.hasOwnProperty.call(src, f))
|
|
360
|
+
continue;
|
|
361
|
+
if (f === "notify") {
|
|
362
|
+
if (src[f] === true || src[f] === false)
|
|
363
|
+
payload[f] = src[f];
|
|
364
|
+
}
|
|
365
|
+
else if (typeof src[f] === "string")
|
|
366
|
+
payload[f] = src[f];
|
|
367
|
+
}
|
|
368
|
+
out.push({
|
|
369
|
+
v: 1,
|
|
370
|
+
projectKey: String(projectKey),
|
|
371
|
+
kind: e.kind,
|
|
372
|
+
targetId: e.targetId,
|
|
373
|
+
action: e.action,
|
|
374
|
+
payload: payload,
|
|
375
|
+
committedAt: e.committedAt,
|
|
376
|
+
baseBuildId: typeof e.baseBuildId === "string" ? e.baseBuildId : "",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
out.sort(function (a, b) { return a.committedAt - b.committedAt; });
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
// ---- 後勝ちの合成 ----------------------------------------------------------
|
|
383
|
+
/**
|
|
384
|
+
* 同じ (kind, targetId) は後勝ち。
|
|
385
|
+
* - add / remove は既存を全部捨てて置き換える(add → remove で行は消え、remove → add で戻る)
|
|
386
|
+
* - update は既存の add があれば**その payload に畳む**(add の行を残す)。無ければ既存の
|
|
387
|
+
* update と payload を合成して置き換える(name と notify は別の操作で入るので、欄をまたいで捨てない)
|
|
388
|
+
*/
|
|
389
|
+
function rememberPendingOp(list, entry) {
|
|
390
|
+
const cur = list || [];
|
|
391
|
+
if (!entry)
|
|
392
|
+
return cur.slice();
|
|
393
|
+
const same = function (e) { return e.kind === entry.kind && e.targetId === entry.targetId; };
|
|
394
|
+
if (entry.action === "update") {
|
|
395
|
+
let folded = false;
|
|
396
|
+
const out = cur.map(function (e) {
|
|
397
|
+
if (!same(e) || e.action !== "add")
|
|
398
|
+
return e;
|
|
399
|
+
folded = true;
|
|
400
|
+
const merged = {};
|
|
401
|
+
for (const k in e.payload)
|
|
402
|
+
if (Object.prototype.hasOwnProperty.call(e.payload, k))
|
|
403
|
+
merged[k] = e.payload[k];
|
|
404
|
+
for (const k in entry.payload)
|
|
405
|
+
if (Object.prototype.hasOwnProperty.call(entry.payload, k))
|
|
406
|
+
merged[k] = entry.payload[k];
|
|
407
|
+
return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: merged, committedAt: e.committedAt, baseBuildId: e.baseBuildId };
|
|
408
|
+
});
|
|
409
|
+
if (folded)
|
|
410
|
+
return out;
|
|
411
|
+
// 既存の update があれば **payload を合成して** 置き換える(add に畳むときと同じ)。
|
|
412
|
+
// 欄ごとに捨てると、通知先 ON → 名前の取り直し の順に押したとき notify の仮置きが消え、
|
|
413
|
+
// 据え置きの窓で再読み込みすると通知先が OFF に見える(実装レビュー④)
|
|
414
|
+
const merged = {};
|
|
415
|
+
for (const e of cur) {
|
|
416
|
+
if (!same(e) || e.action !== "update")
|
|
417
|
+
continue;
|
|
418
|
+
for (const k in e.payload)
|
|
419
|
+
if (Object.prototype.hasOwnProperty.call(e.payload, k))
|
|
420
|
+
merged[k] = e.payload[k];
|
|
421
|
+
}
|
|
422
|
+
for (const k in entry.payload)
|
|
423
|
+
if (Object.prototype.hasOwnProperty.call(entry.payload, k))
|
|
424
|
+
merged[k] = entry.payload[k];
|
|
425
|
+
const rest = cur.filter(function (e) { return !(same(e) && e.action === "update"); });
|
|
426
|
+
rest.push({ v: 1, projectKey: entry.projectKey, kind: entry.kind, targetId: entry.targetId, action: entry.action, payload: merged, committedAt: entry.committedAt, baseBuildId: entry.baseBuildId });
|
|
427
|
+
return rest;
|
|
428
|
+
}
|
|
429
|
+
const kept = cur.filter(function (e) { return !same(e); });
|
|
430
|
+
kept.push(entry);
|
|
431
|
+
return kept;
|
|
432
|
+
}
|
|
433
|
+
// ---- 失効 ----------------------------------------------------------------
|
|
434
|
+
/** SITE.generatedAt("2026-09-03 00:00 UTC" / ISO)→ ms。読めなければ NaN */
|
|
435
|
+
function pendingSiteTimeMs(s) {
|
|
436
|
+
if (typeof s !== "string" || !s)
|
|
437
|
+
return NaN;
|
|
438
|
+
return Date.parse(s.replace(" UTC", "Z").replace(" ", "T"));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* **「操作より後のビルドが来たか」の唯一の判定(#391)。**
|
|
442
|
+
*
|
|
443
|
+
* 削除のトゥームストーン・アイコンの反映待ち・操作タブの仮置きは、どれも「押した結果が
|
|
444
|
+
* 次のビルドで本物になるまでブラウザが覚えておく」という同じ性質の待ちで、破棄の条件も
|
|
445
|
+
* 同じだった。**同じものが 3 実装あると、片方だけ直したときに挙動が割れる**(値が同じ
|
|
446
|
+
* うちは誰も気づかない)ので、ここ 1 つに寄せてある。
|
|
447
|
+
*
|
|
448
|
+
* 判定: いま画面が持っている世代(nowMs)が、**押した時刻+余裕**と**押したとき見ていた
|
|
449
|
+
* 世代+余裕**の両方より新しい。generatedAt はコミット時点ではない——押す前に始まった
|
|
450
|
+
* ビルドが後から完成すると、世代は押した時刻より新しいのに操作は入っていない。
|
|
451
|
+
* ビルドの所要時間とクライアント時計のずれを見て余裕(既定 30 分)を取る。
|
|
452
|
+
*
|
|
453
|
+
* **主軸はサーバ時刻同士の比較**(現行の世代と、押した画面の gen0)。クライアントの時計が
|
|
454
|
+
* 大きく狂っていても機能が丸ごとは死なない(進んだ時計なら破棄が遅れるだけ)。
|
|
455
|
+
* gen0 を持たない古い形式のエントリは at だけで見る(後方互換・安全側)。
|
|
456
|
+
* nowMs が読めない(NaN)ときは false =**隠し続ける/残し続ける側に倒す**(嘘の再表示より安全)。
|
|
457
|
+
*
|
|
458
|
+
* @param at 押した時刻(ms・クライアント時計)
|
|
459
|
+
* @param gen0 押した画面の世代(generatedAt の文字列)。読めなければ at だけで見る
|
|
460
|
+
* @param nowMs いまの世代(ms)
|
|
461
|
+
*
|
|
462
|
+
* ⚠ 余裕は引数にしない。3 経路とも 30 分で、可変にすると**呼び出しが 1 つも無い引数**が
|
|
463
|
+
* 残る(壊しても何も起きない=変異検証の網から静かに漏れる形)。
|
|
464
|
+
*/
|
|
465
|
+
function pendingStale(at, gen0, nowMs) {
|
|
466
|
+
const margin = PENDING_OPS_MARGIN_MS;
|
|
467
|
+
if (typeof nowMs !== "number" || isNaN(nowMs))
|
|
468
|
+
return false;
|
|
469
|
+
if (typeof at !== "number" || !isFinite(at))
|
|
470
|
+
return false;
|
|
471
|
+
if (!(nowMs > at + margin))
|
|
472
|
+
return false;
|
|
473
|
+
const g = pendingSiteTimeMs(gen0);
|
|
474
|
+
if (isNaN(g))
|
|
475
|
+
return true;
|
|
476
|
+
return nowMs > g + margin;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* 本物の一覧の世代が追いついたか。**判定の実体は pendingStale**(トゥームストーン・
|
|
480
|
+
* アイコンの反映待ちと同じもの・#391)。
|
|
481
|
+
*
|
|
482
|
+
* 🔴 **世代は「一覧の下地」のもの(fleet-status の generatedAt)を渡すこと。** ページの
|
|
483
|
+
* generatedAt(SITE.generatedAt)は記録の追加でも進むが、一覧の下地は夜間の自己チェックの
|
|
484
|
+
* 成果物なので、ページが新しくても下地は古いままのことがある(設計レビュー②の中3)。
|
|
485
|
+
* 生成時刻はコミット時点ではない(保存前に始まった実行が後から完成する)ので余裕を取る。
|
|
486
|
+
*/
|
|
487
|
+
function pendingOpBuildCaughtUp(e, siteNowMs) {
|
|
488
|
+
if (!e)
|
|
489
|
+
return false;
|
|
490
|
+
return pendingStale(e.committedAt, e.baseBuildId, siteNowMs);
|
|
491
|
+
}
|
|
492
|
+
/** registeredNow の行が、このエントリの対象か */
|
|
493
|
+
function pendingLiveRowMatches(kind, r, targetId) {
|
|
494
|
+
if (!r)
|
|
495
|
+
return false;
|
|
496
|
+
if (kind === "figma")
|
|
497
|
+
return String(r.key) === targetId;
|
|
498
|
+
if (kind === "drive")
|
|
499
|
+
return String(r.id) === targetId;
|
|
500
|
+
if (kind === "backlog")
|
|
501
|
+
return pendingIssuesRef(r.domain, r.projectKey) === targetId;
|
|
502
|
+
return String(r.ref) === targetId;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* live(op=state の registeredNow)が仮置きと同じことを言っているか。
|
|
506
|
+
*
|
|
507
|
+
* **保存から 5 分(PENDING_OPS_SETTLE_MS)を過ぎてから出した要求の答えだけを信じる。**
|
|
508
|
+
* 据え置きの古い答えが仮置きと食い違っていても残す——その局面こそ仮置きが要る場面。
|
|
509
|
+
* 5 分以内の同意で捨てないのは、別インスタンスの据え置きが保存前の答えを返す窓があるため
|
|
510
|
+
* (PENDING_OPS_SETTLE_MS の項)。同意していれば本物の行が出ているので、残っていても害は無い。
|
|
511
|
+
* その kind のキーが配列で無ければ判断しない(読めなかった=分からない)。
|
|
512
|
+
*/
|
|
513
|
+
function pendingOpLiveAgrees(e, live, liveAt) {
|
|
514
|
+
if (!e || !live || typeof live !== "object")
|
|
515
|
+
return false;
|
|
516
|
+
// 保存から据え置きの余裕(PENDING_OPS_SETTLE_MS)を過ぎてから出した要求の答えだけを信じる
|
|
517
|
+
if (typeof liveAt !== "number" || !(liveAt >= e.committedAt + PENDING_OPS_SETTLE_MS))
|
|
518
|
+
return false;
|
|
519
|
+
const meta = PENDING_KINDS[e.kind];
|
|
520
|
+
if (!meta)
|
|
521
|
+
return false;
|
|
522
|
+
const rows = live[meta.reg];
|
|
523
|
+
if (!Array.isArray(rows))
|
|
524
|
+
return false;
|
|
525
|
+
let found = null;
|
|
526
|
+
for (const r of rows)
|
|
527
|
+
if (pendingLiveRowMatches(e.kind, r, e.targetId)) {
|
|
528
|
+
found = r;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
if (e.action === "remove") {
|
|
532
|
+
if (found)
|
|
533
|
+
return false;
|
|
534
|
+
// 🔴 **github は「読み取り停止中」(exclude)の行が live の dev に載らない(#391)。**
|
|
535
|
+
// dev だけを見ると、exclude された行の remove は**保存が効く前から**「無い=同意」に
|
|
536
|
+
// なり、5 分で仮置きが捨てられて、まとめ表の行が次のビルドまで戻る(点滅する)。
|
|
537
|
+
// devExcluded を返すサーバなら、そちらにも無いことまで確かめてから同意する。
|
|
538
|
+
// 返さない古いサーバでは従来どおり(dev だけ)——判断材料が無いのに厳しくすると、
|
|
539
|
+
// 普通のリポジトリの解除まで 24 時間残る
|
|
540
|
+
if (e.kind === "github" && Array.isArray(live.devExcluded)) {
|
|
541
|
+
for (const r of live.devExcluded)
|
|
542
|
+
if (pendingLiveRowMatches(e.kind, r, e.targetId))
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
if (e.action === "add")
|
|
548
|
+
return !!found;
|
|
549
|
+
if (!found)
|
|
550
|
+
return false;
|
|
551
|
+
const p = e.payload || {};
|
|
552
|
+
// 名前は緩く比べる(サーバは cleanLabel 済み・画面は先頭の # を剥いで出す)。厳密一致だと
|
|
553
|
+
// 表記の差だけで 24 時間残る
|
|
554
|
+
const loose = function (s) { return String(s == null ? "" : s).trim().replace(/^#+/, ""); };
|
|
555
|
+
if (typeof p.name === "string" && loose(found.name) !== loose(p.name))
|
|
556
|
+
return false;
|
|
557
|
+
if (typeof p.notify === "boolean" && (found.notify === true) !== p.notify)
|
|
558
|
+
return false;
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* タイルの「登録件数」に仮置きを加減する(設計レビュー②の重大2)。
|
|
563
|
+
*
|
|
564
|
+
* タイルと接続マップの語は registeredNow の**件数**から導く(registeredCountOf → deriveCapTile)ので、
|
|
565
|
+
* 一覧だけ仮置きで足し引きしても「行は消えたのにタイルは登録済み 1 件」「行は 1 件なのに未接続」が並ぶ。
|
|
566
|
+
* live の行に無い add は +1、live の行に在る remove は −1(本物が既に追いついていれば 0)。
|
|
567
|
+
*
|
|
568
|
+
* @param live registeredNow(無ければ 0)
|
|
569
|
+
* @param regKey registeredNow のキー(chat / google-chat / dev / design / materials / issues)
|
|
570
|
+
*/
|
|
571
|
+
function pendingCountDelta(list, live, regKey) {
|
|
572
|
+
const rows = live && typeof live === "object" && Array.isArray(live[regKey]) ? live[regKey] : null;
|
|
573
|
+
if (!rows)
|
|
574
|
+
return 0;
|
|
575
|
+
let delta = 0;
|
|
576
|
+
for (const e of list || []) {
|
|
577
|
+
const meta = e && PENDING_KINDS[e.kind];
|
|
578
|
+
if (!meta || meta.reg !== regKey)
|
|
579
|
+
continue;
|
|
580
|
+
let found = false;
|
|
581
|
+
for (const r of rows)
|
|
582
|
+
if (pendingLiveRowMatches(e.kind, r, e.targetId)) {
|
|
583
|
+
found = true;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
if (e.action === "add" && !found)
|
|
587
|
+
delta += 1;
|
|
588
|
+
if (e.action === "remove" && found)
|
|
589
|
+
delta -= 1;
|
|
590
|
+
}
|
|
591
|
+
return delta;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* 失効したエントリを落とす。
|
|
595
|
+
*
|
|
596
|
+
* @param ctx { now: Date.now(), siteNowMs: 画面の generatedAt の ms, live: LIVE_REGISTERED, liveAt: live が届いた時刻 }
|
|
597
|
+
*/
|
|
598
|
+
function prunePendingOps(list, ctx) {
|
|
599
|
+
const c = ctx || {};
|
|
600
|
+
const now = typeof c.now === "number" ? c.now : Date.now();
|
|
601
|
+
return (list || []).filter(function (e) {
|
|
602
|
+
if (!e)
|
|
603
|
+
return false;
|
|
604
|
+
if (now > e.committedAt + PENDING_OPS_TTL_MS)
|
|
605
|
+
return false;
|
|
606
|
+
if (pendingOpBuildCaughtUp(e, c.siteNowMs))
|
|
607
|
+
return false;
|
|
608
|
+
if (pendingOpLiveAgrees(e, c.live, c.liveAt))
|
|
609
|
+
return false;
|
|
610
|
+
return true;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
// ---- 合成 ----------------------------------------------------------------
|
|
614
|
+
/** 外部ソース(externalSources)の行が、このエントリの対象か。Slack は ref のみ(subtractRemovedRegistered と同じ理由) */
|
|
615
|
+
function pendingExternalMatches(kind, s, targetId) {
|
|
616
|
+
if (!s || !s.ref || String(s.ref) !== targetId)
|
|
617
|
+
return false;
|
|
618
|
+
if (kind === "slack")
|
|
619
|
+
return s.type === "slack";
|
|
620
|
+
if (kind === "google-chat")
|
|
621
|
+
return s.type === "google-chat";
|
|
622
|
+
if (kind === "github")
|
|
623
|
+
return String(s.type || "").indexOf("github") === 0;
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
/** 仮置きの add で足す行(registeredRowsToAdd と同じ形+ pending の印) */
|
|
627
|
+
function pendingExternalRow(e) {
|
|
628
|
+
const p = e.payload || {};
|
|
629
|
+
const row = {};
|
|
630
|
+
if (e.kind === "slack") {
|
|
631
|
+
row.type = "slack";
|
|
632
|
+
row.name = p.name || e.targetId;
|
|
633
|
+
row.ref = e.targetId;
|
|
634
|
+
// channels.json に書いた行= derived(dev-sources 側の宣言ではない)
|
|
635
|
+
row.origin = "derived";
|
|
636
|
+
if (p.workspace)
|
|
637
|
+
row.workspace = p.workspace;
|
|
638
|
+
if (p.notify === true)
|
|
639
|
+
row.notify = true;
|
|
640
|
+
// **サーバが実測した組織を、本物の行と同じ欄に展開する(#391)。**
|
|
641
|
+
// 展開できたときだけ pendingOrg を立てる——立っていない行に札を出すと、
|
|
642
|
+
// 測れていないのに「クラスメソッド」と名乗る(画面側の既定がそれなので)
|
|
643
|
+
// 値は 3 通りだけ通す("unknown" / "internal" / team_id)。**"internal" を team_id として
|
|
644
|
+
// 札に出さない**ための門で、形の検査はサーバの正本(pendingIsSlackTeamId)と同じにする
|
|
645
|
+
// ——狭くすると Enterprise Grid(E…)の組織だけ札が出ない
|
|
646
|
+
const org = typeof p.slackOrg === "string" ? p.slackOrg : "";
|
|
647
|
+
if (org === "unknown") {
|
|
648
|
+
row.pendingOrg = true;
|
|
649
|
+
row.orgUnknown = true;
|
|
650
|
+
}
|
|
651
|
+
else if (org === "internal") {
|
|
652
|
+
row.pendingOrg = true;
|
|
653
|
+
}
|
|
654
|
+
else if (pendingIsSlackTeamId(org)) {
|
|
655
|
+
row.pendingOrg = true;
|
|
656
|
+
row.workspace = org;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else if (e.kind === "google-chat") {
|
|
660
|
+
row.type = "google-chat";
|
|
661
|
+
row.name = p.name || e.targetId;
|
|
662
|
+
row.ref = e.targetId;
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
row.type = p.type || "github-issues";
|
|
666
|
+
row.name = e.targetId;
|
|
667
|
+
row.ref = e.targetId;
|
|
668
|
+
row.origin = "explicit";
|
|
669
|
+
}
|
|
670
|
+
row.pending = true;
|
|
671
|
+
row.pendingAt = e.committedAt;
|
|
672
|
+
return row;
|
|
673
|
+
}
|
|
674
|
+
function pendingInternalOf(list, kind) {
|
|
675
|
+
for (const s of list || [])
|
|
676
|
+
if (s && s.kind === kind)
|
|
677
|
+
return s;
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* 本物の設定(4層合成の結果)に仮置きを重ねる。**描画の手前のここ 1 箇所で合成する**
|
|
682
|
+
* (一覧・タイル・接続マップの注記は全部 fleetVal を通るので、描画側に散らすと食い違う)。
|
|
683
|
+
*
|
|
684
|
+
* - remove: 行を消す。Drive は 0 件になったら `on → empty`、Backlog は単数欄を先頭に揃える
|
|
685
|
+
* (subtractRemovedRegistered と同じ倒し方)
|
|
686
|
+
* - add: 同じ鍵の行が**既にあれば何もしない**(本物が先に来ている)。無ければ
|
|
687
|
+
* registeredRowsToAdd と同じ形の行を足し、`pending: true` / `pendingAt` を付ける
|
|
688
|
+
* (Drive は行が ID 文字列なので `drivePending: { id: committedAt }` を内部ソースに付ける)
|
|
689
|
+
* - update: Slack の `name` / `notify` だけ差し替える
|
|
690
|
+
*/
|
|
691
|
+
function applyPendingOps(view, list) {
|
|
692
|
+
const v = view || {};
|
|
693
|
+
const ops = list || [];
|
|
694
|
+
if (!ops.length)
|
|
695
|
+
return v;
|
|
696
|
+
let externals = (v.externalSources || []).slice();
|
|
697
|
+
let internals = (v.internalSources || []).slice();
|
|
698
|
+
let bt = v.backlogTarget;
|
|
699
|
+
let extChanged = false;
|
|
700
|
+
let intChanged = false;
|
|
701
|
+
let btChanged = false;
|
|
702
|
+
const putInternal = function (kind, patch) {
|
|
703
|
+
for (let i = 0; i < internals.length; i++) {
|
|
704
|
+
if (internals[i] && internals[i].kind === kind) {
|
|
705
|
+
internals[i] = Object.assign({}, internals[i], patch);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
internals.push(Object.assign({ kind: kind }, patch));
|
|
710
|
+
};
|
|
711
|
+
for (const e of ops) {
|
|
712
|
+
if (!e || !PENDING_KINDS[e.kind])
|
|
713
|
+
continue;
|
|
714
|
+
const p = e.payload || {};
|
|
715
|
+
if (e.kind === "slack" || e.kind === "google-chat" || e.kind === "github") {
|
|
716
|
+
const idx = externals.findIndex(function (s) { return pendingExternalMatches(e.kind, s, e.targetId); });
|
|
717
|
+
if (e.action === "remove") {
|
|
718
|
+
// **Slack は origin が "derived" の行だけ消す**(subtractRemovedRegistered と同じ守り)。
|
|
719
|
+
// dev-sources.json 側にも宣言された行(both / explicit)は channels.json から消しても
|
|
720
|
+
// エンジンがまだ読む——消すと「読まれているのに画面に無い」逆向きの嘘になり、
|
|
721
|
+
// live が追いついた後に復活する(設計レビュー②の中5)。そういう行は従来どおり取り消し線のまま
|
|
722
|
+
//
|
|
723
|
+
// **「読み取り停止中」(goldState: "excluded")の GitHub 行も、押したら消す(#391)。**
|
|
724
|
+
// サーバの applyRemoveGithubSource は sources と exclude の両方から実際に消すので、
|
|
725
|
+
// 保存は完全に効いている。ここで守ると「押しても何も起きない」=保存が失敗したのと
|
|
726
|
+
// 区別が付かない。消えたままにする仕掛けは 2 つ: 同意の判定(pendingOpLiveAgrees が
|
|
727
|
+
// devExcluded も見る)と、引き算(subtractRemovedRegistered が devExcluded で判断する)
|
|
728
|
+
const removable = function (s) {
|
|
729
|
+
return pendingExternalMatches(e.kind, s, e.targetId) && (e.kind !== "slack" || s.origin === "derived");
|
|
730
|
+
};
|
|
731
|
+
if (!externals.some(removable))
|
|
732
|
+
continue;
|
|
733
|
+
externals = externals.filter(function (s) { return !removable(s); });
|
|
734
|
+
extChanged = true;
|
|
735
|
+
}
|
|
736
|
+
else if (e.action === "add") {
|
|
737
|
+
if (idx >= 0)
|
|
738
|
+
continue;
|
|
739
|
+
externals.push(pendingExternalRow(e));
|
|
740
|
+
extChanged = true;
|
|
741
|
+
}
|
|
742
|
+
else if (e.kind === "slack" && idx >= 0) {
|
|
743
|
+
const patch = {};
|
|
744
|
+
if (typeof p.name === "string" && p.name)
|
|
745
|
+
patch.name = p.name;
|
|
746
|
+
if (typeof p.notify === "boolean")
|
|
747
|
+
patch.notify = p.notify;
|
|
748
|
+
externals[idx] = Object.assign({}, externals[idx], patch);
|
|
749
|
+
extChanged = true;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
else if (e.kind === "figma") {
|
|
753
|
+
const cur = pendingInternalOf(internals, "デザイン");
|
|
754
|
+
const files = (cur && cur.figmaFiles) || [];
|
|
755
|
+
const has = files.some(function (f) { return f && String(f.key) === e.targetId; });
|
|
756
|
+
if (e.action === "remove") {
|
|
757
|
+
if (!has)
|
|
758
|
+
continue;
|
|
759
|
+
putInternal("デザイン", { figmaFiles: files.filter(function (f) { return !f || String(f.key) !== e.targetId; }) });
|
|
760
|
+
intChanged = true;
|
|
761
|
+
}
|
|
762
|
+
else if (e.action === "add") {
|
|
763
|
+
if (has)
|
|
764
|
+
continue;
|
|
765
|
+
putInternal("デザイン", { figmaFiles: files.concat([{ key: e.targetId, name: p.name || e.targetId, pending: true, pendingAt: e.committedAt }]) });
|
|
766
|
+
intChanged = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
else if (e.kind === "drive") {
|
|
770
|
+
const cur = pendingInternalOf(internals, "共有資料");
|
|
771
|
+
const ids = (cur && cur.driveFolderIds) || [];
|
|
772
|
+
const has = ids.indexOf(e.targetId) >= 0;
|
|
773
|
+
if (e.action === "remove") {
|
|
774
|
+
if (!has)
|
|
775
|
+
continue;
|
|
776
|
+
const kept = ids.filter(function (id) { return id !== e.targetId; });
|
|
777
|
+
const names = {};
|
|
778
|
+
for (const id of kept)
|
|
779
|
+
if (cur.driveFolderNames && cur.driveFolderNames[id])
|
|
780
|
+
names[id] = cur.driveFolderNames[id];
|
|
781
|
+
const patch = { driveFolderIds: kept, driveFolderNames: names };
|
|
782
|
+
if (cur.drivePending && cur.drivePending[e.targetId] != null) {
|
|
783
|
+
const pend = Object.assign({}, cur.drivePending);
|
|
784
|
+
delete pend[e.targetId];
|
|
785
|
+
patch.drivePending = pend;
|
|
786
|
+
}
|
|
787
|
+
// 0 件になったら empty に倒す(on のときだけ。broken / off は別の答えなので潰さない)
|
|
788
|
+
if (!kept.length && cur.driveState === "on")
|
|
789
|
+
patch.driveState = "empty";
|
|
790
|
+
putInternal("共有資料", patch);
|
|
791
|
+
intChanged = true;
|
|
792
|
+
}
|
|
793
|
+
else if (e.action === "add") {
|
|
794
|
+
if (has)
|
|
795
|
+
continue;
|
|
796
|
+
const names = Object.assign({}, (cur && cur.driveFolderNames) || {});
|
|
797
|
+
if (p.name)
|
|
798
|
+
names[e.targetId] = p.name;
|
|
799
|
+
const pend = Object.assign({}, (cur && cur.drivePending) || {});
|
|
800
|
+
pend[e.targetId] = e.committedAt;
|
|
801
|
+
const patch = { driveFolderIds: ids.concat([e.targetId]), driveFolderNames: names, drivePending: pend };
|
|
802
|
+
// 一覧そのものが driveState 無しでは描かれない。無い/0 件(empty)なら on にする
|
|
803
|
+
if (!cur || !cur.driveState || cur.driveState === "empty")
|
|
804
|
+
patch.driveState = "on";
|
|
805
|
+
putInternal("共有資料", patch);
|
|
806
|
+
intChanged = true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
else if (e.kind === "backlog") {
|
|
810
|
+
const cur = bt && Array.isArray(bt.targets) ? bt.targets : bt && bt.domain && bt.projectKey ? [bt] : [];
|
|
811
|
+
// 突き合わせはドメイン込み(同じキーが 2 つのドメインにある案件で片方だけを消す)
|
|
812
|
+
const has = cur.some(function (t) { return t && pendingIssuesRef(t.domain, t.projectKey) === e.targetId; });
|
|
813
|
+
let next = null;
|
|
814
|
+
if (e.action === "remove" && has) {
|
|
815
|
+
next = cur.filter(function (t) { return !t || pendingIssuesRef(t.domain, t.projectKey) !== e.targetId; });
|
|
816
|
+
}
|
|
817
|
+
else if (e.action === "add" && !has && p.domain && p.projectKey) {
|
|
818
|
+
next = cur.concat([{ domain: p.domain, projectKey: p.projectKey, pending: true, pendingAt: e.committedAt }]);
|
|
819
|
+
}
|
|
820
|
+
if (!next)
|
|
821
|
+
continue;
|
|
822
|
+
const patch = Object.assign({}, bt || {}, { targets: next });
|
|
823
|
+
if (next.length) {
|
|
824
|
+
patch.domain = next[0].domain;
|
|
825
|
+
patch.projectKey = next[0].projectKey;
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
delete patch.domain;
|
|
829
|
+
delete patch.projectKey;
|
|
830
|
+
}
|
|
831
|
+
bt = patch;
|
|
832
|
+
btChanged = true;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (!extChanged && !intChanged && !btChanged)
|
|
836
|
+
return v;
|
|
837
|
+
const out = Object.assign({}, v);
|
|
838
|
+
if (extChanged)
|
|
839
|
+
out.externalSources = externals;
|
|
840
|
+
if (intChanged)
|
|
841
|
+
out.internalSources = internals;
|
|
842
|
+
if (btChanged)
|
|
843
|
+
out.backlogTarget = bt;
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
// ---- タイルと行の語 --------------------------------------------------------
|
|
847
|
+
/**
|
|
848
|
+
* 仮置きを持つ能力/アダプタの鍵(pendingCaps と同じ形: `能力` と `能力\tアダプタ`)。
|
|
849
|
+
* タイルは「登録状況を確認中です」に倒す——仮置きの add で 0 → 1 件になった能力に
|
|
850
|
+
* 「未接続」と言わせないため(語は増やさない)。
|
|
851
|
+
*/
|
|
852
|
+
function pendingCapsOf(list) {
|
|
853
|
+
const out = {};
|
|
854
|
+
for (const e of list || []) {
|
|
855
|
+
const meta = e && PENDING_KINDS[e.kind];
|
|
856
|
+
if (!meta)
|
|
857
|
+
continue;
|
|
858
|
+
out[meta.cap] = 1;
|
|
859
|
+
out[meta.cap + "\t" + meta.adapter] = 1;
|
|
860
|
+
}
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* 仮置きの行に出す語。印の無い行は空文字。
|
|
865
|
+
* 「反映待ち」/ 30 分経っても live で確かめられなければ「反映を確認できていません」。
|
|
866
|
+
*/
|
|
867
|
+
function pendingRowNote(row, nowMs) {
|
|
868
|
+
if (!row || row.pending !== true)
|
|
869
|
+
return "";
|
|
870
|
+
if (typeof row.pendingAt === "number" && typeof nowMs === "number" && nowMs > row.pendingAt + PENDING_OPS_UNCONFIRMED_MS) {
|
|
871
|
+
return "反映を確認できていません";
|
|
872
|
+
}
|
|
873
|
+
return "反映待ち";
|
|
874
|
+
}
|