@unson/brainbase-mcp 0.16.0 → 0.17.1
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/graph-corrections.d.ts +7 -1
- package/dist/graph-corrections.js +89 -4
- package/dist/graph-web-http.js +3 -1
- package/dist/graph-web.d.ts +2 -0
- package/dist/graph-web.js +15 -3
- package/dist/local-web-host.js +1 -0
- package/package.json +3 -2
- package/ui/graph-projects-view.js +26 -8
- package/ui/graph-view-shared.css +49 -0
- package/ui/graph-view-shared.js +156 -7
- package/ui/project-icon.js +155 -0
- package/ui/workspace-kit.css +11 -0
- package/ui/workspace-kit.js +5 -2
|
@@ -11,11 +11,13 @@ export declare const GRAPH_CORRECTION_SCHEMA: "brainbase-graph-correction.v1";
|
|
|
11
11
|
/** Relative to the data directory. Committed as a sidecar of the canonical SSOT transaction. */
|
|
12
12
|
export declare const GRAPH_CORRECTIONS_FILE = "evidence/graph-corrections.jsonl";
|
|
13
13
|
export declare const GRAPH_CORRECTION_ENTITY_FIELDS: readonly ["name", "aliases", "summary", "validFrom", "validTo"];
|
|
14
|
-
export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status"];
|
|
14
|
+
export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status", "icon"];
|
|
15
15
|
export declare const GRAPH_CORRECTION_EDGE_FIELDS: readonly ["role", "context", "validTo"];
|
|
16
16
|
export declare const GRAPH_CORRECTION_NEW_EDGE_RELATIONS: readonly ["participates_in", "accountable_for", "member_of"];
|
|
17
17
|
/** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
|
|
18
18
|
export declare const GRAPH_CORRECTION_APPLIES_TO: readonly ["search", "get_context", "resolve_entity"];
|
|
19
|
+
/** Decoded bytes, shared with the browser-side project-icon helper. */
|
|
20
|
+
export declare const PROJECT_ICON_MAX_BYTES: number;
|
|
19
21
|
export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge';
|
|
20
22
|
export type GraphNewEdgeRelation = typeof GRAPH_CORRECTION_NEW_EDGE_RELATIONS[number];
|
|
21
23
|
export interface GraphEntityCorrectionChanges {
|
|
@@ -28,6 +30,8 @@ export interface GraphEntityCorrectionChanges {
|
|
|
28
30
|
goal?: string | null;
|
|
29
31
|
/** Projects only. Stored as `metadata.status`. */
|
|
30
32
|
status?: string | null;
|
|
33
|
+
/** Projects only. Stored as `metadata.icon` as a bounded PNG/JPEG/WebP data URL. */
|
|
34
|
+
icon?: string | null;
|
|
31
35
|
}
|
|
32
36
|
export interface GraphEdgeCorrectionChanges {
|
|
33
37
|
role?: string | null;
|
|
@@ -155,3 +159,5 @@ export declare function readGraphCorrectionHistory(dataDir: string): Promise<{
|
|
|
155
159
|
records: GraphCorrectionRecord[];
|
|
156
160
|
issues: GraphCorrectionHistoryIssue[];
|
|
157
161
|
}>;
|
|
162
|
+
/** Returns true only for a bounded, signature-checked project icon data URL. */
|
|
163
|
+
export declare function isProjectIconDataUrl(value: unknown): value is string;
|
|
@@ -18,7 +18,7 @@ export const GRAPH_CORRECTION_SCHEMA = 'brainbase-graph-correction.v1';
|
|
|
18
18
|
/** Relative to the data directory. Committed as a sidecar of the canonical SSOT transaction. */
|
|
19
19
|
export const GRAPH_CORRECTIONS_FILE = 'evidence/graph-corrections.jsonl';
|
|
20
20
|
export const GRAPH_CORRECTION_ENTITY_FIELDS = ['name', 'aliases', 'summary', 'validFrom', 'validTo'];
|
|
21
|
-
export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status'];
|
|
21
|
+
export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status', 'icon'];
|
|
22
22
|
export const GRAPH_CORRECTION_EDGE_FIELDS = ['role', 'context', 'validTo'];
|
|
23
23
|
export const GRAPH_CORRECTION_NEW_EDGE_RELATIONS = ['participates_in', 'accountable_for', 'member_of'];
|
|
24
24
|
/** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
|
|
@@ -27,6 +27,8 @@ const MAX_REASON_LENGTH = 500;
|
|
|
27
27
|
const MAX_NAME_LENGTH = 200;
|
|
28
28
|
const MAX_TEXT_LENGTH = 2000;
|
|
29
29
|
const MAX_ALIASES = 50;
|
|
30
|
+
/** Decoded bytes, shared with the browser-side project-icon helper. */
|
|
31
|
+
export const PROJECT_ICON_MAX_BYTES = 256 * 1024;
|
|
30
32
|
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
31
33
|
const PROJECTED_RELATIONS = new Set(['participates_in', 'accountable_for']);
|
|
32
34
|
export class GraphCorrectionError extends Error {
|
|
@@ -213,8 +215,8 @@ function planEntityUpdate(graph, relationships, correction, at) {
|
|
|
213
215
|
const before = matches[0];
|
|
214
216
|
assertExpectedDigest('entity', before, correction.expectedDigest);
|
|
215
217
|
const changes = correction.changes;
|
|
216
|
-
if ((changes.goal !== undefined || changes.status !== undefined) && before.type !== 'project') {
|
|
217
|
-
invalid('correction_field_not_allowed', 'goal and
|
|
218
|
+
if ((changes.goal !== undefined || changes.status !== undefined || changes.icon !== undefined) && before.type !== 'project') {
|
|
219
|
+
invalid('correction_field_not_allowed', 'goal, status and icon can be corrected only on a project');
|
|
218
220
|
}
|
|
219
221
|
const after = structuredClone(before);
|
|
220
222
|
const diff = {};
|
|
@@ -228,7 +230,7 @@ function planEntityUpdate(graph, relationships, correction, at) {
|
|
|
228
230
|
setField(after, 'validFrom', changes.validFrom, before, diff);
|
|
229
231
|
if (changes.validTo !== undefined)
|
|
230
232
|
setField(after, 'validTo', changes.validTo, before, diff);
|
|
231
|
-
if (changes.goal !== undefined || changes.status !== undefined) {
|
|
233
|
+
if (changes.goal !== undefined || changes.status !== undefined || changes.icon !== undefined) {
|
|
232
234
|
const metadata = { ...(before.metadata ?? {}) };
|
|
233
235
|
for (const key of ['goal', 'status']) {
|
|
234
236
|
const next = changes[key];
|
|
@@ -243,6 +245,18 @@ function planEntityUpdate(graph, relationships, correction, at) {
|
|
|
243
245
|
metadata[key] = next;
|
|
244
246
|
diff[`metadata.${key}`] = { before: previous, after: next };
|
|
245
247
|
}
|
|
248
|
+
if (changes.icon !== undefined) {
|
|
249
|
+
const previous = typeof metadata.icon === 'string' ? metadata.icon : null;
|
|
250
|
+
const next = changes.icon;
|
|
251
|
+
if (previous !== next) {
|
|
252
|
+
if (next === null)
|
|
253
|
+
delete metadata.icon;
|
|
254
|
+
else
|
|
255
|
+
metadata.icon = next;
|
|
256
|
+
// Keep image bytes and the long data URL out of append-only history.
|
|
257
|
+
diff['metadata.icon'] = { before: projectIconSummary(previous), after: projectIconSummary(next) };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
246
260
|
if (Object.keys(metadata).length > 0)
|
|
247
261
|
after.metadata = metadata;
|
|
248
262
|
else
|
|
@@ -488,6 +502,8 @@ function parseEntityChanges(value) {
|
|
|
488
502
|
changes.goal = optionalText(value.goal, 'changes.goal', MAX_TEXT_LENGTH);
|
|
489
503
|
if (value.status !== undefined)
|
|
490
504
|
changes.status = optionalText(value.status, 'changes.status', MAX_NAME_LENGTH);
|
|
505
|
+
if (value.icon !== undefined)
|
|
506
|
+
changes.icon = parseProjectIcon(value.icon);
|
|
491
507
|
return changes;
|
|
492
508
|
}
|
|
493
509
|
function parseEdgeChanges(value) {
|
|
@@ -577,6 +593,75 @@ function optionalDateTime(value, field) {
|
|
|
577
593
|
}
|
|
578
594
|
return value;
|
|
579
595
|
}
|
|
596
|
+
const PROJECT_ICON_DATA_URL = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]*={0,2})(?![\s\S])/u;
|
|
597
|
+
function parseProjectIcon(value) {
|
|
598
|
+
if (value === null)
|
|
599
|
+
return null;
|
|
600
|
+
if (typeof value !== 'string')
|
|
601
|
+
invalid('correction_invalid', 'changes.icon must be a PNG, JPEG, WebP data URL or null');
|
|
602
|
+
const match = PROJECT_ICON_DATA_URL.exec(value);
|
|
603
|
+
if (!match || value.length === 0)
|
|
604
|
+
invalid('correction_invalid', 'changes.icon must be a PNG, JPEG, WebP data URL or null');
|
|
605
|
+
const payload = match[2];
|
|
606
|
+
if (!payload || payload.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(payload)) {
|
|
607
|
+
invalid('correction_invalid', 'changes.icon must contain canonical base64 data');
|
|
608
|
+
}
|
|
609
|
+
let decoded;
|
|
610
|
+
try {
|
|
611
|
+
decoded = Buffer.from(payload, 'base64');
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
invalid('correction_invalid', 'changes.icon contains invalid base64 data');
|
|
615
|
+
}
|
|
616
|
+
if (decoded.length === 0 || decoded.toString('base64') !== payload) {
|
|
617
|
+
invalid('correction_invalid', 'changes.icon contains invalid base64 data');
|
|
618
|
+
}
|
|
619
|
+
if (decoded.length > PROJECT_ICON_MAX_BYTES) {
|
|
620
|
+
invalid('correction_invalid', `changes.icon must be at most ${PROJECT_ICON_MAX_BYTES} decoded bytes`);
|
|
621
|
+
}
|
|
622
|
+
const mimeType = match[1];
|
|
623
|
+
const validSignature = mimeType === 'image/png'
|
|
624
|
+
? decoded.length >= 8 && Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).equals(decoded.subarray(0, 8))
|
|
625
|
+
: mimeType === 'image/jpeg'
|
|
626
|
+
? decoded.length >= 4 && decoded[0] === 0xff && decoded[1] === 0xd8 && decoded[2] === 0xff
|
|
627
|
+
&& decoded[decoded.length - 2] === 0xff && decoded[decoded.length - 1] === 0xd9
|
|
628
|
+
: decoded.length >= 12 && decoded.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
629
|
+
&& decoded.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
630
|
+
if (!validSignature)
|
|
631
|
+
invalid('correction_invalid', 'changes.icon image signature does not match its MIME type');
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
/** Returns true only for a bounded, signature-checked project icon data URL. */
|
|
635
|
+
export function isProjectIconDataUrl(value) {
|
|
636
|
+
if (value === null)
|
|
637
|
+
return false;
|
|
638
|
+
try {
|
|
639
|
+
return parseProjectIcon(value) !== null;
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
if (error instanceof GraphCorrectionError && error.kind === 'invalid')
|
|
643
|
+
return false;
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function projectIconSummary(value) {
|
|
648
|
+
if (value === null || value === undefined)
|
|
649
|
+
return null;
|
|
650
|
+
if (typeof value !== 'string')
|
|
651
|
+
return { present: true, valid: false };
|
|
652
|
+
const match = PROJECT_ICON_DATA_URL.exec(value);
|
|
653
|
+
if (!match)
|
|
654
|
+
return { present: true, valid: false };
|
|
655
|
+
if (!isProjectIconDataUrl(value))
|
|
656
|
+
return { present: true, valid: false };
|
|
657
|
+
try {
|
|
658
|
+
const decoded = Buffer.from(match[2], 'base64');
|
|
659
|
+
return { present: true, valid: true, mimeType: match[1], bytes: decoded.length };
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return { present: true, valid: false };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
580
665
|
function requireId(value, field) {
|
|
581
666
|
if (typeof value !== 'string' || value.trim() === '' || value.length > 500)
|
|
582
667
|
invalid('correction_invalid', `${field} must be a non-empty id`);
|
package/dist/graph-web-http.js
CHANGED
|
@@ -2,7 +2,9 @@ import { applyGraphCorrection, GraphCorrectionError } from './graph-corrections.
|
|
|
2
2
|
import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
|
|
3
3
|
export const GRAPH_WEB_HTTP_VERSION = 'graph-web-http.v1';
|
|
4
4
|
const DEFAULT_BASE_PATH = '/api/graph';
|
|
5
|
-
|
|
5
|
+
// A 256KiB decoded image becomes roughly 350KiB as base64 in JSON. Keep a
|
|
6
|
+
// bounded request envelope while allowing the project-icon correction through.
|
|
7
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
6
8
|
class HttpError extends Error {
|
|
7
9
|
statusCode;
|
|
8
10
|
code;
|
package/dist/graph-web.d.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface GraphEntityView {
|
|
|
74
74
|
digest: string;
|
|
75
75
|
goal?: string | null;
|
|
76
76
|
status?: string | null;
|
|
77
|
+
/** Projects only. A validated PNG/JPEG/WebP data URL, or null when unset. */
|
|
78
|
+
icon?: string | null;
|
|
77
79
|
}
|
|
78
80
|
export type GraphProvenanceReference = {
|
|
79
81
|
status: 'none';
|
package/dist/graph-web.js
CHANGED
|
@@ -2,7 +2,7 @@ import { access, readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isActiveAt } from './canonical-graph.js';
|
|
4
4
|
import { assertFoundationCatalog, findFoundationRecord } from './foundation-catalog.js';
|
|
5
|
-
import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, readGraphCorrectionHistory } from './graph-corrections.js';
|
|
5
|
+
import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
|
|
6
6
|
import { ONTOLOGY_VERSION, portableOntology } from './ontology.js';
|
|
7
7
|
import { canonicalRelationRegistry, getCanonicalRelation } from './relation-registry.js';
|
|
8
8
|
import { loadPersonalOs } from './ssot.js';
|
|
@@ -534,11 +534,23 @@ function entityView(entity, asOf) {
|
|
|
534
534
|
validTo: entity.validTo ?? null,
|
|
535
535
|
active: isActiveAt(entity, asOf),
|
|
536
536
|
digest: graphRecordDigest(entity),
|
|
537
|
-
...(entity.type === 'project'
|
|
537
|
+
...(entity.type === 'project'
|
|
538
|
+
? { goal: metadataString(entity, 'goal'), status: metadataString(entity, 'status'), icon: projectIconString(entity) }
|
|
539
|
+
: {})
|
|
538
540
|
};
|
|
539
541
|
}
|
|
540
542
|
function projectView(project, asOf) {
|
|
541
|
-
return {
|
|
543
|
+
return {
|
|
544
|
+
...entityView(project, asOf),
|
|
545
|
+
type: 'project',
|
|
546
|
+
goal: metadataString(project, 'goal'),
|
|
547
|
+
status: metadataString(project, 'status'),
|
|
548
|
+
icon: projectIconString(project),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function projectIconString(entity) {
|
|
552
|
+
const value = entity.metadata?.icon;
|
|
553
|
+
return isProjectIconDataUrl(value) ? value : null;
|
|
542
554
|
}
|
|
543
555
|
function historyFor(records, ids) {
|
|
544
556
|
return records.filter((record) => ids.has(record.target.id)).reverse();
|
package/dist/local-web-host.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -228,6 +228,7 @@
|
|
|
228
228
|
"./ui/workspace-kit.css": "./ui/workspace-kit.css",
|
|
229
229
|
"./ui/graph-view-shared": "./ui/graph-view-shared.js",
|
|
230
230
|
"./ui/graph-view-shared.css": "./ui/graph-view-shared.css",
|
|
231
|
+
"./ui/project-icon": "./ui/project-icon.js",
|
|
231
232
|
"./ui/graph-own-share": "./ui/graph-own-share.js",
|
|
232
233
|
"./ui/graph-projects-view": "./ui/graph-projects-view.js",
|
|
233
234
|
"./ui/graph-projects-view.css": "./ui/graph-projects-view.css",
|
|
@@ -358,5 +359,5 @@
|
|
|
358
359
|
"engines": {
|
|
359
360
|
"node": ">=20"
|
|
360
361
|
},
|
|
361
|
-
"gitHead": "
|
|
362
|
+
"gitHead": "e7f165e9a2dd0ebbcb0a561160f927afbad95b95"
|
|
362
363
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Built on the organization edition's screen pattern (workspace kit): the
|
|
6
6
|
* workspace has the page head, the source notice, summary metrics and the
|
|
7
7
|
* project ledger; the right rail has the selected project — its outline, each
|
|
8
|
-
* 関係者 and the corrections. The first project is selected on load
|
|
8
|
+
* 関係者 and the corrections. The first project is selected on load when the
|
|
9
|
+
* host has not provided a selection.
|
|
9
10
|
*
|
|
10
11
|
* Reads `GET {base}/projects` and `GET {base}/projects/:id` and corrects
|
|
11
12
|
* through `POST {base}/corrections` (see `graph-view-shared.js`). 「関係者」
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
graphHostEmptyNotice,
|
|
56
57
|
} from './graph-view-shared.js';
|
|
57
58
|
import { graphOwnShareSource, ownProjectDetail, ownShareSection, ownShareStateNotice } from './graph-own-share.js';
|
|
59
|
+
import { renderProjectIcon } from './project-icon.js';
|
|
58
60
|
import {
|
|
59
61
|
createProjectKnowledgeWorkspace,
|
|
60
62
|
normalizeProjectContext,
|
|
@@ -208,8 +210,9 @@ function normalizeProjectSummary(value) {
|
|
|
208
210
|
* history). Called synchronously on every rail render while a project is selected, with the read
|
|
209
211
|
* project detail, or null while it loads or when it could not be read. A throw or a value that is
|
|
210
212
|
* not a node is shown as a small danger notice instead of breaking the rail.
|
|
211
|
-
* @param {string} [options.selectedId] The project to select first.
|
|
212
|
-
*
|
|
213
|
+
* @param {string} [options.selectedId] The project to select first. When it
|
|
214
|
+
* is absent, the first project is selected. When it is not in the list, the
|
|
215
|
+
* requested id stays selected and the rail shows not found.
|
|
213
216
|
* @param {(id: string) => void} [options.onSelect] Called when the view changes the selection itself
|
|
214
217
|
* (a ledger row, or the automatic first project). Not called for `select(id)`.
|
|
215
218
|
* @param {{ basePath: string, fetcher?: Function, label?: string }} [options.ownShare]
|
|
@@ -617,7 +620,7 @@ export function createGraphProjectsView({
|
|
|
617
620
|
void reading;
|
|
618
621
|
},
|
|
619
622
|
cells: [
|
|
620
|
-
{ primary: project.name, secondary: project.id },
|
|
623
|
+
{ primary: project.name, secondary: project.id, leading: renderProjectIcon(doc, project.icon, project.name, { size: 'sm' }) },
|
|
621
624
|
textCell(project.goal, '目的未記入'),
|
|
622
625
|
accountableCell(project),
|
|
623
626
|
textCell(project.status, '未記入'),
|
|
@@ -721,7 +724,7 @@ export function createGraphProjectsView({
|
|
|
721
724
|
const project = loaded?.project ?? listItem(state.selectedId) ?? { id: state.selectedId, name: state.selectedId };
|
|
722
725
|
// The record a host's read-only note may point at.
|
|
723
726
|
const noteSubject = { id: project.id, type: 'project', metadata: loaded?.project?.metadata ?? {} };
|
|
724
|
-
const children = [workspaceRailHead(doc, { kicker: 'プロジェクト', title: project.name, sub: project.id })];
|
|
727
|
+
const children = [workspaceRailHead(doc, { kicker: 'プロジェクト', title: project.name, sub: project.id, leading: renderProjectIcon(doc, project.icon, project.name) })];
|
|
725
728
|
if (!loaded) {
|
|
726
729
|
const readState = detail?.id === state.selectedId
|
|
727
730
|
? detail
|
|
@@ -756,7 +759,7 @@ export function createGraphProjectsView({
|
|
|
756
759
|
children.push(renderParticipants(loaded));
|
|
757
760
|
if (writable) {
|
|
758
761
|
const canAdd = scope.createEdges;
|
|
759
|
-
const projectFields = scope.entity('project') ? scope.fields(['name', 'goal', 'status']) : [];
|
|
762
|
+
const projectFields = scope.entity('project') ? scope.fields(['name', 'goal', 'status', 'icon']) : [];
|
|
760
763
|
const actions = [
|
|
761
764
|
canAdd ? workspaceButton(doc, {
|
|
762
765
|
text: '関係者を加える',
|
|
@@ -809,7 +812,7 @@ export function createGraphProjectsView({
|
|
|
809
812
|
return state.list;
|
|
810
813
|
}
|
|
811
814
|
const { projects } = state.list.payload;
|
|
812
|
-
if (!state.selectedId
|
|
815
|
+
if (!state.selectedId) {
|
|
813
816
|
if (correction.form) correction.close();
|
|
814
817
|
state.panelAt = null;
|
|
815
818
|
state.detail = null;
|
|
@@ -824,7 +827,22 @@ export function createGraphProjectsView({
|
|
|
824
827
|
if (viewDestroyed || request !== hostListRequest) return state.list;
|
|
825
828
|
return state.list;
|
|
826
829
|
}
|
|
827
|
-
} else if (
|
|
830
|
+
} else if (!projects.some((project) => project.id === state.selectedId)) {
|
|
831
|
+
// An explicit host selection, or a project selected by the user, must
|
|
832
|
+
// never be replaced with the first row when the refreshed list no
|
|
833
|
+
// longer contains it. Keeping the id makes the missing target visible
|
|
834
|
+
// and lets the host decide whether its identity or access changed.
|
|
835
|
+
if (correction.form) correction.close();
|
|
836
|
+
detailRequest += 1;
|
|
837
|
+
state.panelAt = null;
|
|
838
|
+
state.detail = {
|
|
839
|
+
id: state.selectedId,
|
|
840
|
+
state: 'not_found',
|
|
841
|
+
reason: 'このプロジェクトは一覧から見つかりません。一覧を読み直してください。',
|
|
842
|
+
};
|
|
843
|
+
state.context = null;
|
|
844
|
+
contextRequest += 1;
|
|
845
|
+
} else if (state.detail?.id !== state.selectedId || state.detail?.state === 'not_found') {
|
|
828
846
|
// The project the host asked for is listed; read it for the rail.
|
|
829
847
|
if (viewDestroyed || request !== hostListRequest) return state.list;
|
|
830
848
|
await controller.loadDetail(state.selectedId);
|
package/ui/graph-view-shared.css
CHANGED
|
@@ -100,6 +100,55 @@
|
|
|
100
100
|
min-width: 0;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/* Project marks are shared by the ledger, rail and correction readback. The
|
|
104
|
+
unregistered initial is painted from an attribute so it never pollutes the
|
|
105
|
+
ledger's accessible project name or correction history with data URLs. */
|
|
106
|
+
.bb-project-icon {
|
|
107
|
+
display: inline-grid;
|
|
108
|
+
flex: 0 0 36px;
|
|
109
|
+
place-items: center;
|
|
110
|
+
width: 36px;
|
|
111
|
+
height: 36px;
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
border: 1px solid var(--bb-color-border);
|
|
114
|
+
border-radius: var(--bb-radius-sm);
|
|
115
|
+
color: var(--bb-color-accent-strong);
|
|
116
|
+
background: var(--bb-color-accent-soft);
|
|
117
|
+
font-size: 15px;
|
|
118
|
+
font-weight: 750;
|
|
119
|
+
line-height: 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.bb-project-icon.is-sm {
|
|
123
|
+
flex-basis: 26px;
|
|
124
|
+
width: 26px;
|
|
125
|
+
height: 26px;
|
|
126
|
+
border-radius: 6px;
|
|
127
|
+
font-size: 11px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.bb-project-icon::before { content: attr(data-initial); }
|
|
131
|
+
.bb-project-icon.is-registered::before { content: ''; }
|
|
132
|
+
.bb-project-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
133
|
+
|
|
134
|
+
.bb-graph-icon-value,
|
|
135
|
+
.bb-graph-icon-picker {
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
flex-wrap: wrap;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: var(--bb-space-2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.bb-graph-icon-picker { margin-top: var(--bb-space-2); }
|
|
143
|
+
.bb-graph-icon-picker-state { color: var(--bb-color-muted); font-size: 12px; }
|
|
144
|
+
.bb-graph-field-error { display: block; color: var(--bb-color-danger); font-size: 12px; }
|
|
145
|
+
.bb-graph-correction .bb-project-icon { margin-right: var(--bb-space-1); }
|
|
146
|
+
|
|
147
|
+
.bb-ws-ledger-object > .bb-project-icon {
|
|
148
|
+
grid-row: 1 / span 2;
|
|
149
|
+
}
|
|
150
|
+
.bb-ws-rail-head > .bb-project-icon { margin-bottom: var(--bb-space-3); }
|
|
151
|
+
|
|
103
152
|
/* Compact items: each 関係者 or relation in the rail. */
|
|
104
153
|
.bb-graph-compact-list {
|
|
105
154
|
display: grid;
|
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). */
|
|
@@ -718,6 +730,9 @@ export function graphRecordValues(record) {
|
|
|
718
730
|
validTo: formatDay(record?.validTo) ?? '',
|
|
719
731
|
goal: pick(typeof record?.goal === 'string' ? record.goal : metadata.goal),
|
|
720
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 : '',
|
|
721
736
|
role: pick(record?.role),
|
|
722
737
|
context: pick(record?.context),
|
|
723
738
|
};
|
|
@@ -725,16 +740,32 @@ export function graphRecordValues(record) {
|
|
|
725
740
|
|
|
726
741
|
function displayValue(field, value) {
|
|
727
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
|
+
}
|
|
728
747
|
return value ? value : '未記入';
|
|
729
748
|
}
|
|
730
749
|
|
|
731
|
-
function valueRows(record, fields) {
|
|
750
|
+
function valueRows(doc, record, fields) {
|
|
732
751
|
const values = graphRecordValues(record);
|
|
733
|
-
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
|
+
]);
|
|
734
756
|
}
|
|
735
757
|
|
|
736
758
|
function renderValues(doc, record, fields, leadingRows = []) {
|
|
737
|
-
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;
|
|
738
769
|
}
|
|
739
770
|
|
|
740
771
|
function splitAliases(value) {
|
|
@@ -791,6 +822,15 @@ export function buildCorrectionRequest(form) {
|
|
|
791
822
|
|
|
792
823
|
const changes = {};
|
|
793
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
|
+
}
|
|
794
834
|
const before = String(form.original[field] ?? '');
|
|
795
835
|
const after = String(form.draft[field] ?? '');
|
|
796
836
|
if (after.trim() === before.trim()) continue;
|
|
@@ -846,6 +886,29 @@ function fieldControl(doc, form, field, callbacks) {
|
|
|
846
886
|
const id = `${form.formId}-${field}`;
|
|
847
887
|
const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
|
|
848
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
|
+
}
|
|
849
912
|
const control = definition.control === 'textarea'
|
|
850
913
|
? makeElement(doc, 'textarea', { attrs: { id, name: field, rows: 3, maxlength: definition.max } })
|
|
851
914
|
: makeElement(doc, 'input', { attrs: { id, name: field, type: definition.control === 'date' ? 'date' : 'text', maxlength: definition.max } });
|
|
@@ -1010,9 +1073,21 @@ export function renderCorrectionPanel(doc, form, callbacks) {
|
|
|
1010
1073
|
formElement.append(reasonControl(doc, form, callbacks));
|
|
1011
1074
|
const actions = makeElement(doc, 'div', { className: 'bb-graph-actions' });
|
|
1012
1075
|
const saving = form.phase === 'saving';
|
|
1013
|
-
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
|
+
: '保存する';
|
|
1014
1085
|
actions.append(
|
|
1015
|
-
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
|
+
}),
|
|
1016
1091
|
button(doc, '取り消す', callbacks.onClose, { attrs: { disabled: saving } }),
|
|
1017
1092
|
);
|
|
1018
1093
|
formElement.append(actions);
|
|
@@ -1034,18 +1109,24 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1034
1109
|
let form = null;
|
|
1035
1110
|
let focusPending = false;
|
|
1036
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;
|
|
1037
1115
|
// Label/input ids stay unique when two screens are on one page.
|
|
1038
1116
|
const idPrefix = `bb-graph-form-${Math.random().toString(36).slice(2, 8)}`;
|
|
1039
1117
|
const redraw = () => rerender?.();
|
|
1040
1118
|
|
|
1041
1119
|
function baseForm(kind, extra) {
|
|
1042
1120
|
sequence += 1;
|
|
1121
|
+
iconReadGeneration += 1;
|
|
1043
1122
|
focusPending = true;
|
|
1044
1123
|
return {
|
|
1045
1124
|
kind,
|
|
1046
1125
|
formId: `${idPrefix}-${sequence}`,
|
|
1047
1126
|
phase: 'editing',
|
|
1048
1127
|
message: null,
|
|
1128
|
+
fieldErrors: {},
|
|
1129
|
+
iconReadPending: false,
|
|
1049
1130
|
reason: '',
|
|
1050
1131
|
current: null,
|
|
1051
1132
|
conflictCode: null,
|
|
@@ -1079,7 +1160,60 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1079
1160
|
|
|
1080
1161
|
const callbacks = {
|
|
1081
1162
|
onInput(field, value) {
|
|
1082
|
-
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();
|
|
1083
1217
|
},
|
|
1084
1218
|
onReason(value) {
|
|
1085
1219
|
if (form) form.reason = value;
|
|
@@ -1115,6 +1249,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1115
1249
|
title,
|
|
1116
1250
|
subject: subject ?? `${typeLabel(record.type)}「${record.name}」`,
|
|
1117
1251
|
recordType: record.type,
|
|
1252
|
+
recordName: record.name,
|
|
1118
1253
|
targetId: record.id,
|
|
1119
1254
|
expectedDigest: record.digest,
|
|
1120
1255
|
fields: shown,
|
|
@@ -1164,12 +1299,26 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
|
|
|
1164
1299
|
return form;
|
|
1165
1300
|
},
|
|
1166
1301
|
close() {
|
|
1302
|
+
iconReadGeneration += 1;
|
|
1167
1303
|
form = null;
|
|
1168
1304
|
redraw();
|
|
1169
1305
|
},
|
|
1170
1306
|
async submit() {
|
|
1171
1307
|
const target = form;
|
|
1172
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
|
+
}
|
|
1173
1322
|
const built = buildCorrectionRequest(target);
|
|
1174
1323
|
if (!built.ok) {
|
|
1175
1324
|
target.message = { tone: 'danger', text: built.message };
|
|
@@ -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 }));
|