@unson/brainbase-mcp 0.40.0 → 0.41.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.
@@ -16,6 +16,15 @@ function text(value, fallback = '') {
16
16
  return typeof value === 'string' && value.trim() ? value.trim() : fallback;
17
17
  }
18
18
 
19
+ function meetingLabelText(meetingLabel, meeting) {
20
+ if (typeof meetingLabel !== 'function') return '';
21
+ try {
22
+ return text(meetingLabel(meeting));
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
27
+
19
28
  function makeElement(doc, tag, { className, textContent, type, value, disabled, attrs = {} } = {}) {
20
29
  const element = doc.createElement(tag);
21
30
  if (className) element.className = className;
@@ -205,6 +214,9 @@ export function createMeetingMinutesUI({
205
214
  token,
206
215
  basePath = '/api/meeting-minutes',
207
216
  versionActionExtensions = [],
217
+ meetingLabel,
218
+ canCreateMeeting,
219
+ canEditMinutes,
208
220
  } = {}) {
209
221
  if (!root) throw new TypeError('root is required');
210
222
  const doc = explicitDocument ?? (typeof document === 'undefined' ? null : document);
@@ -213,6 +225,8 @@ export function createMeetingMinutesUI({
213
225
  ? fetcher
214
226
  : typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
215
227
  const base = String(basePath).replace(/\/+$/u, '');
228
+ const createBlockedMessage = 'この画面では会議を作成できません。';
229
+ const editBlockedMessage = 'この画面では議事録を編集できません。';
216
230
  const state = {
217
231
  meetings: [],
218
232
  detail: null,
@@ -223,6 +237,8 @@ export function createMeetingMinutesUI({
223
237
  listError: null,
224
238
  detailError: null,
225
239
  };
240
+ let listRequestGeneration = 0;
241
+ let detailRequestGeneration = 0;
226
242
 
227
243
  const screen = makeElement(doc, 'div', { className: 'bb-minutes-screen', attrs: { 'data-contract-version': MEETING_MINUTES_UI_CONTRACT_VERSION } });
228
244
  const heading = makeElement(doc, 'header', { className: 'bb-minutes-heading' });
@@ -233,7 +249,10 @@ export function createMeetingMinutesUI({
233
249
  );
234
250
  const status = makeElement(doc, 'p', { className: 'bb-minutes-status', attrs: { role: 'status', 'aria-live': 'polite' } });
235
251
  const columns = makeElement(doc, 'div', { className: 'bb-minutes-columns' });
236
- const listPane = makeElement(doc, 'section', { className: 'bb-minutes-list-pane', attrs: { 'aria-label': '会議一覧' } });
252
+ const listPane = makeElement(doc, 'section', {
253
+ className: 'bb-minutes-list-pane',
254
+ attrs: { 'aria-label': '会議一覧', tabindex: '-1' },
255
+ });
237
256
  const detailPane = makeElement(doc, 'section', { className: 'bb-minutes-detail-pane', attrs: { 'aria-label': '会議詳細' } });
238
257
  columns.append(listPane, detailPane);
239
258
  screen.append(heading, status, columns);
@@ -271,6 +290,49 @@ export function createMeetingMinutesUI({
271
290
 
272
291
  function clear(node) { node.replaceChildren(); return node; }
273
292
 
293
+ function canCreate() {
294
+ if (typeof canCreateMeeting === 'function') {
295
+ try {
296
+ return canCreateMeeting() === true;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+ return canCreateMeeting !== false;
302
+ }
303
+
304
+ function canEdit() {
305
+ if (typeof canEditMinutes === 'function') {
306
+ try {
307
+ return canEditMinutes() === true;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+ return canEditMinutes !== false;
313
+ }
314
+
315
+ function appendCreateBlockedNotice(parent, { alert = false } = {}) {
316
+ parent.append(makeElement(doc, 'p', {
317
+ className: 'bb-minutes-create-guard',
318
+ textContent: createBlockedMessage,
319
+ attrs: alert ? { role: 'alert' } : {},
320
+ }));
321
+ }
322
+
323
+ function appendEditBlockedNotice(parent, { alert = false } = {}) {
324
+ const existing = [...(parent.children ?? [])].find((child) => child?.className === 'bb-minutes-edit-guard');
325
+ if (existing) {
326
+ if (alert) existing.setAttribute('role', 'alert');
327
+ return existing;
328
+ }
329
+ parent.append(makeElement(doc, 'p', {
330
+ className: 'bb-minutes-edit-guard',
331
+ textContent: editBlockedMessage,
332
+ attrs: alert ? { role: 'alert' } : {},
333
+ }));
334
+ }
335
+
274
336
  function appendLoadError(parent, message, retry) {
275
337
  const notice = makeElement(doc, 'div', {
276
338
  className: 'bb-minutes-load-error',
@@ -285,10 +347,15 @@ export function createMeetingMinutesUI({
285
347
  clear(listPane);
286
348
  const head = makeElement(doc, 'div', { className: 'bb-minutes-pane-head' });
287
349
  head.append(makeElement(doc, 'h2', { textContent: '会議一覧' }));
288
- head.append(button(doc, '会議を作成', () => renderCreateForm(), { className: 'bb-minutes-button is-primary' }));
350
+ const createAllowed = canCreate();
351
+ head.append(button(doc, '会議を作成', () => renderCreateForm(), {
352
+ className: 'bb-minutes-button is-primary',
353
+ disabled: !createAllowed,
354
+ }));
355
+ if (!createAllowed) appendCreateBlockedNotice(head);
289
356
  listPane.append(head);
290
357
  if (state.listError) {
291
- appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, loadMeetings);
358
+ appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, () => loadMeetings({ restoreFocus: true }));
292
359
  }
293
360
  if (state.meetings.length === 0) {
294
361
  if (state.listError) return;
@@ -304,8 +371,10 @@ export function createMeetingMinutesUI({
304
371
  type: 'button',
305
372
  attrs: { type: 'button' },
306
373
  });
374
+ const label = meetingLabelText(meetingLabel, meeting);
307
375
  item.append(
308
376
  makeElement(doc, 'strong', { textContent: text(meeting.title, '無題の会議') }),
377
+ ...(label ? [makeElement(doc, 'span', { className: 'bb-minutes-meeting-label', textContent: label })] : []),
309
378
  makeElement(doc, 'span', { className: 'bb-minutes-item-meta', textContent: displayDate(meeting.scheduled_at) }),
310
379
  makeElement(doc, 'span', { className: 'bb-minutes-item-meta', textContent: `${Array.isArray(entry.minutes) ? entry.minutes.length : 0}件の議事録` }),
311
380
  );
@@ -379,8 +448,14 @@ export function createMeetingMinutesUI({
379
448
  section.append(content);
380
449
  if (selected) {
381
450
  const actions = makeElement(doc, 'div', { className: 'bb-minutes-actions' });
451
+ const editAllowed = canEdit();
382
452
  const confirm = button(doc, selected.confirmation ? '確認済み' : 'この版を確認', async () => {
383
453
  if (selected.confirmation) return;
454
+ if (!canEdit()) {
455
+ confirm.disabled = true;
456
+ appendEditBlockedNotice(section, { alert: true });
457
+ return;
458
+ }
384
459
  confirm.disabled = true;
385
460
  try {
386
461
  await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}/confirm`, {
@@ -393,9 +468,10 @@ export function createMeetingMinutesUI({
393
468
  confirm.disabled = false;
394
469
  setStatus(error instanceof Error ? error.message : '確認を保存できませんでした。', 'error');
395
470
  }
396
- }, { className: 'bb-minutes-button is-primary', disabled: Boolean(selected.confirmation) });
471
+ }, { className: 'bb-minutes-button is-primary', disabled: Boolean(selected.confirmation) || !editAllowed });
397
472
  actions.append(confirm);
398
473
  section.append(actions);
474
+ if (!editAllowed) appendEditBlockedNotice(section);
399
475
  }
400
476
  appendVersionActionExtensions(doc, section, {
401
477
  extensions: versionActionExtensions,
@@ -419,6 +495,10 @@ export function createMeetingMinutesUI({
419
495
  }
420
496
 
421
497
  function appendSaveForm(section, detail, documentRecord) {
498
+ if (!canEdit()) {
499
+ appendEditBlockedNotice(section);
500
+ return;
501
+ }
422
502
  const form = makeElement(doc, 'form', { className: 'bb-minutes-save-form' });
423
503
  form.append(makeElement(doc, 'h4', { textContent: '訂正版を保存' }));
424
504
  const field = labelInput(doc, '本文(保存すると新しい版になります)', { type: 'textarea', name: 'body', placeholder: '議事録の本文を入力' });
@@ -427,6 +507,11 @@ export function createMeetingMinutesUI({
427
507
  form.append(field.wrapper, save);
428
508
  form.addEventListener('submit', async (event) => {
429
509
  event.preventDefault();
510
+ if (!canEdit()) {
511
+ save.disabled = false;
512
+ appendEditBlockedNotice(section, { alert: true });
513
+ return;
514
+ }
430
515
  save.disabled = true;
431
516
  try {
432
517
  await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes/${encodeURIComponent(documentRecord.minutes_id)}`, {
@@ -463,6 +548,8 @@ export function createMeetingMinutesUI({
463
548
  const meeting = detail.meeting;
464
549
  const header = makeElement(doc, 'header', { className: 'bb-minutes-detail-head' });
465
550
  header.append(makeElement(doc, 'h2', { textContent: text(meeting.title, '無題の会議') }));
551
+ const label = meetingLabelText(meetingLabel, meeting);
552
+ if (label) header.append(makeElement(doc, 'p', { className: 'bb-minutes-meeting-label', textContent: label }));
466
553
  header.append(makeElement(doc, 'p', { className: 'bb-minutes-item-meta', textContent: `${displayDate(meeting.scheduled_at)} / ${Array.isArray(meeting.participant_ids) && meeting.participant_ids.length ? meeting.participant_ids.join('、') : '参加者未登録'}` }));
467
554
  detailPane.append(header);
468
555
  const documents = Array.isArray(detail.minutes) ? detail.minutes : [];
@@ -474,6 +561,11 @@ export function createMeetingMinutesUI({
474
561
  }
475
562
 
476
563
  function renderCreateMinutesForm(detail) {
564
+ if (!canEdit()) {
565
+ const guard = makeElement(doc, 'section', { className: 'bb-minutes-edit-guard' });
566
+ appendEditBlockedNotice(guard);
567
+ return guard;
568
+ }
477
569
  const form = makeElement(doc, 'form', { className: 'bb-minutes-create-form' });
478
570
  form.append(makeElement(doc, 'h3', { textContent: '議事録文書を追加' }));
479
571
  const title = labelInput(doc, '議事録の名前', { name: 'title', value: '議事録' });
@@ -483,6 +575,11 @@ export function createMeetingMinutesUI({
483
575
  form.append(title.wrapper, body.wrapper, save);
484
576
  form.addEventListener('submit', async (event) => {
485
577
  event.preventDefault();
578
+ if (!canEdit()) {
579
+ save.disabled = false;
580
+ appendEditBlockedNotice(form, { alert: true });
581
+ return;
582
+ }
486
583
  save.disabled = true;
487
584
  try {
488
585
  await mutate(`/${encodeURIComponent(detail.meeting.meeting_id)}/minutes`, {
@@ -503,6 +600,10 @@ export function createMeetingMinutesUI({
503
600
 
504
601
  function renderCreateForm() {
505
602
  clear(detailPane);
603
+ if (!canCreate()) {
604
+ appendCreateBlockedNotice(detailPane, { alert: true });
605
+ return;
606
+ }
506
607
  const form = makeElement(doc, 'form', { className: 'bb-minutes-create-form' });
507
608
  form.append(makeElement(doc, 'h2', { textContent: '会議を作成' }), makeElement(doc, 'p', { className: 'bb-minutes-item-meta', textContent: '外部サービスを接続しなくても、会議と議事録を保存できます。' }));
508
609
  const title = labelInput(doc, '会議名', { name: 'title', placeholder: '例: 週次定例' });
@@ -515,6 +616,11 @@ export function createMeetingMinutesUI({
515
616
  form.append(title.wrapper, date.wrapper, participants.wrapper, minutesTitle.wrapper, body.wrapper, save);
516
617
  form.addEventListener('submit', async (event) => {
517
618
  event.preventDefault();
619
+ if (!canCreate()) {
620
+ save.disabled = false;
621
+ appendCreateBlockedNotice(detailPane, { alert: true });
622
+ return;
623
+ }
518
624
  save.disabled = true;
519
625
  try {
520
626
  const scheduledAt = date.input.value ? new Date(date.input.value).toISOString() : undefined;
@@ -538,17 +644,21 @@ export function createMeetingMinutesUI({
538
644
  detailPane.append(form);
539
645
  }
540
646
 
541
- async function loadMeetings() {
647
+ async function loadMeetings({ restoreFocus = false } = {}) {
648
+ const requestGeneration = ++listRequestGeneration;
649
+ const isCurrentRequest = () => requestGeneration === listRequestGeneration;
542
650
  state.loading = true;
543
651
  setStatus('会議一覧を読み込んでいます。');
544
652
  try {
545
653
  const payload = await call('');
654
+ if (!isCurrentRequest()) return state.meetings;
546
655
  if (!isRecord(payload) || !Array.isArray(payload.meetings) || payload.absence_confirmed !== true) throw new Error('会議一覧の形式を確認できません。');
547
656
  state.meetings = payload.meetings;
548
657
  state.listError = null;
549
658
  const selectedMeetingStillListed = state.meetingId !== null
550
659
  && state.meetings.some((entry) => entry?.meeting?.meeting_id === state.meetingId);
551
660
  if (state.meetingId !== null && !selectedMeetingStillListed) {
661
+ detailRequestGeneration += 1;
552
662
  state.detail = null;
553
663
  state.meetingId = null;
554
664
  state.minutesId = null;
@@ -562,13 +672,16 @@ export function createMeetingMinutesUI({
562
672
  renderList();
563
673
  if (state.detailError && state.detail === null) renderDetail(null);
564
674
  setStatus(state.meetings.length ? `${state.meetings.length}件の会議を読み込みました。` : '保存された会議はありません。');
675
+ if (restoreFocus && typeof listPane.focus === 'function') listPane.focus({ preventScroll: true });
565
676
  } catch (error) {
677
+ if (!isCurrentRequest()) return state.meetings;
566
678
  state.listError = loadErrorMessage(error, '会議一覧を読み込めませんでした。');
567
679
  const preserve = preserveLoadedStateOnFailure(error);
568
680
  const hadDetail = Boolean(state.detail) || state.meetingId !== null;
569
681
  if (!preserve) {
570
682
  state.meetings = [];
571
683
  if (hadDetail) {
684
+ detailRequestGeneration += 1;
572
685
  state.detail = null;
573
686
  state.meetingId = null;
574
687
  state.minutesId = null;
@@ -580,12 +693,15 @@ export function createMeetingMinutesUI({
580
693
  if (!preserve && hadDetail) renderDetail(null);
581
694
  setStatus(state.listError, 'error');
582
695
  } finally {
583
- state.loading = false;
696
+ if (isCurrentRequest()) state.loading = false;
584
697
  }
585
698
  return state.meetings;
586
699
  }
587
700
 
588
701
  async function loadDetail(meetingId) {
702
+ const requestGeneration = ++detailRequestGeneration;
703
+ const isCurrentRequest = () => requestGeneration === detailRequestGeneration
704
+ && state.meetingId === meetingId;
589
705
  state.meetingId = meetingId;
590
706
  state.minutesId = null;
591
707
  state.detailError = null;
@@ -593,12 +709,14 @@ export function createMeetingMinutesUI({
593
709
  setStatus('会議詳細を読み込んでいます。');
594
710
  try {
595
711
  const detail = await call(`/${encodeURIComponent(meetingId)}`);
712
+ if (!isCurrentRequest()) return state.detail;
596
713
  if (!isRecord(detail) || !isRecord(detail.meeting) || !Array.isArray(detail.minutes) || !Array.isArray(detail.versions)) throw new Error('会議詳細の形式を確認できません。');
597
714
  state.detail = detail;
598
715
  state.detailError = null;
599
716
  renderDetail(detail);
600
717
  setStatus('会議詳細を読み込みました。');
601
718
  } catch (error) {
719
+ if (!isCurrentRequest()) return state.detail;
602
720
  const preserved = preserveLoadedStateOnFailure(error) && state.detail?.meeting?.meeting_id === meetingId
603
721
  ? state.detail
604
722
  : null;
@@ -10,6 +10,8 @@
10
10
  * Constraint links are only read here; there is no replace method.
11
11
  */
12
12
 
13
+ import { normalizeObjectiveReadback } from './objective-readback-contract.js';
14
+
13
15
  export const OBJECTIVE_EDITOR_HTTP_PORT_VERSION = 'brainbase.objective-editor-http-port.v1';
14
16
 
15
17
  /** Top-level fields the Foundation route rejects with `authority_field_in_body`. */
@@ -93,7 +95,7 @@ export function createObjectiveEditorHttpPort({
93
95
  : typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
94
96
  const base = String(basePath).replace(/\/+$/u, '');
95
97
 
96
- async function call(path, init) {
98
+ async function call(path, init, { allowObjectiveReadback = false, expected } = {}) {
97
99
  if (!request) throw new ObjectiveEditorHttpError({ code: 'network_error', status: 0, message: ERROR_MESSAGES.network_error });
98
100
  let response;
99
101
  try {
@@ -107,7 +109,14 @@ export function createObjectiveEditorHttpPort({
107
109
  } catch {
108
110
  payload = null;
109
111
  }
110
- if (!response.ok) throw errorFromResponse(response.status, payload);
112
+ // Detail GETs use a typed envelope for both a canonical record and a
113
+ // confirmed miss/read failure. Accept that envelope even when its HTTP
114
+ // status is 404/5xx; every other non-2xx response remains an error.
115
+ if (!response.ok) {
116
+ const readback = allowObjectiveReadback ? normalizeObjectiveReadback(payload, expected) : null;
117
+ if (readback) return readback;
118
+ throw errorFromResponse(response.status, payload);
119
+ }
111
120
  if (payload === null) throw new ObjectiveEditorHttpError({ code: 'invalid_response', status: response.status, message: ERROR_MESSAGES.invalid_response });
112
121
  return payload;
113
122
  }
@@ -128,13 +137,17 @@ export function createObjectiveEditorHttpPort({
128
137
  return payload;
129
138
  },
130
139
  async readObjective(id, _context, revision) {
131
- try {
132
- return await call(withRevision(objectivePath(id), revision));
133
- } catch (error) {
134
- // A missing Objective is a readback miss, not a failure of the request.
135
- if (error instanceof ObjectiveEditorHttpError && error.status === 404 && error.code === 'not_found') return null;
136
- throw error;
140
+ const expected = { id, revision };
141
+ const payload = await call(withRevision(objectivePath(id), revision), undefined, { allowObjectiveReadback: true, expected });
142
+ const readback = normalizeObjectiveReadback(payload, expected);
143
+ if (!readback) {
144
+ throw new ObjectiveEditorHttpError({
145
+ code: 'invalid_response',
146
+ status: 200,
147
+ message: ERROR_MESSAGES.invalid_response,
148
+ });
137
149
  }
150
+ return readback;
138
151
  },
139
152
  checkObjectiveReadiness(id, _context, revision) {
140
153
  return call(withRevision(objectivePath(id, '/readiness'), revision));