@unson/brainbase-mcp 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contracts/foundation/graph-history.sql +634 -0
- package/contracts/judgment-value-proof/schema.json +43 -1
- package/dist/canonical-task-contract.d.ts +9 -0
- package/dist/canonical-task-contract.js +39 -0
- package/dist/canonical-task-principal.d.ts +16 -0
- package/dist/canonical-task-principal.js +36 -0
- package/dist/canonical-task-service.d.ts +251 -0
- package/dist/canonical-task-service.js +820 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/company-os-evaluation.d.ts +193 -0
- package/dist/company-os-evaluation.js +1150 -0
- package/dist/company-os-impact-review.d.ts +248 -0
- package/dist/company-os-impact-review.js +663 -0
- package/dist/company-os-learning-adoption.d.ts +288 -0
- package/dist/company-os-learning-adoption.js +944 -0
- package/dist/company-os-objectives.d.ts +63 -0
- package/dist/company-os-objectives.js +234 -0
- package/dist/company-os-receipt-adapter.d.ts +109 -0
- package/dist/company-os-receipt-adapter.js +636 -0
- package/dist/constraint-exception-store.d.ts +23 -0
- package/dist/constraint-exception-store.js +108 -0
- package/dist/constraint-resolution.d.ts +175 -0
- package/dist/constraint-resolution.js +922 -0
- package/dist/decision-adapter.d.ts +145 -0
- package/dist/decision-adapter.js +586 -0
- package/dist/durable-wait-http.d.ts +51 -0
- package/dist/durable-wait-http.js +497 -0
- package/dist/durable-waits.d.ts +234 -0
- package/dist/durable-waits.js +944 -0
- package/dist/execution-authority-http.d.ts +28 -0
- package/dist/execution-authority-http.js +359 -0
- package/dist/execution-authority.d.ts +201 -0
- package/dist/execution-authority.js +634 -0
- package/dist/foundation-authenticated-tools.d.ts +56 -0
- package/dist/foundation-authenticated-tools.js +347 -0
- package/dist/foundation-catalog.d.ts +26 -0
- package/dist/foundation-catalog.js +206 -0
- package/dist/foundation-constraint-store.d.ts +50 -0
- package/dist/foundation-constraint-store.js +215 -0
- package/dist/foundation-graph-http.d.ts +43 -0
- package/dist/foundation-graph-http.js +243 -0
- package/dist/foundation-graph-write.d.ts +90 -0
- package/dist/foundation-graph-write.js +447 -0
- package/dist/foundation-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-public-provider.d.ts +140 -0
- package/dist/foundation-public-provider.js +192 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -0
- package/dist/graph-corrections.d.ts +157 -0
- package/dist/graph-corrections.js +620 -0
- package/dist/graph-foundation-reader.d.ts +60 -0
- package/dist/graph-foundation-reader.js +494 -0
- package/dist/graph-retrieval.d.ts +6 -1
- package/dist/graph-revision-reader.d.ts +80 -0
- package/dist/graph-revision-reader.js +246 -0
- package/dist/graph-web-http.d.ts +26 -0
- package/dist/graph-web-http.js +215 -0
- package/dist/graph-web.d.ts +364 -0
- package/dist/graph-web.js +639 -0
- package/dist/judgment-dag-composition.d.ts +224 -0
- package/dist/judgment-dag-composition.js +731 -0
- package/dist/judgment-dag.d.ts +2 -0
- package/dist/judgment-dag.js +1 -0
- package/dist/judgment-problem-snapshot.d.ts +213 -0
- package/dist/judgment-problem-snapshot.js +1073 -0
- package/dist/judgment-value-proof-review.d.ts +166 -0
- package/dist/judgment-value-proof-review.js +437 -0
- package/dist/judgment-value-proof.d.ts +28 -1
- package/dist/judgment-value-proof.js +53 -8
- package/dist/judgment-view-http.d.ts +22 -0
- package/dist/judgment-view-http.js +128 -0
- package/dist/judgment-view.d.ts +222 -0
- package/dist/judgment-view.js +627 -0
- package/dist/knowledge-adapter.d.ts +112 -0
- package/dist/knowledge-adapter.js +691 -0
- package/dist/knowledge-continuation.d.ts +127 -0
- package/dist/knowledge-continuation.js +446 -0
- package/dist/knowledge-lookup.d.ts +178 -0
- package/dist/knowledge-lookup.js +758 -0
- package/dist/local-web-fetch-bridge.d.ts +5 -0
- package/dist/local-web-fetch-bridge.js +37 -0
- package/dist/local-web-host.d.ts +109 -0
- package/dist/local-web-host.js +688 -0
- package/dist/local-web-security.d.ts +25 -0
- package/dist/local-web-security.js +95 -0
- package/dist/ontology-foundation.d.ts +209 -0
- package/dist/ontology-foundation.js +1000 -0
- package/dist/ontology.d.ts +13 -0
- package/dist/ontology.js +14 -0
- package/dist/organization-connection.d.ts +164 -0
- package/dist/organization-connection.js +428 -0
- package/dist/organization-graph.d.ts +15 -0
- package/dist/organization-graph.js +24 -4
- package/dist/personal-knowledge.d.ts +132 -0
- package/dist/personal-knowledge.js +779 -0
- package/dist/personal-memory-handover.d.ts +108 -0
- package/dist/personal-memory-handover.js +369 -0
- package/dist/philosophy-revision-reader.d.ts +85 -0
- package/dist/philosophy-revision-reader.js +241 -0
- package/dist/portable-graph.d.ts +24 -0
- package/dist/portable-graph.js +77 -0
- package/dist/problem-candidates.d.ts +149 -0
- package/dist/problem-candidates.js +898 -0
- package/dist/problem-selection.d.ts +238 -0
- package/dist/problem-selection.js +995 -0
- package/dist/resource-reservation-http.d.ts +31 -0
- package/dist/resource-reservation-http.js +371 -0
- package/dist/resource-reservations.d.ts +221 -0
- package/dist/resource-reservations.js +577 -0
- package/dist/server.d.ts +104 -2
- package/dist/server.js +186 -4
- package/dist/ssot.d.ts +6 -1
- package/dist/ssot.js +63 -5
- package/dist/streamable-http.d.ts +42 -0
- package/dist/streamable-http.js +288 -0
- package/dist/types.d.ts +20 -0
- package/dist/value-proof-review-http.d.ts +25 -0
- package/dist/value-proof-review-http.js +185 -0
- package/dist/world-model.d.ts +199 -0
- package/dist/world-model.js +955 -0
- package/package.json +230 -3
- package/ui/brainbase-tokens.css +56 -0
- package/ui/graph-own-share.js +367 -0
- package/ui/graph-projects-view.css +23 -0
- package/ui/graph-projects-view.js +730 -0
- package/ui/graph-registry-view.css +101 -0
- package/ui/graph-registry-view.js +804 -0
- package/ui/graph-view-shared.css +522 -0
- package/ui/graph-view-shared.js +1227 -0
- package/ui/judgment-view.css +312 -0
- package/ui/judgment-view.js +508 -0
- package/ui/local-web-shell.css +396 -0
- package/ui/local-web-shell.js +378 -0
- package/ui/objective-editor-http-port.js +154 -0
- package/ui/objective-editor.css +286 -0
- package/ui/objective-editor.js +1586 -0
- package/ui/outcome-knowledge.css +50 -14
- package/ui/outcome-knowledge.js +15 -1
- package/ui/outcome-mana.css +91 -14
- package/ui/outcome-mana.js +958 -61
- package/ui/value-proof-review.css +504 -0
- package/ui/value-proof-review.js +1104 -0
- package/ui/workspace-kit.css +485 -0
- package/ui/workspace-kit.js +229 -0
- package/ui/world-model-view.css +188 -0
- package/ui/world-model-view.js +486 -0
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared parts of the local Graph screens (「プロジェクトと関係者」「情報と関係」).
|
|
3
|
+
*
|
|
4
|
+
* - A client for the Graph routes (`GET {base}/...`, `POST {base}/corrections`).
|
|
5
|
+
* - Read-state rendering that never turns Graph v1, a data directory without
|
|
6
|
+
* Brainbase data, or a failed read into zero items.
|
|
7
|
+
* - Plain-Japanese labels for types, relations, provenance and validity.
|
|
8
|
+
* - The correction form used by both screens. Every correction needs a
|
|
9
|
+
* one-sentence reason, sends the digest of the record it was based on, keeps
|
|
10
|
+
* the draft and shows the current record on a conflict, and shows the values
|
|
11
|
+
* read back after a save. Records are never deleted; an entity or relation
|
|
12
|
+
* ends with an end date.
|
|
13
|
+
*
|
|
14
|
+
* - The layout both screens share: a workspace (page head, notices, ledgers)
|
|
15
|
+
* and the right rail with the selected item. The look is the workspace kit
|
|
16
|
+
* (`workspace-kit.js`/`.css`), the organization edition's screen pattern.
|
|
17
|
+
*
|
|
18
|
+
* The host injects the fetcher, base path and launch token. Nothing here
|
|
19
|
+
* keeps global state. Pages load `workspace-kit.css` and
|
|
20
|
+
* `graph-view-shared.css` with the screen CSS.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { workspaceButton, workspaceNotice } from './workspace-kit.js';
|
|
24
|
+
|
|
25
|
+
export const GRAPH_VIEW_SHARED_CONTRACT_VERSION = 'brainbase.graph-view-shared.v2';
|
|
26
|
+
|
|
27
|
+
export const GRAPH_ENTITY_TYPE_LABELS = Object.freeze({
|
|
28
|
+
person: '人物',
|
|
29
|
+
org: '組織',
|
|
30
|
+
project: 'プロジェクト',
|
|
31
|
+
decision: '判断',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const GRAPH_ENTITY_TYPE_MEANINGS = Object.freeze({
|
|
35
|
+
person: 'Graphに登録された人',
|
|
36
|
+
org: 'Graphに登録された組織',
|
|
37
|
+
project: '目的を持つ、区切りのある仕事',
|
|
38
|
+
decision: '続けて使う判断や原則。以前の判断を置き換えることがあります',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `outgoing` follows the counterpart name when this record is the start of the
|
|
43
|
+
* relation; `incoming` follows it when this record is the end.
|
|
44
|
+
*/
|
|
45
|
+
export const GRAPH_RELATION_LABELS = Object.freeze({
|
|
46
|
+
member_of: Object.freeze({ label: '所属', meaning: '人物が組織に所属している', outgoing: 'に所属', incoming: 'が所属' }),
|
|
47
|
+
participates_in: Object.freeze({ label: '参加', meaning: '人物がプロジェクトに参加している', outgoing: 'に参加', incoming: 'が参加' }),
|
|
48
|
+
accountable_for: Object.freeze({ label: '責任', meaning: '人物がプロジェクトの成果や判断に責任を持つ', outgoing: 'に責任を持つ', incoming: 'が責任を持つ' }),
|
|
49
|
+
owned_by: Object.freeze({ label: '所有', meaning: 'プロジェクトを組織が所有している', outgoing: 'が所有', incoming: 'を所有' }),
|
|
50
|
+
governs: Object.freeze({ label: '方針', meaning: '判断や原則がプロジェクトの進め方を決める', outgoing: 'の進め方を決める', incoming: 'が進め方を決める' }),
|
|
51
|
+
supersedes: Object.freeze({ label: '置き換え', meaning: '判断が以前の判断を置き換える', outgoing: 'を置き換える', incoming: 'に置き換えられた' }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Relations a correction may add (the host refuses any other). */
|
|
55
|
+
export const GRAPH_NEW_RELATIONS = Object.freeze({
|
|
56
|
+
participates_in: Object.freeze({ from: 'person', to: 'project' }),
|
|
57
|
+
accountable_for: Object.freeze({ from: 'person', to: 'project' }),
|
|
58
|
+
member_of: Object.freeze({ from: 'person', to: 'org' }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const GRAPH_SOURCE_KIND_LABELS = Object.freeze({
|
|
62
|
+
user_approved: '利用者が承認',
|
|
63
|
+
migration: '移行',
|
|
64
|
+
import: '取り込み',
|
|
65
|
+
onboarding: 'オンボーディング',
|
|
66
|
+
organization_graph: '組織のGraph',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const REVIEW_STATUS_LABELS = Object.freeze({ approved: '承認済み', pending: '確認待ち', rejected: '却下', edited: '修正して承認', merged: '統合' });
|
|
70
|
+
|
|
71
|
+
const FIELD_LABELS = Object.freeze({
|
|
72
|
+
name: '名前',
|
|
73
|
+
aliases: '別名',
|
|
74
|
+
summary: '要約',
|
|
75
|
+
validFrom: '開始日',
|
|
76
|
+
validTo: '終了日',
|
|
77
|
+
goal: '目的',
|
|
78
|
+
status: '状態',
|
|
79
|
+
'metadata.goal': '目的',
|
|
80
|
+
'metadata.status': '状態',
|
|
81
|
+
role: '役割',
|
|
82
|
+
context: '文脈',
|
|
83
|
+
fromId: '起点',
|
|
84
|
+
toId: '終点',
|
|
85
|
+
relation: '関係の種類',
|
|
86
|
+
provenance: '出典',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const CORRECTION_KIND_LABELS = Object.freeze({
|
|
90
|
+
update_entity: '記録を直した',
|
|
91
|
+
update_edge: '関係を直した',
|
|
92
|
+
create_edge: '関係を加えた',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Inputs of the correction form. `date` inputs hold a local day (YYYY-MM-DD). */
|
|
96
|
+
export const GRAPH_CORRECTION_FIELDS = Object.freeze({
|
|
97
|
+
name: Object.freeze({ label: '名前', control: 'text', max: 200 }),
|
|
98
|
+
aliases: Object.freeze({ label: '別名', control: 'text', max: 2000, help: '複数あるときは「、」で区切ります。' }),
|
|
99
|
+
summary: Object.freeze({ label: '要約', control: 'textarea', max: 2000 }),
|
|
100
|
+
goal: Object.freeze({ label: '目的', control: 'textarea', max: 2000 }),
|
|
101
|
+
status: Object.freeze({ label: '状態', control: 'text', max: 200, help: '自由記述です。例: 進行中、保留、完了' }),
|
|
102
|
+
validFrom: Object.freeze({ label: '開始日', control: 'date' }),
|
|
103
|
+
validTo: Object.freeze({ label: '終了日', control: 'date', help: 'この日の0時から、終わったものとして扱います。空にすると終了日を消します。' }),
|
|
104
|
+
role: Object.freeze({ label: '役割', control: 'text', max: 200, help: '自由記述です。例: 最終判断、進行管理' }),
|
|
105
|
+
context: Object.freeze({ label: '文脈', control: 'textarea', max: 2000, help: 'どのように関わっているか' }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const GRAPH_CORRECTION_ERROR_MESSAGES = Object.freeze({
|
|
109
|
+
correction_reason_required: '理由を1文で書いてください。',
|
|
110
|
+
correction_reason_invalid: '理由は改行を含まない1文(500文字まで)にしてください。',
|
|
111
|
+
correction_no_change: '変更がありません。直したい項目を書き換えてください。',
|
|
112
|
+
correction_digest_required: '元にした記録を確かめられません。読み直してから、もう一度直してください。',
|
|
113
|
+
correction_delete_not_allowed: '記録は削除できません。終わったものは終了日で表します。',
|
|
114
|
+
correction_relation_not_allowed: 'この種類の関係は、ここでは加えられません。',
|
|
115
|
+
correction_relation_change_not_allowed: '関係の種類は変えられません。今の関係を終えてから、新しい関係を加えてください。',
|
|
116
|
+
correction_endpoint_change_not_allowed: '関係の相手は変えられません。今の関係を終えてから、新しい関係を加えてください。',
|
|
117
|
+
correction_endpoint_type_invalid: 'この組み合わせの関係は加えられません。',
|
|
118
|
+
correction_field_not_allowed: 'この項目は、ここでは直せません。',
|
|
119
|
+
correction_changes_required: '変更がありません。直したい項目を書き換えてください。',
|
|
120
|
+
correction_scope_not_allowed: 'この画面では、この記録や関係は直せません。',
|
|
121
|
+
entity_not_found: '対象の記録が見つかりません。読み直してください。',
|
|
122
|
+
edge_not_found: '対象の関係が見つかりません。読み直してください。',
|
|
123
|
+
entity_id_ambiguous: '同じIDの記録が複数あるため直せません。CLIで確かめてください。',
|
|
124
|
+
graph_not_initialized: 'Brainbaseのデータがまだありません。',
|
|
125
|
+
migration_required: 'Graphの移行が必要です。ホストは自動で移行しません。',
|
|
126
|
+
readback_mismatch: '保存後に読み直した内容が一致しませんでした。読み直して確かめてください。',
|
|
127
|
+
web_token_required: '起動時のトークンを確かめられません。ページを開き直してください。',
|
|
128
|
+
write_protection_missing: 'このホストでは保存できません。',
|
|
129
|
+
cross_origin_rejected: '別のサイトからの保存は受け付けません。',
|
|
130
|
+
host_rejected: 'このアドレスからの保存は受け付けません。',
|
|
131
|
+
payload_too_large: '入力が大きすぎます。',
|
|
132
|
+
unsupported_media_type: 'ホストに送る形式が正しくありません。',
|
|
133
|
+
network_error: 'ホストに接続できません。',
|
|
134
|
+
invalid_response: 'ホストの応答を読み取れません。',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const DEFAULT_APPLIES_TO = Object.freeze(['search', 'get_context', 'resolve_entity']);
|
|
138
|
+
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/u;
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// DOM helpers
|
|
142
|
+
|
|
143
|
+
export function getDocument(explicit) {
|
|
144
|
+
const value = explicit ?? (typeof document === 'undefined' ? null : document);
|
|
145
|
+
if (!value || typeof value.createElement !== 'function') throw new Error('document_unavailable');
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function makeElement(doc, tag, { className, text, attrs = {} } = {}) {
|
|
150
|
+
const element = doc.createElement(tag);
|
|
151
|
+
if (className) element.className = className;
|
|
152
|
+
if (text !== undefined && text !== null) element.textContent = String(text);
|
|
153
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
154
|
+
if (value === undefined || value === null || value === false) continue;
|
|
155
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
156
|
+
}
|
|
157
|
+
return element;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isRecord(value) {
|
|
161
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function textOrNull(value) {
|
|
165
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function notice(doc, tone, message, role = 'status') {
|
|
169
|
+
return makeElement(doc, 'p', { className: `bb-graph-notice is-${tone}`, text: message, attrs: { role } });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function badge(doc, label, tone = '') {
|
|
173
|
+
return makeElement(doc, 'span', { className: `bb-graph-badge${tone ? ` is-${tone}` : ''}`, text: label });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A button in the workspace kit's shape; `bb-graph-link` makes a name that opens a record. */
|
|
177
|
+
export function button(doc, label, onClick, { className = 'bb-ws-button', attrs = {} } = {}) {
|
|
178
|
+
const element = makeElement(doc, 'button', { className, text: label, attrs: { type: 'button', ...attrs } });
|
|
179
|
+
element.addEventListener('click', (event) => {
|
|
180
|
+
event?.preventDefault?.();
|
|
181
|
+
return onClick(event);
|
|
182
|
+
});
|
|
183
|
+
return element;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function facts(doc, rows, className = 'bb-graph-facts') {
|
|
187
|
+
const list = makeElement(doc, 'dl', { className });
|
|
188
|
+
for (const [label, value] of rows) {
|
|
189
|
+
if (value === null || value === undefined || value === '') continue;
|
|
190
|
+
const dd = makeElement(doc, 'dd');
|
|
191
|
+
if (typeof value === 'object' && value.tagName) dd.append(value);
|
|
192
|
+
else dd.textContent = String(value);
|
|
193
|
+
list.append(makeElement(doc, 'dt', { text: label }), dd);
|
|
194
|
+
}
|
|
195
|
+
return list;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Ids and digests stay in this disclosure, never in the visible copy. */
|
|
199
|
+
export function recordDetails(doc, rows) {
|
|
200
|
+
const details = makeElement(doc, 'details', { className: 'bb-graph-audit' });
|
|
201
|
+
details.append(makeElement(doc, 'summary', { text: '記録の詳細' }), facts(doc, rows, 'bb-graph-facts is-mono'));
|
|
202
|
+
return details;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Dates and validity
|
|
207
|
+
|
|
208
|
+
/** The calendar day written in an RFC 3339 value, without converting time zones. */
|
|
209
|
+
export function formatDay(value) {
|
|
210
|
+
const match = typeof value === 'string' ? /^(\d{4}-\d{2}-\d{2})T/u.exec(value) : null;
|
|
211
|
+
return match ? match[1] : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function formatDateTime(value) {
|
|
215
|
+
const time = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
216
|
+
if (Number.isNaN(time)) return '日時不明';
|
|
217
|
+
const date = new Date(time);
|
|
218
|
+
const pad = (number) => String(number).padStart(2, '0');
|
|
219
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Today (or `date`) as a local calendar day, the value a date input uses. */
|
|
223
|
+
export function localDay(date = new Date()) {
|
|
224
|
+
const pad = (number) => String(number).padStart(2, '0');
|
|
225
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Local midnight of a calendar day as RFC 3339 with the local offset. */
|
|
229
|
+
export function dayToRfc3339(day) {
|
|
230
|
+
const match = typeof day === 'string' ? DAY_PATTERN.exec(day) : null;
|
|
231
|
+
if (!match) return null;
|
|
232
|
+
const local = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
233
|
+
if (local.getFullYear() !== Number(match[1]) || local.getMonth() !== Number(match[2]) - 1 || local.getDate() !== Number(match[3])) return null;
|
|
234
|
+
const east = -local.getTimezoneOffset();
|
|
235
|
+
const pad = (number) => String(number).padStart(2, '0');
|
|
236
|
+
const sign = east >= 0 ? '+' : '-';
|
|
237
|
+
return `${day}T00:00:00${sign}${pad(Math.floor(Math.abs(east) / 60))}:${pad(Math.abs(east) % 60)}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function validityText(validFrom, validTo) {
|
|
241
|
+
const from = formatDay(validFrom);
|
|
242
|
+
const to = formatDay(validTo);
|
|
243
|
+
if (from && to) return `${from} から ${to} に終了`;
|
|
244
|
+
if (from) return `${from} から`;
|
|
245
|
+
if (to) return `${to} に終了`;
|
|
246
|
+
return '期限なし';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** '有効', '開始前' or '終了' for a record at the response time. */
|
|
250
|
+
export function activityText(record, asOf) {
|
|
251
|
+
if (record.active === true) return '有効';
|
|
252
|
+
const starts = typeof record.validFrom === 'string' ? Date.parse(record.validFrom) : Number.NaN;
|
|
253
|
+
const at = typeof asOf === 'string' ? Date.parse(asOf) : Number.NaN;
|
|
254
|
+
if (!Number.isNaN(starts) && !Number.isNaN(at) && starts > at) return '開始前';
|
|
255
|
+
return '終了';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Badge for a record's validity at the response time. */
|
|
259
|
+
export function activityBadge(doc, record, asOf) {
|
|
260
|
+
const text = activityText(record, asOf);
|
|
261
|
+
return badge(doc, text, text === '有効' ? 'success' : 'muted');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Validity in one line for a ledger cell: the period, and why a record is not in effect. */
|
|
265
|
+
export function validityLabel(record, asOf) {
|
|
266
|
+
const period = validityText(record.validFrom, record.validTo);
|
|
267
|
+
const activity = activityText(record, asOf);
|
|
268
|
+
if (activity === '有効') return period;
|
|
269
|
+
if (activity === '開始前') return `開始前(${period})`;
|
|
270
|
+
return period.includes('終了') ? period : `終了(${period})`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Payload checks
|
|
275
|
+
|
|
276
|
+
export function isEntityView(value) {
|
|
277
|
+
return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' && typeof value.name === 'string'
|
|
278
|
+
&& typeof value.digest === 'string' && Array.isArray(value.aliases);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function isEdgeView(value) {
|
|
282
|
+
return isRecord(value) && typeof value.id === 'string' && typeof value.relation === 'string' && typeof value.digest === 'string'
|
|
283
|
+
&& (value.direction === 'incoming' || value.direction === 'outgoing')
|
|
284
|
+
&& isRecord(value.counterpart) && typeof value.counterpart.id === 'string' && typeof value.counterpart.name === 'string'
|
|
285
|
+
&& isRecord(value.provenance) && isRecord(value.provenance.reference);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A value of a label table by its own key only, so a type or relation id such as `constructor` is never read from the prototype. */
|
|
289
|
+
export function ownLabel(table, key) {
|
|
290
|
+
return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A relation's plain label; an unknown relation shows its raw id. */
|
|
294
|
+
export function relationLabel(relation) {
|
|
295
|
+
return ownLabel(GRAPH_RELATION_LABELS, relation)?.label ?? relation;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A type's plain label; an unknown type (from another host's Graph) shows its raw id. */
|
|
299
|
+
export function typeLabel(type) {
|
|
300
|
+
return ownLabel(GRAPH_ENTITY_TYPE_LABELS, type) ?? type;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Client
|
|
305
|
+
|
|
306
|
+
/** A value quoted for a shell command line only when it needs quoting. */
|
|
307
|
+
export function shellArg(value) {
|
|
308
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/u.test(value) ? value : JSON.stringify(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readJson(response) {
|
|
312
|
+
try {
|
|
313
|
+
return await response.json();
|
|
314
|
+
} catch {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function errorOf(status, payload) {
|
|
320
|
+
const error = isRecord(payload?.error) ? payload.error : {};
|
|
321
|
+
const code = typeof error.code === 'string' && error.code ? error.code : status ? `http_${status}` : 'invalid_response';
|
|
322
|
+
const detail = textOrNull(error.message);
|
|
323
|
+
const known = GRAPH_CORRECTION_ERROR_MESSAGES[code];
|
|
324
|
+
const message = code === 'correction_invalid' || code === 'invalid_json'
|
|
325
|
+
? `入力が保存の条件を満たしていません${detail ? `(${detail})` : ''}。`
|
|
326
|
+
: known ?? (detail ? `保存できませんでした(${detail})。` : `保存できませんでした(HTTP ${status})。`);
|
|
327
|
+
return { code, detail, message, error };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Graph routes client. `read` resolves to one of
|
|
332
|
+
* `{ state: 'ok' | 'migration_required' | 'not_initialized' | 'not_handed_over', payload }`
|
|
333
|
+
* (`not_handed_over`: a host's owner-private routes when the owner has sent
|
|
334
|
+
* nothing yet, see `graph-own-share.js`),
|
|
335
|
+
* `{ state: 'error', code, reason, status }` or `{ state: 'invalid', reason }`.
|
|
336
|
+
* `correct` resolves to `{ state: 'saved', payload }`,
|
|
337
|
+
* `{ state: 'conflict', code, message, current }` or `{ state: 'error', code, message }`.
|
|
338
|
+
*/
|
|
339
|
+
export function createGraphClient({ fetcher, basePath = '/api/graph', token, tokenHeader = 'X-Brainbase-Review-Token' } = {}) {
|
|
340
|
+
const request = typeof fetcher === 'function'
|
|
341
|
+
? fetcher
|
|
342
|
+
: typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
|
|
343
|
+
const base = String(basePath).replace(/\/+$/u, '');
|
|
344
|
+
|
|
345
|
+
function url(path, params = {}) {
|
|
346
|
+
const query = new URLSearchParams();
|
|
347
|
+
for (const [key, value] of Object.entries(params)) {
|
|
348
|
+
if (value === undefined || value === null || value === '') continue;
|
|
349
|
+
query.set(key, String(value));
|
|
350
|
+
}
|
|
351
|
+
const search = query.toString();
|
|
352
|
+
return `${base}${path}${search ? `?${search}` : ''}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return Object.freeze({
|
|
356
|
+
basePath: base,
|
|
357
|
+
url,
|
|
358
|
+
async read(path, params) {
|
|
359
|
+
if (!request) return { state: 'error', code: 'fetcher_missing', reason: '取得先が設定されていません', status: 0 };
|
|
360
|
+
let response;
|
|
361
|
+
try {
|
|
362
|
+
response = await request(url(path, params));
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return { state: 'error', code: 'network_error', reason: error instanceof Error ? error.message : 'ホストに接続できません', status: 0 };
|
|
365
|
+
}
|
|
366
|
+
const payload = await readJson(response);
|
|
367
|
+
if (!response.ok) {
|
|
368
|
+
const error = isRecord(payload?.error) ? payload.error : {};
|
|
369
|
+
return {
|
|
370
|
+
state: 'error',
|
|
371
|
+
code: textOrNull(error.code) ?? `http_${response.status}`,
|
|
372
|
+
reason: textOrNull(error.message) ?? textOrNull(error.code) ?? `HTTP ${response.status}`,
|
|
373
|
+
status: response.status,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (!isRecord(payload)) return { state: 'invalid', reason: '応答の形式が不正です' };
|
|
377
|
+
if (['migration_required', 'not_initialized', 'not_handed_over', 'ok'].includes(payload.status)) {
|
|
378
|
+
return { state: payload.status, payload };
|
|
379
|
+
}
|
|
380
|
+
return { state: 'invalid', reason: '応答の形式が不正です' };
|
|
381
|
+
},
|
|
382
|
+
async correct(body) {
|
|
383
|
+
if (!request) return { state: 'error', code: 'fetcher_missing', message: '保存先が設定されていません。' };
|
|
384
|
+
let response;
|
|
385
|
+
try {
|
|
386
|
+
response = await request(url('/corrections'), {
|
|
387
|
+
method: 'POST',
|
|
388
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { [tokenHeader]: token } : {}) },
|
|
389
|
+
body: JSON.stringify(body),
|
|
390
|
+
});
|
|
391
|
+
} catch {
|
|
392
|
+
return { state: 'error', code: 'network_error', message: GRAPH_CORRECTION_ERROR_MESSAGES.network_error };
|
|
393
|
+
}
|
|
394
|
+
const payload = await readJson(response);
|
|
395
|
+
if (response.ok) {
|
|
396
|
+
if (isRecord(payload) && payload.status === 'saved' && isRecord(payload.record) && typeof payload.digest === 'string') {
|
|
397
|
+
return { state: 'saved', payload };
|
|
398
|
+
}
|
|
399
|
+
return { state: 'error', code: 'invalid_response', message: GRAPH_CORRECTION_ERROR_MESSAGES.invalid_response };
|
|
400
|
+
}
|
|
401
|
+
const failure = errorOf(response.status, payload);
|
|
402
|
+
const current = isRecord(failure.error.current) && isRecord(failure.error.current.record) ? failure.error.current : null;
|
|
403
|
+
if (response.status === 409 && current) {
|
|
404
|
+
return { state: 'conflict', code: failure.code, message: failure.message, current };
|
|
405
|
+
}
|
|
406
|
+
return { state: 'error', code: failure.code, message: failure.message };
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The same client with every correction refused before anything is sent, for
|
|
413
|
+
* a host that shows its Graph read-only (`canCorrect: false`).
|
|
414
|
+
*/
|
|
415
|
+
export function readOnlyGraphClient(client) {
|
|
416
|
+
return Object.freeze({
|
|
417
|
+
...client,
|
|
418
|
+
async correct() {
|
|
419
|
+
return { state: 'error', code: 'write_protection_missing', message: GRAPH_CORRECTION_ERROR_MESSAGES.write_protection_missing };
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The corrections a host accepts (`correctionScope` of both screens). Without
|
|
426
|
+
* a scope every correction is accepted, as on the local host.
|
|
427
|
+
*
|
|
428
|
+
* - `entityTypes`: the kinds whose record may be corrected (default: every kind).
|
|
429
|
+
* - `fields`: the record fields that may be corrected, keys of
|
|
430
|
+
* `GRAPH_CORRECTION_FIELDS` such as `name` or `goal` (default: every field).
|
|
431
|
+
* - `edges: false`: relations cannot be corrected or ended.
|
|
432
|
+
* - `createEdges: false`: relations cannot be added.
|
|
433
|
+
*/
|
|
434
|
+
export function graphCorrectionScope(scope) {
|
|
435
|
+
const given = isRecord(scope) ? scope : {};
|
|
436
|
+
const listed = (value) => (Array.isArray(value) ? new Set(value.filter((item) => typeof item === 'string')) : null);
|
|
437
|
+
const types = listed(given.entityTypes);
|
|
438
|
+
const fields = listed(given.fields);
|
|
439
|
+
return Object.freeze({
|
|
440
|
+
edges: given.edges !== false,
|
|
441
|
+
createEdges: given.createEdges !== false,
|
|
442
|
+
/** Whether a record of this kind may be corrected. */
|
|
443
|
+
entity: (type) => types === null || types.has(type),
|
|
444
|
+
/** The given record fields that may be corrected, in their order. */
|
|
445
|
+
fields: (names) => names.filter((name) => fields === null || fields.has(name)),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The record fields the correction form offers for an entity of this kind. */
|
|
450
|
+
export function graphEntityCorrectionFields(type) {
|
|
451
|
+
return ['name', 'aliases', 'summary', 'validFrom', 'validTo', ...(type === 'project' ? ['goal', 'status'] : [])];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Whether a built correction request stays inside the scope (`recordType` is the corrected entity's kind). */
|
|
455
|
+
function withinCorrectionScope(scope, body, recordType) {
|
|
456
|
+
if (body.kind === 'create_edge') return scope.createEdges;
|
|
457
|
+
if (body.kind === 'update_edge') return scope.edges;
|
|
458
|
+
const changed = Object.keys(body.changes ?? {});
|
|
459
|
+
return scope.entity(recordType) && scope.fields(changed).length === changed.length;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A host's note that corrections are not available here, or null when it passed none. */
|
|
463
|
+
export function hostReadOnlyNote(doc, readOnlyNote) {
|
|
464
|
+
const text = textOrNull(readOnlyNote);
|
|
465
|
+
return text ? workspaceNotice(doc, { text, tone: 'info' }) : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Read states
|
|
470
|
+
|
|
471
|
+
function commandList(doc, commands) {
|
|
472
|
+
const list = makeElement(doc, 'ol', { className: 'bb-graph-commands' });
|
|
473
|
+
for (const command of commands) {
|
|
474
|
+
const item = makeElement(doc, 'li');
|
|
475
|
+
item.append(makeElement(doc, 'code', { text: command }));
|
|
476
|
+
list.append(item);
|
|
477
|
+
}
|
|
478
|
+
return list;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Commands to register data, as a numbered list (used by the empty states). */
|
|
482
|
+
export function graphCommandList(doc, commands) {
|
|
483
|
+
return commandList(doc, commands);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function noticeBody(doc, ...children) {
|
|
487
|
+
const body = makeElement(doc, 'div', { className: 'bb-graph-notice-body' });
|
|
488
|
+
body.append(...children.filter(Boolean));
|
|
489
|
+
return body;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function retryButton(doc, onRetry) {
|
|
493
|
+
return typeof onRetry === 'function' ? workspaceButton(doc, { text: '再試行', variant: 'quiet', onClick: () => onRetry() }) : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A read state as a workspace-kit notice, or null when the caller should
|
|
498
|
+
* render the payload. Graph v1, a missing data set, an unconfirmed absence
|
|
499
|
+
* and a failure are never shown as zero items.
|
|
500
|
+
*/
|
|
501
|
+
export function graphStateNotice(doc, readState, { onRetry, loadingText = '読み込んでいます。' } = {}) {
|
|
502
|
+
const state = readState?.state ?? 'loading';
|
|
503
|
+
if (state === 'ok') return null;
|
|
504
|
+
if (state === 'loading' || state === 'idle') {
|
|
505
|
+
return workspaceNotice(doc, { label: '読み込み中', text: loadingText, tone: 'info', role: 'status' });
|
|
506
|
+
}
|
|
507
|
+
if (state === 'migration_required') {
|
|
508
|
+
const payload = readState.payload ?? {};
|
|
509
|
+
const commands = [payload.command, payload.writeCommand].filter((command) => typeof command === 'string' && command);
|
|
510
|
+
return workspaceNotice(doc, {
|
|
511
|
+
label: '移行が必要',
|
|
512
|
+
tone: 'warning',
|
|
513
|
+
text: noticeBody(
|
|
514
|
+
doc,
|
|
515
|
+
makeElement(doc, 'p', {
|
|
516
|
+
text: 'Graphの移行が必要です。このデータはGraph v1のため、表示も訂正もできません。0件ではありません。ホストは自動で移行しません。次のコマンドで、内容を確かめてから移行してください。',
|
|
517
|
+
}),
|
|
518
|
+
Number.isInteger(payload.legacyEntityCount) ? makeElement(doc, 'p', { text: `Graph v1には${payload.legacyEntityCount}件の記録があります。` }) : null,
|
|
519
|
+
commands.length > 0 ? commandList(doc, commands) : null,
|
|
520
|
+
),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (state === 'not_initialized') {
|
|
524
|
+
const payload = readState.payload ?? {};
|
|
525
|
+
const dir = textOrNull(payload.source?.dataDir);
|
|
526
|
+
const command = textOrNull(payload.command) ?? 'brainbase onboard:start';
|
|
527
|
+
return workspaceNotice(doc, {
|
|
528
|
+
label: '未登録',
|
|
529
|
+
tone: 'info',
|
|
530
|
+
role: 'status',
|
|
531
|
+
text: noticeBody(
|
|
532
|
+
doc,
|
|
533
|
+
makeElement(doc, 'p', { text: 'まだ登録がありません。このデータの場所には、Brainbaseのデータがまだありません。次のコマンドで始められます。CodexやClaude Codeからは、MCPのオンボーディング(brainbase_onboarding_start)でも始められます。' }),
|
|
534
|
+
commandList(doc, [dir ? `${command} --dir ${shellArg(dir)}` : command]),
|
|
535
|
+
),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (state === 'not_found') {
|
|
539
|
+
return workspaceNotice(doc, { label: '見つかりません', tone: 'warning', text: readState.reason ?? '見つかりません。読み直してください。' });
|
|
540
|
+
}
|
|
541
|
+
if (state === 'unknown') {
|
|
542
|
+
return workspaceNotice(doc, {
|
|
543
|
+
label: '未確認',
|
|
544
|
+
tone: 'warning',
|
|
545
|
+
text: noticeBody(doc, makeElement(doc, 'p', { text: '記録の有無を確かめられません。0件ではありません。' }), retryButton(doc, onRetry)),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return workspaceNotice(doc, {
|
|
549
|
+
label: '読み取り失敗',
|
|
550
|
+
tone: 'danger',
|
|
551
|
+
text: noticeBody(doc, makeElement(doc, 'p', { text: `読み取れませんでした(${readState.reason ?? '理由不明'})。0件ではありません。` }), retryButton(doc, onRetry)),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Renders a read state and returns true only when the caller should render
|
|
557
|
+
* the payload. Graph v1, a missing data set and a failure are never zero items.
|
|
558
|
+
*/
|
|
559
|
+
export function renderGraphReadState(doc, container, readState, options = {}) {
|
|
560
|
+
const box = graphStateNotice(doc, readState, options);
|
|
561
|
+
if (!box) return true;
|
|
562
|
+
container.append(box);
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Records that could not be read are counted apart from the readable ones. */
|
|
567
|
+
export function renderGraphIssues(doc, issues) {
|
|
568
|
+
if (!Array.isArray(issues) || issues.length === 0) return null;
|
|
569
|
+
const list = makeElement(doc, 'ul', { className: 'bb-graph-issue-list' });
|
|
570
|
+
for (const issue of issues) {
|
|
571
|
+
if (!isRecord(issue)) continue;
|
|
572
|
+
const where = `${String(issue.file ?? '不明')}${Number.isInteger(issue.line) ? `(${issue.line}行目)` : ''}`;
|
|
573
|
+
list.append(makeElement(doc, 'li', { text: `${where}: ${String(issue.reason ?? '理由不明')}` }));
|
|
574
|
+
}
|
|
575
|
+
return workspaceNotice(doc, {
|
|
576
|
+
label: '一部読めません',
|
|
577
|
+
tone: 'warning',
|
|
578
|
+
text: noticeBody(doc, makeElement(doc, 'p', { text: `出典や訂正の履歴のうち、読めない記録が${issues.length}件あります。読めた記録だけを表示しています。` }), list),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Layout
|
|
584
|
+
|
|
585
|
+
/** Moves focus back to a ledger row after the ledger was drawn again (a browser DOM only). */
|
|
586
|
+
export function focusLedgerRow(root, key) {
|
|
587
|
+
if (typeof root?.querySelectorAll !== 'function') return;
|
|
588
|
+
const row = [...root.querySelectorAll('.bb-ws-ledger-row[data-key]')].find((candidate) => candidate.getAttribute('data-key') === key);
|
|
589
|
+
row?.focus?.({ preventScroll: true });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The page context the host passes: breadcrumb and source label. */
|
|
593
|
+
/**
|
|
594
|
+
* The host's own guidance for a Graph with nothing registered, `{ label?, text }`, in place of the
|
|
595
|
+
* local `brainbase onboard:*` commands (another host's Graph is not filled by this Mac's CLI).
|
|
596
|
+
*/
|
|
597
|
+
export function graphHostEmptyNotice(doc, notice) {
|
|
598
|
+
if (!notice || typeof notice !== 'object' || Array.isArray(notice) || !notice.text) return null;
|
|
599
|
+
const label = typeof notice.label === 'string' && notice.label.trim() ? notice.label.trim() : '未登録';
|
|
600
|
+
return workspaceNotice(doc, { label, text: notice.text, tone: 'info', role: 'status' });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function graphPageContext(page) {
|
|
604
|
+
const crumbs = Array.isArray(page?.crumbs) ? page.crumbs.filter((crumb) => typeof crumb === 'string' && crumb) : [];
|
|
605
|
+
return { crumbs, source: textOrNull(page?.source) };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Puts a screen's workspace in `root` and its selected item in `rail`. A host
|
|
610
|
+
* without a right rail (no `rail`) gets the rail content after the workspace,
|
|
611
|
+
* inside `root`, so the screen still works there.
|
|
612
|
+
*/
|
|
613
|
+
export function createGraphLayout(doc, { root, rail, label, className, contractVersion }) {
|
|
614
|
+
return Object.freeze({
|
|
615
|
+
inline: !rail,
|
|
616
|
+
render(workspaceChildren, railChildren) {
|
|
617
|
+
const surface = makeElement(doc, 'section', {
|
|
618
|
+
className: `bb-graph ${className}`,
|
|
619
|
+
attrs: { 'data-contract-version': contractVersion, 'aria-label': label },
|
|
620
|
+
});
|
|
621
|
+
surface.append(...workspaceChildren.filter(Boolean));
|
|
622
|
+
const detail = makeElement(doc, 'div', { className: `bb-graph-rail ${className}-rail` });
|
|
623
|
+
detail.append(...railChildren.filter(Boolean));
|
|
624
|
+
if (rail) {
|
|
625
|
+
root.replaceChildren(surface);
|
|
626
|
+
rail.replaceChildren(detail);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const inline = makeElement(doc, 'aside', { className: 'bb-graph-rail-inline', attrs: { 'aria-label': '選択中の項目' } });
|
|
630
|
+
inline.append(detail);
|
|
631
|
+
root.replaceChildren(surface, inline);
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Provenance and history
|
|
638
|
+
|
|
639
|
+
/** Plain-Japanese provenance: kind, and the source record it resolves to. */
|
|
640
|
+
export function provenanceSummary(provenance) {
|
|
641
|
+
const kind = GRAPH_SOURCE_KIND_LABELS[provenance?.sourceKind] ?? (textOrNull(provenance?.sourceKind) ?? '記録なし');
|
|
642
|
+
const reference = isRecord(provenance?.reference) ? provenance.reference : { status: 'none' };
|
|
643
|
+
if (reference.status === 'unresolved') {
|
|
644
|
+
return { kind, reference: `出典 ${String(reference.sourceId)}(未解決)`, unresolved: true };
|
|
645
|
+
}
|
|
646
|
+
if (reference.status !== 'resolved') return { kind, reference: null, unresolved: false };
|
|
647
|
+
if (reference.kind === 'extracted_candidate') {
|
|
648
|
+
return { kind, reference: `取り込み候補「${reference.label ?? reference.candidateId}」(${reference.file})`, unresolved: false };
|
|
649
|
+
}
|
|
650
|
+
if (reference.kind === 'onboarding_run_candidate') {
|
|
651
|
+
const review = REVIEW_STATUS_LABELS[reference.reviewStatus] ?? reference.reviewStatus;
|
|
652
|
+
return {
|
|
653
|
+
kind,
|
|
654
|
+
reference: `オンボーディングの候補「${reference.label ?? reference.candidateId}」(実行 ${reference.runId}${review ? `、${review}` : ''}、${reference.file})`,
|
|
655
|
+
unresolved: false,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (reference.kind === 'graph_correction') {
|
|
659
|
+
return { kind, reference: `訂正 ${formatDay(reference.at) ?? ''}「${reference.reason}」(${reference.file})`, unresolved: false };
|
|
660
|
+
}
|
|
661
|
+
return { kind, reference: null, unresolved: false };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function renderProvenance(doc, provenance) {
|
|
665
|
+
const summary = provenanceSummary(provenance);
|
|
666
|
+
const wrap = makeElement(doc, 'span', { className: 'bb-graph-source' });
|
|
667
|
+
wrap.append(badge(doc, summary.kind, 'muted'));
|
|
668
|
+
if (summary.reference) {
|
|
669
|
+
wrap.append(makeElement(doc, 'span', { className: `bb-graph-source-ref${summary.unresolved ? ' is-unresolved' : ''}`, text: summary.reference }));
|
|
670
|
+
}
|
|
671
|
+
return wrap;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function renderCorrectionHistory(doc, history) {
|
|
675
|
+
if (!Array.isArray(history) || history.length === 0) return null;
|
|
676
|
+
const details = makeElement(doc, 'details', { className: 'bb-graph-history' });
|
|
677
|
+
details.append(makeElement(doc, 'summary', { text: `訂正の履歴(${history.length}件)` }));
|
|
678
|
+
const list = makeElement(doc, 'ol', { className: 'bb-graph-history-list' });
|
|
679
|
+
for (const record of history) {
|
|
680
|
+
if (!isRecord(record)) continue;
|
|
681
|
+
const fields = Array.isArray(record.changedFields) ? record.changedFields.map((field) => FIELD_LABELS[field] ?? field).join('・') : '';
|
|
682
|
+
list.append(makeElement(doc, 'li', {
|
|
683
|
+
text: `${formatDateTime(record.at)} ${CORRECTION_KIND_LABELS[record.kind] ?? '直した'}${fields ? `(${fields})` : ''}。理由: ${String(record.reason ?? '')}`,
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
details.append(list);
|
|
687
|
+
return details;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
// Correction form
|
|
692
|
+
|
|
693
|
+
/** Form values of an entity or relation, from a read view or a raw stored record. */
|
|
694
|
+
export function graphRecordValues(record) {
|
|
695
|
+
const metadata = isRecord(record?.metadata) ? record.metadata : {};
|
|
696
|
+
const pick = (value) => (typeof value === 'string' ? value : '');
|
|
697
|
+
return {
|
|
698
|
+
name: pick(record?.name),
|
|
699
|
+
aliases: Array.isArray(record?.aliases) ? record.aliases.filter((alias) => typeof alias === 'string').join('、') : '',
|
|
700
|
+
summary: pick(record?.summary),
|
|
701
|
+
validFrom: formatDay(record?.validFrom) ?? '',
|
|
702
|
+
validTo: formatDay(record?.validTo) ?? '',
|
|
703
|
+
goal: pick(typeof record?.goal === 'string' ? record.goal : metadata.goal),
|
|
704
|
+
status: pick(typeof record?.status === 'string' && record?.type === 'project' ? record.status : metadata.status),
|
|
705
|
+
role: pick(record?.role),
|
|
706
|
+
context: pick(record?.context),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function displayValue(field, value) {
|
|
711
|
+
if (field === 'validFrom' || field === 'validTo') return value ? value : 'なし';
|
|
712
|
+
return value ? value : '未記入';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function valueRows(record, fields) {
|
|
716
|
+
const values = graphRecordValues(record);
|
|
717
|
+
return fields.map((field) => [GRAPH_CORRECTION_FIELDS[field]?.label ?? FIELD_LABELS[field] ?? field, displayValue(field, values[field])]);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function renderValues(doc, record, fields, leadingRows = []) {
|
|
721
|
+
return facts(doc, [...leadingRows, ...valueRows(record, fields)], 'bb-graph-facts');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function splitAliases(value) {
|
|
725
|
+
const seen = [];
|
|
726
|
+
for (const part of String(value).split(/[、,,\n]/u)) {
|
|
727
|
+
const alias = part.trim();
|
|
728
|
+
if (alias && !seen.includes(alias)) seen.push(alias);
|
|
729
|
+
}
|
|
730
|
+
return seen;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Relation choices a correction can add from or to an entity of this type. */
|
|
734
|
+
export function newRelationOptions(anchorType, allowed = Object.keys(GRAPH_NEW_RELATIONS)) {
|
|
735
|
+
return allowed.flatMap((id) => {
|
|
736
|
+
const definition = GRAPH_NEW_RELATIONS[id];
|
|
737
|
+
if (!definition) return [];
|
|
738
|
+
if (definition.from === anchorType) return [{ id, anchorIs: 'from', counterpartType: definition.to }];
|
|
739
|
+
if (definition.to === anchorType) return [{ id, anchorIs: 'to', counterpartType: definition.from }];
|
|
740
|
+
return [];
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Turns the form into a correction request. Only fields the owner changed
|
|
746
|
+
* from what the form first showed are sent, so a value someone else saved in
|
|
747
|
+
* an untouched field is never overwritten.
|
|
748
|
+
*/
|
|
749
|
+
export function buildCorrectionRequest(form) {
|
|
750
|
+
const reason = String(form.reason ?? '').trim();
|
|
751
|
+
if (!reason) return { ok: false, message: GRAPH_CORRECTION_ERROR_MESSAGES.correction_reason_required };
|
|
752
|
+
if (/[\r\n]/u.test(reason) || reason.length > 500) return { ok: false, message: GRAPH_CORRECTION_ERROR_MESSAGES.correction_reason_invalid };
|
|
753
|
+
|
|
754
|
+
if (form.kind === 'create_edge') {
|
|
755
|
+
const option = form.relationOptions.find((candidate) => candidate.id === form.draft.relation);
|
|
756
|
+
if (!option) return { ok: false, message: '関わり方を選んでください。' };
|
|
757
|
+
const counterpartId = String(form.draft.counterpartId ?? '');
|
|
758
|
+
if (!counterpartId) return { ok: false, message: '相手を選んでください。' };
|
|
759
|
+
const edge = {
|
|
760
|
+
fromId: option.anchorIs === 'from' ? form.anchor.id : counterpartId,
|
|
761
|
+
relation: option.id,
|
|
762
|
+
toId: option.anchorIs === 'from' ? counterpartId : form.anchor.id,
|
|
763
|
+
};
|
|
764
|
+
const role = String(form.draft.role ?? '').trim();
|
|
765
|
+
const context = String(form.draft.context ?? '').trim();
|
|
766
|
+
if (role) edge.role = role;
|
|
767
|
+
if (context) edge.context = context;
|
|
768
|
+
if (form.draft.validFrom) {
|
|
769
|
+
const validFrom = dayToRfc3339(form.draft.validFrom);
|
|
770
|
+
if (!validFrom) return { ok: false, message: '開始日の形式が正しくありません。' };
|
|
771
|
+
edge.validFrom = validFrom;
|
|
772
|
+
}
|
|
773
|
+
return { ok: true, body: { kind: 'create_edge', reason, edge } };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const changes = {};
|
|
777
|
+
for (const field of form.fields) {
|
|
778
|
+
const before = String(form.original[field] ?? '');
|
|
779
|
+
const after = String(form.draft[field] ?? '');
|
|
780
|
+
if (after.trim() === before.trim()) continue;
|
|
781
|
+
if (field === 'validFrom' || field === 'validTo') {
|
|
782
|
+
if (after.trim() === '') {
|
|
783
|
+
changes[field] = null;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const value = dayToRfc3339(after.trim());
|
|
787
|
+
if (!value) return { ok: false, message: `${GRAPH_CORRECTION_FIELDS[field].label}の形式が正しくありません。` };
|
|
788
|
+
changes[field] = value;
|
|
789
|
+
} else if (field === 'aliases') {
|
|
790
|
+
changes.aliases = splitAliases(after);
|
|
791
|
+
} else if (field === 'name') {
|
|
792
|
+
if (!after.trim()) return { ok: false, message: '名前は空にできません。' };
|
|
793
|
+
changes.name = after.trim();
|
|
794
|
+
} else {
|
|
795
|
+
changes[field] = after.trim() === '' ? null : after.trim();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (Object.keys(changes).length === 0) return { ok: false, message: GRAPH_CORRECTION_ERROR_MESSAGES.correction_no_change };
|
|
799
|
+
const body = form.kind === 'update_entity'
|
|
800
|
+
? { kind: 'update_entity', entityId: form.targetId, expectedDigest: form.expectedDigest, reason, changes }
|
|
801
|
+
: { kind: 'update_edge', edgeId: form.targetId, expectedDigest: form.expectedDigest, reason, changes };
|
|
802
|
+
return { ok: true, body };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function readBackFields(form, payload) {
|
|
806
|
+
if (form.kind === 'create_edge') return ['role', 'context', 'validFrom', 'validTo'];
|
|
807
|
+
const changed = Array.isArray(payload?.correction?.changedFields) ? payload.correction.changedFields : [];
|
|
808
|
+
const fields = changed.map((field) => field.replace(/^metadata\./u, '')).filter((field) => GRAPH_CORRECTION_FIELDS[field]);
|
|
809
|
+
return fields.length > 0 ? fields : form.fields;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** What a new relation connects, as rows shown before its values. */
|
|
813
|
+
function createdRows(form, record) {
|
|
814
|
+
const counterpartId = record?.fromId === form.anchor.id ? record?.toId : record?.fromId;
|
|
815
|
+
const counterpart = form.candidates?.items?.find((entity) => entity.id === counterpartId);
|
|
816
|
+
const relation = form.relationNames[record?.relation] ?? relationLabel(record?.relation);
|
|
817
|
+
return [
|
|
818
|
+
[form.relationLabel, relation],
|
|
819
|
+
['相手', counterpart ? counterpart.name : String(counterpartId ?? '')],
|
|
820
|
+
];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function mcpNote(payload) {
|
|
824
|
+
const tools = Array.isArray(payload?.appliesTo) && payload.appliesTo.length > 0 ? payload.appliesTo : DEFAULT_APPLIES_TO;
|
|
825
|
+
return `次にMCPの ${tools.join('・')} を使うときから、この内容が使われます。`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function fieldControl(doc, form, field, callbacks) {
|
|
829
|
+
const definition = GRAPH_CORRECTION_FIELDS[field];
|
|
830
|
+
const id = `${form.formId}-${field}`;
|
|
831
|
+
const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
|
|
832
|
+
wrap.append(makeElement(doc, 'label', { text: definition.label, attrs: { for: id } }));
|
|
833
|
+
const control = definition.control === 'textarea'
|
|
834
|
+
? makeElement(doc, 'textarea', { attrs: { id, name: field, rows: 3, maxlength: definition.max } })
|
|
835
|
+
: makeElement(doc, 'input', { attrs: { id, name: field, type: definition.control === 'date' ? 'date' : 'text', maxlength: definition.max } });
|
|
836
|
+
control.value = String(form.draft[field] ?? '');
|
|
837
|
+
const update = (event) => callbacks.onInput(field, event?.target?.value ?? control.value);
|
|
838
|
+
control.addEventListener('input', update);
|
|
839
|
+
control.addEventListener('change', update);
|
|
840
|
+
wrap.append(control);
|
|
841
|
+
if (definition.help) wrap.append(makeElement(doc, 'small', { className: 'bb-graph-help', text: definition.help }));
|
|
842
|
+
return wrap;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function reasonControl(doc, form, callbacks) {
|
|
846
|
+
const id = `${form.formId}-reason`;
|
|
847
|
+
const wrap = makeElement(doc, 'div', { className: 'bb-graph-field is-reason' });
|
|
848
|
+
wrap.append(makeElement(doc, 'label', { text: '理由(1文、必須)', attrs: { for: id } }));
|
|
849
|
+
const control = makeElement(doc, 'input', {
|
|
850
|
+
attrs: { id, name: 'reason', type: 'text', required: true, maxlength: 500, placeholder: '例: 役割が変わったため。', 'aria-required': 'true' },
|
|
851
|
+
});
|
|
852
|
+
control.value = String(form.reason ?? '');
|
|
853
|
+
const update = (event) => callbacks.onReason(event?.target?.value ?? control.value);
|
|
854
|
+
control.addEventListener('input', update);
|
|
855
|
+
control.addEventListener('change', update);
|
|
856
|
+
wrap.append(control, makeElement(doc, 'small', { className: 'bb-graph-help', text: '直した理由は履歴に残り、後から「いつ何を直したか」をたどれます。' }));
|
|
857
|
+
return wrap;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function selectControl(doc, { id, name, label, options, value, onChange, disabled = false }) {
|
|
861
|
+
const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
|
|
862
|
+
wrap.append(makeElement(doc, 'label', { text: label, attrs: { for: id } }));
|
|
863
|
+
const select = makeElement(doc, 'select', { attrs: { id, name, disabled } });
|
|
864
|
+
for (const option of options) {
|
|
865
|
+
const element = makeElement(doc, 'option', { text: option.label, attrs: { value: option.value } });
|
|
866
|
+
if (option.value === value) {
|
|
867
|
+
element.setAttribute('selected', 'selected');
|
|
868
|
+
element.selected = true;
|
|
869
|
+
}
|
|
870
|
+
select.append(element);
|
|
871
|
+
}
|
|
872
|
+
select.value = value;
|
|
873
|
+
const update = (event) => onChange(event?.target?.value ?? select.value);
|
|
874
|
+
select.addEventListener('change', update);
|
|
875
|
+
select.addEventListener('input', update);
|
|
876
|
+
wrap.append(select);
|
|
877
|
+
return wrap;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function candidateOptionLabel(entity) {
|
|
881
|
+
const aliases = Array.isArray(entity.aliases) && entity.aliases.length > 0 ? `(${entity.aliases.join('、')})` : '';
|
|
882
|
+
return `${entity.name}${aliases}${entity.active === false ? '(終了)' : ''}`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function renderCreateFields(doc, form, callbacks) {
|
|
886
|
+
const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
|
|
887
|
+
const option = form.relationOptions.find((candidate) => candidate.id === form.draft.relation) ?? form.relationOptions[0];
|
|
888
|
+
group.append(selectControl(doc, {
|
|
889
|
+
id: `${form.formId}-relation`,
|
|
890
|
+
name: 'relation',
|
|
891
|
+
label: form.relationLabel,
|
|
892
|
+
value: form.draft.relation,
|
|
893
|
+
options: form.relationOptions.map((candidate) => ({
|
|
894
|
+
value: candidate.id,
|
|
895
|
+
label: `${form.relationNames[candidate.id] ?? relationLabel(candidate.id)}(${GRAPH_RELATION_LABELS[candidate.id]?.meaning ?? candidate.id})`,
|
|
896
|
+
})),
|
|
897
|
+
onChange: callbacks.onRelation,
|
|
898
|
+
}));
|
|
899
|
+
|
|
900
|
+
const counterpartLabel = typeLabel(option.counterpartType);
|
|
901
|
+
const search = makeElement(doc, 'div', { className: 'bb-graph-field bb-graph-candidate-search' });
|
|
902
|
+
const searchId = `${form.formId}-candidate-q`;
|
|
903
|
+
search.append(makeElement(doc, 'label', { text: `${counterpartLabel}を名前で絞り込む`, attrs: { for: searchId } }));
|
|
904
|
+
const row = makeElement(doc, 'div', { className: 'bb-graph-inline' });
|
|
905
|
+
const query = makeElement(doc, 'input', { attrs: { id: searchId, name: 'candidateQuery', type: 'search', maxlength: 200 } });
|
|
906
|
+
query.value = String(form.candidates.query ?? '');
|
|
907
|
+
const updateQuery = (event) => { form.candidates.query = event?.target?.value ?? query.value; };
|
|
908
|
+
query.addEventListener('input', updateQuery);
|
|
909
|
+
query.addEventListener('change', updateQuery);
|
|
910
|
+
row.append(query, button(doc, '探す', () => callbacks.onSearchCandidates(form.candidates.query)));
|
|
911
|
+
search.append(row);
|
|
912
|
+
group.append(search);
|
|
913
|
+
|
|
914
|
+
const candidates = form.candidates;
|
|
915
|
+
if (candidates.state === 'ok') {
|
|
916
|
+
const options = [{ value: '', label: `${counterpartLabel}を選んでください` }, ...candidates.items.map((entity) => ({ value: entity.id, label: candidateOptionLabel(entity) }))];
|
|
917
|
+
group.append(selectControl(doc, {
|
|
918
|
+
id: `${form.formId}-counterpart`,
|
|
919
|
+
name: 'counterpartId',
|
|
920
|
+
label: `相手(${counterpartLabel})`,
|
|
921
|
+
value: String(form.draft.counterpartId ?? ''),
|
|
922
|
+
options,
|
|
923
|
+
onChange: (value) => callbacks.onInput('counterpartId', value),
|
|
924
|
+
}));
|
|
925
|
+
if (candidates.items.length === 0) {
|
|
926
|
+
group.append(notice(doc, 'muted', `条件に合う${counterpartLabel}がいません。まだ登録していない場合は、CLIやMCPで先に登録してください。`));
|
|
927
|
+
}
|
|
928
|
+
if (candidates.truncated) group.append(notice(doc, 'muted', `候補が多いため、先頭の${candidates.items.length}件だけを出しています。名前で絞り込んでください。`));
|
|
929
|
+
} else {
|
|
930
|
+
const holder = makeElement(doc, 'div', { className: 'bb-graph-candidate-state' });
|
|
931
|
+
renderGraphReadState(doc, holder, candidates, { onRetry: () => callbacks.onSearchCandidates(candidates.query), loadingText: `${counterpartLabel}の候補を読み込んでいます。` });
|
|
932
|
+
group.append(holder);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
for (const field of ['role', 'context', 'validFrom']) group.append(fieldControl(doc, form, field, callbacks));
|
|
936
|
+
return group;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The correction panel. `form` is the state held by `createGraphCorrection`;
|
|
941
|
+
* callbacks are the controller's handlers.
|
|
942
|
+
*/
|
|
943
|
+
export function renderCorrectionPanel(doc, form, callbacks) {
|
|
944
|
+
const panel = makeElement(doc, 'section', { className: `bb-graph-correction is-${form.phase}`, attrs: { 'aria-label': form.title, tabindex: '-1' } });
|
|
945
|
+
const head = makeElement(doc, 'div', { className: 'bb-graph-correction-head' });
|
|
946
|
+
head.append(makeElement(doc, 'h3', { text: form.title }));
|
|
947
|
+
if (form.subject) head.append(makeElement(doc, 'p', { className: 'bb-graph-lead', text: form.subject }));
|
|
948
|
+
panel.append(head);
|
|
949
|
+
|
|
950
|
+
if (form.phase === 'saved') {
|
|
951
|
+
const payload = form.saved;
|
|
952
|
+
const verified = payload?.readback?.verified === true && payload?.readback?.historyRecorded === true;
|
|
953
|
+
panel.append(notice(doc, verified ? 'success' : 'warning', verified
|
|
954
|
+
? '保存しました。保存後に読み直し、保存した内容と一致することを確かめました。理由と前後の内容は履歴に残しました。'
|
|
955
|
+
: '保存の応答はありましたが、読み直しを確かめられませんでした。読み直して確かめてください。', verified ? 'status' : 'alert'));
|
|
956
|
+
const readBack = makeElement(doc, 'div', { className: 'bb-graph-readback', attrs: { 'aria-label': '読み直した内容' } });
|
|
957
|
+
readBack.append(
|
|
958
|
+
makeElement(doc, 'strong', { text: '読み直した内容' }),
|
|
959
|
+
renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge' ? createdRows(form, payload.record) : []),
|
|
960
|
+
);
|
|
961
|
+
panel.append(readBack, notice(doc, 'accent', mcpNote(payload)));
|
|
962
|
+
panel.append(recordDetails(doc, [['記録ID', payload.record?.id], ['保存した内容のdigest', payload.digest], ['訂正ID', payload.correction?.id]]));
|
|
963
|
+
panel.append(button(doc, '閉じる', callbacks.onClose, { className: 'bb-ws-button is-quiet' }));
|
|
964
|
+
return panel;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (form.phase === 'conflict') {
|
|
968
|
+
const exists = form.conflictCode === 'edge_already_exists';
|
|
969
|
+
panel.append(notice(doc, 'warning', exists
|
|
970
|
+
? 'この関わりはすでに登録されているため、加えませんでした。今の内容を下に示します。終わった関わりをもう一度始めるときは、その関わりの終了日を消してください。入力は残しています。'
|
|
971
|
+
: 'ほかの保存で内容が変わっていたため、保存しませんでした。今の内容を下に示します。入力は残しています。今の内容を確かめてから、もう一度保存してください。', 'alert'));
|
|
972
|
+
const current = makeElement(doc, 'div', { className: 'bb-graph-current', attrs: { 'aria-label': '今の内容' } });
|
|
973
|
+
const fields = exists ? ['role', 'context', 'validFrom', 'validTo'] : form.fields;
|
|
974
|
+
current.append(makeElement(doc, 'strong', { text: '今の内容' }), renderValues(doc, form.current.record, fields));
|
|
975
|
+
current.append(recordDetails(doc, [['記録ID', form.current.record?.id], ['今の内容のdigest', form.current.digest]]));
|
|
976
|
+
panel.append(current);
|
|
977
|
+
} else if (form.message) {
|
|
978
|
+
panel.append(notice(doc, form.message.tone, form.message.text, form.message.tone === 'danger' ? 'alert' : 'status'));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const formElement = makeElement(doc, 'form', { className: 'bb-graph-form', attrs: { novalidate: true } });
|
|
982
|
+
formElement.addEventListener('submit', (event) => {
|
|
983
|
+
event?.preventDefault?.();
|
|
984
|
+
// Returned so a host (or a test) can wait for the save to finish.
|
|
985
|
+
return callbacks.onSubmit();
|
|
986
|
+
});
|
|
987
|
+
if (form.kind === 'create_edge') {
|
|
988
|
+
formElement.append(renderCreateFields(doc, form, callbacks));
|
|
989
|
+
} else {
|
|
990
|
+
const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
|
|
991
|
+
for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
|
|
992
|
+
formElement.append(group);
|
|
993
|
+
}
|
|
994
|
+
formElement.append(reasonControl(doc, form, callbacks));
|
|
995
|
+
const actions = makeElement(doc, 'div', { className: 'bb-graph-actions' });
|
|
996
|
+
const saving = form.phase === 'saving';
|
|
997
|
+
const submitLabel = saving ? '保存しています' : form.phase === 'conflict' && form.conflictCode === 'digest_conflict' ? '今の内容に対して保存する' : '保存する';
|
|
998
|
+
actions.append(
|
|
999
|
+
makeElement(doc, 'button', { className: 'bb-ws-button is-primary', text: submitLabel, attrs: { type: 'submit', disabled: saving } }),
|
|
1000
|
+
button(doc, '取り消す', callbacks.onClose, { attrs: { disabled: saving } }),
|
|
1001
|
+
);
|
|
1002
|
+
formElement.append(actions);
|
|
1003
|
+
panel.append(formElement);
|
|
1004
|
+
return panel;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Holds one open correction at a time. `rerender` redraws the owning view;
|
|
1009
|
+
* `onSaved(payload, form)` lets it read the saved record back into its lists;
|
|
1010
|
+
* `onConflict(form)` lets it refresh what it shows. `scope` is the host's
|
|
1011
|
+
* `correctionScope` (see `graphCorrectionScope`): a correction outside it is
|
|
1012
|
+
* not opened, a field outside it is left out of the form, and a request
|
|
1013
|
+
* outside it is refused before anything is sent.
|
|
1014
|
+
*/
|
|
1015
|
+
export function createGraphCorrection({ client, rerender, onSaved, onConflict, now = () => new Date(), scope } = {}) {
|
|
1016
|
+
if (!client) throw new TypeError('client is required');
|
|
1017
|
+
const allowed = graphCorrectionScope(scope);
|
|
1018
|
+
let form = null;
|
|
1019
|
+
let focusPending = false;
|
|
1020
|
+
let sequence = 0;
|
|
1021
|
+
// Label/input ids stay unique when two screens are on one page.
|
|
1022
|
+
const idPrefix = `bb-graph-form-${Math.random().toString(36).slice(2, 8)}`;
|
|
1023
|
+
const redraw = () => rerender?.();
|
|
1024
|
+
|
|
1025
|
+
function baseForm(kind, extra) {
|
|
1026
|
+
sequence += 1;
|
|
1027
|
+
focusPending = true;
|
|
1028
|
+
return {
|
|
1029
|
+
kind,
|
|
1030
|
+
formId: `${idPrefix}-${sequence}`,
|
|
1031
|
+
phase: 'editing',
|
|
1032
|
+
message: null,
|
|
1033
|
+
reason: '',
|
|
1034
|
+
current: null,
|
|
1035
|
+
conflictCode: null,
|
|
1036
|
+
saved: null,
|
|
1037
|
+
...extra,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function searchCandidates(query = '') {
|
|
1042
|
+
if (!form || form.kind !== 'create_edge') return;
|
|
1043
|
+
const target = form;
|
|
1044
|
+
const option = target.relationOptions.find((candidate) => candidate.id === target.draft.relation) ?? target.relationOptions[0];
|
|
1045
|
+
target.candidates = { state: 'loading', items: [], query };
|
|
1046
|
+
redraw();
|
|
1047
|
+
const result = await client.read('/search', { q: query, type: option.counterpartType, limit: 100 });
|
|
1048
|
+
if (form !== target) return;
|
|
1049
|
+
if (result.state === 'ok' && Array.isArray(result.payload.results) && result.payload.results.every(isEntityView)) {
|
|
1050
|
+
target.candidates = {
|
|
1051
|
+
state: 'ok',
|
|
1052
|
+
items: result.payload.results.filter((entity) => entity.id !== target.anchor.id && entity.type === option.counterpartType),
|
|
1053
|
+
truncated: result.payload.truncated === true,
|
|
1054
|
+
query,
|
|
1055
|
+
};
|
|
1056
|
+
} else if (result.state === 'ok') {
|
|
1057
|
+
target.candidates = { state: 'invalid', reason: '応答の形式が不正です', query };
|
|
1058
|
+
} else {
|
|
1059
|
+
target.candidates = { ...result, query };
|
|
1060
|
+
}
|
|
1061
|
+
redraw();
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const callbacks = {
|
|
1065
|
+
onInput(field, value) {
|
|
1066
|
+
if (form) form.draft[field] = value;
|
|
1067
|
+
},
|
|
1068
|
+
onReason(value) {
|
|
1069
|
+
if (form) form.reason = value;
|
|
1070
|
+
},
|
|
1071
|
+
onRelation(value) {
|
|
1072
|
+
if (!form || form.kind !== 'create_edge') return;
|
|
1073
|
+
const previous = form.relationOptions.find((candidate) => candidate.id === form.draft.relation);
|
|
1074
|
+
const next = form.relationOptions.find((candidate) => candidate.id === value);
|
|
1075
|
+
if (!next) return;
|
|
1076
|
+
form.draft.relation = value;
|
|
1077
|
+
if (previous?.counterpartType !== next.counterpartType) {
|
|
1078
|
+
form.draft.counterpartId = '';
|
|
1079
|
+
void searchCandidates('');
|
|
1080
|
+
} else {
|
|
1081
|
+
redraw();
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
onSearchCandidates: (query) => searchCandidates(query ?? ''),
|
|
1085
|
+
onSubmit: () => controller.submit(),
|
|
1086
|
+
onClose: () => controller.close(),
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
const controller = {
|
|
1090
|
+
get form() { return form; },
|
|
1091
|
+
get callbacks() { return callbacks; },
|
|
1092
|
+
/** Correct an entity. `fields` limits what the form shows. Null when the scope allows no field of it. */
|
|
1093
|
+
openEntity(record, { fields, title = '記録を直す', subject } = {}) {
|
|
1094
|
+
const shown = allowed.entity(record.type) ? allowed.fields(fields ?? graphEntityCorrectionFields(record.type)) : [];
|
|
1095
|
+
if (shown.length === 0) return null;
|
|
1096
|
+
const values = graphRecordValues(record);
|
|
1097
|
+
const original = Object.fromEntries(shown.map((field) => [field, values[field]]));
|
|
1098
|
+
form = baseForm('update_entity', {
|
|
1099
|
+
title,
|
|
1100
|
+
subject: subject ?? `${typeLabel(record.type)}「${record.name}」`,
|
|
1101
|
+
recordType: record.type,
|
|
1102
|
+
targetId: record.id,
|
|
1103
|
+
expectedDigest: record.digest,
|
|
1104
|
+
fields: shown,
|
|
1105
|
+
original,
|
|
1106
|
+
draft: { ...original },
|
|
1107
|
+
});
|
|
1108
|
+
redraw();
|
|
1109
|
+
return form;
|
|
1110
|
+
},
|
|
1111
|
+
/** Correct a relation. `mode`: 'role' (role and context), 'end' (end date, today by default) or 'edit' (all). Null outside the scope. */
|
|
1112
|
+
openEdge(edge, { mode = 'edit', title, subject } = {}) {
|
|
1113
|
+
if (!allowed.edges) return null;
|
|
1114
|
+
const values = graphRecordValues(edge);
|
|
1115
|
+
const fields = mode === 'role' ? ['role', 'context'] : mode === 'end' ? ['validTo'] : ['role', 'context', 'validTo'];
|
|
1116
|
+
const original = Object.fromEntries(fields.map((field) => [field, values[field]]));
|
|
1117
|
+
const draft = { ...original };
|
|
1118
|
+
if (mode === 'end' && !draft.validTo) draft.validTo = localDay(now());
|
|
1119
|
+
form = baseForm('update_edge', {
|
|
1120
|
+
title: title ?? (mode === 'role' ? '役割を直す' : mode === 'end' ? '関わりを終える' : '関係を直す'),
|
|
1121
|
+
subject: subject ?? null,
|
|
1122
|
+
targetId: edge.id,
|
|
1123
|
+
expectedDigest: edge.digest,
|
|
1124
|
+
fields,
|
|
1125
|
+
original,
|
|
1126
|
+
draft,
|
|
1127
|
+
});
|
|
1128
|
+
redraw();
|
|
1129
|
+
return form;
|
|
1130
|
+
},
|
|
1131
|
+
/** Add a relation to or from `anchor`. Null outside the scope or when no relation fits. */
|
|
1132
|
+
openCreate(anchor, { relations, relationNames = {}, relationLabel: label = '関係の種類', title = '関係を加える', subject } = {}) {
|
|
1133
|
+
const relationOptions = allowed.createEdges ? newRelationOptions(anchor.type, relations) : [];
|
|
1134
|
+
if (relationOptions.length === 0) return null;
|
|
1135
|
+
form = baseForm('create_edge', {
|
|
1136
|
+
title,
|
|
1137
|
+
subject: subject ?? `${typeLabel(anchor.type)}「${anchor.name}」`,
|
|
1138
|
+
anchor: { id: anchor.id, type: anchor.type, name: anchor.name },
|
|
1139
|
+
relationOptions,
|
|
1140
|
+
relationNames,
|
|
1141
|
+
relationLabel: label,
|
|
1142
|
+
fields: ['role', 'context', 'validFrom'],
|
|
1143
|
+
original: {},
|
|
1144
|
+
draft: { relation: relationOptions[0].id, counterpartId: '', role: '', context: '', validFrom: '' },
|
|
1145
|
+
candidates: { state: 'loading', items: [], query: '' },
|
|
1146
|
+
});
|
|
1147
|
+
void searchCandidates('');
|
|
1148
|
+
return form;
|
|
1149
|
+
},
|
|
1150
|
+
close() {
|
|
1151
|
+
form = null;
|
|
1152
|
+
redraw();
|
|
1153
|
+
},
|
|
1154
|
+
async submit() {
|
|
1155
|
+
const target = form;
|
|
1156
|
+
if (!target || target.phase === 'saving') return target;
|
|
1157
|
+
const built = buildCorrectionRequest(target);
|
|
1158
|
+
if (!built.ok) {
|
|
1159
|
+
target.message = { tone: 'danger', text: built.message };
|
|
1160
|
+
if (target.phase !== 'conflict') target.phase = 'editing';
|
|
1161
|
+
redraw();
|
|
1162
|
+
return target;
|
|
1163
|
+
}
|
|
1164
|
+
if (!withinCorrectionScope(allowed, built.body, target.recordType)) {
|
|
1165
|
+
target.phase = 'error';
|
|
1166
|
+
target.message = { tone: 'danger', text: GRAPH_CORRECTION_ERROR_MESSAGES.correction_scope_not_allowed };
|
|
1167
|
+
redraw();
|
|
1168
|
+
return target;
|
|
1169
|
+
}
|
|
1170
|
+
target.phase = 'saving';
|
|
1171
|
+
target.message = null;
|
|
1172
|
+
redraw();
|
|
1173
|
+
const result = await client.correct(built.body);
|
|
1174
|
+
if (form !== target) return target;
|
|
1175
|
+
if (result.state === 'saved') {
|
|
1176
|
+
target.phase = 'saved';
|
|
1177
|
+
target.saved = result.payload;
|
|
1178
|
+
focusPending = true;
|
|
1179
|
+
redraw();
|
|
1180
|
+
await onSaved?.(result.payload, target);
|
|
1181
|
+
return target;
|
|
1182
|
+
}
|
|
1183
|
+
if (result.state === 'conflict') {
|
|
1184
|
+
target.phase = 'conflict';
|
|
1185
|
+
target.conflictCode = result.code;
|
|
1186
|
+
target.current = result.current;
|
|
1187
|
+
target.message = null;
|
|
1188
|
+
// The owner now sees the current record; the next save is based on it
|
|
1189
|
+
// and still sends only the fields the owner changed. Untouched fields
|
|
1190
|
+
// show the current values so the form never suggests reverting them.
|
|
1191
|
+
if (target.kind !== 'create_edge' && typeof result.current.digest === 'string') {
|
|
1192
|
+
target.expectedDigest = result.current.digest;
|
|
1193
|
+
const currentValues = graphRecordValues(result.current.record);
|
|
1194
|
+
for (const field of target.fields) {
|
|
1195
|
+
const touched = String(target.draft[field] ?? '').trim() !== String(target.original[field] ?? '').trim();
|
|
1196
|
+
target.original[field] = currentValues[field];
|
|
1197
|
+
if (!touched) target.draft[field] = currentValues[field];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
focusPending = true;
|
|
1201
|
+
redraw();
|
|
1202
|
+
await onConflict?.(target);
|
|
1203
|
+
return target;
|
|
1204
|
+
}
|
|
1205
|
+
target.phase = 'error';
|
|
1206
|
+
target.message = { tone: 'danger', text: result.message };
|
|
1207
|
+
redraw();
|
|
1208
|
+
return target;
|
|
1209
|
+
},
|
|
1210
|
+
/** The open panel, or null. */
|
|
1211
|
+
render(doc) {
|
|
1212
|
+
if (!form) return null;
|
|
1213
|
+
const panel = renderCorrectionPanel(doc, form, callbacks);
|
|
1214
|
+
if (focusPending) {
|
|
1215
|
+
focusPending = false;
|
|
1216
|
+
const focus = () => {
|
|
1217
|
+
panel.scrollIntoView?.({ block: 'nearest' });
|
|
1218
|
+
panel.focus?.({ preventScroll: true });
|
|
1219
|
+
};
|
|
1220
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(focus);
|
|
1221
|
+
else setTimeout(focus, 0);
|
|
1222
|
+
}
|
|
1223
|
+
return panel;
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
return controller;
|
|
1227
|
+
}
|