@unson/brainbase-mcp 0.15.7 → 0.17.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/dist/cli.js +6 -23
- package/dist/graph-corrections.d.ts +7 -1
- package/dist/graph-corrections.js +89 -4
- package/dist/graph-web-http.d.ts +0 -7
- package/dist/graph-web-http.js +3 -15
- package/dist/graph-web.d.ts +4 -32
- package/dist/graph-web.js +16 -44
- package/dist/local-web-host.d.ts +0 -23
- package/dist/local-web-host.js +4 -15
- package/dist/organization-graph-web.d.ts +5 -27
- package/dist/organization-graph-web.js +4 -128
- package/dist/world-extension.d.ts +4 -19
- package/dist/world-extension.js +15 -75
- package/package.json +3 -2
- package/ui/graph-projects-view.js +4 -3
- package/ui/graph-view-shared.css +49 -0
- package/ui/graph-view-shared.js +156 -46
- package/ui/local-web-shell.js +3 -53
- package/ui/project-icon.js +155 -0
- package/ui/workspace-kit.css +11 -0
- package/ui/workspace-kit.js +5 -2
- package/ui/world/world-view.js +5 -19
package/ui/graph-view-shared.js
CHANGED
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { workspaceButton, workspaceNotice } from './workspace-kit.js';
|
|
24
|
+
import {
|
|
25
|
+
PROJECT_ICON_MAX_BYTES,
|
|
26
|
+
PROJECT_ICON_MIME_TYPES,
|
|
27
|
+
projectIconMimeLabel,
|
|
28
|
+
projectIconSummary,
|
|
29
|
+
readProjectIconFile,
|
|
30
|
+
renderProjectIcon,
|
|
31
|
+
validateProjectIconDataUrl,
|
|
32
|
+
} from './project-icon.js';
|
|
24
33
|
|
|
25
34
|
export const GRAPH_VIEW_SHARED_CONTRACT_VERSION = 'brainbase.graph-view-shared.v2';
|
|
26
35
|
|
|
@@ -76,8 +85,10 @@ const FIELD_LABELS = Object.freeze({
|
|
|
76
85
|
validTo: '終了日',
|
|
77
86
|
goal: '目的',
|
|
78
87
|
status: '状態',
|
|
88
|
+
icon: 'アイコン',
|
|
79
89
|
'metadata.goal': '目的',
|
|
80
90
|
'metadata.status': '状態',
|
|
91
|
+
'metadata.icon': 'アイコン',
|
|
81
92
|
role: '役割',
|
|
82
93
|
context: '文脈',
|
|
83
94
|
fromId: '起点',
|
|
@@ -99,6 +110,7 @@ export const GRAPH_CORRECTION_FIELDS = Object.freeze({
|
|
|
99
110
|
summary: Object.freeze({ label: '要約', control: 'textarea', max: 2000 }),
|
|
100
111
|
goal: Object.freeze({ label: '目的', control: 'textarea', max: 2000 }),
|
|
101
112
|
status: Object.freeze({ label: '状態', control: 'text', max: 200, help: '自由記述です。例: 進行中、保留、完了' }),
|
|
113
|
+
icon: Object.freeze({ label: 'アイコン', control: 'project-icon', help: 'PNG、JPEG、WebP。256KiB以下。' }),
|
|
102
114
|
validFrom: Object.freeze({ label: '開始日', control: 'date' }),
|
|
103
115
|
validTo: Object.freeze({ label: '終了日', control: 'date', help: 'この日の0時から、終わったものとして扱います。空にすると終了日を消します。' }),
|
|
104
116
|
role: Object.freeze({ label: '役割', control: 'text', max: 200, help: '自由記述です。例: 最終判断、進行管理' }),
|
|
@@ -448,7 +460,7 @@ export function graphCorrectionScope(scope) {
|
|
|
448
460
|
|
|
449
461
|
/** The record fields the correction form offers for an entity of this kind. */
|
|
450
462
|
export function graphEntityCorrectionFields(type) {
|
|
451
|
-
return ['name', 'aliases', 'summary', 'validFrom', 'validTo', ...(type === 'project' ? ['goal', 'status'] : [])];
|
|
463
|
+
return ['name', 'aliases', 'summary', 'validFrom', 'validTo', ...(type === 'project' ? ['goal', 'status', 'icon'] : [])];
|
|
452
464
|
}
|
|
453
465
|
|
|
454
466
|
/** Whether a built correction request stays inside the scope (`recordType` is the corrected entity's kind). */
|
|
@@ -469,45 +481,6 @@ export function hostSourceNotice(sourceNotice, subject) {
|
|
|
469
481
|
return { ...sourceNotice, text: sourceNotice.text(subject ?? null) };
|
|
470
482
|
}
|
|
471
483
|
|
|
472
|
-
// ---------------------------------------------------------------------------
|
|
473
|
-
// The organization's web (Inspector UX-20261004-01)
|
|
474
|
-
|
|
475
|
-
const ORGANIZATION_RECORD_ID = /^[A-Za-z0-9_.:-]{1,200}$/u;
|
|
476
|
-
|
|
477
|
-
/** The organization web's origin the host was told about, or null. */
|
|
478
|
-
export function organizationWebOrigin(organizationGraph) {
|
|
479
|
-
if (organizationGraph?.status !== 'connected') return null;
|
|
480
|
-
const origin = organizationGraph.web_url;
|
|
481
|
-
return typeof origin === 'string' && /^https:\/\/[^/?#@\s]+$/u.test(origin) ? origin : null;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Where a record opens in the organization web, `{ href, label }`, or null when it has no usable id. The
|
|
486
|
-
* organization web opens 情報と関係 by record id and プロジェクトと関係者 by project code, and keeps that record
|
|
487
|
-
* across sign-in. A project opens by its own code only: a sub-project's scope code is its parent's and
|
|
488
|
-
* would open the parent, so without its own code the record opens by id.
|
|
489
|
-
*/
|
|
490
|
-
export function organizationRecordLink(origin, screenId, record) {
|
|
491
|
-
const id = typeof record?.id === 'string' && ORGANIZATION_RECORD_ID.test(record.id) ? record.id : null;
|
|
492
|
-
const own = typeof record?.metadata?.code === 'string' ? record.metadata.code.trim() : '';
|
|
493
|
-
const code = own && ORGANIZATION_RECORD_ID.test(own) ? own : null;
|
|
494
|
-
if (screenId === 'projects' && record?.type === 'project' && code) {
|
|
495
|
-
return { href: `${origin}/?screen=projects&project=${encodeURIComponent(code)}`, label: '「プロジェクトと関係者」' };
|
|
496
|
-
}
|
|
497
|
-
return id ? { href: `${origin}/?screen=graph&entity_id=${encodeURIComponent(id)}`, label: '「情報と関係」' } : null;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
export const ORGANIZATION_RECORD_LINK_TEXT = '組織版でこの記録を開く';
|
|
501
|
-
|
|
502
|
-
/** A link that opens `target` from organizationRecordLink in a new tab. */
|
|
503
|
-
export function organizationRecordAnchor(doc, target, className) {
|
|
504
|
-
return makeElement(doc, 'a', {
|
|
505
|
-
...(className ? { className } : {}),
|
|
506
|
-
text: ORGANIZATION_RECORD_LINK_TEXT,
|
|
507
|
-
attrs: { href: target.href, target: '_blank', rel: 'noopener noreferrer' },
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
|
|
511
484
|
/**
|
|
512
485
|
* A host's note that corrections are not available here, or null when it passed none. The host may
|
|
513
486
|
* pass a function of the selected record (`{ id, type, metadata }`) returning a string or an element,
|
|
@@ -757,6 +730,9 @@ export function graphRecordValues(record) {
|
|
|
757
730
|
validTo: formatDay(record?.validTo) ?? '',
|
|
758
731
|
goal: pick(typeof record?.goal === 'string' ? record.goal : metadata.goal),
|
|
759
732
|
status: pick(typeof record?.status === 'string' && record?.type === 'project' ? record.status : metadata.status),
|
|
733
|
+
// Keep the raw value in form state for exact comparison; renderers below
|
|
734
|
+
// always replace it with a short summary and never put the data URL in text.
|
|
735
|
+
icon: typeof metadata.icon === 'string' ? metadata.icon : '',
|
|
760
736
|
role: pick(record?.role),
|
|
761
737
|
context: pick(record?.context),
|
|
762
738
|
};
|
|
@@ -764,16 +740,32 @@ export function graphRecordValues(record) {
|
|
|
764
740
|
|
|
765
741
|
function displayValue(field, value) {
|
|
766
742
|
if (field === 'validFrom' || field === 'validTo') return value ? value : 'なし';
|
|
743
|
+
if (field === 'icon') {
|
|
744
|
+
const summary = projectIconSummary(value);
|
|
745
|
+
return summary ? `${projectIconMimeLabel(summary.mimeType)}(${Math.ceil(summary.bytes / 1024)}KiB)` : '未登録';
|
|
746
|
+
}
|
|
767
747
|
return value ? value : '未記入';
|
|
768
748
|
}
|
|
769
749
|
|
|
770
|
-
function valueRows(record, fields) {
|
|
750
|
+
function valueRows(doc, record, fields) {
|
|
771
751
|
const values = graphRecordValues(record);
|
|
772
|
-
return fields.map((field) => [
|
|
752
|
+
return fields.map((field) => [
|
|
753
|
+
GRAPH_CORRECTION_FIELDS[field]?.label ?? FIELD_LABELS[field] ?? field,
|
|
754
|
+
field === 'icon' ? renderIconValue(doc, values[field], record?.name) : displayValue(field, values[field]),
|
|
755
|
+
]);
|
|
773
756
|
}
|
|
774
757
|
|
|
775
758
|
function renderValues(doc, record, fields, leadingRows = []) {
|
|
776
|
-
return facts(doc, [...leadingRows, ...valueRows(record, fields)], 'bb-graph-facts');
|
|
759
|
+
return facts(doc, [...leadingRows, ...valueRows(doc, record, fields)], 'bb-graph-facts');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function renderIconValue(doc, value, name) {
|
|
763
|
+
const summary = projectIconSummary(value);
|
|
764
|
+
const wrap = makeElement(doc, 'span', { className: 'bb-graph-icon-value' });
|
|
765
|
+
wrap.append(renderProjectIcon(doc, value, name, { size: 'sm' }), makeElement(doc, 'span', {
|
|
766
|
+
text: summary ? `${projectIconMimeLabel(summary.mimeType)}(${Math.ceil(summary.bytes / 1024)}KiB)` : '未登録',
|
|
767
|
+
}));
|
|
768
|
+
return wrap;
|
|
777
769
|
}
|
|
778
770
|
|
|
779
771
|
function splitAliases(value) {
|
|
@@ -830,6 +822,15 @@ export function buildCorrectionRequest(form) {
|
|
|
830
822
|
|
|
831
823
|
const changes = {};
|
|
832
824
|
for (const field of form.fields) {
|
|
825
|
+
if (field === 'icon') {
|
|
826
|
+
const before = form.original[field] ?? '';
|
|
827
|
+
const after = form.draft[field] ?? '';
|
|
828
|
+
if (after === before) continue;
|
|
829
|
+
const parsed = validateProjectIconDataUrl(after === '' ? null : after);
|
|
830
|
+
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
831
|
+
changes.icon = parsed.value;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
833
834
|
const before = String(form.original[field] ?? '');
|
|
834
835
|
const after = String(form.draft[field] ?? '');
|
|
835
836
|
if (after.trim() === before.trim()) continue;
|
|
@@ -885,6 +886,29 @@ function fieldControl(doc, form, field, callbacks) {
|
|
|
885
886
|
const id = `${form.formId}-${field}`;
|
|
886
887
|
const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
|
|
887
888
|
wrap.append(makeElement(doc, 'label', { text: definition.label, attrs: { for: id } }));
|
|
889
|
+
if (definition.control === 'project-icon') {
|
|
890
|
+
const input = makeElement(doc, 'input', { attrs: {
|
|
891
|
+
id,
|
|
892
|
+
name: field,
|
|
893
|
+
type: 'file',
|
|
894
|
+
accept: 'image/png,image/jpeg,image/webp',
|
|
895
|
+
} });
|
|
896
|
+
input.addEventListener('change', (event) => callbacks.onIconFile(event?.target?.files?.[0] ?? null));
|
|
897
|
+
const preview = makeElement(doc, 'div', { className: 'bb-graph-icon-picker' });
|
|
898
|
+
preview.append(renderProjectIcon(doc, form.draft[field], form.recordName ?? form.subject ?? 'プロジェクト', { size: 'md' }));
|
|
899
|
+
const summary = projectIconSummary(form.draft[field]);
|
|
900
|
+
preview.append(makeElement(doc, 'span', {
|
|
901
|
+
className: 'bb-graph-icon-picker-state',
|
|
902
|
+
text: summary ? `${projectIconMimeLabel(summary.mimeType)}(${Math.ceil(summary.bytes / 1024)}KiB)` : '未登録',
|
|
903
|
+
}));
|
|
904
|
+
if (form.draft[field]) {
|
|
905
|
+
preview.append(button(doc, 'アイコンを外す', () => callbacks.onInput(field, null), { className: 'bb-ws-button is-quiet' }));
|
|
906
|
+
}
|
|
907
|
+
wrap.append(input, preview);
|
|
908
|
+
if (form.fieldErrors?.[field]) wrap.append(makeElement(doc, 'small', { className: 'bb-graph-field-error', text: form.fieldErrors[field], attrs: { role: 'alert' } }));
|
|
909
|
+
if (definition.help) wrap.append(makeElement(doc, 'small', { className: 'bb-graph-help', text: definition.help }));
|
|
910
|
+
return wrap;
|
|
911
|
+
}
|
|
888
912
|
const control = definition.control === 'textarea'
|
|
889
913
|
? makeElement(doc, 'textarea', { attrs: { id, name: field, rows: 3, maxlength: definition.max } })
|
|
890
914
|
: makeElement(doc, 'input', { attrs: { id, name: field, type: definition.control === 'date' ? 'date' : 'text', maxlength: definition.max } });
|
|
@@ -1049,9 +1073,21 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1049
1073
|
formElement.append(reasonControl(doc, form, callbacks));
|
|
1050
1074
|
const actions = makeElement(doc, 'div', { className: 'bb-graph-actions' });
|
|
1051
1075
|
const saving = form.phase === 'saving';
|
|
1052
|
-
const
|
|
1076
|
+
const iconReadPending = form.iconReadPending === true;
|
|
1077
|
+
const iconHasError = Boolean(form.fieldErrors?.icon);
|
|
1078
|
+
const submitLabel = saving
|
|
1079
|
+
? '保存しています'
|
|
1080
|
+
: iconReadPending
|
|
1081
|
+
? 'アイコンを読み込んでいます'
|
|
1082
|
+
: form.phase === 'conflict' && form.conflictCode === 'digest_conflict'
|
|
1083
|
+
? '今の内容に対して保存する'
|
|
1084
|
+
: '保存する';
|
|
1053
1085
|
actions.append(
|
|
1054
|
-
makeElement(doc, 'button', {
|
|
1086
|
+
makeElement(doc, 'button', {
|
|
1087
|
+
className: 'bb-ws-button is-primary',
|
|
1088
|
+
text: submitLabel,
|
|
1089
|
+
attrs: { type: 'submit', disabled: saving || iconReadPending || iconHasError },
|
|
1090
|
+
}),
|
|
1055
1091
|
button(doc, '取り消す', callbacks.onClose, { attrs: { disabled: saving } }),
|
|
1056
1092
|
);
|
|
1057
1093
|
formElement.append(actions);
|
|
@@ -1073,18 +1109,24 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1073
1109
|
let form = null;
|
|
1074
1110
|
let focusPending = false;
|
|
1075
1111
|
let sequence = 0;
|
|
1112
|
+
// A file read can finish after a newer selection, removal, cancellation or
|
|
1113
|
+
// form switch. Invalidate the older read before touching the draft.
|
|
1114
|
+
let iconReadGeneration = 0;
|
|
1076
1115
|
// Label/input ids stay unique when two screens are on one page.
|
|
1077
1116
|
const idPrefix = `bb-graph-form-${Math.random().toString(36).slice(2, 8)}`;
|
|
1078
1117
|
const redraw = () => rerender?.();
|
|
1079
1118
|
|
|
1080
1119
|
function baseForm(kind, extra) {
|
|
1081
1120
|
sequence += 1;
|
|
1121
|
+
iconReadGeneration += 1;
|
|
1082
1122
|
focusPending = true;
|
|
1083
1123
|
return {
|
|
1084
1124
|
kind,
|
|
1085
1125
|
formId: `${idPrefix}-${sequence}`,
|
|
1086
1126
|
phase: 'editing',
|
|
1087
1127
|
message: null,
|
|
1128
|
+
fieldErrors: {},
|
|
1129
|
+
iconReadPending: false,
|
|
1088
1130
|
reason: '',
|
|
1089
1131
|
current: null,
|
|
1090
1132
|
conflictCode: null,
|
|
@@ -1118,7 +1160,60 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1118
1160
|
|
|
1119
1161
|
const callbacks = {
|
|
1120
1162
|
onInput(field, value) {
|
|
1121
|
-
if (form)
|
|
1163
|
+
if (form) {
|
|
1164
|
+
if (field === 'icon') {
|
|
1165
|
+
iconReadGeneration += 1;
|
|
1166
|
+
form.iconReadPending = false;
|
|
1167
|
+
form.fieldErrors.icon = null;
|
|
1168
|
+
form.message = null;
|
|
1169
|
+
}
|
|
1170
|
+
form.draft[field] = value;
|
|
1171
|
+
if (field === 'icon') redraw();
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
async onIconFile(file) {
|
|
1175
|
+
const generation = ++iconReadGeneration;
|
|
1176
|
+
const target = form;
|
|
1177
|
+
if (!target || target.kind === 'create_edge') return;
|
|
1178
|
+
// Selecting the file input and then cancelling is a no-op; keep the
|
|
1179
|
+
// current draft and any already selected icon intact.
|
|
1180
|
+
if (!file) {
|
|
1181
|
+
target.iconReadPending = false;
|
|
1182
|
+
target.fieldErrors.icon = null;
|
|
1183
|
+
target.message = null;
|
|
1184
|
+
redraw();
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
target.iconReadPending = false;
|
|
1188
|
+
target.message = null;
|
|
1189
|
+
if (typeof file.size === 'number' && file.size > PROJECT_ICON_MAX_BYTES) {
|
|
1190
|
+
target.fieldErrors.icon = `アイコンは${Math.floor(PROJECT_ICON_MAX_BYTES / 1024)}KiB以下にしてください。`;
|
|
1191
|
+
redraw();
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (typeof file.type !== 'string' || !PROJECT_ICON_MIME_TYPES.includes(file.type)) {
|
|
1195
|
+
target.fieldErrors.icon = 'アイコンはPNG、JPEG、WebPの画像を選んでください。';
|
|
1196
|
+
redraw();
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
target.iconReadPending = true;
|
|
1200
|
+
target.fieldErrors.icon = null;
|
|
1201
|
+
target.message = null;
|
|
1202
|
+
redraw();
|
|
1203
|
+
try {
|
|
1204
|
+
const value = await readProjectIconFile(file);
|
|
1205
|
+
const parsed = validateProjectIconDataUrl(value);
|
|
1206
|
+
if (!parsed.ok) throw new Error(parsed.message);
|
|
1207
|
+
if (form !== target || iconReadGeneration !== generation) return;
|
|
1208
|
+
target.draft.icon = parsed.value;
|
|
1209
|
+
target.iconReadPending = false;
|
|
1210
|
+
target.message = null;
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
if (form !== target || iconReadGeneration !== generation) return;
|
|
1213
|
+
target.iconReadPending = false;
|
|
1214
|
+
target.fieldErrors.icon = error instanceof Error ? error.message : 'アイコンを読み取れません。';
|
|
1215
|
+
}
|
|
1216
|
+
if (form === target && iconReadGeneration === generation) redraw();
|
|
1122
1217
|
},
|
|
1123
1218
|
onReason(value) {
|
|
1124
1219
|
if (form) form.reason = value;
|
|
@@ -1154,6 +1249,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1154
1249
|
title,
|
|
1155
1250
|
subject: subject ?? `${typeLabel(record.type)}「${record.name}」`,
|
|
1156
1251
|
recordType: record.type,
|
|
1252
|
+
recordName: record.name,
|
|
1157
1253
|
targetId: record.id,
|
|
1158
1254
|
expectedDigest: record.digest,
|
|
1159
1255
|
fields: shown,
|
|
@@ -1203,12 +1299,26 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1203
1299
|
return form;
|
|
1204
1300
|
},
|
|
1205
1301
|
close() {
|
|
1302
|
+
iconReadGeneration += 1;
|
|
1206
1303
|
form = null;
|
|
1207
1304
|
redraw();
|
|
1208
1305
|
},
|
|
1209
1306
|
async submit() {
|
|
1210
1307
|
const target = form;
|
|
1211
1308
|
if (!target || target.phase === 'saving') return target;
|
|
1309
|
+
if (target.fieldErrors?.icon) {
|
|
1310
|
+
target.message = { tone: 'danger', text: target.fieldErrors.icon };
|
|
1311
|
+
redraw();
|
|
1312
|
+
return target;
|
|
1313
|
+
}
|
|
1314
|
+
if (target.iconReadPending === true) {
|
|
1315
|
+
target.message = {
|
|
1316
|
+
tone: 'warning',
|
|
1317
|
+
text: 'アイコンを読み込んでいます。読み込みが終わってから保存してください。',
|
|
1318
|
+
};
|
|
1319
|
+
redraw();
|
|
1320
|
+
return target;
|
|
1321
|
+
}
|
|
1212
1322
|
const built = buildCorrectionRequest(target);
|
|
1213
1323
|
if (!built.ok) {
|
|
1214
1324
|
target.message = { tone: 'danger', text: built.message };
|
package/ui/local-web-shell.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createObjectiveEditorController } from './objective-editor.js';
|
|
|
21
21
|
import { createObjectiveEditorHttpPort } from './objective-editor-http-port.js';
|
|
22
22
|
import { createValueProofReviewUI } from './value-proof-review.js';
|
|
23
23
|
import { createWorldModelView } from './world-model-view.js';
|
|
24
|
-
import { createGraphClient
|
|
24
|
+
import { createGraphClient } from './graph-view-shared.js';
|
|
25
25
|
|
|
26
26
|
export const LOCAL_WEB_SHELL_CONTRACT_VERSION = 'brainbase.local-web-shell.v1';
|
|
27
27
|
|
|
@@ -203,58 +203,11 @@ function mountObjectives(container, context) {
|
|
|
203
203
|
return { editor, worldModel };
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
const ORGANIZATION_READ_ONLY_NOTE = '組織のGraphを読み取り専用で表示しています。登録の訂正は組織版で行ってください。';
|
|
207
|
-
// What the owner needs before leaving for the organization web (Inspector UX-20261004-01): a link to this
|
|
208
|
-
// very record there, who may correct it, and what happens after saving.
|
|
209
|
-
export { organizationRecordLink };
|
|
210
|
-
const organizationReadOnlyNoteLinked = (doc, origin, screenId) => (record) => {
|
|
211
|
-
const target = organizationRecordLink(origin, screenId, record);
|
|
212
|
-
if (!target) return ORGANIZATION_READ_ONLY_NOTE;
|
|
213
|
-
const note = makeElement(doc, 'span');
|
|
214
|
-
note.append(
|
|
215
|
-
makeElement(doc, 'span', { text: [
|
|
216
|
-
'組織のGraphを読み取り専用で表示しています。ここでは直せません。訂正の手順:',
|
|
217
|
-
`① 下のリンク(上の「出典」にも同じリンクがあります)から、組織版の${target.label}でこの記録を開きます。ログインしていなければSlackでログインすると、そのままこの記録に戻ります。`,
|
|
218
|
-
'② 直せるのは、組織版での役割がgmかceoで、このプロジェクトが属する事業(事業そのものなら、その事業)を許可されている人です。直せるのはプロジェクトの記録(名前・別名・要約・期間・目的・状態)だけで、人物の記録と、関係者の追加・終了は組織版でもまだ直せません。',
|
|
219
|
-
'③ 保存すると、理由といっしょに組織のGraphへ書き込まれ、組織版がその場で読み戻して表示します。この画面には、1分ほどたってから再読み込みすると反映されます。',
|
|
220
|
-
'',
|
|
221
|
-
].join('\n') }),
|
|
222
|
-
organizationRecordAnchor(doc, target),
|
|
223
|
-
);
|
|
224
|
-
return note;
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* The 出典 notice of a screen reading the organization Graph. Once a record is selected its link opens
|
|
229
|
-
* that record in the organization web; before that, the screen there.
|
|
230
|
-
*/
|
|
231
|
-
const organizationSourceText = (doc, origin, screenId, sourceText) => (record) => {
|
|
232
|
-
const target = record ? organizationRecordLink(origin, screenId, record) : null;
|
|
233
|
-
const link = target
|
|
234
|
-
? organizationRecordAnchor(doc, target)
|
|
235
|
-
: makeElement(doc, 'a', {
|
|
236
|
-
text: '組織版で開いて訂正する',
|
|
237
|
-
attrs: { href: `${origin}/?screen=${encodeURIComponent(screenId)}`, target: '_blank', rel: 'noopener noreferrer' },
|
|
238
|
-
});
|
|
239
|
-
const source = makeElement(doc, 'span');
|
|
240
|
-
source.append(makeElement(doc, 'span', { text: `${sourceText} ` }), link);
|
|
241
|
-
return source;
|
|
242
|
-
};
|
|
243
|
-
|
|
244
206
|
function mountGraphScreen(screenId, createView) {
|
|
245
207
|
return (container, context) => {
|
|
246
208
|
const viewRoot = makeElement(context.document, 'div');
|
|
247
209
|
makePage(container, context).append(viewRoot);
|
|
248
|
-
// Ledger C1: when the host reads the organization Graph, nothing here can be corrected.
|
|
249
|
-
const readOnly = context.organizationGraph?.status === 'connected';
|
|
250
|
-
const origin = readOnly ? organizationWebOrigin(context.organizationGraph) : null;
|
|
251
|
-
const sourceText = `組織のGraph(${context.organizationGraph?.server ?? '接続先不明'})を読み取り専用で表示しています。このMacのGraphではありません。`;
|
|
252
210
|
return createView({
|
|
253
|
-
...(readOnly ? {
|
|
254
|
-
canCorrect: false,
|
|
255
|
-
readOnlyNote: origin ? organizationReadOnlyNoteLinked(context.document, origin, screenId) : ORGANIZATION_READ_ONLY_NOTE,
|
|
256
|
-
sourceNotice: { label: '出典', text: origin ? organizationSourceText(context.document, origin, screenId, sourceText) : sourceText },
|
|
257
|
-
} : {}),
|
|
258
211
|
root: viewRoot,
|
|
259
212
|
rail: context.rail,
|
|
260
213
|
page: context.page,
|
|
@@ -334,7 +287,6 @@ function renderSource(doc, bar, status, onRecheck) {
|
|
|
334
287
|
bar.append(
|
|
335
288
|
fact('データ', data.data_dir),
|
|
336
289
|
fact('Graph', graphLabel, data.graph.status === 'ready' ? '' : 'is-warning'),
|
|
337
|
-
fact('組織のGraph', data.organization_graph?.status === 'connected' ? '読んでいます(読み取りのみ)' : '読んでいません'),
|
|
338
290
|
);
|
|
339
291
|
}
|
|
340
292
|
|
|
@@ -446,10 +398,8 @@ export function createLocalWebShell({
|
|
|
446
398
|
slot.replaceChildren();
|
|
447
399
|
const rail = rails.get(screen.id) ?? null;
|
|
448
400
|
rail?.replaceChildren();
|
|
449
|
-
const
|
|
450
|
-
const
|
|
451
|
-
const page = Object.freeze({ crumbs: Object.freeze(['あなたのBrainbase', screen.label]), source: readsOrganization ? '組織のGraph(読み取りのみ)' : screen.source ?? null });
|
|
452
|
-
const view = screen.mount(slot, Object.freeze({ ...context, rail, page, initialEntityId: entityId, organizationGraph })) ?? true;
|
|
401
|
+
const page = Object.freeze({ crumbs: Object.freeze(['あなたのBrainbase', screen.label]), source: screen.source ?? null });
|
|
402
|
+
const view = screen.mount(slot, Object.freeze({ ...context, rail, page, initialEntityId: entityId })) ?? true;
|
|
453
403
|
mounted.set(screen.id, view);
|
|
454
404
|
if (entityId && screen.id === 'today' && typeof view?.openDecision === 'function') view.openDecision(entityId);
|
|
455
405
|
if (entityId && screen.id === 'projects' && typeof view?.openProject === 'function') void view.openProject(entityId);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Project icon validation and rendering shared by the local and organization
|
|
3
|
+
* Graph screens.
|
|
4
|
+
*
|
|
5
|
+
* Icons are deliberately kept as bounded data URLs. The Graph correction
|
|
6
|
+
* endpoint stores the value in the project metadata, while this module keeps
|
|
7
|
+
* the browser side independent from Node's Buffer implementation so the
|
|
8
|
+
* organization edition can consume the same helper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const PROJECT_ICON_MAX_BYTES = 256 * 1024;
|
|
12
|
+
export const PROJECT_ICON_MIME_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/webp']);
|
|
13
|
+
|
|
14
|
+
const MIME_LABELS = Object.freeze({
|
|
15
|
+
'image/png': 'PNG',
|
|
16
|
+
'image/jpeg': 'JPEG',
|
|
17
|
+
'image/webp': 'WebP',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]*={0,2})(?![\s\S])/u;
|
|
21
|
+
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
22
|
+
const PNG_SIGNATURE = Object.freeze([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
23
|
+
|
|
24
|
+
/** A compact representation safe to put in correction history or audit UI. */
|
|
25
|
+
export function projectIconSummary(value) {
|
|
26
|
+
const result = validateProjectIconDataUrl(value);
|
|
27
|
+
if (!result.ok || result.value === null) return null;
|
|
28
|
+
return { present: true, mimeType: result.mimeType, bytes: result.bytes };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks a project icon data URL, including the decoded byte limit and the
|
|
33
|
+
* file signature. `null` means an explicit removal and is valid for a
|
|
34
|
+
* correction; callers can distinguish it from an invalid non-null value.
|
|
35
|
+
*/
|
|
36
|
+
export function validateProjectIconDataUrl(value, { maxBytes = PROJECT_ICON_MAX_BYTES } = {}) {
|
|
37
|
+
if (value === null || value === undefined || value === '') return { ok: true, value: null, mimeType: null, bytes: 0 };
|
|
38
|
+
if (typeof value !== 'string') return invalidIcon('type', 'アイコンはPNG、JPEG、WebPの画像を選んでください。');
|
|
39
|
+
const match = DATA_URL_PATTERN.exec(value);
|
|
40
|
+
if (!match) return invalidIcon('format', 'アイコンはPNG、JPEG、WebPの画像を選んでください。');
|
|
41
|
+
const [, mimeType, payload] = match;
|
|
42
|
+
if (!isCanonicalBase64(payload)) return invalidIcon('base64', 'アイコンの画像データを読み取れません。もう一度選んでください。');
|
|
43
|
+
const bytes = decodedLength(payload);
|
|
44
|
+
if (bytes === 0) return invalidIcon('empty', '空の画像は登録できません。');
|
|
45
|
+
if (bytes > maxBytes) return invalidIcon('too_large', `アイコンは${Math.floor(maxBytes / 1024)}KiB以下にしてください。`);
|
|
46
|
+
if (!matchesSignature(mimeType, payload, bytes)) return invalidIcon('signature', '画像形式とデータが一致しません。もう一度選んでください。');
|
|
47
|
+
return { ok: true, value, mimeType, bytes };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Human-readable type for a validated icon summary. */
|
|
51
|
+
export function projectIconMimeLabel(mimeType) {
|
|
52
|
+
return MIME_LABELS[mimeType] ?? '画像';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A stable fallback mark for an unregistered project icon. */
|
|
56
|
+
export function projectIconInitial(name) {
|
|
57
|
+
const text = typeof name === 'string' ? name.trim() : '';
|
|
58
|
+
return [...text][0]?.toUpperCase() ?? 'P';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render an icon without putting the data URL into text content. Fallback
|
|
63
|
+
* initials are drawn by CSS from `data-initial`, which keeps ledger accessible
|
|
64
|
+
* names and existing screen-reader text compact.
|
|
65
|
+
*/
|
|
66
|
+
export function renderProjectIcon(doc, value, name, { size = 'md' } = {}) {
|
|
67
|
+
const valid = validateProjectIconDataUrl(value);
|
|
68
|
+
const registered = valid.ok && valid.value;
|
|
69
|
+
const label = `${typeof name === 'string' && name.trim() ? name.trim() : 'プロジェクト'}のアイコン`;
|
|
70
|
+
const icon = doc.createElement('span');
|
|
71
|
+
icon.className = `bb-project-icon is-${size}${registered ? ' is-registered' : ' is-unregistered'}`;
|
|
72
|
+
icon.setAttribute('aria-label', registered ? label : `${label}(未登録)`);
|
|
73
|
+
icon.setAttribute('title', registered ? label : `${label}(未登録)`);
|
|
74
|
+
icon.setAttribute('data-state', registered ? 'registered' : 'unregistered');
|
|
75
|
+
if (registered) {
|
|
76
|
+
const image = doc.createElement('img');
|
|
77
|
+
image.setAttribute('src', value);
|
|
78
|
+
image.setAttribute('alt', '');
|
|
79
|
+
image.setAttribute('aria-hidden', 'true');
|
|
80
|
+
icon.append(image);
|
|
81
|
+
} else {
|
|
82
|
+
icon.setAttribute('data-initial', projectIconInitial(name));
|
|
83
|
+
}
|
|
84
|
+
return icon;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reads a browser File as a data URL; validation is left to the caller. */
|
|
88
|
+
export function readProjectIconFile(file) {
|
|
89
|
+
if (!file) return Promise.reject(new Error('icon_file_required'));
|
|
90
|
+
const Reader = globalThis.FileReader;
|
|
91
|
+
if (typeof Reader === 'function') {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const reader = new Reader();
|
|
94
|
+
reader.addEventListener?.('error', () => reject(new Error('icon_file_read_failed')));
|
|
95
|
+
reader.onerror = () => reject(new Error('icon_file_read_failed'));
|
|
96
|
+
reader.onload = () => {
|
|
97
|
+
if (typeof reader.result !== 'string') reject(new Error('icon_file_read_failed'));
|
|
98
|
+
else resolve(reader.result);
|
|
99
|
+
};
|
|
100
|
+
reader.readAsDataURL(file);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (typeof file.arrayBuffer === 'function' && typeof globalThis.btoa === 'function') {
|
|
104
|
+
return file.arrayBuffer().then((buffer) => {
|
|
105
|
+
const bytes = new Uint8Array(buffer);
|
|
106
|
+
let binary = '';
|
|
107
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
108
|
+
const mimeType = typeof file.type === 'string' && file.type ? file.type : 'application/octet-stream';
|
|
109
|
+
return `data:${mimeType};base64,${globalThis.btoa(binary)}`;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return Promise.reject(new Error('icon_file_reader_unavailable'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function invalidIcon(code, message) {
|
|
116
|
+
return { ok: false, code, message };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isCanonicalBase64(value) {
|
|
120
|
+
if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) return false;
|
|
121
|
+
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
|
|
122
|
+
const meaningful = value.length - padding;
|
|
123
|
+
if (meaningful === 0) return false;
|
|
124
|
+
const last = BASE64_ALPHABET.indexOf(value[meaningful - 1]);
|
|
125
|
+
if (last < 0) return false;
|
|
126
|
+
if (padding === 1 && (last & 0x03) !== 0) return false;
|
|
127
|
+
if (padding === 2 && (last & 0x0f) !== 0) return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function decodedLength(value) {
|
|
132
|
+
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
|
|
133
|
+
return (value.length / 4) * 3 - padding;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function base64Byte(value, index) {
|
|
137
|
+
const group = Math.floor(index / 3) * 4;
|
|
138
|
+
const a = BASE64_ALPHABET.indexOf(value[group]);
|
|
139
|
+
const b = BASE64_ALPHABET.indexOf(value[group + 1]);
|
|
140
|
+
const c = value[group + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(value[group + 2]);
|
|
141
|
+
const d = value[group + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(value[group + 3]);
|
|
142
|
+
const first = (a << 2) | (b >> 4);
|
|
143
|
+
const second = ((b & 0x0f) << 4) | (c >> 2);
|
|
144
|
+
const third = ((c & 0x03) << 6) | d;
|
|
145
|
+
const offset = index % 3;
|
|
146
|
+
return offset === 0 ? first : offset === 1 ? second : third;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function matchesSignature(mimeType, payload, bytes) {
|
|
150
|
+
if (mimeType === 'image/png') return bytes >= PNG_SIGNATURE.length && PNG_SIGNATURE.every((byte, index) => base64Byte(payload, index) === byte);
|
|
151
|
+
if (mimeType === 'image/jpeg') return bytes >= 4 && base64Byte(payload, 0) === 0xff && base64Byte(payload, 1) === 0xd8 && base64Byte(payload, 2) === 0xff
|
|
152
|
+
&& base64Byte(payload, bytes - 2) === 0xff && base64Byte(payload, bytes - 1) === 0xd9;
|
|
153
|
+
return bytes >= 12 && base64Byte(payload, 0) === 0x52 && base64Byte(payload, 1) === 0x49 && base64Byte(payload, 2) === 0x46 && base64Byte(payload, 3) === 0x46
|
|
154
|
+
&& base64Byte(payload, 8) === 0x57 && base64Byte(payload, 9) === 0x45 && base64Byte(payload, 10) === 0x42 && base64Byte(payload, 11) === 0x50;
|
|
155
|
+
}
|
package/ui/workspace-kit.css
CHANGED
|
@@ -286,9 +286,20 @@
|
|
|
286
286
|
|
|
287
287
|
.bb-ws-ledger-object {
|
|
288
288
|
display: grid;
|
|
289
|
+
grid-template-columns: minmax(0, 1fr);
|
|
289
290
|
gap: 4px;
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
.bb-ws-ledger-object:has(> .bb-project-icon) {
|
|
294
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
295
|
+
align-items: center;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.bb-ws-ledger-object:has(> .bb-project-icon) > strong,
|
|
299
|
+
.bb-ws-ledger-object:has(> .bb-project-icon) > code {
|
|
300
|
+
grid-column: 2;
|
|
301
|
+
}
|
|
302
|
+
|
|
292
303
|
.bb-ws-ledger-object strong {
|
|
293
304
|
overflow: hidden;
|
|
294
305
|
color: var(--bb-color-ink);
|
package/ui/workspace-kit.js
CHANGED
|
@@ -139,7 +139,8 @@ function ledgerRowLabel(cells = []) {
|
|
|
139
139
|
/**
|
|
140
140
|
* A ledger: a header row and selectable rows. `className` sets the column
|
|
141
141
|
* template in the caller's CSS. A cell is text, an element, `{ text, className }`,
|
|
142
|
-
* or `{ primary, secondary }` for a name with its id underneath
|
|
142
|
+
* or `{ primary, secondary, leading }` for a name with its id underneath and
|
|
143
|
+
* an optional leading element (for example a project icon).
|
|
143
144
|
*/
|
|
144
145
|
export function workspaceLedger(doc, { columns = [], rows = [], className = '', ariaLabel, empty } = {}) {
|
|
145
146
|
const ledger = el(doc, 'div', { className: `bb-ws-ledger${className ? ` ${className}` : ''}`, attrs: { role: 'table', 'aria-label': ariaLabel } });
|
|
@@ -165,6 +166,7 @@ export function workspaceLedger(doc, { columns = [], rows = [], className = '',
|
|
|
165
166
|
for (const cell of row.cells ?? []) {
|
|
166
167
|
if (cell && typeof cell === 'object' && 'primary' in cell) {
|
|
167
168
|
const object = el(doc, 'span', { className: 'bb-ws-ledger-object', attrs: { role: 'cell' } });
|
|
169
|
+
if (cell.leading && typeof cell.leading.tagName === 'string') object.append(cell.leading);
|
|
168
170
|
object.append(el(doc, 'strong', { text: cell.primary }));
|
|
169
171
|
if (cell.secondary) object.append(el(doc, 'code', { text: cell.secondary }));
|
|
170
172
|
item.append(object);
|
|
@@ -185,9 +187,10 @@ export function workspaceLedger(doc, { columns = [], rows = [], className = '',
|
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
/** The selected item's head in the right rail. */
|
|
188
|
-
export function workspaceRailHead(doc, { kicker, title, sub, lead } = {}) {
|
|
190
|
+
export function workspaceRailHead(doc, { kicker, title, sub, lead, leading } = {}) {
|
|
189
191
|
const head = el(doc, 'div', { className: 'bb-ws-rail-head' });
|
|
190
192
|
if (kicker) head.append(el(doc, 'small', { text: kicker }));
|
|
193
|
+
if (leading && typeof leading.tagName === 'string') head.append(leading);
|
|
191
194
|
head.append(el(doc, 'h2', { text: title }));
|
|
192
195
|
if (sub) head.append(el(doc, 'span', { text: sub }));
|
|
193
196
|
if (lead) head.append(el(doc, 'p', { text: lead }));
|