@unson/brainbase-mcp 0.41.8 → 0.41.10
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/canonical-task-service.d.ts +6 -0
- package/dist/canonical-task-service.js +18 -1
- package/dist/foundation-graph-http.js +5 -1
- package/dist/graph-foundation-reader.d.ts +27 -2
- package/dist/graph-foundation-reader.js +97 -1
- package/dist/meeting-minutes-http.js +19 -0
- package/dist/meeting-minutes.js +4 -0
- package/package.json +2 -2
- package/ui/foundation-overview.js +70 -14
- package/ui/meeting-minutes.css +151 -4
- package/ui/meeting-minutes.js +416 -94
- package/ui/workspace-kit.css +42 -0
- package/ui/workspace-kit.js +165 -0
- package/ui/world/world-canvas-notes.js +2 -1
- package/ui/world/world-canvas-ui.css +48 -3
- package/ui/world/world-canvas-ui.js +17 -4
- package/ui/world/world-district.js +34 -5
- package/ui/world/world-view.css +6 -0
- package/ui/world/world-view.js +109 -19
package/ui/meeting-minutes.js
CHANGED
|
@@ -153,6 +153,46 @@ function httpUrl(value) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// Only http(s) URLs and same-site absolute paths become links.
|
|
157
|
+
function connectionHref(value) {
|
|
158
|
+
const raw = text(value);
|
|
159
|
+
if (raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) return raw;
|
|
160
|
+
return httpUrl(raw);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const CONNECTION_ROWS = Object.freeze([
|
|
164
|
+
['project', 'プロジェクト'],
|
|
165
|
+
['tasks', 'タスク'],
|
|
166
|
+
['decisions', '判断'],
|
|
167
|
+
['stored', '保存先'],
|
|
168
|
+
['shared', '共有先'],
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
// A host answer is trusted only for what it states. Anything missing or
|
|
172
|
+
// malformed reads as unrecorded, never as a confirmed empty list (P9).
|
|
173
|
+
function normalizeConnectionRow(row) {
|
|
174
|
+
if (!isRecord(row)) return { status: 'unrecorded', reason: '' };
|
|
175
|
+
const reason = text(row.reason);
|
|
176
|
+
if (row.status === 'none') return { status: 'none', reason: '' };
|
|
177
|
+
if (row.status === 'unavailable') return { status: 'unavailable', reason };
|
|
178
|
+
if (row.status === 'recorded') {
|
|
179
|
+
const items = (Array.isArray(row.items) ? row.items : [])
|
|
180
|
+
.filter(isRecord)
|
|
181
|
+
.map((item) => ({ label: text(item.label), href: connectionHref(item.href), detail: text(item.detail) }))
|
|
182
|
+
.filter((item) => item.label);
|
|
183
|
+
if (items.length) return { status: 'recorded', items };
|
|
184
|
+
}
|
|
185
|
+
return { status: 'unrecorded', reason };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeConnections(value) {
|
|
189
|
+
const unreadable = !isRecord(value);
|
|
190
|
+
return Object.fromEntries(CONNECTION_ROWS.map(([key]) => [
|
|
191
|
+
key,
|
|
192
|
+
unreadable ? { status: 'unavailable', reason: '' } : normalizeConnectionRow(value[key]),
|
|
193
|
+
]));
|
|
194
|
+
}
|
|
195
|
+
|
|
156
196
|
function firstDate(values) {
|
|
157
197
|
for (const value of values) {
|
|
158
198
|
const date = parseDate(value);
|
|
@@ -294,9 +334,24 @@ function sourceLabel(source) {
|
|
|
294
334
|
return `${provider} / ${locator} / ${revision}`;
|
|
295
335
|
}
|
|
296
336
|
|
|
297
|
-
function
|
|
337
|
+
function sameTitle(left, right) {
|
|
338
|
+
const normalize = (value) => String(value ?? '').normalize('NFKC').replace(/\s+/gu, '');
|
|
339
|
+
const a = normalize(left);
|
|
340
|
+
return a !== '' && a === normalize(right);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The meeting title is already the detail heading; a body that opens with the same
|
|
344
|
+
// heading would show it twice (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-07).
|
|
345
|
+
function withoutRepeatedTitle(lines, title) {
|
|
346
|
+
const first = lines.findIndex((line) => line.trim() !== '');
|
|
347
|
+
if (first < 0) return lines;
|
|
348
|
+
const heading = /^#{1,6}\s+(.+)$/u.exec(lines[first]);
|
|
349
|
+
return heading && sameTitle(heading[1], title) ? lines.slice(first + 1) : lines;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function renderMarkdown(doc, body, { title } = {}) {
|
|
298
353
|
const root = makeElement(doc, 'div', { className: 'bb-minutes-markdown' });
|
|
299
|
-
const lines = String(body).replace(/\r\n/g, '\n').split('\n');
|
|
354
|
+
const lines = withoutRepeatedTitle(String(body).replace(/\r\n/g, '\n').split('\n'), title);
|
|
300
355
|
let list = null;
|
|
301
356
|
let listKind = null;
|
|
302
357
|
const closeList = () => {
|
|
@@ -342,12 +397,12 @@ function renderMarkdown(doc, body) {
|
|
|
342
397
|
return root;
|
|
343
398
|
}
|
|
344
399
|
|
|
345
|
-
function versionContent(doc, version) {
|
|
400
|
+
function versionContent(doc, version, { title } = {}) {
|
|
346
401
|
if (!isRecord(version)) return makeElement(doc, 'p', { textContent: '版の形式を確認できません。', className: 'bb-minutes-warning' });
|
|
347
402
|
if (isRecord(version.source_ref)) {
|
|
348
403
|
return makeElement(doc, 'p', { className: 'bb-minutes-source', textContent: `本文は元の記録にあります。${sourceLabel(version.source_ref)}` });
|
|
349
404
|
}
|
|
350
|
-
if (typeof version.body === 'string') return renderMarkdown(doc, version.body);
|
|
405
|
+
if (typeof version.body === 'string') return renderMarkdown(doc, version.body, { title });
|
|
351
406
|
return makeElement(doc, 'p', { className: 'bb-minutes-warning', textContent: '本文または外部参照を確認できません。' });
|
|
352
407
|
}
|
|
353
408
|
|
|
@@ -513,8 +568,10 @@ export function createMeetingMinutesUI({
|
|
|
513
568
|
basePath = '/api/meeting-minutes',
|
|
514
569
|
versionActionExtensions = [],
|
|
515
570
|
meetingLabel,
|
|
571
|
+
meetingConnections,
|
|
516
572
|
canCreateMeeting,
|
|
517
573
|
canEditMinutes,
|
|
574
|
+
currentPrincipalId,
|
|
518
575
|
now = () => new Date(),
|
|
519
576
|
} = {}) {
|
|
520
577
|
if (!root) throw new TypeError('root is required');
|
|
@@ -533,9 +590,17 @@ export function createMeetingMinutesUI({
|
|
|
533
590
|
loading: false,
|
|
534
591
|
listError: null,
|
|
535
592
|
detailError: null,
|
|
593
|
+
// Which document is being edited or about to be withdrawn; both reset on every detail load.
|
|
594
|
+
editingMinutesId: null,
|
|
595
|
+
withdrawingMinutesId: null,
|
|
596
|
+
// { meetingId, rows } where rows is null while the host is answering.
|
|
597
|
+
connections: null,
|
|
536
598
|
};
|
|
537
599
|
let listRequestGeneration = 0;
|
|
538
600
|
let detailRequestGeneration = 0;
|
|
601
|
+
let connectionsSlot = null;
|
|
602
|
+
let railProjectSlot = null;
|
|
603
|
+
let railProjectMeeting = null;
|
|
539
604
|
|
|
540
605
|
const screen = makeElement(doc, 'div', { className: 'bb-minutes-screen', attrs: { 'data-contract-version': MEETING_MINUTES_UI_CONTRACT_VERSION } });
|
|
541
606
|
const heading = makeElement(doc, 'header', { className: 'bb-minutes-heading' });
|
|
@@ -619,6 +684,21 @@ export function createMeetingMinutesUI({
|
|
|
619
684
|
return canEditMinutes !== false;
|
|
620
685
|
}
|
|
621
686
|
|
|
687
|
+
function viewerId() {
|
|
688
|
+
try {
|
|
689
|
+
const value = typeof currentPrincipalId === 'function' ? currentPrincipalId() : currentPrincipalId;
|
|
690
|
+
return text(value);
|
|
691
|
+
} catch {
|
|
692
|
+
return '';
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The server decides; the screen only hides the button from people it already knows it will refuse.
|
|
697
|
+
function mayWithdraw(documentRecord) {
|
|
698
|
+
const viewer = viewerId();
|
|
699
|
+
return !viewer || viewer === text(documentRecord?.created_actor_id);
|
|
700
|
+
}
|
|
701
|
+
|
|
622
702
|
function stateChip(versionStatus, { detailed = false } = {}) {
|
|
623
703
|
const label = detailed && versionStatus.detail ? versionStatus.detail : versionStatus.label;
|
|
624
704
|
return makeElement(doc, 'span', {
|
|
@@ -632,28 +712,122 @@ export function createMeetingMinutesUI({
|
|
|
632
712
|
line.append(node);
|
|
633
713
|
}
|
|
634
714
|
|
|
715
|
+
// One gray notice per detail, only when something cannot be done here.
|
|
635
716
|
function appendReadOnlyNotice(parent, version) {
|
|
636
717
|
const source = isRecord(version?.source_ref) ? version.source_ref : null;
|
|
718
|
+
const editable = canEdit();
|
|
719
|
+
if (editable && !source) return false;
|
|
637
720
|
const url = httpUrl(source?.locator);
|
|
638
721
|
const provider = text(source?.provider, '元の記録');
|
|
639
722
|
const notice = makeElement(doc, 'div', { className: 'bb-minutes-edit-guard is-info' });
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
723
|
+
if (!editable) {
|
|
724
|
+
notice.append(makeElement(doc, 'p', {
|
|
725
|
+
textContent: 'この画面では読むことだけができます。確認はプロジェクトの編集権限がある人が行います。',
|
|
726
|
+
}));
|
|
727
|
+
}
|
|
645
728
|
if (url) {
|
|
646
|
-
const line = makeElement(doc, 'p', { textContent: '
|
|
729
|
+
const line = makeElement(doc, 'p', { textContent: '本文は元の記録にあります。本文を直すときは、元の記録で編集してください。' });
|
|
647
730
|
line.append(makeElement(doc, 'a', {
|
|
648
731
|
className: 'bb-minutes-source-link',
|
|
649
732
|
textContent: `元の記録を開く(${provider})`,
|
|
650
733
|
attrs: { href: url, target: '_blank', rel: 'noopener noreferrer' },
|
|
651
734
|
}));
|
|
652
735
|
notice.append(line);
|
|
653
|
-
} else {
|
|
736
|
+
} else if (source) {
|
|
654
737
|
notice.append(makeElement(doc, 'p', { textContent: '本文を直すときは、議事録を取り込んだ元で編集してください。' }));
|
|
738
|
+
} else {
|
|
739
|
+
notice.append(makeElement(doc, 'p', { textContent: '本文を直すのも、プロジェクトの編集権限がある人です。' }));
|
|
655
740
|
}
|
|
656
741
|
parent.append(notice);
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function hasConnections() {
|
|
746
|
+
return typeof meetingConnections === 'function';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function currentConnections(meeting) {
|
|
750
|
+
const value = state.connections;
|
|
751
|
+
return value && value.meetingId === meeting?.meeting_id ? value.rows : null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function connectionLink(item, className) {
|
|
755
|
+
if (!item.href) return makeElement(doc, 'span', { className, textContent: item.label });
|
|
756
|
+
const external = /^https?:/iu.test(item.href);
|
|
757
|
+
return makeElement(doc, 'a', {
|
|
758
|
+
className,
|
|
759
|
+
textContent: item.label,
|
|
760
|
+
attrs: {
|
|
761
|
+
href: item.href,
|
|
762
|
+
target: external ? '_blank' : undefined,
|
|
763
|
+
rel: external ? 'noopener noreferrer' : undefined,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function connectionValue(row) {
|
|
769
|
+
if (!row) return makeElement(doc, 'p', { className: 'bb-minutes-connection-note', textContent: '読み込んでいます' });
|
|
770
|
+
if (row.status === 'recorded') {
|
|
771
|
+
const list = makeElement(doc, 'ul', { className: 'bb-minutes-connection-items' });
|
|
772
|
+
for (const item of row.items) {
|
|
773
|
+
const entry = makeElement(doc, 'li');
|
|
774
|
+
entry.append(connectionLink(item, 'bb-minutes-connection-link'));
|
|
775
|
+
if (item.detail) entry.append(makeElement(doc, 'span', { className: 'bb-minutes-connection-detail', textContent: item.detail }));
|
|
776
|
+
list.append(entry);
|
|
777
|
+
}
|
|
778
|
+
return list;
|
|
779
|
+
}
|
|
780
|
+
const label = row.status === 'none' ? 'なし' : row.status === 'unavailable' ? '読み込めません' : '記録なし';
|
|
781
|
+
return makeElement(doc, 'p', {
|
|
782
|
+
className: `bb-minutes-connection-note is-${row.status}`,
|
|
783
|
+
textContent: row.reason ? `${label}(${row.reason})` : label,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function fillConnections(slot, rows) {
|
|
788
|
+
slot.replaceChildren(makeElement(doc, 'h3', { className: 'bb-minutes-connections-title', textContent: 'この会議から' }));
|
|
789
|
+
const list = makeElement(doc, 'div', { className: 'bb-minutes-connection-list' });
|
|
790
|
+
for (const [key, label] of CONNECTION_ROWS) {
|
|
791
|
+
const row = makeElement(doc, 'div', { className: 'bb-minutes-connection-row', attrs: { 'data-connection': key } });
|
|
792
|
+
row.append(
|
|
793
|
+
makeElement(doc, 'span', { className: 'bb-minutes-connection-label', textContent: label }),
|
|
794
|
+
connectionValue(rows ? rows[key] : null),
|
|
795
|
+
);
|
|
796
|
+
list.append(row);
|
|
797
|
+
}
|
|
798
|
+
slot.append(list);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function fillRailProject(slot, meeting) {
|
|
802
|
+
const project = currentConnections(meeting)?.project;
|
|
803
|
+
const item = project?.status === 'recorded' ? project.items.find((candidate) => candidate.href) : null;
|
|
804
|
+
if (item) {
|
|
805
|
+
slot.textContent = '';
|
|
806
|
+
slot.replaceChildren(connectionLink(item, 'bb-minutes-source-link'));
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const label = meetingLabelText(meetingLabel, meeting);
|
|
810
|
+
slot.replaceChildren();
|
|
811
|
+
slot.textContent = label || 'プロジェクトは記録されていません';
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function loadConnections(detail, isCurrentRequest) {
|
|
815
|
+
const meetingId = detail.meeting.meeting_id;
|
|
816
|
+
state.connections = { meetingId, rows: null };
|
|
817
|
+
let rows;
|
|
818
|
+
try {
|
|
819
|
+
rows = normalizeConnections(await meetingConnections({
|
|
820
|
+
meeting: detail.meeting,
|
|
821
|
+
minutes: detail.minutes,
|
|
822
|
+
versions: detail.versions,
|
|
823
|
+
}));
|
|
824
|
+
} catch {
|
|
825
|
+
rows = normalizeConnections(null);
|
|
826
|
+
}
|
|
827
|
+
if (!isCurrentRequest() || state.connections?.meetingId !== meetingId) return;
|
|
828
|
+
state.connections = { meetingId, rows };
|
|
829
|
+
if (connectionsSlot) fillConnections(connectionsSlot, rows);
|
|
830
|
+
if (railProjectSlot && railProjectMeeting?.meeting_id === meetingId) fillRailProject(railProjectSlot, railProjectMeeting);
|
|
657
831
|
}
|
|
658
832
|
|
|
659
833
|
function appendLoadError(parent, message, retry) {
|
|
@@ -753,6 +927,8 @@ export function createMeetingMinutesUI({
|
|
|
753
927
|
function renderRail(version, documentRecord, meeting) {
|
|
754
928
|
if (!rail) return;
|
|
755
929
|
clear(rail);
|
|
930
|
+
railProjectSlot = null;
|
|
931
|
+
railProjectMeeting = null;
|
|
756
932
|
const block = makeElement(doc, 'section', { className: 'bb-minutes-rail-block' });
|
|
757
933
|
block.append(makeElement(doc, 'h2', { textContent: '関連' }));
|
|
758
934
|
if (!meeting && !version) {
|
|
@@ -763,8 +939,10 @@ export function createMeetingMinutesUI({
|
|
|
763
939
|
block.append(makeElement(doc, 'h3', { textContent: '予定' }));
|
|
764
940
|
block.append(makeElement(doc, 'p', { className: 'bb-minutes-rail-note', textContent: '予定とのつながりは未対応です。' }));
|
|
765
941
|
block.append(makeElement(doc, 'h3', { textContent: 'プロジェクト' }));
|
|
766
|
-
|
|
767
|
-
|
|
942
|
+
railProjectSlot = makeElement(doc, 'p', { className: 'bb-minutes-rail-project' });
|
|
943
|
+
railProjectMeeting = meeting;
|
|
944
|
+
fillRailProject(railProjectSlot, meeting);
|
|
945
|
+
block.append(railProjectSlot);
|
|
768
946
|
appendIdentifiers(doc, block, {
|
|
769
947
|
meetingId: meeting?.meeting_id,
|
|
770
948
|
minutesId: documentRecord?.minutes_id,
|
|
@@ -773,6 +951,11 @@ export function createMeetingMinutesUI({
|
|
|
773
951
|
rail.append(block);
|
|
774
952
|
}
|
|
775
953
|
|
|
954
|
+
function rerender(detail, documentRecord, version) {
|
|
955
|
+
clear(detailPane);
|
|
956
|
+
renderDetail(detail, documentRecord, version);
|
|
957
|
+
}
|
|
958
|
+
|
|
776
959
|
function renderVersion(detail, documentRecord, selectedVersion, noticeState) {
|
|
777
960
|
const versions = detailVersions(detail, documentRecord.minutes_id);
|
|
778
961
|
const latest = versions.find((version) => version.version_id === documentRecord.current_version_id) ?? versions[versions.length - 1] ?? null;
|
|
@@ -782,105 +965,143 @@ export function createMeetingMinutesUI({
|
|
|
782
965
|
const selectedState = versionState(selected, versions, latest);
|
|
783
966
|
const latestState = versionState(latest, versions, latest);
|
|
784
967
|
const viewingOld = Boolean(selected && latest && selected.version_id !== latest.version_id);
|
|
968
|
+
const withdrawal = isRecord(documentRecord.withdrawal) ? documentRecord.withdrawal : null;
|
|
969
|
+
const native = isRecord(selected) && typeof selected.body === 'string' && !isRecord(selected.source_ref);
|
|
970
|
+
const editing = state.editingMinutesId === documentRecord.minutes_id && native && !viewingOld && !withdrawal && canEdit();
|
|
785
971
|
const section = makeElement(doc, 'section', { className: 'bb-minutes-document' });
|
|
972
|
+
if (withdrawal) {
|
|
973
|
+
const when = formatStamp(withdrawal.withdrawn_at);
|
|
974
|
+
const who = text(withdrawal.actor_id);
|
|
975
|
+
section.append(makeElement(doc, 'p', {
|
|
976
|
+
className: 'bb-minutes-withdrawn',
|
|
977
|
+
textContent: `取り下げ済み(${[who, when].filter(Boolean).join('・') || '取り下げた人と日時は未記録'})。プロジェクトの議事録の一覧には出ません。`,
|
|
978
|
+
}));
|
|
979
|
+
}
|
|
786
980
|
if (viewingOld) {
|
|
787
981
|
const banner = makeElement(doc, 'div', { className: 'bb-minutes-old-version' });
|
|
788
982
|
const latestName = latestState.code === 'confirmed' ? '確認済み' : latestState.label;
|
|
789
983
|
banner.append(makeElement(doc, 'p', {
|
|
790
984
|
textContent: `古い版を表示しています。最新は${versionLabel(versions, latest)}(${latestName})です。`,
|
|
791
985
|
}));
|
|
792
|
-
banner.append(button(doc, '最新の版を見る', () =>
|
|
793
|
-
clear(detailPane);
|
|
794
|
-
renderDetail(detail, documentRecord, latest);
|
|
795
|
-
}));
|
|
986
|
+
banner.append(button(doc, '最新の版を見る', () => rerender(detail, documentRecord, latest)));
|
|
796
987
|
section.append(banner);
|
|
797
988
|
}
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
989
|
+
// State and actions come first; which version this is goes below the body.
|
|
990
|
+
const actions = makeElement(doc, 'div', { className: 'bb-minutes-version-actions' });
|
|
991
|
+
actions.append(stateChip(selectedState, { detailed: true }));
|
|
992
|
+
if (!withdrawal && !editing) {
|
|
993
|
+
if (selected && canEdit() && !isRecord(selected.confirmation) && selectedState.code !== 'unknown' && selectedState.code !== 'creating') {
|
|
994
|
+
const confirm = button(doc, 'この版を確認済みにする', async () => {
|
|
995
|
+
if (!canEdit()) {
|
|
996
|
+
rerender(detail, documentRecord, selected);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
confirm.disabled = true;
|
|
1000
|
+
try {
|
|
1001
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
|
|
1002
|
+
version_id: selected.version_id,
|
|
1003
|
+
expected_revision: documentRecord.revision,
|
|
1004
|
+
});
|
|
1005
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
1006
|
+
if (!state.detailError) setStatus(`${selectedLabel}を確認済みにしました。`, 'success');
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
confirm.disabled = false;
|
|
1009
|
+
setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
|
|
1010
|
+
}
|
|
1011
|
+
}, { className: 'bb-minutes-button is-primary' });
|
|
1012
|
+
actions.append(confirm);
|
|
1013
|
+
}
|
|
1014
|
+
if (native && !viewingOld && canEdit()) {
|
|
1015
|
+
actions.append(button(doc, '編集', () => {
|
|
1016
|
+
state.editingMinutesId = documentRecord.minutes_id;
|
|
1017
|
+
state.withdrawingMinutesId = null;
|
|
1018
|
+
rerender(detail, documentRecord, selected);
|
|
1019
|
+
}));
|
|
1020
|
+
}
|
|
1021
|
+
if (canEdit() && mayWithdraw(documentRecord)) {
|
|
1022
|
+
actions.append(button(doc, '取り下げる', () => {
|
|
1023
|
+
state.withdrawingMinutesId = documentRecord.minutes_id;
|
|
1024
|
+
state.editingMinutesId = null;
|
|
1025
|
+
rerender(detail, documentRecord, selected);
|
|
1026
|
+
}, { className: 'bb-minutes-button is-quiet' }));
|
|
1027
|
+
}
|
|
815
1028
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
type: 'button',
|
|
820
|
-
attrs: { type: 'button' },
|
|
821
|
-
});
|
|
822
|
-
const itemState = versionState(version, versions, latest);
|
|
823
|
-
const isLatest = Boolean(latest) && version.version_id === latest.version_id;
|
|
824
|
-
const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
|
|
825
|
-
line1.append(
|
|
826
|
-
makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
|
|
827
|
-
stateChip(itemState),
|
|
828
|
-
makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
|
|
829
|
-
);
|
|
830
|
-
versionButton.append(
|
|
831
|
-
line1,
|
|
832
|
-
makeElement(doc, 'span', {
|
|
833
|
-
className: 'bb-minutes-version-line is-secondary',
|
|
834
|
-
textContent: historySecondLine(version, versions, latest),
|
|
835
|
-
}),
|
|
836
|
-
);
|
|
837
|
-
versionButton.addEventListener('click', () => {
|
|
838
|
-
clear(detailPane);
|
|
839
|
-
renderDetail(detail, documentRecord, version);
|
|
840
|
-
});
|
|
841
|
-
history.append(versionButton);
|
|
1029
|
+
section.append(actions);
|
|
1030
|
+
if (!withdrawal && canEdit() && !mayWithdraw(documentRecord)) {
|
|
1031
|
+
section.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-guard', textContent: '取り下げられるのは、この議事録を作った人だけです。' }));
|
|
842
1032
|
}
|
|
843
|
-
|
|
1033
|
+
if (!withdrawal && state.withdrawingMinutesId === documentRecord.minutes_id && canEdit() && mayWithdraw(documentRecord)) {
|
|
1034
|
+
section.append(renderWithdrawConfirm(detail, documentRecord, selected));
|
|
1035
|
+
}
|
|
1036
|
+
if (!noticeState.placed && appendReadOnlyNotice(section, selected)) noticeState.placed = true;
|
|
844
1037
|
const content = makeElement(doc, 'div', { className: 'bb-minutes-version-content' });
|
|
845
1038
|
const bodyRoot = makeElement(doc, 'div', {
|
|
846
1039
|
className: 'bb-minutes-external-body-slot',
|
|
847
1040
|
attrs: { 'data-minutes-external-body': 'true' },
|
|
848
1041
|
});
|
|
849
|
-
|
|
1042
|
+
if (editing) {
|
|
1043
|
+
content.append(renderEditForm(detail, documentRecord, selected, versions));
|
|
1044
|
+
} else {
|
|
1045
|
+
content.append(versionContent(doc, selected, { title: detail.meeting?.title }), bodyRoot);
|
|
1046
|
+
}
|
|
850
1047
|
section.append(content);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1048
|
+
const meta = makeElement(doc, 'div', { className: 'bb-minutes-version-meta' });
|
|
1049
|
+
const latestMark = selected && latest && selected.version_id === latest.version_id ? '(最新)' : '';
|
|
1050
|
+
meta.append(makeElement(doc, 'span', {
|
|
1051
|
+
className: 'bb-minutes-item-meta',
|
|
1052
|
+
textContent: [`${selectedLabel}${latestMark}`, historyStamp(selected, clock()), creatorFact(selected)].join('・'),
|
|
1053
|
+
}));
|
|
1054
|
+
section.append(meta);
|
|
1055
|
+
if (versions.length >= 2) {
|
|
1056
|
+
const history = makeElement(doc, 'div', { className: 'bb-minutes-version-history is-collapsed', attrs: { 'aria-label': '版の履歴' } });
|
|
1057
|
+
const historyButton = button(doc, `版の履歴(${versions.length}件)`, () => {
|
|
1058
|
+
const collapsed = history.className.includes('is-collapsed');
|
|
1059
|
+
history.className = collapsed ? 'bb-minutes-version-history' : 'bb-minutes-version-history is-collapsed';
|
|
1060
|
+
historyButton.textContent = collapsed ? `版の履歴を閉じる(${versions.length}件)` : `版の履歴(${versions.length}件)`;
|
|
1061
|
+
});
|
|
1062
|
+
meta.append(historyButton);
|
|
1063
|
+
for (const version of [...versions].reverse()) {
|
|
1064
|
+
const versionButton = makeElement(doc, 'button', {
|
|
1065
|
+
className: `bb-minutes-version${version.version_id === selected?.version_id ? ' is-selected' : ''}`,
|
|
1066
|
+
type: 'button',
|
|
1067
|
+
attrs: { type: 'button' },
|
|
1068
|
+
});
|
|
1069
|
+
const itemState = versionState(version, versions, latest);
|
|
1070
|
+
const isLatest = Boolean(latest) && version.version_id === latest.version_id;
|
|
1071
|
+
const line1 = makeElement(doc, 'span', { className: 'bb-minutes-version-line' });
|
|
1072
|
+
line1.append(
|
|
1073
|
+
makeElement(doc, 'strong', { textContent: `${versionLabel(versions, version)}${isLatest ? '(最新)' : ''}` }),
|
|
1074
|
+
stateChip(itemState),
|
|
1075
|
+
makeElement(doc, 'span', { className: 'bb-minutes-version-stamp', textContent: historyStamp(version, clock()) }),
|
|
1076
|
+
);
|
|
1077
|
+
versionButton.append(
|
|
1078
|
+
line1,
|
|
1079
|
+
makeElement(doc, 'span', {
|
|
1080
|
+
className: 'bb-minutes-version-line is-secondary',
|
|
1081
|
+
textContent: historySecondLine(version, versions, latest),
|
|
1082
|
+
}),
|
|
1083
|
+
);
|
|
1084
|
+
versionButton.addEventListener('click', () => {
|
|
1085
|
+
state.editingMinutesId = null;
|
|
1086
|
+
rerender(detail, documentRecord, version);
|
|
1087
|
+
});
|
|
1088
|
+
history.append(versionButton);
|
|
1089
|
+
}
|
|
1090
|
+
section.append(history);
|
|
1091
|
+
}
|
|
1092
|
+
if (!editing) {
|
|
1093
|
+
appendVersionActionExtensions(doc, section, {
|
|
1094
|
+
extensions: versionActionExtensions,
|
|
1095
|
+
detail,
|
|
1096
|
+
documentRecord,
|
|
1097
|
+
version: selected,
|
|
1098
|
+
request: call,
|
|
1099
|
+
mutate,
|
|
1100
|
+
refresh: () => loadDetail(detail.meeting.meeting_id),
|
|
1101
|
+
setStatus,
|
|
1102
|
+
bodyRoot,
|
|
1103
|
+
});
|
|
872
1104
|
}
|
|
873
|
-
appendVersionActionExtensions(doc, section, {
|
|
874
|
-
extensions: versionActionExtensions,
|
|
875
|
-
detail,
|
|
876
|
-
documentRecord,
|
|
877
|
-
version: selected,
|
|
878
|
-
request: call,
|
|
879
|
-
mutate,
|
|
880
|
-
refresh: () => loadDetail(detail.meeting.meeting_id),
|
|
881
|
-
setStatus,
|
|
882
|
-
bodyRoot,
|
|
883
|
-
});
|
|
884
1105
|
appendIdentifiers(doc, section, {
|
|
885
1106
|
meetingId: detail.meeting?.meeting_id,
|
|
886
1107
|
minutesId: documentRecord.minutes_id,
|
|
@@ -890,8 +1111,97 @@ export function createMeetingMinutesUI({
|
|
|
890
1111
|
return section;
|
|
891
1112
|
}
|
|
892
1113
|
|
|
1114
|
+
// Editing writes a new version; the earlier versions stay (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-01).
|
|
1115
|
+
function renderEditForm(detail, documentRecord, selected, versions) {
|
|
1116
|
+
const form = makeElement(doc, 'form', { className: 'bb-minutes-edit-form' });
|
|
1117
|
+
const field = labelInput(doc, '本文', { type: 'textarea', name: 'body', value: selected.body });
|
|
1118
|
+
field.input.value = selected.body;
|
|
1119
|
+
field.input.rows = 16;
|
|
1120
|
+
form.append(
|
|
1121
|
+
field.wrapper,
|
|
1122
|
+
makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: `保存すると第${versions.length + 1}版になります。前の版は残ります。` }),
|
|
1123
|
+
makeElement(doc, 'p', { className: 'bb-minutes-edit-note', textContent: '公開したときにGitへ書き出した分とSlackの知らせは、前の版のまま変わりません。' }),
|
|
1124
|
+
);
|
|
1125
|
+
const save = makeElement(doc, 'button', { className: 'bb-minutes-button is-primary', textContent: '新しい版として保存', type: 'submit' });
|
|
1126
|
+
const cancel = button(doc, 'やめる', () => {
|
|
1127
|
+
state.editingMinutesId = null;
|
|
1128
|
+
rerender(detail, documentRecord, selected);
|
|
1129
|
+
});
|
|
1130
|
+
const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
|
|
1131
|
+
row.append(save, cancel);
|
|
1132
|
+
form.append(row);
|
|
1133
|
+
form.addEventListener('submit', async (event) => {
|
|
1134
|
+
event.preventDefault();
|
|
1135
|
+
if (!canEdit()) {
|
|
1136
|
+
state.editingMinutesId = null;
|
|
1137
|
+
rerender(detail, documentRecord, selected);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
save.disabled = true;
|
|
1141
|
+
try {
|
|
1142
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}`, {
|
|
1143
|
+
body: field.input.value,
|
|
1144
|
+
expected_revision: documentRecord.revision,
|
|
1145
|
+
});
|
|
1146
|
+
state.editingMinutesId = null;
|
|
1147
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
1148
|
+
if (!state.detailError) setStatus(`第${versions.length + 1}版として保存しました。確認待ちです。`, 'success');
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
save.disabled = false;
|
|
1151
|
+
setStatus(error instanceof Error ? error.message : '新しい版を保存できませんでした。', 'error');
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
return form;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Withdrawal takes the document back without deleting it (台帳 D9, story-meeting-minutes-withdraw-v1).
|
|
1158
|
+
function renderWithdrawConfirm(detail, documentRecord, selected) {
|
|
1159
|
+
const panel = makeElement(doc, 'div', { className: 'bb-minutes-withdraw-confirm', attrs: { role: 'group', 'aria-label': '取り下げの確認' } });
|
|
1160
|
+
panel.append(makeElement(doc, 'p', { className: 'bb-minutes-withdraw-title', textContent: 'この議事録を取り下げますか?' }));
|
|
1161
|
+
const facts = makeElement(doc, 'ul', { className: 'bb-minutes-md-list' });
|
|
1162
|
+
for (const line of [
|
|
1163
|
+
'プロジェクトの議事録の一覧から外れます。',
|
|
1164
|
+
'版と本文は消えず、取り下げた人と日時が残ります。',
|
|
1165
|
+
'Gitへ書き出した分と、Slackで送った知らせは取り消せません。',
|
|
1166
|
+
'取り下げは元に戻せません。もう一度公開すると、新しい議事録として作られます。',
|
|
1167
|
+
]) facts.append(makeElement(doc, 'li', { textContent: line }));
|
|
1168
|
+
panel.append(facts);
|
|
1169
|
+
const row = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
|
|
1170
|
+
const confirm = button(doc, '取り下げる', async () => {
|
|
1171
|
+
if (!canEdit() || !mayWithdraw(documentRecord)) {
|
|
1172
|
+
state.withdrawingMinutesId = null;
|
|
1173
|
+
rerender(detail, documentRecord, selected);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
confirm.disabled = true;
|
|
1177
|
+
try {
|
|
1178
|
+
await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/withdraw`, {
|
|
1179
|
+
expected_revision: documentRecord.revision,
|
|
1180
|
+
});
|
|
1181
|
+
state.withdrawingMinutesId = null;
|
|
1182
|
+
await loadMeetings();
|
|
1183
|
+
await loadDetail(detail.meeting.meeting_id);
|
|
1184
|
+
if (!state.detailError) setStatus('議事録を取り下げました。', 'success');
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
confirm.disabled = false;
|
|
1187
|
+
const denied = error?.status === 403;
|
|
1188
|
+
setStatus(denied
|
|
1189
|
+
? '取り下げられませんでした。取り下げられるのは、この議事録を作った人だけです。'
|
|
1190
|
+
: error instanceof Error ? error.message : '取り下げられませんでした。', 'error');
|
|
1191
|
+
}
|
|
1192
|
+
}, { className: 'bb-minutes-button is-danger' });
|
|
1193
|
+
const cancel = button(doc, 'やめる', () => {
|
|
1194
|
+
state.withdrawingMinutesId = null;
|
|
1195
|
+
rerender(detail, documentRecord, selected);
|
|
1196
|
+
});
|
|
1197
|
+
row.append(confirm, cancel);
|
|
1198
|
+
panel.append(row);
|
|
1199
|
+
return panel;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
893
1202
|
function renderDetail(detail, documentRecord = null, selectedVersion = null) {
|
|
894
1203
|
clear(detailPane);
|
|
1204
|
+
connectionsSlot = null;
|
|
895
1205
|
if (state.detailError) {
|
|
896
1206
|
const retry = state.meetingId === null ? loadMeetings : () => loadDetail(state.meetingId);
|
|
897
1207
|
appendLoadError(detailPane, state.detailError.startsWith('会議詳細を読み込めませんでした。')
|
|
@@ -924,6 +1234,14 @@ export function createMeetingMinutesUI({
|
|
|
924
1234
|
textContent: participants ? `${when} / ${participants}` : when,
|
|
925
1235
|
}));
|
|
926
1236
|
detailPane.append(header);
|
|
1237
|
+
if (hasConnections()) {
|
|
1238
|
+
connectionsSlot = makeElement(doc, 'section', {
|
|
1239
|
+
className: 'bb-minutes-connections',
|
|
1240
|
+
attrs: { 'aria-label': 'この会議から' },
|
|
1241
|
+
});
|
|
1242
|
+
fillConnections(connectionsSlot, currentConnections(meeting));
|
|
1243
|
+
detailPane.append(connectionsSlot);
|
|
1244
|
+
}
|
|
927
1245
|
const documents = Array.isArray(detail.minutes) ? detail.minutes : [];
|
|
928
1246
|
const noticeState = { placed: false };
|
|
929
1247
|
if (documents.length === 0) {
|
|
@@ -1058,6 +1376,8 @@ export function createMeetingMinutesUI({
|
|
|
1058
1376
|
state.meetingId = meetingId;
|
|
1059
1377
|
state.minutesId = null;
|
|
1060
1378
|
state.detailError = null;
|
|
1379
|
+
state.editingMinutesId = null;
|
|
1380
|
+
state.withdrawingMinutesId = null;
|
|
1061
1381
|
renderList();
|
|
1062
1382
|
setStatus('会議詳細を読み込んでいます。');
|
|
1063
1383
|
try {
|
|
@@ -1066,8 +1386,10 @@ export function createMeetingMinutesUI({
|
|
|
1066
1386
|
if (!isRecord(detail) || !isRecord(detail.meeting) || !Array.isArray(detail.minutes) || !Array.isArray(detail.versions)) throw new Error('会議詳細の形式を確認できません。');
|
|
1067
1387
|
state.detail = detail;
|
|
1068
1388
|
state.detailError = null;
|
|
1389
|
+
if (hasConnections()) state.connections = { meetingId, rows: null };
|
|
1069
1390
|
renderDetail(detail);
|
|
1070
1391
|
setStatus('会議詳細を読み込みました。');
|
|
1392
|
+
if (hasConnections()) void loadConnections(detail, isCurrentRequest);
|
|
1071
1393
|
} catch (error) {
|
|
1072
1394
|
if (!isCurrentRequest()) return state.detail;
|
|
1073
1395
|
const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId
|