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