@unson/brainbase-mcp 0.41.9 → 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.
|
@@ -31,6 +31,8 @@ export interface CanonicalTaskRecord extends JsonRecord {
|
|
|
31
31
|
assignee_display_name?: string | null;
|
|
32
32
|
due_at?: string | null;
|
|
33
33
|
waiting_on?: string | null;
|
|
34
|
+
/** The Graph person a waiting task waits on; null when it waits on a condition or nobody in particular. */
|
|
35
|
+
waiting_on_person_id?: string | null;
|
|
34
36
|
review_at?: string | null;
|
|
35
37
|
completed_at?: string | null;
|
|
36
38
|
source_refs?: unknown[];
|
|
@@ -50,6 +52,7 @@ export interface CanonicalTaskCreateInput extends JsonRecord {
|
|
|
50
52
|
assignee_person_id?: string | null;
|
|
51
53
|
due_at?: string | null;
|
|
52
54
|
waiting_on?: string | null;
|
|
55
|
+
waiting_on_person_id?: string | null;
|
|
53
56
|
review_at?: string | null;
|
|
54
57
|
source_refs?: unknown[];
|
|
55
58
|
project_codes?: unknown;
|
|
@@ -63,6 +66,7 @@ export interface CanonicalTaskUpdateInput extends JsonRecord {
|
|
|
63
66
|
assignee_person_id?: string | null;
|
|
64
67
|
due_at?: string | null;
|
|
65
68
|
waiting_on?: string | null;
|
|
69
|
+
waiting_on_person_id?: string | null;
|
|
66
70
|
review_at?: string | null;
|
|
67
71
|
source_refs?: unknown[];
|
|
68
72
|
source_refs_append?: unknown[];
|
|
@@ -231,12 +235,14 @@ export declare class CanonicalTaskService {
|
|
|
231
235
|
}, context: CanonicalTaskContext): Promise<CanonicalTaskRecord>;
|
|
232
236
|
transitionTask(taskId: string, status: unknown, expectedVersion: number, context: CanonicalTaskContext, input?: {
|
|
233
237
|
waitingOn?: unknown;
|
|
238
|
+
waitingOnPersonId?: unknown;
|
|
234
239
|
reviewAt?: unknown;
|
|
235
240
|
}): Promise<CanonicalTaskRecord>;
|
|
236
241
|
transitionTask(taskId: string, input: {
|
|
237
242
|
to_status?: unknown;
|
|
238
243
|
expected_version?: number;
|
|
239
244
|
waiting_on?: unknown;
|
|
245
|
+
waiting_on_person_id?: unknown;
|
|
240
246
|
review_at?: unknown;
|
|
241
247
|
}, context: CanonicalTaskContext): Promise<CanonicalTaskRecord>;
|
|
242
248
|
deleteTask(taskId: string, expectedVersion: number, context: CanonicalTaskContext): Promise<{
|
|
@@ -34,6 +34,7 @@ const CREATE_FIELDS = new Set([
|
|
|
34
34
|
'assignee_person_id',
|
|
35
35
|
'due_at',
|
|
36
36
|
'waiting_on',
|
|
37
|
+
'waiting_on_person_id',
|
|
37
38
|
'review_at',
|
|
38
39
|
'source_refs',
|
|
39
40
|
'project_codes',
|
|
@@ -48,6 +49,7 @@ const MUTABLE_FIELDS = new Set([
|
|
|
48
49
|
'assignee_person_id',
|
|
49
50
|
'due_at',
|
|
50
51
|
'waiting_on',
|
|
52
|
+
'waiting_on_person_id',
|
|
51
53
|
'review_at',
|
|
52
54
|
'source_refs',
|
|
53
55
|
'source_refs_append',
|
|
@@ -78,6 +80,10 @@ function normalizeString(value, field, options = {}) {
|
|
|
78
80
|
}
|
|
79
81
|
return normalized;
|
|
80
82
|
}
|
|
83
|
+
/** A Graph person ID, as for the assignee; empty or null means no person. */
|
|
84
|
+
function normalizeWaitingOnPersonId(value) {
|
|
85
|
+
return normalizeString(value, 'waiting_on_person_id', { max: 200 });
|
|
86
|
+
}
|
|
81
87
|
/** A purpose label: a single line of at most 30 characters; empty or null clears it. */
|
|
82
88
|
const MAX_PURPOSE_LABEL_LENGTH = 30;
|
|
83
89
|
function normalizePurposeLabel(value) {
|
|
@@ -511,7 +517,7 @@ export class CanonicalTaskService {
|
|
|
511
517
|
const expectedVersion = legacyInput ? legacyInput.expected_version : expectedVersionOrContext;
|
|
512
518
|
const context = legacyInput ? expectedVersionOrContext : maybeContext;
|
|
513
519
|
const input = legacyInput
|
|
514
|
-
? { waitingOn: legacyInput.waiting_on, reviewAt: legacyInput.review_at }
|
|
520
|
+
? { waitingOn: legacyInput.waiting_on, waitingOnPersonId: legacyInput.waiting_on_person_id, reviewAt: legacyInput.review_at }
|
|
515
521
|
: maybeInput;
|
|
516
522
|
const normalizedContext = await this.normalizeContext(context);
|
|
517
523
|
const expected = this.requireExpectedVersion(expectedVersion);
|
|
@@ -523,10 +529,13 @@ export class CanonicalTaskService {
|
|
|
523
529
|
failValidation({ waiting_on: ['required_for_waiting'] });
|
|
524
530
|
}
|
|
525
531
|
patch.waiting_on = normalizeString(input.waitingOn, 'waiting_on', { required: true, max: 500 });
|
|
532
|
+
// The person is optional and always replaced, so waiting on a condition clears an earlier person.
|
|
533
|
+
patch.waiting_on_person_id = normalizeWaitingOnPersonId(input.waitingOnPersonId);
|
|
526
534
|
patch.review_at = normalizeIsoDate(input.reviewAt, 'review_at');
|
|
527
535
|
}
|
|
528
536
|
else {
|
|
529
537
|
patch.waiting_on = input.waitingOn === undefined ? null : normalizeString(input.waitingOn, 'waiting_on', { max: 500 });
|
|
538
|
+
patch.waiting_on_person_id = input.waitingOnPersonId === undefined ? null : normalizeWaitingOnPersonId(input.waitingOnPersonId);
|
|
530
539
|
patch.review_at = input.reviewAt === undefined ? null : normalizeIsoDate(input.reviewAt, 'review_at');
|
|
531
540
|
}
|
|
532
541
|
return this.versionedMutation({
|
|
@@ -801,6 +810,7 @@ export class CanonicalTaskService {
|
|
|
801
810
|
transition: {
|
|
802
811
|
to_status: input.transitionStatus,
|
|
803
812
|
...(input.patch.waiting_on !== undefined ? { waiting_on: input.patch.waiting_on } : {}),
|
|
813
|
+
...(input.patch.waiting_on_person_id !== undefined ? { waiting_on_person_id: input.patch.waiting_on_person_id } : {}),
|
|
804
814
|
...(input.patch.review_at !== undefined ? { review_at: input.patch.review_at } : {}),
|
|
805
815
|
},
|
|
806
816
|
};
|
|
@@ -818,6 +828,7 @@ export class CanonicalTaskService {
|
|
|
818
828
|
if (!isCanonicalTaskPriority(priority))
|
|
819
829
|
fail('validation_error', 'priority is invalid', 400, { field: 'priority' });
|
|
820
830
|
const purposeLabel = normalizePurposeLabel(input.purpose_label);
|
|
831
|
+
const waitingOnPersonId = normalizeWaitingOnPersonId(input.waiting_on_person_id);
|
|
821
832
|
const completionContract = normalizeCompletionContract(input.completion_contract);
|
|
822
833
|
return {
|
|
823
834
|
...(completionContract ? { completion_contract: completionContract } : {}),
|
|
@@ -832,6 +843,8 @@ export class CanonicalTaskService {
|
|
|
832
843
|
project_codes: normalizeProjectCodes(input.project_codes) ?? [],
|
|
833
844
|
// Only a label that is there is written, so a store without the column still accepts unlabelled work.
|
|
834
845
|
...(purposeLabel ? { purpose_label: purposeLabel } : {}),
|
|
846
|
+
// Likewise only a person that is there is written.
|
|
847
|
+
...(waitingOnPersonId ? { waiting_on_person_id: waitingOnPersonId } : {}),
|
|
835
848
|
};
|
|
836
849
|
}
|
|
837
850
|
normalizeUpdateInput(input) {
|
|
@@ -866,6 +879,8 @@ export class CanonicalTaskService {
|
|
|
866
879
|
patch.due_at = normalizeIsoDate(input.due_at, 'due_at');
|
|
867
880
|
if ('waiting_on' in input)
|
|
868
881
|
patch.waiting_on = normalizeString(input.waiting_on, 'waiting_on', { max: 500 });
|
|
882
|
+
if ('waiting_on_person_id' in input)
|
|
883
|
+
patch.waiting_on_person_id = normalizeWaitingOnPersonId(input.waiting_on_person_id);
|
|
869
884
|
if ('review_at' in input)
|
|
870
885
|
patch.review_at = normalizeIsoDate(input.review_at, 'review_at');
|
|
871
886
|
if ('source_refs' in input && hasSourceRefsAppend) {
|
|
@@ -1052,6 +1067,8 @@ export class CanonicalTaskService {
|
|
|
1052
1067
|
response.due_at = null;
|
|
1053
1068
|
if (response.waiting_on === undefined)
|
|
1054
1069
|
response.waiting_on = null;
|
|
1070
|
+
if (response.waiting_on_person_id === undefined)
|
|
1071
|
+
response.waiting_on_person_id = null;
|
|
1055
1072
|
if (response.review_at === undefined)
|
|
1056
1073
|
response.review_at = null;
|
|
1057
1074
|
if (response.completed_at === undefined)
|
|
@@ -44,6 +44,9 @@ function routeFor(pathname, basePath) {
|
|
|
44
44
|
if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'confirm') {
|
|
45
45
|
return { kind: 'confirm', meetingId, minutesId };
|
|
46
46
|
}
|
|
47
|
+
if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'withdraw') {
|
|
48
|
+
return { kind: 'withdraw', meetingId, minutesId };
|
|
49
|
+
}
|
|
47
50
|
if (segments.length === 5 && noun === 'minutes' && minutesId && action === 'versions' && versionId) {
|
|
48
51
|
return { kind: 'version', meetingId, minutesId, versionId };
|
|
49
52
|
}
|
|
@@ -157,6 +160,13 @@ function confirmInput(body, minutesId, key, fingerprint) {
|
|
|
157
160
|
...idempotencyFields(key, fingerprint)
|
|
158
161
|
};
|
|
159
162
|
}
|
|
163
|
+
function withdrawInput(body, minutesId, key, fingerprint) {
|
|
164
|
+
return {
|
|
165
|
+
minutes_id: minutesId,
|
|
166
|
+
expected_revision: body.expected_revision,
|
|
167
|
+
...idempotencyFields(key, fingerprint)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
160
170
|
export function createMeetingMinutesHttpHandler(options) {
|
|
161
171
|
if (!options || !options.store || typeof options.resolveContext !== 'function') {
|
|
162
172
|
throw new TypeError('store and resolveContext are required');
|
|
@@ -202,6 +212,15 @@ export function createMeetingMinutesHttpHandler(options) {
|
|
|
202
212
|
}
|
|
203
213
|
return { statusCode: 200, body: await options.store.confirm_version(confirmInput(body, document.minutes_id, key, fingerprint), context) };
|
|
204
214
|
}
|
|
215
|
+
case 'withdraw': {
|
|
216
|
+
if (body === undefined)
|
|
217
|
+
throw new MeetingMinutesError('invalid_input', 'Request body is required');
|
|
218
|
+
const detail = await options.store.get_meeting(route.meetingId ?? '', context);
|
|
219
|
+
const document = detail.minutes.find((candidate) => candidate.minutes_id === route.minutesId);
|
|
220
|
+
if (!document)
|
|
221
|
+
throw new MeetingMinutesError('not_found', `Minutes document ${route.minutesId} was not found`);
|
|
222
|
+
return { statusCode: 200, body: await options.store.withdraw_minutes(withdrawInput(body, document.minutes_id, key, fingerprint), context) };
|
|
223
|
+
}
|
|
205
224
|
case 'version':
|
|
206
225
|
return {
|
|
207
226
|
statusCode: 200,
|
package/dist/meeting-minutes.js
CHANGED
|
@@ -1132,6 +1132,10 @@ export function createMeetingMinutesStore(options) {
|
|
|
1132
1132
|
const meeting = findMeeting(snapshot, document.meeting_id);
|
|
1133
1133
|
await authorized(authorizer, 'write', meeting, context);
|
|
1134
1134
|
await authorized(authorizer, 'write', document, context);
|
|
1135
|
+
// Only the person who made the document takes it back (story-meeting-minutes-edit-and-withdraw-ui-v1).
|
|
1136
|
+
if (document.created_actor_id !== principal) {
|
|
1137
|
+
throw new MeetingMinutesError('authorization_denied', 'Only the person who made this minutes document can withdraw it');
|
|
1138
|
+
}
|
|
1135
1139
|
// Withdrawing twice keeps the first record; a retry is not a second withdrawal.
|
|
1136
1140
|
if (document.withdrawal && !idempotency.token)
|
|
1137
1141
|
return detailForCaller(snapshot, meeting, context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.10",
|
|
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",
|
|
@@ -483,5 +483,5 @@
|
|
|
483
483
|
"engines": {
|
|
484
484
|
"node": ">=20"
|
|
485
485
|
},
|
|
486
|
-
"gitHead": "
|
|
486
|
+
"gitHead": "9ea5345d8da4b0cf825daf73610474dd8903a8f5"
|
|
487
487
|
}
|
package/ui/meeting-minutes.css
CHANGED
|
@@ -136,6 +136,12 @@
|
|
|
136
136
|
overflow-y: auto;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/* Keep the heading, actions and body together at the top instead of spreading them over the pane. */
|
|
140
|
+
.bb-minutes-detail-pane,
|
|
141
|
+
.bb-minutes-document {
|
|
142
|
+
align-content: start;
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
.bb-minutes-list-pane {
|
|
140
146
|
grid-template-rows: auto minmax(0, 1fr);
|
|
141
147
|
gap: 0;
|
|
@@ -146,7 +152,8 @@
|
|
|
146
152
|
.bb-minutes-detail-head,
|
|
147
153
|
.bb-minutes-document-head,
|
|
148
154
|
.bb-minutes-actions,
|
|
149
|
-
.bb-minutes-version-
|
|
155
|
+
.bb-minutes-version-actions,
|
|
156
|
+
.bb-minutes-version-meta {
|
|
150
157
|
display: flex;
|
|
151
158
|
gap: 8px;
|
|
152
159
|
align-items: center;
|
|
@@ -328,12 +335,66 @@
|
|
|
328
335
|
margin: 0;
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
.bb-minutes-version-
|
|
338
|
+
.bb-minutes-version-actions {
|
|
339
|
+
justify-content: flex-start;
|
|
340
|
+
margin: 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* Which version is shown sits quietly below the body (story-meeting-minutes-edit-and-withdraw-ui-v1 AC-06). */
|
|
344
|
+
.bb-minutes-version-meta {
|
|
345
|
+
justify-content: flex-start;
|
|
332
346
|
margin: 0;
|
|
347
|
+
padding-top: 8px;
|
|
348
|
+
border-top: 1px solid var(--bb-color-border);
|
|
349
|
+
color: var(--bb-color-muted);
|
|
350
|
+
font-size: 12px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.bb-minutes-withdrawn,
|
|
354
|
+
.bb-minutes-withdraw-guard,
|
|
355
|
+
.bb-minutes-edit-note {
|
|
356
|
+
margin: 0;
|
|
357
|
+
color: var(--bb-color-muted);
|
|
358
|
+
font-size: 13px;
|
|
359
|
+
overflow-wrap: anywhere;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.bb-minutes-withdrawn {
|
|
333
363
|
padding: 8px 12px;
|
|
364
|
+
background: var(--bb-color-surface);
|
|
334
365
|
border: 1px solid var(--bb-color-border);
|
|
335
|
-
border-radius:
|
|
336
|
-
|
|
366
|
+
border-radius: var(--bb-radius-sm, 4px);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.bb-minutes-withdraw-confirm {
|
|
370
|
+
display: grid;
|
|
371
|
+
gap: 8px;
|
|
372
|
+
padding: 12px;
|
|
373
|
+
border: 1px solid var(--bb-color-danger);
|
|
374
|
+
border-radius: var(--bb-radius-sm, 4px);
|
|
375
|
+
background: var(--bb-color-paper);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.bb-minutes-withdraw-confirm p,
|
|
379
|
+
.bb-minutes-withdraw-confirm ul {
|
|
380
|
+
margin: 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.bb-minutes-withdraw-title {
|
|
384
|
+
font-weight: 600;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.bb-minutes-edit-form {
|
|
388
|
+
display: grid;
|
|
389
|
+
gap: 8px;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.bb-minutes-edit-form textarea {
|
|
393
|
+
width: 100%;
|
|
394
|
+
box-sizing: border-box;
|
|
395
|
+
min-height: 16rem;
|
|
396
|
+
font: inherit;
|
|
397
|
+
line-height: 1.6;
|
|
337
398
|
}
|
|
338
399
|
|
|
339
400
|
.bb-minutes-version-content {
|
|
@@ -419,6 +480,18 @@
|
|
|
419
480
|
color: var(--bb-color-action-text);
|
|
420
481
|
}
|
|
421
482
|
|
|
483
|
+
.bb-minutes-button.is-quiet {
|
|
484
|
+
border-color: var(--bb-color-border);
|
|
485
|
+
color: var(--bb-color-muted);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.bb-minutes-button.is-danger,
|
|
489
|
+
.bb-minutes-button.is-danger:hover {
|
|
490
|
+
border-color: var(--bb-color-danger);
|
|
491
|
+
background: var(--bb-color-danger);
|
|
492
|
+
color: var(--bb-color-action-text);
|
|
493
|
+
}
|
|
494
|
+
|
|
422
495
|
.bb-minutes-button:disabled {
|
|
423
496
|
cursor: wait;
|
|
424
497
|
opacity: 0.6;
|
|
@@ -465,3 +538,77 @@
|
|
|
465
538
|
grid-template-columns: 1fr;
|
|
466
539
|
}
|
|
467
540
|
}
|
|
541
|
+
|
|
542
|
+
/* この会議から: project, tasks, decisions, storage and sharing. */
|
|
543
|
+
.bb-minutes-connections {
|
|
544
|
+
display: grid;
|
|
545
|
+
align-self: start;
|
|
546
|
+
align-content: start;
|
|
547
|
+
gap: 4px;
|
|
548
|
+
margin: 8px 0 4px;
|
|
549
|
+
padding: 8px 12px;
|
|
550
|
+
border: 1px solid var(--bb-color-border);
|
|
551
|
+
border-radius: var(--bb-radius-md, 6px);
|
|
552
|
+
background: var(--bb-color-surface);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.bb-minutes-connections-title {
|
|
556
|
+
margin: 0;
|
|
557
|
+
color: var(--bb-color-muted);
|
|
558
|
+
font-size: 12px;
|
|
559
|
+
font-weight: 700;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.bb-minutes-connection-list {
|
|
563
|
+
display: grid;
|
|
564
|
+
gap: 2px;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.bb-minutes-connection-row {
|
|
568
|
+
display: grid;
|
|
569
|
+
grid-template-columns: 7em minmax(0, 1fr);
|
|
570
|
+
gap: 8px;
|
|
571
|
+
align-items: baseline;
|
|
572
|
+
font-size: 13px;
|
|
573
|
+
line-height: 1.5;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
.bb-minutes-connection-label {
|
|
577
|
+
color: var(--bb-color-muted);
|
|
578
|
+
font-size: 12px;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.bb-minutes-connection-items {
|
|
582
|
+
display: flex;
|
|
583
|
+
flex-wrap: wrap;
|
|
584
|
+
gap: 2px 12px;
|
|
585
|
+
margin: 0;
|
|
586
|
+
padding: 0;
|
|
587
|
+
list-style: none;
|
|
588
|
+
min-width: 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.bb-minutes-connection-items li {
|
|
592
|
+
min-width: 0;
|
|
593
|
+
overflow-wrap: anywhere;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.bb-minutes-connection-link {
|
|
597
|
+
color: var(--bb-color-brand-indigo);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.bb-minutes-connection-detail {
|
|
601
|
+
margin-left: 6px;
|
|
602
|
+
color: var(--bb-color-muted);
|
|
603
|
+
font-size: 12px;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.bb-minutes-connection-note {
|
|
607
|
+
margin: 0;
|
|
608
|
+
color: var(--bb-color-muted);
|
|
609
|
+
overflow-wrap: anywhere;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.bb-minutes-connection-note.is-unavailable {
|
|
613
|
+
color: var(--bb-color-warning);
|
|
614
|
+
}
|
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
|