@unson/brainbase-mcp 0.41.11 → 0.42.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/README.md +1 -1
- package/contracts/judgment-dag/digest.json +2 -2
- package/dist/local-web-host.d.ts +6 -0
- package/dist/local-web-host.js +54 -3
- package/dist/personal-memory.d.ts +101 -0
- package/dist/personal-memory.js +204 -0
- package/dist/ssot.d.ts +3 -1
- package/dist/ssot.js +4 -0
- package/package.json +13 -2
- package/ui/area-nav.css +79 -0
- package/ui/area-nav.js +249 -0
- package/ui/foundation-overview.css +9 -0
- package/ui/foundation-overview.js +75 -5
- package/ui/graph-ontology-view.css +88 -0
- package/ui/graph-ontology-view.js +248 -0
- package/ui/graph-own-share.js +167 -0
- package/ui/graph-registry-view.css +272 -50
- package/ui/graph-registry-view.js +539 -456
- package/ui/graph-view-shared.css +32 -0
- package/ui/graph-view-shared.js +191 -12
- package/ui/judgment-history.js +74 -3
- package/ui/local-web-shell.css +1 -13
- package/ui/local-web-shell.js +119 -41
- package/ui/outcome-knowledge.js +6 -2
- package/ui/people-and-companies-view.css +166 -15
- package/ui/people-and-companies-view.js +307 -93
- package/ui/personal-memory-http-port.js +72 -0
- package/ui/personal-memory.css +254 -0
- package/ui/personal-memory.js +677 -0
- package/ui/world/world-canvas-notes.js +3 -2
- package/ui/world/world-view.css +19 -0
- package/ui/world/world-view.js +52 -10
- package/ui/world/world-work-rail.js +114 -8
|
@@ -2,32 +2,38 @@
|
|
|
2
2
|
* 「情報と関係」: what the local Graph holds, how records are connected, and
|
|
3
3
|
* where to correct a mistake so the next MCP use sees the fix.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
5
|
+
* 「調べる」 (premise ledger D14, story-graph-registry-immediate-feedback-v1):
|
|
6
|
+
* the list has the search, the kinds as tabs with counts, 詳しい条件 (an
|
|
7
|
+
* earlier day), the conditions in force as removable chips and the results.
|
|
8
|
+
* Everything applies as it changes. A record opens as its own screen with an
|
|
9
|
+
* address the host keeps (`onRouteChange`): what it is, its connections read
|
|
10
|
+
* as sentences and grouped by the other record's kind, 根拠と履歴 (folded:
|
|
11
|
+
* source, period, corrections, ids) and 直す, which first shows what a fix
|
|
12
|
+
* reaches. The right rail stays empty.
|
|
13
|
+
*
|
|
14
|
+
* This screen answers what the Graph knows (ledger D14). The kinds of
|
|
15
|
+
* information and relations (the ontology) are definitions, drawn by
|
|
16
|
+
* `graph-ontology-view.js` under the host's settings; the owner's own snapshot
|
|
17
|
+
* is drawn by `createOwnShareView` (`graph-own-share.js`) where the owner takes
|
|
18
|
+
* records in. Neither is drawn here.
|
|
10
19
|
*
|
|
11
20
|
* Reads `GET {base}/search`, `GET {base}/entities/:id` and `GET {base}/ontology`
|
|
12
|
-
* and corrects through `POST {base}/corrections`
|
|
13
|
-
* The
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* keeps no global state. Another host composes it unchanged and adds only its
|
|
18
|
-
* own controls through the options of `createGraphRegistryView`, including the
|
|
19
|
-
* owner's own share in a separate section (`graph-own-share.js`).
|
|
21
|
+
* (for the kinds and their counts) and corrects through `POST {base}/corrections`
|
|
22
|
+
* (see `graph-view-shared.js`). The host injects the fetcher, base path, launch
|
|
23
|
+
* token, page context and the record's address; this module keeps no global
|
|
24
|
+
* state. Another host composes it unchanged and adds only its own controls
|
|
25
|
+
* through the options of `createGraphRegistryView`.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
import {
|
|
23
29
|
activityBadge,
|
|
24
|
-
badge,
|
|
25
30
|
button,
|
|
26
31
|
createGraphClient,
|
|
27
32
|
createGraphCorrection,
|
|
28
33
|
createGraphLayout,
|
|
29
34
|
dayToRfc3339,
|
|
30
35
|
focusLedgerRow,
|
|
36
|
+
formatDay,
|
|
31
37
|
getDocument,
|
|
32
38
|
graphCommandList,
|
|
33
39
|
graphCorrectionScope,
|
|
@@ -38,68 +44,77 @@ import {
|
|
|
38
44
|
hostSourceNotice,
|
|
39
45
|
isEdgeView,
|
|
40
46
|
isEntityView,
|
|
41
|
-
isRecord,
|
|
42
47
|
makeElement,
|
|
43
48
|
newRelationOptions,
|
|
44
49
|
ownLabel,
|
|
45
50
|
readOnlyGraphClient,
|
|
46
51
|
recordDetails,
|
|
47
52
|
relationLabel,
|
|
48
|
-
|
|
49
|
-
renderGraphIssues,
|
|
53
|
+
renderEvidenceAndHistory,
|
|
50
54
|
renderProvenance,
|
|
51
55
|
shellArg,
|
|
52
56
|
textOrNull,
|
|
53
57
|
typeLabel,
|
|
54
58
|
validityLabel,
|
|
55
59
|
validityText,
|
|
56
|
-
GRAPH_CORRECTION_FIELDS,
|
|
57
|
-
GRAPH_ENTITY_TYPE_LABELS,
|
|
58
|
-
GRAPH_ENTITY_TYPE_MEANINGS,
|
|
59
60
|
GRAPH_RELATION_LABELS,
|
|
60
61
|
graphHostEmptyNotice,
|
|
61
62
|
} from './graph-view-shared.js';
|
|
62
|
-
import {
|
|
63
|
+
import { normalizeOntology } from './graph-ontology-view.js';
|
|
63
64
|
import {
|
|
64
65
|
workspaceActions,
|
|
65
66
|
workspaceButton,
|
|
66
67
|
workspaceDefinition,
|
|
67
|
-
workspaceDetailEmpty,
|
|
68
68
|
workspaceHostNotice,
|
|
69
69
|
workspaceLedger,
|
|
70
70
|
workspaceNotice,
|
|
71
71
|
workspacePageHeader,
|
|
72
|
-
workspaceRailBlock,
|
|
73
|
-
workspaceRailHead,
|
|
74
|
-
workspaceSectionTitle,
|
|
75
72
|
} from './workspace-kit.js';
|
|
76
73
|
|
|
77
|
-
export const GRAPH_REGISTRY_VIEW_CONTRACT_VERSION = 'brainbase.graph-registry-view.
|
|
74
|
+
export const GRAPH_REGISTRY_VIEW_CONTRACT_VERSION = 'brainbase.graph-registry-view.v3';
|
|
78
75
|
export const GRAPH_SEARCH_LIMIT = 50;
|
|
79
|
-
export const GRAPH_ENTITY_LEDGER_COLUMNS = Object.freeze(['名前', '種類', '
|
|
80
|
-
export const GRAPH_RELATION_TYPE_LEDGER_COLUMNS = Object.freeze(['関係', '起点 → 終点', '意味', '件数']);
|
|
81
|
-
export const GRAPH_ENTITY_TYPE_LEDGER_COLUMNS = Object.freeze(['種類', '意味', '件数']);
|
|
76
|
+
export const GRAPH_ENTITY_LEDGER_COLUMNS = Object.freeze(['名前', '種類', '説明', '最終更新']);
|
|
82
77
|
|
|
83
78
|
const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
|
|
84
|
-
const
|
|
85
|
-
const TYPE_FILTERS = Object.freeze([ALL_TYPES, ...Object.entries(GRAPH_ENTITY_TYPE_LABELS)]);
|
|
79
|
+
const DETAIL_ID = 'bb-gr-detail';
|
|
86
80
|
|
|
87
81
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
82
|
+
* People, companies and projects lead the tabs, under the names owners use for
|
|
83
|
+
* them (ledger D14). Every other kind uses the shared plain name; a kind
|
|
84
|
+
* without one is internal and is never named.
|
|
91
85
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
86
|
+
const LEADING_KINDS = Object.freeze({ person: '人', org: '会社', project: 'プロジェクト' });
|
|
87
|
+
const DEFAULT_KIND_TABS = Object.freeze(['person', 'org', 'project', 'decision']);
|
|
88
|
+
|
|
89
|
+
function kindLabel(type) {
|
|
90
|
+
const leading = ownLabel(LEADING_KINDS, type);
|
|
91
|
+
if (leading) return leading;
|
|
92
|
+
const shared = typeLabel(type);
|
|
93
|
+
return typeof type === 'string' && shared !== type ? shared : 'その他の記録';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasKindLabel(type) {
|
|
97
|
+
return kindLabel(type) !== 'その他の記録';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Leading kinds first, the rest in the order given. */
|
|
101
|
+
function kindRank(type) {
|
|
102
|
+
const index = Object.keys(LEADING_KINDS).indexOf(type);
|
|
103
|
+
return index < 0 ? Object.keys(LEADING_KINDS).length : index;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
|
-
/**
|
|
99
|
-
function
|
|
100
|
-
|
|
106
|
+
/** How a relation reads from this record's side; a relation without a Japanese name only says they connect. */
|
|
107
|
+
function relationPhrase(edge) {
|
|
108
|
+
const labels = ownLabel(GRAPH_RELATION_LABELS, edge.relation);
|
|
109
|
+
if (!labels) return 'とつながる';
|
|
110
|
+
return edge.direction === 'outgoing' ? labels.outgoing : labels.incoming;
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
/** The day a record last changed, when the host says so. */
|
|
114
|
+
function updatedDay(entity) {
|
|
115
|
+
const value = textOrNull(entity?.updatedAt) ?? textOrNull(entity?.updated_at);
|
|
116
|
+
return value ? (formatDay(value) ?? value.slice(0, 10)) : null;
|
|
117
|
+
}
|
|
103
118
|
/** Results are `ok` when well formed; no match is shown only when the whole Graph was searched. */
|
|
104
119
|
export function normalizeSearch(result) {
|
|
105
120
|
if (result?.state !== 'ok') return result ?? INVALID;
|
|
@@ -227,33 +242,7 @@ function relationAvailability(payload, direction) {
|
|
|
227
242
|
return { state: 'confirmed_empty' };
|
|
228
243
|
}
|
|
229
244
|
|
|
230
|
-
|
|
231
|
-
// 'unknown', or 'at_least' plus atLeast) rather than a number it cannot vouch for.
|
|
232
|
-
function isCount(value) {
|
|
233
|
-
return value === null || Number.isInteger(value);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function countText(item, key = 'count') {
|
|
237
|
-
if (Number.isInteger(item[key])) return `${item[key]}件`;
|
|
238
|
-
if (key === 'count' && item.countStatus === 'at_least' && Number.isInteger(item.atLeast)) return `${item.atLeast}件以上`;
|
|
239
|
-
return '件数は未確認';
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export function normalizeOntology(result) {
|
|
243
|
-
if (result?.state !== 'ok') return result ?? INVALID;
|
|
244
|
-
const payload = result.payload;
|
|
245
|
-
const entityTypesOk = Array.isArray(payload.entityTypes) && payload.entityTypes.every((item) => isRecord(item)
|
|
246
|
-
&& typeof item.id === 'string' && isCount(item.count));
|
|
247
|
-
const relationsOk = Array.isArray(payload.relations) && payload.relations.every((item) => isRecord(item)
|
|
248
|
-
&& typeof item.id === 'string' && typeof item.from === 'string' && typeof item.to === 'string'
|
|
249
|
-
&& isCount(item.count) && isCount(item.activeCount));
|
|
250
|
-
if (!entityTypesOk || !relationsOk || !isRecord(payload.ontology)) return INVALID;
|
|
251
|
-
return { state: 'ok', payload };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function fieldNames(fields) {
|
|
255
|
-
return (Array.isArray(fields) ? fields : []).map((field) => GRAPH_CORRECTION_FIELDS[field]?.label ?? field).join('・');
|
|
256
|
-
}
|
|
245
|
+
export { normalizeOntology };
|
|
257
246
|
|
|
258
247
|
function textCell(value, fallback) {
|
|
259
248
|
return textOrNull(value) ?? { text: fallback, className: 'is-unresolved' };
|
|
@@ -264,7 +253,7 @@ function textCell(value, fallback) {
|
|
|
264
253
|
*
|
|
265
254
|
* @param {object} options
|
|
266
255
|
* @param {Element} options.root Where the workspace is drawn.
|
|
267
|
-
* @param {Element} [options.rail] The host's right rail
|
|
256
|
+
* @param {Element} [options.rail] The host's right rail. It is left empty: the record opens in the workspace.
|
|
268
257
|
* @param {{ crumbs?: string[], source?: string }} [options.page] Breadcrumb and source label of the page head.
|
|
269
258
|
* @param {Document} [options.document] The document to build elements with (default: the global one).
|
|
270
259
|
* @param {Function} [options.fetcher] `fetch`-compatible function for the Graph routes.
|
|
@@ -272,6 +261,12 @@ function textCell(value, fallback) {
|
|
|
272
261
|
* @param {string} [options.token] Launch token sent with a correction.
|
|
273
262
|
* @param {string} [options.tokenHeader] Header name for the token.
|
|
274
263
|
* @param {boolean} [options.autoLoad=true] Runs the empty search and reads the kinds on mount.
|
|
264
|
+
* @param {number} [options.searchDelayMs=300] How long the name field waits after the last keystroke
|
|
265
|
+
* before it searches (never while a Japanese conversion is still being composed).
|
|
266
|
+
* @param {(entityId: string | null, options: { back: boolean }) => void} [options.onRouteChange]
|
|
267
|
+
* Called when the owner opens a record (its id) or goes back to the list (null, `back: true`), so the
|
|
268
|
+
* host can give the record's screen an address. When that address changes from outside (the
|
|
269
|
+
* browser's back), the host calls `openEntity(id)` or `closeEntity()`; neither reports back.
|
|
275
270
|
* @param {string} [options.initialEntityId] Opens this exact entity after the initial Graph read.
|
|
276
271
|
* @param {() => Date} [options.now] The owner's clock.
|
|
277
272
|
* @param {boolean} [options.canCorrect=true] When false, no correction control is drawn anywhere
|
|
@@ -284,26 +279,23 @@ function textCell(value, fallback) {
|
|
|
284
279
|
* such a correction before sending it. Without it every correction is allowed.
|
|
285
280
|
* @param {string | ((record: { id: string, type: string | null, metadata: object } | null) => string | Element | null)} [options.readOnlyNote]
|
|
286
281
|
* With `canCorrect: false`, or where `correctionScope` withholds a
|
|
287
|
-
* control of the
|
|
288
|
-
* Without it
|
|
282
|
+
* control of the open record, a note shown under 直す where the correction buttons would be.
|
|
283
|
+
* Without it nothing is said about corrections.
|
|
289
284
|
* @param {{ label?: string, text: string | Element }} [options.emptyNotice] Replaces the 未登録 notice
|
|
290
285
|
* and its `brainbase onboard:*` commands when the host's Graph has nothing registered.
|
|
291
|
-
* @param {{ label?: string, text: string | Element }} [options.sourceNotice] Replaces the default 出典 notice
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* results, labelled 「引き継いだ自分の分(組織には未反映)」 unless `label` says otherwise; a record
|
|
296
|
-
* opens below them from `{basePath}/entities/:id`, with a decision's judgment record. They never
|
|
297
|
-
* enter the host's results or rail, and a failed read shows only in that section. The host's
|
|
298
|
-
* results are then captioned as the organization's. Without it nothing of the kind is drawn or read.
|
|
286
|
+
* @param {{ label?: string, text: string | Element }} [options.sourceNotice] Replaces the default 出典 notice
|
|
287
|
+
* inside the open record's 根拠と履歴.
|
|
288
|
+
* @param {unknown} [options.ownShare] Ignored. The owner's own snapshot is not part of this screen
|
|
289
|
+
* (ledger D14); a host mounts `createOwnShareView` from `graph-own-share.js` where the owner takes records in.
|
|
299
290
|
*
|
|
300
|
-
* The
|
|
301
|
-
*
|
|
302
|
-
*
|
|
291
|
+
* The kind tabs list the entity types of the `/ontology` answer that have a
|
|
292
|
+
* plain name, people, companies and projects first (the four local kinds until
|
|
293
|
+
* it has been read). A type without one is never named: it is listed under
|
|
294
|
+
* すべて as その他の記録.
|
|
303
295
|
*
|
|
304
296
|
* @returns The controller: `state`, `correction`, `render()`, `load()`, `search()`,
|
|
305
|
-
* `
|
|
306
|
-
* `
|
|
297
|
+
* `clearFilter(key)`, `clearFilters()`, `refreshResults()`, `loadEntity(id)`, `navigate(id)`,
|
|
298
|
+
* `openEntity(id)`, `closeEntity()`, `backToList()`, `back()`, `loadOntology()`.
|
|
307
299
|
*/
|
|
308
300
|
export function createGraphRegistryView({
|
|
309
301
|
root,
|
|
@@ -315,19 +307,19 @@ export function createGraphRegistryView({
|
|
|
315
307
|
token,
|
|
316
308
|
tokenHeader,
|
|
317
309
|
autoLoad = true,
|
|
310
|
+
searchDelayMs = 300,
|
|
318
311
|
initialEntityId,
|
|
312
|
+
onRouteChange,
|
|
319
313
|
now = () => new Date(),
|
|
320
314
|
canCorrect = true,
|
|
321
315
|
correctionScope,
|
|
322
316
|
readOnlyNote,
|
|
323
317
|
sourceNotice,
|
|
324
318
|
emptyNotice,
|
|
325
|
-
ownShare,
|
|
326
319
|
} = {}) {
|
|
327
320
|
if (!root) throw new TypeError('root is required');
|
|
328
321
|
const doc = getDocument(explicitDocument);
|
|
329
322
|
const graphClient = createGraphClient({ fetcher, basePath, token, ...(tokenHeader ? { tokenHeader } : {}) });
|
|
330
|
-
const own = graphOwnShareSource(ownShare, { fetcher });
|
|
331
323
|
const writable = canCorrect !== false;
|
|
332
324
|
const client = writable ? graphClient : readOnlyGraphClient(graphClient);
|
|
333
325
|
const scope = graphCorrectionScope(correctionScope);
|
|
@@ -346,16 +338,25 @@ export function createGraphRegistryView({
|
|
|
346
338
|
applied: { q: '', type: '', asOfDay: '' },
|
|
347
339
|
queryMessage: null,
|
|
348
340
|
search: { state: 'loading' },
|
|
349
|
-
//
|
|
341
|
+
// A new search is being read while the last results stay on screen.
|
|
342
|
+
searching: false,
|
|
343
|
+
// 詳しい条件 stays open across redraws once the owner opened it.
|
|
344
|
+
advancedOpen: false,
|
|
345
|
+
// `detail` is the open record: { id, state, payload?, pending? }.
|
|
350
346
|
detail: null,
|
|
347
|
+
// The row of the results the open record hangs from (the record the owner clicked).
|
|
348
|
+
anchor: null,
|
|
351
349
|
trail: [],
|
|
352
350
|
ontology: { state: 'loading' },
|
|
353
351
|
// Where the open correction shows: a relation id (inside that relation) or anything else (below the record actions).
|
|
354
352
|
panelAt: null,
|
|
355
|
-
// The owner's own share: the same search on its own routes, and its own open record.
|
|
356
|
-
own: own ? { search: { state: 'loading' }, detail: null } : null,
|
|
357
353
|
};
|
|
358
354
|
let focusRow = null;
|
|
355
|
+
// The control to keep focused across a redraw, and the open record to bring into view once.
|
|
356
|
+
let revealDetail = false;
|
|
357
|
+
let composing = false;
|
|
358
|
+
let renderDeferred = false;
|
|
359
|
+
let searchTimer = null;
|
|
359
360
|
|
|
360
361
|
const correction = createGraphCorrection({
|
|
361
362
|
client,
|
|
@@ -386,53 +387,131 @@ export function createGraphRegistryView({
|
|
|
386
387
|
// -------------------------------------------------------------------------
|
|
387
388
|
// Workspace
|
|
388
389
|
|
|
390
|
+
function cancelScheduledSearch() {
|
|
391
|
+
if (searchTimer !== null) clearTimeout(searchTimer);
|
|
392
|
+
searchTimer = null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The name field searches once typing pauses; nothing is searched mid-conversion. */
|
|
396
|
+
function scheduleSearch() {
|
|
397
|
+
cancelScheduledSearch();
|
|
398
|
+
searchTimer = setTimeout(() => {
|
|
399
|
+
searchTimer = null;
|
|
400
|
+
void controller.search();
|
|
401
|
+
}, Math.max(0, Number(searchDelayMs) || 0));
|
|
402
|
+
}
|
|
403
|
+
|
|
389
404
|
function renderSearchForm() {
|
|
390
405
|
const section = makeElement(doc, 'section', { className: 'bb-gr-search-section', attrs: { 'aria-label': '検索' } });
|
|
391
406
|
const form = makeElement(doc, 'form', { className: 'bb-graph-form bb-gr-search', attrs: { role: 'search', novalidate: true } });
|
|
407
|
+
// Enter searches at once; every other change searches by itself.
|
|
392
408
|
form.addEventListener('submit', (event) => {
|
|
393
409
|
event?.preventDefault?.();
|
|
410
|
+
cancelScheduledSearch();
|
|
394
411
|
return controller.search();
|
|
395
412
|
});
|
|
396
|
-
const field = (id, label, control) => {
|
|
397
|
-
const wrap = makeElement(doc, 'div', { className:
|
|
413
|
+
const field = (id, label, control, className = '') => {
|
|
414
|
+
const wrap = makeElement(doc, 'div', { className: `bb-graph-field${className ? ` ${className}` : ''}` });
|
|
398
415
|
wrap.append(makeElement(doc, 'label', { text: label, attrs: { for: id } }), control);
|
|
399
416
|
return wrap;
|
|
400
417
|
};
|
|
401
|
-
const
|
|
402
|
-
const update = (event) => { state.query[key] = event?.target?.value ?? control.value; };
|
|
403
|
-
control.addEventListener('input', update);
|
|
404
|
-
control.addEventListener('change', update);
|
|
405
|
-
};
|
|
406
|
-
const q = makeElement(doc, 'input', { attrs: { id: 'bb-gr-q', name: 'q', type: 'search', maxlength: 200, placeholder: '例: 田中' } });
|
|
418
|
+
const q = makeElement(doc, 'input', { attrs: { id: 'bb-gr-q', name: 'q', type: 'search', maxlength: 200, placeholder: '名前・別名で探す(例: 田中)', autocomplete: 'off', 'data-focus-key': 'q' } });
|
|
407
419
|
q.value = state.query.q;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
420
|
+
q.addEventListener('input', (event) => {
|
|
421
|
+
state.query.q = event?.target?.value ?? q.value;
|
|
422
|
+
if (event?.isComposing || composing) return;
|
|
423
|
+
scheduleSearch();
|
|
424
|
+
});
|
|
425
|
+
q.addEventListener('compositionstart', () => { composing = true; });
|
|
426
|
+
q.addEventListener('compositionend', (event) => {
|
|
427
|
+
composing = false;
|
|
428
|
+
state.query.q = event?.target?.value ?? q.value;
|
|
429
|
+
scheduleSearch();
|
|
430
|
+
if (renderDeferred) controller.render();
|
|
431
|
+
});
|
|
432
|
+
form.append(field('bb-gr-q', '名前・別名', q));
|
|
433
|
+
// Looking at another day is rare: it stays under 詳しい条件.
|
|
434
|
+
const asOf = makeElement(doc, 'input', { attrs: { id: 'bb-gr-as-of', name: 'as_of', type: 'date', 'data-focus-key': 'as_of' } });
|
|
421
435
|
asOf.value = state.query.asOfDay;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
436
|
+
asOf.addEventListener('change', (event) => {
|
|
437
|
+
state.query.asOfDay = event?.target?.value ?? asOf.value;
|
|
438
|
+
cancelScheduledSearch();
|
|
439
|
+
void controller.search();
|
|
440
|
+
});
|
|
441
|
+
const advanced = makeElement(doc, 'details', { className: 'bb-gr-advanced' });
|
|
442
|
+
if (state.advancedOpen || state.query.asOfDay) advanced.setAttribute('open', '');
|
|
443
|
+
advanced.addEventListener('toggle', () => { state.advancedOpen = Boolean(advanced.open); });
|
|
444
|
+
advanced.append(
|
|
445
|
+
makeElement(doc, 'summary', { text: '詳しい条件' }),
|
|
446
|
+
field('bb-gr-as-of', '過去のある日の状態で見る', asOf, 'bb-gr-as-of-field'),
|
|
447
|
+
makeElement(doc, 'p', { className: 'bb-gr-hint', text: '日付を選ぶと、その日に有効だった記録だけを、その日の状態で見ます。空なら今日です。' }),
|
|
428
448
|
);
|
|
429
|
-
section.append(form);
|
|
449
|
+
section.append(form, renderTypeTabs(), advanced);
|
|
430
450
|
if (state.queryMessage) section.append(workspaceNotice(doc, { label: '入力', text: state.queryMessage, tone: 'danger' }));
|
|
451
|
+
const chips = renderActiveFilters();
|
|
452
|
+
if (chips) section.append(chips);
|
|
431
453
|
return section;
|
|
432
454
|
}
|
|
433
455
|
|
|
456
|
+
/**
|
|
457
|
+
* The kinds as tabs with their counts: people, companies and projects first.
|
|
458
|
+
* Only kinds with a plain name become tabs (an internal type id is never
|
|
459
|
+
* shown); an unread or unfinished count is left off rather than shown as 0.
|
|
460
|
+
*/
|
|
461
|
+
function renderTypeTabs() {
|
|
462
|
+
const read = state.ontology.state === 'ok' && Array.isArray(state.ontology.payload?.entityTypes) ? state.ontology.payload.entityTypes : null;
|
|
463
|
+
const counts = new Map((read ?? []).map((item) => [item.id, item]));
|
|
464
|
+
const selected = state.query.type;
|
|
465
|
+
const ids = (read ? read.map((item) => item.id).filter(hasKindLabel) : DEFAULT_KIND_TABS)
|
|
466
|
+
.map((id, index) => ({ id, index }))
|
|
467
|
+
.sort((left, right) => kindRank(left.id) - kindRank(right.id) || left.index - right.index)
|
|
468
|
+
.map(({ id }) => id)
|
|
469
|
+
.filter((id) => id === selected || counts.get(id)?.count !== 0);
|
|
470
|
+
const tabs = makeElement(doc, 'div', { className: 'bb-gr-tabs', attrs: { role: 'tablist', 'aria-label': '種類' } });
|
|
471
|
+
for (const id of ['', ...ids]) {
|
|
472
|
+
const item = counts.get(id);
|
|
473
|
+
const count = Number.isInteger(item?.count) ? String(item.count)
|
|
474
|
+
: item?.countStatus === 'at_least' && Number.isInteger(item.atLeast) ? `${item.atLeast}+` : null;
|
|
475
|
+
const tab = makeElement(doc, 'button', {
|
|
476
|
+
className: `bb-gr-tab${id === selected ? ' is-selected' : ''}`,
|
|
477
|
+
attrs: { type: 'button', role: 'tab', 'aria-selected': id === selected ? 'true' : 'false', 'data-type': id, 'data-focus-key': `type:${id}` },
|
|
478
|
+
});
|
|
479
|
+
tab.append(makeElement(doc, 'span', { text: id ? kindLabel(id) : 'すべて' }));
|
|
480
|
+
if (count) tab.append(makeElement(doc, 'span', { className: 'bb-gr-tab-count', text: count }));
|
|
481
|
+
tab.addEventListener('click', () => {
|
|
482
|
+
if (state.query.type === id) return;
|
|
483
|
+
state.query.type = id;
|
|
484
|
+
cancelScheduledSearch();
|
|
485
|
+
void controller.search();
|
|
486
|
+
});
|
|
487
|
+
tabs.append(tab);
|
|
488
|
+
}
|
|
489
|
+
return tabs;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The conditions the results were read with, each removable, and すべて外す. */
|
|
493
|
+
function renderActiveFilters() {
|
|
494
|
+
const applied = state.applied;
|
|
495
|
+
const items = [
|
|
496
|
+
applied.q.trim() ? ['q', `名前・別名: ${applied.q.trim()}`] : null,
|
|
497
|
+
applied.type ? ['type', `種類: ${kindLabel(applied.type)}`] : null,
|
|
498
|
+
applied.asOfDay ? ['asOfDay', `${applied.asOfDay} の状態で見ています`] : null,
|
|
499
|
+
].filter(Boolean);
|
|
500
|
+
if (items.length === 0) return null;
|
|
501
|
+
const bar = makeElement(doc, 'div', { className: 'bb-gr-filters', attrs: { role: 'group', 'aria-label': '今の絞り込み' } });
|
|
502
|
+
bar.append(makeElement(doc, 'span', { className: 'bb-gr-filters-label', text: '絞り込み中' }));
|
|
503
|
+
for (const [key, text] of items) {
|
|
504
|
+
const chip = makeElement(doc, 'button', { className: 'bb-gr-filter-chip', attrs: { type: 'button', 'aria-label': `「${text}」を外す` } });
|
|
505
|
+
chip.append(makeElement(doc, 'span', { text }), makeElement(doc, 'span', { className: 'bb-gr-filter-chip-x', text: '×', attrs: { 'aria-hidden': 'true' } }));
|
|
506
|
+
chip.addEventListener('click', () => controller.clearFilter(key));
|
|
507
|
+
bar.append(chip);
|
|
508
|
+
}
|
|
509
|
+
bar.append(button(doc, 'すべて外す', () => controller.clearFilters(), { className: 'bb-graph-link bb-gr-filters-clear' }));
|
|
510
|
+
return bar;
|
|
511
|
+
}
|
|
512
|
+
|
|
434
513
|
function renderResults() {
|
|
435
|
-
const section = makeElement(doc, 'section', { className: 'bb-gr-results', attrs: { 'aria-label': '検索結果' } });
|
|
514
|
+
const section = makeElement(doc, 'section', { className: 'bb-gr-results', attrs: { 'aria-label': '検索結果', 'aria-busy': state.searching ? 'true' : 'false' } });
|
|
436
515
|
const stateNotice = graphStateNotice(doc, state.search, { onRetry: () => controller.search(), loadingText: '記録を探しています。' });
|
|
437
516
|
if (stateNotice) {
|
|
438
517
|
section.append(stateNotice);
|
|
@@ -459,27 +538,26 @@ export function createGraphRegistryView({
|
|
|
459
538
|
const asOfDay = asOf ? state.applied.asOfDay || String(asOf).slice(0, 10) : null;
|
|
460
539
|
section.append(makeElement(doc, 'p', {
|
|
461
540
|
className: 'bb-gr-caption',
|
|
462
|
-
|
|
463
|
-
text: `${
|
|
541
|
+
attrs: { role: 'status' },
|
|
542
|
+
text: `${Number.isInteger(payload.total) ? `検索結果 ${payload.total}件` : `検索結果 ${payload.results.length}件を表示(全体の件数は未確認)`}${asOfDay ? `(${asOfDay} の状態)` : ''}${state.searching ? '。新しい条件で探しています…' : ''}`,
|
|
464
543
|
}));
|
|
544
|
+
// 最終更新 is a column only when the host says when records changed; it is never filled with guesses.
|
|
545
|
+
const updated = payload.results.some((entity) => updatedDay(entity));
|
|
465
546
|
section.append(workspaceLedger(doc, {
|
|
466
|
-
className:
|
|
547
|
+
className: `bb-graph-entity-ledger${updated ? ' has-updated' : ''}`,
|
|
467
548
|
ariaLabel: '検索結果の一覧',
|
|
468
|
-
columns: GRAPH_ENTITY_LEDGER_COLUMNS,
|
|
469
|
-
empty: '
|
|
549
|
+
columns: updated ? GRAPH_ENTITY_LEDGER_COLUMNS : GRAPH_ENTITY_LEDGER_COLUMNS.slice(0, 3),
|
|
550
|
+
empty: '条件に合う記録はありません。名前・別名や種類、日付の条件を変えるか、上の「すべて外す」で条件を外してください。',
|
|
470
551
|
rows: payload.results.map((entity) => ({
|
|
471
552
|
key: entity.id,
|
|
472
|
-
selected: entity.id === state.detail?.id,
|
|
473
553
|
className: entity.active ? '' : 'is-ended',
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
void controller.openEntity(id);
|
|
477
|
-
},
|
|
554
|
+
label: `${entity.name}(${kindLabel(entity.type)})を開く`,
|
|
555
|
+
onSelect: (id) => { void controller.navigate(id); },
|
|
478
556
|
cells: [
|
|
479
|
-
{ primary: entity.name, secondary: entity.
|
|
480
|
-
|
|
481
|
-
textCell(entity.summary, '
|
|
482
|
-
|
|
557
|
+
entity.active ? { primary: entity.name } : { primary: entity.name, secondary: validityLabel(entity, asOf ?? now().toISOString()) },
|
|
558
|
+
kindLabel(entity.type),
|
|
559
|
+
textCell(entity.summary, '説明の記録なし'),
|
|
560
|
+
...(updated ? [updatedDay(entity) ?? { text: '未確認', className: 'is-unresolved' }] : []),
|
|
483
561
|
],
|
|
484
562
|
})),
|
|
485
563
|
}));
|
|
@@ -491,83 +569,23 @@ export function createGraphRegistryView({
|
|
|
491
569
|
return section;
|
|
492
570
|
}
|
|
493
571
|
|
|
494
|
-
function renderOntology() {
|
|
495
|
-
const section = makeElement(doc, 'section', { className: 'bb-gr-ontology', attrs: { 'aria-label': '情報の種類とつながり方' } });
|
|
496
|
-
section.append(workspaceSectionTitle(doc, {
|
|
497
|
-
title: '情報の種類とつながり方',
|
|
498
|
-
lead: 'Graphに登録できる情報の種類と、種類どうしのつながり方です。この欄は表示だけです。',
|
|
499
|
-
}));
|
|
500
|
-
const stateNotice = graphStateNotice(doc, state.ontology, { onRetry: () => controller.loadOntology(), loadingText: '情報の種類を読み込んでいます。' });
|
|
501
|
-
if (stateNotice) {
|
|
502
|
-
section.append(stateNotice);
|
|
503
|
-
return section;
|
|
504
|
-
}
|
|
505
|
-
const { payload } = state.ontology;
|
|
506
|
-
const ontology = payload.ontology;
|
|
507
|
-
const version = textOrNull(ontology.version);
|
|
508
|
-
const currentVersion = textOrNull(ontology.currentVersion);
|
|
509
|
-
const freshness = ontology.upToDate === false && currentVersion
|
|
510
|
-
? `最新ではありません。最新は ${currentVersion}`
|
|
511
|
-
: ontology.upToDate === true && version ? '最新' : '最新性は未確認';
|
|
512
|
-
section.append(makeElement(doc, 'p', {
|
|
513
|
-
className: 'bb-gr-caption',
|
|
514
|
-
text: `定義の版 ${version ?? '未確認'}(${freshness})`,
|
|
515
|
-
}));
|
|
516
|
-
section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: 'つながり方' }));
|
|
517
|
-
section.append(workspaceLedger(doc, {
|
|
518
|
-
className: 'bb-graph-relation-type-ledger',
|
|
519
|
-
ariaLabel: 'つながり方',
|
|
520
|
-
columns: GRAPH_RELATION_TYPE_LEDGER_COLUMNS,
|
|
521
|
-
rows: payload.relations.map((item) => ({
|
|
522
|
-
key: item.id,
|
|
523
|
-
cells: [
|
|
524
|
-
relationLabel(item.id),
|
|
525
|
-
`${typeLabel(item.from)} → ${typeLabel(item.to)}`,
|
|
526
|
-
ownLabel(GRAPH_RELATION_LABELS, item.id)?.meaning ?? textOrNull(item.meaning) ?? '',
|
|
527
|
-
Number.isInteger(item.count) && Number.isInteger(item.activeCount)
|
|
528
|
-
? `${item.count}件(うち有効 ${item.activeCount}件)`
|
|
529
|
-
: countText(item),
|
|
530
|
-
],
|
|
531
|
-
})),
|
|
532
|
-
}));
|
|
533
|
-
section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: '情報の種類' }));
|
|
534
|
-
section.append(workspaceLedger(doc, {
|
|
535
|
-
className: 'bb-graph-entity-type-ledger',
|
|
536
|
-
ariaLabel: '情報の種類',
|
|
537
|
-
columns: GRAPH_ENTITY_TYPE_LEDGER_COLUMNS,
|
|
538
|
-
rows: payload.entityTypes.map((item) => ({
|
|
539
|
-
key: item.id,
|
|
540
|
-
cells: [typeLabel(item.id), typeMeaning(item), countText(item)],
|
|
541
|
-
})),
|
|
542
|
-
}));
|
|
543
|
-
const corrections = writable && isRecord(payload.corrections) ? payload.corrections : null;
|
|
544
|
-
if (corrections) {
|
|
545
|
-
section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: 'この画面で直せること' }));
|
|
546
|
-
const list = makeElement(doc, 'ul', { className: 'bb-graph-plain-list' });
|
|
547
|
-
list.append(
|
|
548
|
-
makeElement(doc, 'li', { text: `記録: ${fieldNames(corrections.entityFields)}(プロジェクトは${fieldNames(corrections.projectFields)}も)` }),
|
|
549
|
-
makeElement(doc, 'li', { text: `関係: ${fieldNames(corrections.edgeFields)}` }),
|
|
550
|
-
makeElement(doc, 'li', { text: `新しく加えられる関係: ${(Array.isArray(corrections.newEdgeRelations) ? corrections.newEdgeRelations : []).map(relationLabel).join('・')}` }),
|
|
551
|
-
makeElement(doc, 'li', { text: '記録と関係は削除しません。終わったものは終了日で表します。関係の種類や相手を変えるときは、今の関係を終えてから新しく加えます。' }),
|
|
552
|
-
);
|
|
553
|
-
section.append(list);
|
|
554
|
-
}
|
|
555
|
-
section.append(recordDetails(doc, [
|
|
556
|
-
['定義ID', textOrNull(ontology.id) ?? '未確認'],
|
|
557
|
-
['定義の版', version ?? '未確認'],
|
|
558
|
-
['release digest', textOrNull(ontology.releaseDigest) ?? '未確認'],
|
|
559
|
-
]));
|
|
560
|
-
return section;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
572
|
/** The record a host's 出典 notice may point at: the selected record once it is read. */
|
|
564
573
|
function sourceSubject() {
|
|
565
574
|
const entity = state.detail?.state === 'ok' ? state.detail.payload.entity : null;
|
|
566
575
|
return entity ? { id: entity.id, type: entity.type, metadata: entity.metadata ?? {} } : null;
|
|
567
576
|
}
|
|
568
577
|
|
|
569
|
-
|
|
578
|
+
/** Where the records come from: the host's notice, or this Mac's Graph. */
|
|
579
|
+
function sourceNoticeElement() {
|
|
570
580
|
const dir = state.search.state === 'ok' ? textOrNull(state.search.payload.source?.dataDir) : null;
|
|
581
|
+
return workspaceHostNotice(doc, hostSourceNotice(sourceNotice, sourceSubject())) ?? workspaceNotice(doc, {
|
|
582
|
+
label: '出典',
|
|
583
|
+
text: `このMacのGraph${dir ? `(${dir})` : ''}だけを表示します。ここで直した内容は、同じGraphを読むMCPの search・get_context・resolve_entity で次から使われます。`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderWorkspace() {
|
|
588
|
+
if (state.detail) return [renderDetailPage()];
|
|
571
589
|
return [
|
|
572
590
|
workspacePageHeader(doc, {
|
|
573
591
|
crumbs: context.crumbs,
|
|
@@ -575,225 +593,241 @@ export function createGraphRegistryView({
|
|
|
575
593
|
lead: writable ? 'Graphに何がどう登録されているかを確かめ、誤りを直します。' : 'Graphに何がどう登録されているかを確かめます。',
|
|
576
594
|
source: context.source,
|
|
577
595
|
}),
|
|
578
|
-
workspaceHostNotice(doc, hostSourceNotice(sourceNotice, sourceSubject())) ?? workspaceNotice(doc, {
|
|
579
|
-
label: '出典',
|
|
580
|
-
text: `このMacのGraph${dir ? `(${dir})` : ''}だけを表示します。ここで直した内容は、同じGraphを読むMCPの search・get_context・resolve_entity で次から使われます。`,
|
|
581
|
-
}),
|
|
582
|
-
state.detail?.state === 'ok' ? renderGraphIssues(doc, state.detail.payload.issues) : null,
|
|
583
596
|
renderSearchForm(),
|
|
584
597
|
renderResults(),
|
|
585
|
-
own ? renderOwnShare() : null,
|
|
586
|
-
renderOntology(),
|
|
587
598
|
];
|
|
588
599
|
}
|
|
589
600
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const asOf = payload.query?.asOf ?? null;
|
|
597
|
-
const children = [makeElement(doc, 'p', {
|
|
598
|
-
className: 'bb-gr-caption',
|
|
599
|
-
text: `${payload.graphEmpty === true ? '束に記録がありません' : `検索結果 ${payload.total}件`}${payload.truncated ? `(うち${payload.results.length}件を表示)` : ''}`,
|
|
600
|
-
})];
|
|
601
|
-
if (payload.graphEmpty !== true) {
|
|
602
|
-
children.push(workspaceLedger(doc, {
|
|
603
|
-
className: 'bb-graph-entity-ledger bb-graph-own-ledger',
|
|
604
|
-
ariaLabel: `${own.label}の検索結果`,
|
|
605
|
-
columns: GRAPH_ENTITY_LEDGER_COLUMNS,
|
|
606
|
-
empty: '条件に合う記録は、この束にはありません。',
|
|
607
|
-
rows: payload.results.map((entity) => ({
|
|
608
|
-
key: entity.id,
|
|
609
|
-
selected: entity.id === ownState.detail?.id,
|
|
610
|
-
className: entity.active ? '' : 'is-ended',
|
|
611
|
-
onSelect: (id) => { void controller.openOwnEntity(id); },
|
|
612
|
-
cells: [
|
|
613
|
-
{ primary: entity.name, secondary: entity.id },
|
|
614
|
-
typeLabel(entity.type),
|
|
615
|
-
textCell(entity.summary, '要約なし'),
|
|
616
|
-
validityLabel(entity, asOf ?? now().toISOString()),
|
|
617
|
-
],
|
|
618
|
-
})),
|
|
619
|
-
}));
|
|
620
|
-
}
|
|
621
|
-
if (ownState.detail) {
|
|
622
|
-
children.push(ownEntityDetail(doc, ownState.detail, {
|
|
623
|
-
onClose: () => controller.openOwnEntity(null),
|
|
624
|
-
onRetry: () => controller.openOwnEntity(ownState.detail.id),
|
|
625
|
-
onOpen: (id) => controller.openOwnEntity(id),
|
|
626
|
-
}));
|
|
627
|
-
}
|
|
628
|
-
return ownShareSection(doc, { label: own.label, handover: payload.handover, children });
|
|
601
|
+
// -------------------------------------------------------------------------
|
|
602
|
+
// The record's own screen: what it is, how it connects, the evidence, and how to fix it.
|
|
603
|
+
|
|
604
|
+
function openEntityHead() {
|
|
605
|
+
const detail = state.detail;
|
|
606
|
+
return (detail.state === 'ok' ? detail.payload.entity : null) ?? detail.pending ?? { id: detail.id, name: detail.id, type: null };
|
|
629
607
|
}
|
|
630
608
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
button(doc, edge.counterpart.name, () => controller.
|
|
609
|
+
function pageSection(title, children, className = '') {
|
|
610
|
+
const section = makeElement(doc, 'section', { className: `bb-gr-page-section${className ? ` ${className}` : ''}`, attrs: { 'aria-label': title } });
|
|
611
|
+
section.append(makeElement(doc, 'h2', { className: 'bb-gr-page-title', text: title }), ...children.filter(Boolean));
|
|
612
|
+
return section;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** A relation read as a sentence about this record: 「Acme に所属」「田中 太郎 が参加」. */
|
|
616
|
+
function relationSentence(edge, entity, { link = true } = {}) {
|
|
617
|
+
const line = makeElement(doc, 'span', { className: 'bb-gr-sentence' });
|
|
618
|
+
const name = link
|
|
619
|
+
? button(doc, edge.counterpart.name, () => controller.navigate(edge.counterpart.id, {
|
|
642
620
|
from: entity,
|
|
643
621
|
pending: { id: edge.counterpart.id, name: edge.counterpart.name, type: edge.counterpart.type },
|
|
644
|
-
}), { className: 'bb-graph-link' })
|
|
645
|
-
makeElement(doc, '
|
|
646
|
-
);
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
['相手の種類', typeLabel(edge.counterpart.type)],
|
|
651
|
-
['役割', textOrNull(edge.role)],
|
|
652
|
-
...(textOrNull(edge.context) ? [['文脈', edge.context]] : []),
|
|
653
|
-
['有効期間', validityText(edge.validFrom, edge.validTo)],
|
|
654
|
-
['出典', renderProvenance(doc, edge.provenance)],
|
|
655
|
-
]));
|
|
656
|
-
const subject = edge.direction === 'outgoing'
|
|
657
|
-
? `${entity.name} → ${relationLabel(edge.relation)} → ${edge.counterpart.name}`
|
|
658
|
-
: `${edge.counterpart.name} → ${relationLabel(edge.relation)} → ${entity.name}`;
|
|
659
|
-
if (edgesWritable) {
|
|
660
|
-
item.append(workspaceActions(doc, [
|
|
661
|
-
workspaceButton(doc, { text: 'この関係を直す', variant: 'quiet', onClick: () => openCorrection(edge.id, () => correction.openEdge(edge, { mode: 'edit', subject })) }),
|
|
662
|
-
]));
|
|
663
|
-
}
|
|
664
|
-
item.append(recordDetails(doc, [
|
|
665
|
-
['関係ID', edge.id],
|
|
666
|
-
['関係の種類ID', edge.relation],
|
|
667
|
-
['起点ID', edge.fromId],
|
|
668
|
-
['終点ID', edge.toId],
|
|
669
|
-
['出典ID', edge.provenance.sourceId],
|
|
670
|
-
['digest', edge.digest],
|
|
671
|
-
]));
|
|
672
|
-
if (edgesWritable && state.panelAt === edge.id) {
|
|
673
|
-
const panel = correction.render(doc);
|
|
674
|
-
if (panel) item.append(panel);
|
|
675
|
-
}
|
|
676
|
-
return item;
|
|
622
|
+
}), { className: 'bb-graph-link' })
|
|
623
|
+
: makeElement(doc, 'strong', { text: edge.counterpart.name });
|
|
624
|
+
line.append(name, makeElement(doc, 'span', { text: relationPhrase(edge) }));
|
|
625
|
+
const notes = [textOrNull(edge.role) ? `役割: ${edge.role}` : null, textOrNull(edge.context)].filter(Boolean);
|
|
626
|
+
if (notes.length > 0) line.append(makeElement(doc, 'span', { className: 'bb-gr-sentence-note', text: `(${notes.join('・')})` }));
|
|
627
|
+
return line;
|
|
677
628
|
}
|
|
678
629
|
|
|
679
|
-
|
|
630
|
+
/** つながり: every relation in one list, grouped by the kind of the other record. */
|
|
631
|
+
function renderConnections(payload) {
|
|
680
632
|
const { entity } = payload;
|
|
681
|
-
const
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
text:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
})];
|
|
689
|
-
const edges = payload[direction];
|
|
633
|
+
const edges = [...payload.outgoing, ...payload.incoming];
|
|
634
|
+
const children = [];
|
|
635
|
+
const unknown = ['outgoing', 'incoming'].map((direction) => relationAvailability(payload, direction)).find((item) => item.state === 'unknown');
|
|
636
|
+
if (unknown) {
|
|
637
|
+
children.push(workspaceNotice(doc, { label: '確認できない', tone: 'warning', role: 'status', text: unknown.reason }));
|
|
638
|
+
children.push(workspaceActions(doc, [workspaceButton(doc, { text: '再試行', variant: 'quiet', onClick: () => controller.loadEntity(entity.id, { keep: true }) })]));
|
|
639
|
+
}
|
|
690
640
|
if (edges.length === 0) {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
641
|
+
if (!unknown) children.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: 'この記録とつながる記録はありません。' }));
|
|
642
|
+
return pageSection('つながり', children, 'bb-gr-connections');
|
|
643
|
+
}
|
|
644
|
+
const groups = new Map();
|
|
645
|
+
for (const edge of edges) {
|
|
646
|
+
const kind = kindLabel(edge.counterpart.type);
|
|
647
|
+
if (!groups.has(kind)) groups.set(kind, []);
|
|
648
|
+
groups.get(kind).push(edge);
|
|
649
|
+
}
|
|
650
|
+
const order = (kind) => {
|
|
651
|
+
const index = Object.values(LEADING_KINDS).indexOf(kind);
|
|
652
|
+
return index < 0 ? Object.keys(LEADING_KINDS).length : index;
|
|
653
|
+
};
|
|
654
|
+
for (const [kind, items] of [...groups].sort(([left], [right]) => order(left) - order(right))) {
|
|
655
|
+
const group = makeElement(doc, 'div', { className: 'bb-gr-connection-group', attrs: { role: 'group', 'aria-label': kind } });
|
|
656
|
+
group.append(makeElement(doc, 'h3', { className: 'bb-gr-connection-kind', text: `${kind}(${items.length})` }));
|
|
657
|
+
const list = makeElement(doc, 'ul', { className: 'bb-gr-connection-list' });
|
|
658
|
+
for (const edge of items) {
|
|
659
|
+
const item = makeElement(doc, 'li', { className: `bb-gr-connection${edge.active ? '' : ' is-ended'}` });
|
|
660
|
+
item.append(relationSentence(edge, entity));
|
|
661
|
+
// An ended or future relation says when, not only that it is not in effect.
|
|
662
|
+
if (!edge.active) item.append(makeElement(doc, 'span', { className: 'bb-gr-connection-validity', text: validityLabel(edge, payload.asOf) }));
|
|
663
|
+
list.append(item);
|
|
706
664
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
for (const edge of edges) list.append(relationItem(edge, entity, payload.asOf));
|
|
710
|
-
content.push(list);
|
|
665
|
+
group.append(list);
|
|
666
|
+
children.push(group);
|
|
711
667
|
}
|
|
712
|
-
return
|
|
668
|
+
return pageSection('つながり', children, 'bb-gr-connections');
|
|
713
669
|
}
|
|
714
670
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
671
|
+
/**
|
|
672
|
+
* 根拠と履歴, folded (the shared part): where the record came from, records
|
|
673
|
+
* that could not be read and the corrections, then its period, where each
|
|
674
|
+
* connection came from, and the ids.
|
|
675
|
+
*/
|
|
676
|
+
function renderEvidence(payload) {
|
|
677
|
+
const { entity } = payload;
|
|
678
|
+
const edges = [...payload.outgoing, ...payload.incoming];
|
|
679
|
+
const details = renderEvidenceAndHistory(doc, { source: sourceNoticeElement(), issues: payload.issues, history: payload.history });
|
|
680
|
+
const part = (title, ...children) => {
|
|
681
|
+
const block = makeElement(doc, 'section', { className: 'bb-graph-evidence-part' });
|
|
682
|
+
block.append(makeElement(doc, 'h4', { text: title }), ...children.filter(Boolean));
|
|
683
|
+
details.append(block);
|
|
684
|
+
};
|
|
685
|
+
part('有効期間', workspaceDefinition(doc, [
|
|
686
|
+
['有効期間', validityText(entity.validFrom, entity.validTo)],
|
|
687
|
+
['読み取った日', formatDay(payload.asOf) ?? textOrNull(payload.asOf)],
|
|
688
|
+
]));
|
|
689
|
+
if (edges.length > 0) {
|
|
690
|
+
const list = makeElement(doc, 'ul', { className: 'bb-gr-evidence-list', attrs: { 'aria-label': 'つながりの出典' } });
|
|
691
|
+
for (const edge of edges) {
|
|
692
|
+
const item = makeElement(doc, 'li', { className: 'bb-gr-evidence-item' });
|
|
693
|
+
item.append(relationSentence(edge, entity, { link: false }), workspaceDefinition(doc, [
|
|
694
|
+
['有効期間', validityText(edge.validFrom, edge.validTo)],
|
|
695
|
+
['出典', renderProvenance(doc, edge.provenance)],
|
|
696
|
+
]), recordDetails(doc, [
|
|
697
|
+
['関係ID', edge.id],
|
|
698
|
+
['関係の種類ID', edge.relation],
|
|
699
|
+
['起点ID', edge.fromId],
|
|
700
|
+
['終点ID', edge.toId],
|
|
701
|
+
['出典ID', edge.provenance.sourceId],
|
|
702
|
+
['digest', edge.digest],
|
|
703
|
+
], { title: 'この関係のID(技術情報)' }));
|
|
704
|
+
list.append(item);
|
|
705
|
+
}
|
|
706
|
+
part('つながりの出典', list);
|
|
707
|
+
}
|
|
708
|
+
part('ID', recordDetails(doc, [['記録ID', entity.id], ['種類ID', entity.type], ['digest', entity.digest], ['読み取った時点', payload.asOf]], { title: 'この記録のID(技術情報)' }));
|
|
709
|
+
// The fold's own summary names the part, so no heading repeats it.
|
|
710
|
+
const section = makeElement(doc, 'section', { className: 'bb-gr-page-section bb-gr-evidence-section', attrs: { 'aria-label': '根拠と履歴' } });
|
|
711
|
+
section.append(details);
|
|
712
|
+
return section;
|
|
719
713
|
}
|
|
720
714
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
715
|
+
/** 直す: first what a fix reaches, then the entries; a correction opens where it was asked for. */
|
|
716
|
+
function renderFix(payload) {
|
|
717
|
+
const { entity } = payload;
|
|
718
|
+
const noteSubject = { id: entity.id, type: entity.type, metadata: payload.entity?.metadata ?? {} };
|
|
719
|
+
const edges = [...payload.outgoing, ...payload.incoming];
|
|
720
|
+
if (!writable) {
|
|
721
|
+
const note = hostReadOnlyNote(doc, readOnlyNote, noteSubject);
|
|
722
|
+
return note ? pageSection('直す', [note], 'bb-gr-fix') : null;
|
|
729
723
|
}
|
|
724
|
+
const canFix = scope.entity(entity.type) && scope.fields(graphEntityCorrectionFields(entity.type)).length > 0;
|
|
725
|
+
const relatable = newRelationOptions(entity.type).length > 0 && scope.createEdges;
|
|
726
|
+
const editableEdges = scope.edges ? edges : [];
|
|
730
727
|
const children = [];
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
728
|
+
const counterparts = [...new Set(edges.map((edge) => edge.counterpart.name))];
|
|
729
|
+
const reach = makeElement(doc, 'ul', { className: 'bb-graph-plain-list bb-gr-reach' });
|
|
730
|
+
reach.append(makeElement(doc, 'li', { text: `この${kindLabel(entity.type)}を名前・別名で探す検索と、MCPの search・get_context・resolve_entity(次の呼び出しから)` }));
|
|
731
|
+
if (counterparts.length > 0) {
|
|
732
|
+
const shown = counterparts.slice(0, 5).join('、');
|
|
733
|
+
reach.append(makeElement(doc, 'li', { text: `つながる${counterparts.length}件の記録(${shown}${counterparts.length > 5 ? ' ほか' : ''})の詳細に出る、この記録の名前とつながり` }));
|
|
734
734
|
}
|
|
735
|
-
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
735
|
+
children.push(makeElement(doc, 'p', { className: 'bb-graph-block-lead', text: '直す前に、影響する先を確かめてください。直した内容は次に効きます。' }), reach);
|
|
736
|
+
const entries = [
|
|
737
|
+
canFix ? '「この記録を直す」' : null,
|
|
738
|
+
relatable ? '「関係を加える」' : null,
|
|
739
|
+
edgesWritable && editableEdges.length > 0 ? '関係ごとの「この関係を直す」' : null,
|
|
740
|
+
].filter(Boolean);
|
|
741
|
+
if (entries.length > 0) {
|
|
742
|
+
const last = entries.at(-1);
|
|
743
|
+
const list = entries.length === 1 ? last : `${entries.slice(0, -1).join('、')}、または${last}`;
|
|
744
|
+
children.push(makeElement(doc, 'p', {
|
|
745
|
+
className: 'bb-gr-fix-flow',
|
|
746
|
+
text: `誤りを見つけたときの訂正入口です。${list}から変更案を入力します。承認待ちの提出ではなく、保存するとその場でGraphに反映され、理由と変更前後が履歴に残ります。保存後に読み直して一致を確かめ、確認できた内容は、次のMCPの search・get_context・resolve_entity で使われます。`,
|
|
747
|
+
}));
|
|
741
748
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
const related = loaded.outgoing.length + loaded.incoming.length > 0;
|
|
762
|
-
const correctionEntries = [
|
|
763
|
-
canFix ? '「この記録を直す」' : null,
|
|
764
|
-
relatable && scope.createEdges ? '「関係を加える」' : null,
|
|
765
|
-
related && scope.edges ? '関係ごとの「この関係を直す」' : null,
|
|
766
|
-
].filter(Boolean);
|
|
767
|
-
if (correctionEntries.length > 0) {
|
|
768
|
-
const lastEntry = correctionEntries.at(-1);
|
|
769
|
-
const entries = correctionEntries.length === 1
|
|
770
|
-
? lastEntry
|
|
771
|
-
: `${correctionEntries.slice(0, -1).join('、')}、または${lastEntry}`;
|
|
772
|
-
children.push(workspaceRailBlock(doc, {
|
|
773
|
-
title: '訂正の流れ',
|
|
774
|
-
content: makeElement(doc, 'p', {
|
|
775
|
-
text: `誤りを見つけたときの訂正入口です。${entries}から変更案を入力します。承認待ちの提出ではなく、保存するとその場でGraphに反映され、理由と変更前後が履歴に残ります。保存後に読み直して一致を確かめ、確認できた内容は、次のMCPの search・get_context・resolve_entity で使われます。`,
|
|
776
|
-
}),
|
|
749
|
+
const actions = [
|
|
750
|
+
canFix ? workspaceButton(doc, { text: 'この記録を直す', onClick: () => openCorrection('entity', () => correction.openEntity(entity, { title: 'この記録を直す' })) }) : null,
|
|
751
|
+
relatable ? workspaceButton(doc, { text: '関係を加える', onClick: () => openCorrection('entity', () => correction.openCreate(entity, { title: '関係を加える' })) }) : null,
|
|
752
|
+
].filter(Boolean);
|
|
753
|
+
if (actions.length > 0) children.push(workspaceActions(doc, actions));
|
|
754
|
+
// A correction opened on a relation shows inside that relation; any other shows here.
|
|
755
|
+
const inRelation = editableEdges.some((edge) => edge.id === state.panelAt);
|
|
756
|
+
if (!inRelation) children.push(correction.render(doc));
|
|
757
|
+
if (edgesWritable && editableEdges.length > 0) {
|
|
758
|
+
const list = makeElement(doc, 'ul', { className: 'bb-gr-fix-relations', attrs: { 'aria-label': '関係を直す' } });
|
|
759
|
+
for (const edge of editableEdges) {
|
|
760
|
+
const item = makeElement(doc, 'li', { className: 'bb-gr-fix-relation' });
|
|
761
|
+
const subject = edge.direction === 'outgoing'
|
|
762
|
+
? `${entity.name} → ${relationLabel(edge.relation)} → ${edge.counterpart.name}`
|
|
763
|
+
: `${edge.counterpart.name} → ${relationLabel(edge.relation)} → ${entity.name}`;
|
|
764
|
+
item.append(relationSentence(edge, entity, { link: false }), workspaceButton(doc, {
|
|
765
|
+
text: 'この関係を直す',
|
|
766
|
+
variant: 'quiet',
|
|
767
|
+
onClick: () => openCorrection(edge.id, () => correction.openEdge(edge, { mode: 'edit', subject })),
|
|
777
768
|
}));
|
|
769
|
+
if (state.panelAt === edge.id) {
|
|
770
|
+
const panel = correction.render(doc);
|
|
771
|
+
if (panel) item.append(panel);
|
|
772
|
+
}
|
|
773
|
+
list.append(item);
|
|
778
774
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
: null,
|
|
784
|
-
].filter(Boolean);
|
|
785
|
-
if (actions.length > 0) children.push(workspaceActions(doc, actions));
|
|
786
|
-
// A correction opened on a relation shows inside that relation; any other shows here.
|
|
787
|
-
const inRelation = [...loaded.outgoing, ...loaded.incoming].some((edge) => edge.id === state.panelAt);
|
|
788
|
-
if (!inRelation) children.push(correction.render(doc));
|
|
789
|
-
// The host's reason for a control it withholds, where that control would be.
|
|
790
|
-
if (!canFix || (relatable && !scope.createEdges) || (related && !scope.edges)) children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
|
|
791
|
-
} else {
|
|
775
|
+
children.push(list);
|
|
776
|
+
}
|
|
777
|
+
// The host's reason for a control it withholds, where that control would be.
|
|
778
|
+
if (!canFix || (newRelationOptions(entity.type).length > 0 && !scope.createEdges) || (edges.length > 0 && !scope.edges)) {
|
|
792
779
|
children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
|
|
793
780
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
781
|
+
return pageSection('直す', children, 'bb-gr-fix');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The open record as its own screen: back to the list, its name and kind, then
|
|
786
|
+
* 何者か → つながり → 根拠と履歴 → 直す. 使われた場所 is not drawn: no read path
|
|
787
|
+
* reaches the work, judgments and minutes that mention a record yet.
|
|
788
|
+
*/
|
|
789
|
+
function renderDetailPage() {
|
|
790
|
+
const detail = state.detail;
|
|
791
|
+
const loaded = detail.state === 'ok' ? detail.payload : null;
|
|
792
|
+
const entity = openEntityHead();
|
|
793
|
+
const page = makeElement(doc, 'article', { className: 'bb-gr-detail', attrs: { id: DETAIL_ID, 'aria-label': `${entity.name}の詳細` } });
|
|
794
|
+
const nav = makeElement(doc, 'nav', { className: 'bb-gr-detail-nav', attrs: { 'aria-label': '戻る' } });
|
|
795
|
+
nav.append(button(doc, '← 情報と関係の一覧に戻る', () => controller.backToList(), { className: 'bb-graph-link bb-gr-detail-back' }));
|
|
796
|
+
const previous = state.trail[state.trail.length - 1];
|
|
797
|
+
if (previous) nav.append(button(doc, `← 前の記録(${previous.name})に戻る`, () => controller.back(), { className: 'bb-graph-link bb-gr-detail-back' }));
|
|
798
|
+
page.append(nav);
|
|
799
|
+
const head = makeElement(doc, 'header', { className: 'bb-gr-detail-head' });
|
|
800
|
+
head.append(makeElement(doc, 'small', { className: 'bb-gr-detail-kind', text: entity.type ? kindLabel(entity.type) : '記録' }));
|
|
801
|
+
// The heading keeps focus across the redraw that follows the record's read.
|
|
802
|
+
const title = makeElement(doc, 'h1', { text: entity.name, attrs: { tabindex: '-1', 'data-detail-heading': '', 'data-focus-key': 'detail-heading' } });
|
|
803
|
+
head.append(title);
|
|
804
|
+
if (loaded && !loaded.entity.active) head.append(activityBadge(doc, loaded.entity, loaded.asOf));
|
|
805
|
+
if (state.trail.length > 0) head.append(makeElement(doc, 'p', { className: 'bb-gr-detail-trail', text: `「${state.trail[0].name}」のつながりからたどっています。` }));
|
|
806
|
+
page.append(head);
|
|
807
|
+
if (!loaded) {
|
|
808
|
+
page.append(graphStateNotice(doc, detail, { onRetry: () => controller.loadEntity(detail.id), loadingText: 'この記録を読み込んでいます。' }));
|
|
809
|
+
return page;
|
|
810
|
+
}
|
|
811
|
+
const record = loaded.entity;
|
|
812
|
+
page.append(
|
|
813
|
+
pageSection('何者か', [workspaceDefinition(doc, [
|
|
814
|
+
['種類', kindLabel(record.type)],
|
|
815
|
+
['説明', textOrNull(record.summary) ?? { text: '説明の記録なし', className: 'is-unrecorded' }],
|
|
816
|
+
['別名', record.aliases.length > 0 ? record.aliases.join('、') : { text: 'なし', className: 'is-unrecorded' }],
|
|
817
|
+
...(record.type === 'project' ? [['目的', textOrNull(record.goal)], ['状態', textOrNull(record.status)]] : []),
|
|
818
|
+
...(state.applied.asOfDay ? [['見ている日', `${state.applied.asOfDay} の状態`]] : []),
|
|
819
|
+
])], 'bb-gr-identity'),
|
|
820
|
+
renderConnections(loaded),
|
|
821
|
+
renderEvidence(loaded),
|
|
822
|
+
);
|
|
823
|
+
const fix = renderFix(loaded);
|
|
824
|
+
if (fix) page.append(fix);
|
|
825
|
+
return page;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** The rail stays empty: the record opens as its own screen in the workspace. */
|
|
829
|
+
function renderRail() {
|
|
830
|
+
return [];
|
|
797
831
|
}
|
|
798
832
|
|
|
799
833
|
function closeCorrection() {
|
|
@@ -809,93 +843,133 @@ export function createGraphRegistryView({
|
|
|
809
843
|
return normalizeSearch(await client.read('/search', searchParams(query)));
|
|
810
844
|
}
|
|
811
845
|
|
|
812
|
-
/**
|
|
813
|
-
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
846
|
+
/** The focused search control, so a redraw (which rebuilds the form) can give focus back. */
|
|
847
|
+
function focusedControl() {
|
|
848
|
+
const active = doc.activeElement;
|
|
849
|
+
const key = active?.getAttribute?.('data-focus-key');
|
|
850
|
+
if (!key || typeof root.contains !== 'function' || !root.contains(active)) return null;
|
|
851
|
+
return { key, start: active.selectionStart ?? null, end: active.selectionEnd ?? null };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function restoreFocus(saved) {
|
|
855
|
+
if (!saved || typeof root.querySelector !== 'function') return;
|
|
856
|
+
const control = root.querySelector(`[data-focus-key="${saved.key}"]`);
|
|
857
|
+
if (!control || doc.activeElement === control) return;
|
|
858
|
+
control.focus?.({ preventScroll: true });
|
|
859
|
+
if (saved.start !== null && typeof control.setSelectionRange === 'function') {
|
|
860
|
+
try { control.setSelectionRange(saved.start, saved.end ?? saved.start); } catch { /* a date field has no caret */ }
|
|
822
861
|
}
|
|
823
|
-
await controller.loadEntity(detail.id, { keep: true });
|
|
824
862
|
}
|
|
825
863
|
|
|
864
|
+
/** Puts the opened record's screen at the top, with focus on its name. */
|
|
865
|
+
function showDetail() {
|
|
866
|
+
if (typeof root.querySelector !== 'function') return;
|
|
867
|
+
const heading = root.querySelector('[data-detail-heading]');
|
|
868
|
+
if (!heading) return;
|
|
869
|
+
heading.scrollIntoView?.({ block: 'start' });
|
|
870
|
+
heading.focus?.({ preventScroll: true });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Escape on the record's screen goes back to the list, like 「← 一覧に戻る」.
|
|
874
|
+
root.addEventListener?.('keydown', (event) => {
|
|
875
|
+
if (event?.key !== 'Escape' || !state.detail || correction.form) return;
|
|
876
|
+
event.preventDefault?.();
|
|
877
|
+
controller.backToList();
|
|
878
|
+
});
|
|
879
|
+
|
|
826
880
|
const controller = {
|
|
827
881
|
get state() { return state; },
|
|
828
882
|
get correction() { return correction; },
|
|
829
883
|
render() {
|
|
884
|
+
// Redrawing mid-conversion would drop the characters being composed; draw when it ends.
|
|
885
|
+
if (composing) {
|
|
886
|
+
renderDeferred = true;
|
|
887
|
+
return controller;
|
|
888
|
+
}
|
|
889
|
+
renderDeferred = false;
|
|
890
|
+
const saved = focusedControl();
|
|
830
891
|
layout.render(renderWorkspace(), renderRail());
|
|
831
|
-
if (focusRow) {
|
|
892
|
+
if (focusRow && !state.detail) {
|
|
893
|
+
// Back on the list: the row of the record just left, in view and focused.
|
|
832
894
|
focusLedgerRow(root, focusRow);
|
|
895
|
+
[...(root.querySelectorAll?.('.bb-ws-ledger-row[data-key]') ?? [])]
|
|
896
|
+
.find((row) => row.getAttribute('data-key') === focusRow)?.scrollIntoView?.({ block: 'center' });
|
|
833
897
|
focusRow = null;
|
|
898
|
+
} else {
|
|
899
|
+
restoreFocus(saved);
|
|
900
|
+
}
|
|
901
|
+
if (revealDetail && state.detail) {
|
|
902
|
+
revealDetail = false;
|
|
903
|
+
showDetail();
|
|
834
904
|
}
|
|
835
905
|
return controller;
|
|
836
906
|
},
|
|
837
|
-
/**
|
|
907
|
+
/**
|
|
908
|
+
* Runs the search in the form. The last results stay on screen while the
|
|
909
|
+
* new ones are read.
|
|
910
|
+
*/
|
|
838
911
|
async search() {
|
|
912
|
+
cancelScheduledSearch();
|
|
839
913
|
if (state.query.asOfDay && !dayToRfc3339(state.query.asOfDay)) {
|
|
840
|
-
state.queryMessage = '
|
|
914
|
+
state.queryMessage = '日付の形式が正しくありません。';
|
|
841
915
|
controller.render();
|
|
842
916
|
return state.search;
|
|
843
917
|
}
|
|
844
918
|
state.queryMessage = null;
|
|
845
919
|
state.applied = { ...state.query };
|
|
846
|
-
state.search
|
|
920
|
+
if (state.search.state === 'ok') state.searching = true;
|
|
921
|
+
else state.search = { state: 'loading' };
|
|
847
922
|
controller.render();
|
|
848
923
|
const applied = state.applied;
|
|
849
|
-
// The owner's own share answers the same search on its own; neither waits on the other's failure.
|
|
850
|
-
const owning = own ? controller.searchOwn() : null;
|
|
851
924
|
const result = await readSearch(applied);
|
|
852
|
-
if (state.applied !== applied)
|
|
853
|
-
|
|
854
|
-
return state.search;
|
|
855
|
-
}
|
|
925
|
+
if (state.applied !== applied) return state.search;
|
|
926
|
+
state.searching = false;
|
|
856
927
|
state.search = result;
|
|
857
|
-
await followResults();
|
|
858
928
|
controller.render();
|
|
859
|
-
await owning;
|
|
860
929
|
return state.search;
|
|
861
930
|
},
|
|
862
|
-
/**
|
|
863
|
-
async
|
|
864
|
-
if (!
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if (state.applied !== applied) return state.own.search;
|
|
870
|
-
// An empty answer is an answer here: the host confirms the whole snapshot was searched.
|
|
871
|
-
state.own.search = result.state === 'ok' && !(Array.isArray(result.payload.results) && result.payload.results.every(isEntityView)) ? INVALID : result;
|
|
872
|
-
const openId = state.own.detail?.id;
|
|
873
|
-
if (openId && !(state.own.search.state === 'ok' && state.own.search.payload.results.some((entity) => entity.id === openId))) state.own.detail = null;
|
|
874
|
-
controller.render();
|
|
875
|
-
return state.own.search;
|
|
931
|
+
/** Removes one condition (`q`, `type` or `asOfDay`) and searches again. */
|
|
932
|
+
async clearFilter(key) {
|
|
933
|
+
if (!Object.hasOwn(state.query, key)) return state.search;
|
|
934
|
+
state.query = { ...state.query, [key]: '' };
|
|
935
|
+
const result = controller.search();
|
|
936
|
+
root.querySelector?.('[data-focus-key="q"]')?.focus?.({ preventScroll: true });
|
|
937
|
+
return result;
|
|
876
938
|
},
|
|
877
|
-
/**
|
|
878
|
-
async
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
state.
|
|
895
|
-
|
|
896
|
-
|
|
939
|
+
/** Removes every condition and searches again. */
|
|
940
|
+
async clearFilters() {
|
|
941
|
+
state.query = { q: '', type: '', asOfDay: '' };
|
|
942
|
+
const result = controller.search();
|
|
943
|
+
root.querySelector?.('[data-focus-key="q"]')?.focus?.({ preventScroll: true });
|
|
944
|
+
return result;
|
|
945
|
+
},
|
|
946
|
+
/**
|
|
947
|
+
* Closes the record's screen and shows the list again, with the row of the
|
|
948
|
+
* record that was opened from it focused. The host calls this when its
|
|
949
|
+
* address no longer names a record (the browser's back).
|
|
950
|
+
*/
|
|
951
|
+
closeEntity() {
|
|
952
|
+
if (!state.detail) return null;
|
|
953
|
+
closeCorrection();
|
|
954
|
+
const anchor = state.anchor;
|
|
955
|
+
state.detail = null;
|
|
956
|
+
state.anchor = null;
|
|
957
|
+
state.trail = [];
|
|
958
|
+
if (anchor) focusRow = anchor;
|
|
897
959
|
controller.render();
|
|
898
|
-
return
|
|
960
|
+
return null;
|
|
961
|
+
},
|
|
962
|
+
/** 「← 一覧に戻る」 and Escape: closes the record and tells the host the address is the list again. */
|
|
963
|
+
backToList() {
|
|
964
|
+
controller.closeEntity();
|
|
965
|
+
onRouteChange?.(null, { back: true });
|
|
966
|
+
return null;
|
|
967
|
+
},
|
|
968
|
+
/** Opens a record the owner chose (a row, or a connection) and tells the host its address. */
|
|
969
|
+
async navigate(id, options = {}) {
|
|
970
|
+
// The address first, while the list is still where the owner left it (the browser keeps that scroll for 戻る).
|
|
971
|
+
onRouteChange?.(id, { back: false });
|
|
972
|
+
return controller.openEntity(id, options);
|
|
899
973
|
},
|
|
900
974
|
/** Reads the last search again without showing it as loading (after a correction). */
|
|
901
975
|
async refreshResults() {
|
|
@@ -910,7 +984,7 @@ export function createGraphRegistryView({
|
|
|
910
984
|
async loadEntity(id, { keep = false, pending } = {}) {
|
|
911
985
|
const previous = state.detail;
|
|
912
986
|
if (!(keep && previous?.id === id && previous.state === 'ok')) {
|
|
913
|
-
// What is already known about the record (name, kind) heads
|
|
987
|
+
// What is already known about the record (name, kind) heads its detail while it loads.
|
|
914
988
|
const known = pending ?? (previous?.id === id ? previous.payload?.entity ?? previous.pending ?? null : null);
|
|
915
989
|
state.detail = { id, state: 'loading', pending: known ? { id, name: known.name, type: known.type } : null };
|
|
916
990
|
controller.render();
|
|
@@ -925,13 +999,20 @@ export function createGraphRegistryView({
|
|
|
925
999
|
return state.detail;
|
|
926
1000
|
},
|
|
927
1001
|
/**
|
|
928
|
-
* Opens a record
|
|
929
|
-
* return to it; a
|
|
1002
|
+
* Opens a record as its own screen. `from` (the record being left) makes
|
|
1003
|
+
* 前の記録に戻る return to it; otherwise a new trail starts. The host calls
|
|
1004
|
+
* this for an address that names a record; it does not report back.
|
|
930
1005
|
*/
|
|
931
1006
|
async openEntity(id, { from, pending } = {}) {
|
|
1007
|
+
// The host's address catching up with a record already open changes nothing.
|
|
1008
|
+
if (!from && state.detail?.id === id) return state.detail;
|
|
932
1009
|
closeCorrection();
|
|
933
1010
|
if (from) state.trail.push({ id: from.id, name: from.name, type: from.type });
|
|
934
|
-
else
|
|
1011
|
+
else {
|
|
1012
|
+
state.trail = [];
|
|
1013
|
+
state.anchor = id;
|
|
1014
|
+
}
|
|
1015
|
+
revealDetail = true;
|
|
935
1016
|
const listed = state.search.state === 'ok' ? state.search.payload.results.find((entity) => entity.id === id) : null;
|
|
936
1017
|
return controller.loadEntity(id, { keep: true, pending: pending ?? listed ?? null });
|
|
937
1018
|
},
|
|
@@ -939,6 +1020,8 @@ export function createGraphRegistryView({
|
|
|
939
1020
|
closeCorrection();
|
|
940
1021
|
const previous = state.trail.pop();
|
|
941
1022
|
if (!previous) return state.detail;
|
|
1023
|
+
revealDetail = true;
|
|
1024
|
+
onRouteChange?.(previous.id, { back: false });
|
|
942
1025
|
return controller.loadEntity(previous.id, { pending: previous });
|
|
943
1026
|
},
|
|
944
1027
|
async loadOntology({ keep = false } = {}) {
|