@takagaki/cortex-decisions-viewer 0.12.8 → 0.12.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/build.js +189 -0
- package/dist/render.js +442 -20
- package/package.json +1 -1
package/dist/build.js
CHANGED
|
@@ -38,6 +38,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.readFleetSources = readFleetSources;
|
|
40
40
|
exports.readGoldProposals = readGoldProposals;
|
|
41
|
+
exports.buildHomeGrounding = buildHomeGrounding;
|
|
42
|
+
exports.collectMeetings = collectMeetings;
|
|
41
43
|
exports.findBacklogDir = findBacklogDir;
|
|
42
44
|
exports.build = build;
|
|
43
45
|
exports.createRefResolver = createRefResolver;
|
|
@@ -517,6 +519,108 @@ async function readGoldProposals(repoRoot) {
|
|
|
517
519
|
}
|
|
518
520
|
return byPath;
|
|
519
521
|
}
|
|
522
|
+
// ---- Home(案件の自己記述)--------------------------------------------------
|
|
523
|
+
//
|
|
524
|
+
// Home.md は「案件そのものの説明」に絞る再構成の途中にある(engine 側で進行中)。
|
|
525
|
+
// **画面はどちらの形でも動く必要がある**——ビューアのほうが先に配られるので、
|
|
526
|
+
// 移行前のリポジトリでも壊れず、移行後に自然に新しい形へ寄る作りにする。
|
|
527
|
+
//
|
|
528
|
+
// 27案件を実測したところ、案件の目的が記入済みなのは3件だけだった。
|
|
529
|
+
// 残る24案件では、毎晩のAI生成が**案件を知らないまま**抽出を続けている。
|
|
530
|
+
// ビューアの仕事は、その未記入を見えるようにして「書かせる圧」をかけること。
|
|
531
|
+
/** 新しい形の自己記述の節。画面の最上部に出す */
|
|
532
|
+
const HOME_GROUNDING_HEADINGS = ["この案件について", "ゴール", "体制"];
|
|
533
|
+
/** 旧い形。1つの節に「解きたいこと」「達成した状態」が同居していた */
|
|
534
|
+
const HOME_LEGACY_GROUNDING_HEADING = "このプロジェクトが目指すもの";
|
|
535
|
+
/**
|
|
536
|
+
* 旧 Home.md が持っていた「使い方の説明」。**ビューア側(操作タブ)へ移した。**
|
|
537
|
+
*
|
|
538
|
+
* 案件ごとのリポジトリに同じ説明が27枚コピーされている状態で、文言を直すのに
|
|
539
|
+
* 全案件へマイグレーションが要った。説明はツールの一部なので、ツールが持つ。
|
|
540
|
+
* ここでは表示から落とすだけ(ファイルからの削除は engine 側のマイグレーションが行う)。
|
|
541
|
+
*/
|
|
542
|
+
const HOME_LEGACY_USAGE_HEADINGS = ["この仕組みについて", "情報の見方", "このサイトで見られるもの"];
|
|
543
|
+
/** 記入待ちの穴(雛形が置いていく {{…}}) */
|
|
544
|
+
const PLACEHOLDER = /\{\{[^{}]*\}\}/;
|
|
545
|
+
/** 本文を `##` の見出しで節に切る(見出しの無い前置きは heading: null で先頭に入る) */
|
|
546
|
+
function splitByH2(content) {
|
|
547
|
+
const out = [{ heading: null, lines: [] }];
|
|
548
|
+
let inFence = false;
|
|
549
|
+
for (const line of content.split("\n")) {
|
|
550
|
+
// **コードブロックの中は見出しとして数えない。** 手順の例に ## が出てくることがある
|
|
551
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
552
|
+
inFence = !inFence;
|
|
553
|
+
const m = inFence ? null : /^##[ \t]+(.+?)[ \t]*$/.exec(line);
|
|
554
|
+
if (m)
|
|
555
|
+
out.push({ heading: m[1], lines: [] });
|
|
556
|
+
else
|
|
557
|
+
out[out.length - 1].lines.push(line);
|
|
558
|
+
}
|
|
559
|
+
return out.map((s) => ({ heading: s.heading, body: s.lines.join("\n") }));
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* 節の中身が「まだ書かれていない」か。
|
|
563
|
+
*
|
|
564
|
+
* 🔴 **見出しの配下だけを見る。** 導入行の `{{プロジェクト名}}` のような、節の外に残った
|
|
565
|
+
* 雛形の穴で発火させない——あれは案件の自己記述ではないので、埋まっていなくても
|
|
566
|
+
* 毎晩のAI生成が案件を誤解する原因にはならない。ここを本文全体に広げると、
|
|
567
|
+
* ほぼ全案件で警告が出っぱなしになり、**本当に書いてほしい案件が埋もれる**。
|
|
568
|
+
*
|
|
569
|
+
* HTML コメントは先に落とす。雛形は書き手への指示をコメントで置いており、
|
|
570
|
+
* その中の例示({{…}})は「未記入」の証拠ではない。
|
|
571
|
+
*/
|
|
572
|
+
function isUnwritten(body) {
|
|
573
|
+
return PLACEHOLDER.test(body.replace(/<!--[\s\S]*?-->/g, ""));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Home.md から「案件の自己記述」を取り出し、残りの本文と分ける。
|
|
577
|
+
*
|
|
578
|
+
* @returns sections … 最上部に出す節(新形式・旧形式のどちらでも拾う)
|
|
579
|
+
* unfilled … 案件の概要・ゴールが未記入か(警告カードを出すか)
|
|
580
|
+
* restHtml … 自己記述と旧説明を除いた残りの本文(使用ツール等)
|
|
581
|
+
*/
|
|
582
|
+
async function buildHomeGrounding(rawMarkdown) {
|
|
583
|
+
const content = (0, gray_matter_1.default)(rawMarkdown).content;
|
|
584
|
+
const parts = splitByH2(content);
|
|
585
|
+
const find = (h) => parts.find((p) => p.heading === h);
|
|
586
|
+
const legacy = find(HOME_LEGACY_GROUNDING_HEADING);
|
|
587
|
+
// **新形式が1つでもあれば新形式として扱う。** 移行の途中で両方が並ぶことがあり、
|
|
588
|
+
// そのとき旧い節を自己記述として出すと、同じ内容が2度出る
|
|
589
|
+
const hasNew = HOME_GROUNDING_HEADINGS.some((h) => find(h));
|
|
590
|
+
const groundingHeadings = hasNew ? HOME_GROUNDING_HEADINGS : legacy ? [HOME_LEGACY_GROUNDING_HEADING] : [];
|
|
591
|
+
const sections = [];
|
|
592
|
+
for (const h of groundingHeadings) {
|
|
593
|
+
const hit = find(h);
|
|
594
|
+
// 「体制」のように**無くてよい節**は、無ければ出さない(空の見出しを並べない)
|
|
595
|
+
if (!hit || !hit.body.trim())
|
|
596
|
+
continue;
|
|
597
|
+
sections.push({
|
|
598
|
+
heading: h,
|
|
599
|
+
html: externalLinksToNewTab(await marked_1.marked.parse(hit.body, { async: true })),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// 未記入の判定。新形式は「この案件について」と「ゴール」の2つ、旧形式は1つの節を見る。
|
|
603
|
+
// **どちらの節も無ければ未記入。** 節ごと消えている案件が実在する
|
|
604
|
+
const checkHeadings = hasNew ? ["この案件について", "ゴール"] : [HOME_LEGACY_GROUNDING_HEADING];
|
|
605
|
+
const unfilled = checkHeadings.some((h) => {
|
|
606
|
+
const hit = find(h);
|
|
607
|
+
return !hit || !hit.body.trim() || isUnwritten(hit.body);
|
|
608
|
+
});
|
|
609
|
+
// 残りの本文=自己記述の節と、旧い使い方の説明を除いたもの
|
|
610
|
+
// **旧い自己記述は、新形式へ移ったあとも本文から落とす。** 移行の途中で新旧が並ぶと、
|
|
611
|
+
// 新しい節を最上部に出したうえで古い節が本文にも出て、どちらが今の説明か分からなくなる
|
|
612
|
+
const dropped = new Set([
|
|
613
|
+
...groundingHeadings,
|
|
614
|
+
HOME_LEGACY_GROUNDING_HEADING,
|
|
615
|
+
...HOME_LEGACY_USAGE_HEADINGS,
|
|
616
|
+
]);
|
|
617
|
+
const rest = parts
|
|
618
|
+
.filter((p) => !(p.heading != null && dropped.has(p.heading)))
|
|
619
|
+
.map((p) => (p.heading == null ? p.body : `## ${p.heading}\n${p.body}`))
|
|
620
|
+
.join("\n");
|
|
621
|
+
const restHtml = externalLinksToNewTab((await marked_1.marked.parse(rest, { async: true })).replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, ""));
|
|
622
|
+
return { sections, unfilled, restHtml };
|
|
623
|
+
}
|
|
520
624
|
/** パスがディレクトリとして存在するか */
|
|
521
625
|
async function isDirectory(dirAbs) {
|
|
522
626
|
try {
|
|
@@ -694,6 +798,79 @@ async function findDirByMarker(repoRoot, marker, fallback) {
|
|
|
694
798
|
}
|
|
695
799
|
return fallback;
|
|
696
800
|
}
|
|
801
|
+
/** 会議の回を入れるディレクトリの名前(`会議/<会議名>/20260120/`) */
|
|
802
|
+
const MEETING_DATE_DIR = /^\d{8}$/;
|
|
803
|
+
/**
|
|
804
|
+
* すでにある会議を列挙する(#277。投入モーダルの「会議名」の選択肢になる)。
|
|
805
|
+
*
|
|
806
|
+
* **「会議名」は画面から自由に打てるが、打たせるのは最後の手段にする。** 手で打つと
|
|
807
|
+
* 表記ゆれ(`定例` と `定例MTG`)で同じ会議が2つのディレクトリに割れ、議事録の連番も
|
|
808
|
+
* 履歴も分かれる。すでにある名前を選べれば、その事故はほとんど起きない。
|
|
809
|
+
*
|
|
810
|
+
* 会議とみなすのは「**日付ディレクトリ(`YYYYMMDD`)を直接ぶら下げているディレクトリ**」。
|
|
811
|
+
* 名前の規則ではなく実際の中身で決めるので、`会議/定例/デイリースクラム/20260120/` のような
|
|
812
|
+
* 入れ子でも当たる(相対パスは `定例/デイリースクラム`)。
|
|
813
|
+
*
|
|
814
|
+
* **会議ディレクトリ直下に日付ディレクトリがある形(相対パスが空)は除外する。**
|
|
815
|
+
* その形は「会議名なしで投入された回」で、選択肢に出しても送り先にできない
|
|
816
|
+
* (`meeting` が空文字になり Lambda が 400 を返す)。
|
|
817
|
+
*
|
|
818
|
+
* `minuteDates` は、その日付ディレクトリに議事録(`*_minutes.md`)が**すでにある**日付。
|
|
819
|
+
* 同じ日に文字起こしを足しても議事録は作り直されないので、送る前に画面で伝えるために使う。
|
|
820
|
+
*/
|
|
821
|
+
async function collectMeetings(repoRoot) {
|
|
822
|
+
const meetingDir = await findDirByMarker(repoRoot, "ingest-config.json", "会議");
|
|
823
|
+
const rootAbs = path.join(repoRoot, meetingDir);
|
|
824
|
+
const out = [];
|
|
825
|
+
const hasMinute = async (dirAbs) => {
|
|
826
|
+
try {
|
|
827
|
+
for (const e of await node_fs_1.promises.readdir(dirAbs, { withFileTypes: true })) {
|
|
828
|
+
if (e.isFile() && e.name.endsWith("_minutes.md"))
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// 読めない回は「議事録なし」扱い(注意を出さないだけで、投入は妨げない)
|
|
834
|
+
}
|
|
835
|
+
return false;
|
|
836
|
+
};
|
|
837
|
+
const walk = async (relParts) => {
|
|
838
|
+
let entries;
|
|
839
|
+
try {
|
|
840
|
+
entries = await node_fs_1.promises.readdir(path.join(rootAbs, ...relParts), { withFileTypes: true });
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return; // 会議ディレクトリが無い案件(未導入)はここで終わる
|
|
844
|
+
}
|
|
845
|
+
const dirs = entries.filter((e) => e.isDirectory() &&
|
|
846
|
+
e.name !== "node_modules" &&
|
|
847
|
+
e.name !== "templates" &&
|
|
848
|
+
!e.name.startsWith("."));
|
|
849
|
+
const rel = relParts.join("/");
|
|
850
|
+
const dates = dirs.filter((d) => MEETING_DATE_DIR.test(d.name)).map((d) => d.name);
|
|
851
|
+
if (dates.length && rel) {
|
|
852
|
+
const minuteDates = [];
|
|
853
|
+
for (const d of dates.sort()) {
|
|
854
|
+
if (await hasMinute(path.join(rootAbs, ...relParts, d)))
|
|
855
|
+
minuteDates.push(d);
|
|
856
|
+
}
|
|
857
|
+
out.push({ path: rel, minuteDates });
|
|
858
|
+
}
|
|
859
|
+
// 日付ディレクトリの中は回の中身なので降りない(会議名にはならない)。
|
|
860
|
+
// 深さは Lambda 側の上限(isValidMeetingDir の 5 セグメント)と揃える——
|
|
861
|
+
// それより深い会議を選択肢に出しても、選んだ瞬間に 400 で断られるだけ
|
|
862
|
+
if (relParts.length >= 5)
|
|
863
|
+
return;
|
|
864
|
+
for (const d of dirs) {
|
|
865
|
+
if (MEETING_DATE_DIR.test(d.name))
|
|
866
|
+
continue;
|
|
867
|
+
await walk([...relParts, d.name]);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
await walk([]);
|
|
871
|
+
out.sort((a, b) => a.path.localeCompare(b.path, "ja"));
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
697
874
|
/** exporter が作る区画名(engine の `BACKLOG_SECTIONS` と同じ固定の名前) */
|
|
698
875
|
const BACKLOG_SECTIONS = ["issues", "documents", "wiki"];
|
|
699
876
|
/**
|
|
@@ -1389,11 +1566,18 @@ async function build(opts) {
|
|
|
1389
1566
|
decisions.push(rec);
|
|
1390
1567
|
}
|
|
1391
1568
|
let home = null;
|
|
1569
|
+
let homeGrounding;
|
|
1392
1570
|
const sections = [];
|
|
1393
1571
|
if (knowledgeRoot) {
|
|
1394
1572
|
try {
|
|
1395
1573
|
const raw = await node_fs_1.promises.readFile(path.join(knowledgeRoot, "Home.md"), "utf8");
|
|
1396
1574
|
home = await parseGeneric(raw, "Home.md", toRepoRelative(knowledgeRoot), repo.baseUrl, repo.branch);
|
|
1575
|
+
// 自己記述の節を取り出し、**本文からは抜く**(最上部に出すので、残すと2度出る)。
|
|
1576
|
+
// 旧い使い方の説明もここで落ちる(説明はビューアが持つようになった)
|
|
1577
|
+
const grounding = await buildHomeGrounding(raw);
|
|
1578
|
+
homeGrounding = { sections: grounding.sections, unfilled: grounding.unfilled };
|
|
1579
|
+
home.bodyHtml = grounding.restHtml;
|
|
1580
|
+
home.searchText = grounding.sections.map((s) => s.heading).join(" ") + " " + home.searchText;
|
|
1397
1581
|
}
|
|
1398
1582
|
catch {
|
|
1399
1583
|
// ホーム未作成の案件では着地画面なし(Decisionsタブが既定になる)
|
|
@@ -1433,6 +1617,9 @@ async function build(opts) {
|
|
|
1433
1617
|
computeLayout(graph); // 各ノードに x/y を書き込む(クライアントはこれを初期位置に使いウォームアップ省略)
|
|
1434
1618
|
// データソースと自動化(fleet-status.json 由来)。無ければ undefined でセクション非表示。
|
|
1435
1619
|
const fleet = await readFleetSources(repoRoot);
|
|
1620
|
+
// すでにある会議(投入モーダルの「会議名」の選択肢)。intake の有無に関わらず載せる——
|
|
1621
|
+
// 出し分けは表示側の仕事で、ここで絞ると「投入の口を後から付けたら選択肢が空」になる
|
|
1622
|
+
const meetings = await collectMeetings(repoRoot);
|
|
1436
1623
|
const site = {
|
|
1437
1624
|
title: opts.title,
|
|
1438
1625
|
generatedAt: opts.generatedAt,
|
|
@@ -1442,6 +1629,7 @@ async function build(opts) {
|
|
|
1442
1629
|
branch: repo.branch,
|
|
1443
1630
|
decisions,
|
|
1444
1631
|
home,
|
|
1632
|
+
homeGrounding: homeGrounding,
|
|
1445
1633
|
sections,
|
|
1446
1634
|
graph,
|
|
1447
1635
|
categories,
|
|
@@ -1453,6 +1641,7 @@ async function build(opts) {
|
|
|
1453
1641
|
projectKey: opts.projectKey,
|
|
1454
1642
|
}
|
|
1455
1643
|
: undefined,
|
|
1644
|
+
meetings,
|
|
1456
1645
|
// **URLとトークンが両方そろったときだけ出す。** 片方だけでは貼れるURLを作れないので、
|
|
1457
1646
|
// 手順だけが出て「専用URLが空」という一番混乱する形になる(intake と同じ扱い)
|
|
1458
1647
|
backlogWebhook: opts.webhookUrl && opts.webhookToken
|
package/dist/render.js
CHANGED
|
@@ -2078,6 +2078,36 @@ a:hover { text-decoration: underline; }
|
|
|
2078
2078
|
.promote-btn:disabled { opacity: .6; cursor: default; }
|
|
2079
2079
|
/* 何を上げようとしているか(リポジトリ上のパス)。リンクではないので控えめな色にする */
|
|
2080
2080
|
.promote-target { font-size: 12.5px; word-break: break-all; color: var(--muted); margin: 0 0 12px; }
|
|
2081
|
+
/* ---- Home(案件の自己記述)---- */
|
|
2082
|
+
/* 未記入の警告。**画面でいちばん強い赤にする**——27案件中24案件が未記入のまま
|
|
2083
|
+
毎晩のAI生成が走っている状態で、控えめに出しても読まれない */
|
|
2084
|
+
.home-unfilled {
|
|
2085
|
+
display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between;
|
|
2086
|
+
background: #fef2f2; border: 2px solid #fca5a5; border-radius: 10px;
|
|
2087
|
+
padding: 16px 18px; margin: 4px 0 18px;
|
|
2088
|
+
}
|
|
2089
|
+
.home-unfilled-body { flex: 1 1 320px; min-width: 0; }
|
|
2090
|
+
.home-unfilled-title { font-size: 15.5px; font-weight: 700; color: #b91c1c; margin-bottom: 6px; }
|
|
2091
|
+
.home-unfilled-text { font-size: 13.5px; color: #991b1b; line-height: 1.8; }
|
|
2092
|
+
.home-unfilled-actions { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
2093
|
+
.home-grounding { margin: 0 0 8px; }
|
|
2094
|
+
.home-grounding-title {
|
|
2095
|
+
font-size: 17px; font-weight: 700; margin: 18px 0 8px;
|
|
2096
|
+
padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
|
2097
|
+
}
|
|
2098
|
+
.home-grounding > .home-grounding-title:first-child { margin-top: 0; }
|
|
2099
|
+
/* ---- 操作タブ末尾の「この仕組みについて」(旧 Home.md から移設)---- */
|
|
2100
|
+
.about-cortex { margin-top: 36px; padding-top: 24px; border-top: 1px solid var(--border); }
|
|
2101
|
+
.about-cortex .section-title { font-size: 17px; font-weight: 700; margin: 0 0 10px; }
|
|
2102
|
+
.about-lead { font-size: 13.5px; line-height: 1.9; color: var(--muted); margin: 0 0 8px; }
|
|
2103
|
+
.about-sub { font-size: 14px; font-weight: 700; margin: 18px 0 6px; }
|
|
2104
|
+
.about-list { margin: 0; padding-left: 1.3em; font-size: 13.5px; line-height: 1.9; }
|
|
2105
|
+
.about-list li { margin-bottom: 2px; }
|
|
2106
|
+
.about-note { font-size: 13.5px; line-height: 1.9; margin: 12px 0 0; }
|
|
2107
|
+
.about-quote {
|
|
2108
|
+
margin: 14px 0 0; padding: 10px 14px; border-left: 3px solid var(--border);
|
|
2109
|
+
background: #f8f9fa; font-size: 13px; line-height: 1.9; color: var(--muted);
|
|
2110
|
+
}
|
|
2081
2111
|
/* AIからの更新提案がある印。draft(黄・要対応)とも latest(緑・新着)とも別の色にする
|
|
2082
2112
|
——「読んで直すかどうか決める」という、どちらとも違う行動を求めるものなので */
|
|
2083
2113
|
.badge.proposed { background: #eef2ff; color: #3730a3; border: 1px solid #c7d2fe; }
|
|
@@ -2710,6 +2740,21 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
|
|
|
2710
2740
|
.file-chip > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
|
|
2711
2741
|
.file-chip-x { border: none; background: none; cursor: pointer; color: var(--muted); font-size: 16px; line-height: 1; padding: 0 4px; }
|
|
2712
2742
|
.file-chip-x:hover { color: #dc2626; }
|
|
2743
|
+
/* 会議日(#277)。ファイル名から推せなかったチップは黄色にして「当日を仮置きした」ことを見せる */
|
|
2744
|
+
.file-chip-date {
|
|
2745
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 1px 5px; font-size: 11.5px;
|
|
2746
|
+
color: var(--text); background: var(--surface); font-family: inherit;
|
|
2747
|
+
}
|
|
2748
|
+
.file-chip.is-unguessed { background: #fffbeb; border-color: #fcd34d; color: #92400e; }
|
|
2749
|
+
.file-chip-guess { font-size: 11px; font-weight: 700; color: #92400e; white-space: nowrap; }
|
|
2750
|
+
/* 会議名の欄(選択肢+新規入力+入力エラー) */
|
|
2751
|
+
.meeting-field { display: flex; flex-direction: column; gap: 6px; }
|
|
2752
|
+
.meeting-error { min-height: 0; }
|
|
2753
|
+
/* 「その日はもう議事録がある」の注意。送信は妨げないので、エラー(赤)とは色を変える */
|
|
2754
|
+
.modal-warn {
|
|
2755
|
+
background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px;
|
|
2756
|
+
padding: 8px 12px; font-size: 12.5px; color: #92400e; line-height: 1.7;
|
|
2757
|
+
}
|
|
2713
2758
|
.summary-box { background: var(--accent-weak); border: 1px solid #dce6fb; border-radius: var(--radius); padding: 12px 16px; color: #1e3a8a; }
|
|
2714
2759
|
.deciders { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
2715
2760
|
.refs { margin: 0; padding-left: 18px; }
|
|
@@ -3163,11 +3208,29 @@ ${deriveCapTile.toString()}
|
|
|
3163
3208
|
// Home(案件の入口)のAI編集。Gold層の4区画と違い規約READMEが無いので、Home自身を読ませて直させる。
|
|
3164
3209
|
// 「このプロジェクトが目指すもの」はAIが判断の方向を決める材料なので、未記入なら埋めることを促す。
|
|
3165
3210
|
var HOME_EDIT_PROMPT = [
|
|
3166
|
-
"Cortex/Home.md
|
|
3167
|
-
"
|
|
3211
|
+
"Cortex/Home.md を読んでから、この案件の入口ページを修正してください。",
|
|
3212
|
+
"何を直すかは、まずわたしに質問してください。「この案件について」「ゴール」が未記入なら、そこを埋めるところから始めてください。",
|
|
3168
3213
|
"frontmatter(識別カード・engine 設定)は触らないでください。日付・進捗・実績値は書かないでください(変わるものの正本は課題管理ツール等の外部にあります)。",
|
|
3214
|
+
"このサイトの使い方の説明は書かないでください(説明は画面の側が持っています)。",
|
|
3169
3215
|
"書き込む前に、変更内容を提示して承認を得てください。",
|
|
3170
3216
|
].join("\\n");
|
|
3217
|
+
/**
|
|
3218
|
+
* 未記入の Home を埋めるためのプロンプト(「AIで書く」)。
|
|
3219
|
+
*
|
|
3220
|
+
* **「編集」とは別に用意する。** 編集のプロンプトは「何を直すか質問して」から始まるが、
|
|
3221
|
+
* ここで必要なのは**ゼロから聞き出して書く**こと。27案件中24案件が未記入のまま、
|
|
3222
|
+
* 毎晩のAI生成が案件を知らずに抽出を続けている状態を解くのが目的なので、
|
|
3223
|
+
* 「何をする案件か・誰のためか・どうなったら成功か」を必ず聞かせる。
|
|
3224
|
+
*/
|
|
3225
|
+
var HOME_WRITE_PROMPT = [
|
|
3226
|
+
"Cortex/Home.md を読んでから、この案件の「この案件について」「ゴール」の2つの節を埋めてください。",
|
|
3227
|
+
"まず、わたしに質問して内容を聞き出してください。少なくとも次の3つを確かめてください: 何をする案件か/誰のための案件か(顧客・利用者は誰か)/どうなったら成功と言えるか。",
|
|
3228
|
+
"聞き出した内容をもとに、2つの節を具体的な散文で書いてください。雛形の {{…}} は残さず、実際の内容に置き換えてください。",
|
|
3229
|
+
"この2つの節は、毎晩のAI生成(決定事項・用語集・メンバー・ルールの抽出)が案件を理解するために読む場所です。抽象的な言葉ではなく、この案件に固有の具体で書いてください。",
|
|
3230
|
+
"このサイトの使い方の説明は書かないでください(説明は画面の側が持っています)。",
|
|
3231
|
+
"frontmatter(識別カード・engine 設定)は触らないでください。日付・進捗・実績値は書かないでください(変わるものの正本は課題管理ツール等の外部にあります)。",
|
|
3232
|
+
"書き込む前に、書いた内容を提示して承認を得てください。",
|
|
3233
|
+
].join("\\n");
|
|
3171
3234
|
var GOLD_SKILL = {
|
|
3172
3235
|
"Decisions": {
|
|
3173
3236
|
skill: "update-decision",
|
|
@@ -3390,12 +3453,13 @@ ${deriveCapTile.toString()}
|
|
|
3390
3453
|
}
|
|
3391
3454
|
// 本文HTMLは bodies/<i>.html に外出しされている。詳細表示時にfetchして埋める(キャッシュ・失敗時フォールバック付き)
|
|
3392
3455
|
var bodyCache = {};
|
|
3393
|
-
function loadBody(rec, mount) {
|
|
3394
|
-
|
|
3395
|
-
if (
|
|
3456
|
+
function loadBody(rec, mount, after) {
|
|
3457
|
+
function done() { if (typeof after === "function") after(mount); }
|
|
3458
|
+
if (!rec || !rec.bodyRef) { mount.innerHTML = linkifyWiki((rec && rec.bodyHtml) || ""); done(); return; }
|
|
3459
|
+
if (bodyCache[rec.bodyRef] != null) { mount.innerHTML = bodyCache[rec.bodyRef]; done(); return; }
|
|
3396
3460
|
mount.innerHTML = '<div class="body-loading">読み込み中…</div>';
|
|
3397
3461
|
fetch(rec.bodyRef).then(function (res) { if (!res.ok) throw new Error("http " + res.status); return res.text(); })
|
|
3398
|
-
.then(function (html) { var lk = linkifyWiki(html); bodyCache[rec.bodyRef] = lk; mount.innerHTML = lk; })
|
|
3462
|
+
.then(function (html) { var lk = linkifyWiki(html); bodyCache[rec.bodyRef] = lk; mount.innerHTML = lk; done(); })
|
|
3399
3463
|
.catch(function () {
|
|
3400
3464
|
mount.innerHTML = '<div class="body-loading">本文を読み込めませんでした(静的サーバで配信してください)。' +
|
|
3401
3465
|
(rec.editUrl ? ' <a href="' + rec.editUrl + '" target="_blank" rel="noopener">GitHubで開く</a>' : '') + '</div>';
|
|
@@ -3603,13 +3667,90 @@ ${deriveCapTile.toString()}
|
|
|
3603
3667
|
}
|
|
3604
3668
|
head.appendChild(el("div", { class: "detail-actions" }, [ homeEditRow ]));
|
|
3605
3669
|
app.appendChild(head);
|
|
3670
|
+
|
|
3671
|
+
// **案件の自己記述を最上部に出す。** ここが埋まっていないと、毎晩のAI生成は
|
|
3672
|
+
// 案件を知らないまま抽出を続ける。未記入なら、本文より先に警告と書く導線を出す
|
|
3673
|
+
var g = SITE.homeGrounding;
|
|
3674
|
+
if (g && g.unfilled) app.appendChild(homeUnfilledCard());
|
|
3675
|
+
if (g && g.sections.length) app.appendChild(homeGroundingSections(g.sections));
|
|
3676
|
+
|
|
3606
3677
|
var homeBody = el("div", { class: "body" });
|
|
3607
3678
|
app.appendChild(homeBody);
|
|
3608
|
-
|
|
3679
|
+
// **旧い節が本文に残っていたら落とす(防御)。** 本来は build 側で抜けているが、
|
|
3680
|
+
// 落とし損ねると自己記述が2度出る・移設したはずの説明が両方に出る、という
|
|
3681
|
+
// 一番読み手を混乱させる形になるので、描画側でも同じ見出しを見張る。
|
|
3682
|
+
// **本文が入った後に呼ぶ**(外出しされていると fetch なので、直後ではまだ空)
|
|
3683
|
+
loadBody(h, homeBody, dropLegacyHomeSections);
|
|
3609
3684
|
// Markdown本文の下にオンボーディングのチュートリアルを差し込む
|
|
3610
3685
|
renderTutorialSection();
|
|
3611
3686
|
}
|
|
3612
3687
|
|
|
3688
|
+
/** 最上部に出す「案件の自己記述」。中身は build 側で組み立て済み(Markdown由来のHTML) */
|
|
3689
|
+
function homeGroundingSections(sections) {
|
|
3690
|
+
var box = el("section", { class: "home-grounding" });
|
|
3691
|
+
sections.forEach(function (s) {
|
|
3692
|
+
box.appendChild(el("h2", { class: "home-grounding-title", text: s.heading }));
|
|
3693
|
+
// **ここだけ innerHTML。** 中身は案件リポジトリの Markdown を build 側で
|
|
3694
|
+
// レンダリングしたもので、本文(.body)と同じ出どころ・同じ扱い
|
|
3695
|
+
box.appendChild(el("div", { class: "body", html: s.html }));
|
|
3696
|
+
});
|
|
3697
|
+
return box;
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/**
|
|
3701
|
+
* 未記入の警告。**「書かれていないと何が起きるか」まで書く。**
|
|
3702
|
+
*
|
|
3703
|
+
* 「未記入です」だけだと、埋めない理由(忙しい・後でいい)に勝てない。
|
|
3704
|
+
* 実際に起きているのは「毎晩のAI生成が案件を誤解したまま走り続ける」ことなので、
|
|
3705
|
+
* その因果を出す。27案件中24案件が未記入という実測がこの文面の根拠。
|
|
3706
|
+
*/
|
|
3707
|
+
function homeUnfilledCard() {
|
|
3708
|
+
var card = el("div", { class: "home-unfilled" }, [
|
|
3709
|
+
el("div", { class: "home-unfilled-body" }, [
|
|
3710
|
+
el("div", { class: "home-unfilled-title", text: "この案件の概要とゴールが未記入です。" }),
|
|
3711
|
+
el("div", {
|
|
3712
|
+
class: "home-unfilled-text",
|
|
3713
|
+
text: "書かれていないと、毎晩のAI生成(決定事項・用語・ルール・名簿)がこの案件を誤解したまま抽出を続けます。",
|
|
3714
|
+
}),
|
|
3715
|
+
]),
|
|
3716
|
+
]);
|
|
3717
|
+
var actions = el("div", { class: "home-unfilled-actions" }, [
|
|
3718
|
+
el("a", { class: "ai-add-btn", href: codeUrl(HOME_WRITE_PROMPT), target: "_blank", rel: "noopener", text: "AIで書く" }),
|
|
3719
|
+
]);
|
|
3720
|
+
if (SITE.home && SITE.home.editUrl) {
|
|
3721
|
+
actions.appendChild(el("a", { class: "edit-btn", href: SITE.home.editUrl, target: "_blank", rel: "noopener", text: "GitHubで編集" }));
|
|
3722
|
+
}
|
|
3723
|
+
card.appendChild(actions);
|
|
3724
|
+
return card;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
/**
|
|
3728
|
+
* 本文に残った旧い節(使い方の説明・旧い自己記述)を、見出しから次の見出しまで丸ごと外す。
|
|
3729
|
+
*
|
|
3730
|
+
* 移行前のリポジトリでも重複表示しないための防御。移行後はそもそも本文に無いので
|
|
3731
|
+
* 何も起きない(自然消滅する)。
|
|
3732
|
+
*/
|
|
3733
|
+
var HOME_DROP_HEADINGS = {
|
|
3734
|
+
"この仕組みについて": 1,
|
|
3735
|
+
"情報の見方": 1,
|
|
3736
|
+
"このサイトで見られるもの": 1,
|
|
3737
|
+
"この案件について": 1,
|
|
3738
|
+
"ゴール": 1,
|
|
3739
|
+
"体制": 1,
|
|
3740
|
+
"このプロジェクトが目指すもの": 1,
|
|
3741
|
+
};
|
|
3742
|
+
function dropLegacyHomeSections(mount) {
|
|
3743
|
+
if (!mount) return;
|
|
3744
|
+
var kids = Array.prototype.slice.call(mount.children);
|
|
3745
|
+
var dropping = false;
|
|
3746
|
+
kids.forEach(function (n) {
|
|
3747
|
+
if (n.tagName === "H2") dropping = !!HOME_DROP_HEADINGS[(n.textContent || "").trim()];
|
|
3748
|
+
// H2 より上位の見出しが来たら、そこで打ち切る(節の外まで消さない)
|
|
3749
|
+
else if (n.tagName === "H1") dropping = false;
|
|
3750
|
+
if (dropping && n.parentNode) n.parentNode.removeChild(n);
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3613
3754
|
// ---------- データソースと自動化(fleet-status.json 由来。無ければ非表示) ----------
|
|
3614
3755
|
// 顧客も読む面なので内部用語(cron・stub等)を避け「毎晩自動で〜」調にする。
|
|
3615
3756
|
var DS_TYPE = {
|
|
@@ -8640,6 +8781,63 @@ ${deriveCapTile.toString()}
|
|
|
8640
8781
|
refreshStateTimer();
|
|
8641
8782
|
// **取り込みカードと鍵カードは能力セクションへ移した**(#101)。
|
|
8642
8783
|
// ここで再度出すと二重になる。手動投入は 会議 / 共有資料 の中にある。
|
|
8784
|
+
app.appendChild(buildAboutSection());
|
|
8785
|
+
}
|
|
8786
|
+
|
|
8787
|
+
/**
|
|
8788
|
+
* 「この仕組みについて」——旧 Home.md にあった使い方の説明。**ビューアが持つ。**
|
|
8789
|
+
*
|
|
8790
|
+
* 案件ごとのリポジトリに同じ説明が27枚コピーされている状態で、文言を1つ直すのに
|
|
8791
|
+
* 全案件へマイグレーションが要った。説明はツールの一部なので、ツールが持つのが正しい。
|
|
8792
|
+
*
|
|
8793
|
+
* **操作タブの最下部に置く。** Home は案件の自己記述に絞る(そこがAI生成の入力になる)ので、
|
|
8794
|
+
* ツールの説明が上に載っていると、案件を知りたい人にとっての雑音になる。
|
|
8795
|
+
* 使い方を知りたい人は設定を見に来た人と重なるので、操作タブの末尾が近い。
|
|
8796
|
+
*
|
|
8797
|
+
* **文言は旧 Home.md から持ってきて、いまの画面に合わせて直してある(分類B)。**
|
|
8798
|
+
* 直したのは1点——「各ページの『AIで編集』から直せます」は、確認済みボタン(0.12.7)が
|
|
8799
|
+
* 入って「直さずに確認だけする」経路ができたので、両方を書く形にした。
|
|
8800
|
+
* 旧文面のままだと、押せるボタンのうち片方しか案内されない。
|
|
8801
|
+
*/
|
|
8802
|
+
function buildAboutSection() {
|
|
8803
|
+
var sec = el("section", { class: "about-cortex" });
|
|
8804
|
+
sec.appendChild(el("h2", { class: "section-title", text: "この仕組みについて" }));
|
|
8805
|
+
sec.appendChild(el("p", { class: "about-lead", text:
|
|
8806
|
+
"議事録・課題・共有資料・デザイン・ソースコードといった案件の情報は、普段使っているツールに散らばっています。この仕組みはそれらを自動で集め、「あとで判断のよりどころになる確定した情報」だけを抽出した層を毎晩育てます。人もAIも、まずここを読めば案件を誤らずに把握できる、という状態を作るのが目的です。" }));
|
|
8807
|
+
|
|
8808
|
+
sec.appendChild(el("h3", { class: "about-sub", text: "情報の見方は2つあります" }));
|
|
8809
|
+
var ways = el("ul", { class: "about-list" });
|
|
8810
|
+
ways.appendChild(el("li", {}, [
|
|
8811
|
+
el("strong", { text: "このサイト" }),
|
|
8812
|
+
el("span", { text: ": 精製された確定情報を読む" }),
|
|
8813
|
+
]));
|
|
8814
|
+
var codeLi = el("li", {}, [
|
|
8815
|
+
el("a", { href: "https://claude.ai/code/", target: "_blank", rel: "noopener", text: "Claude Code" }),
|
|
8816
|
+
el("span", { text: ": 議事録・課題・資料の生データまで含めて、AIに質問・調査する" }),
|
|
8817
|
+
]);
|
|
8818
|
+
ways.appendChild(codeLi);
|
|
8819
|
+
sec.appendChild(ways);
|
|
8820
|
+
|
|
8821
|
+
sec.appendChild(el("h3", { class: "about-sub", text: "このサイトで見られるもの" }));
|
|
8822
|
+
var what = el("ul", { class: "about-list" });
|
|
8823
|
+
[
|
|
8824
|
+
["決定事項", "何が決まったか(1決定=1ページ)。議事録や課題から毎晩自動で追加されます"],
|
|
8825
|
+
["用語集", "この案件固有の言葉の意味。人とAIの語彙を揃えます"],
|
|
8826
|
+
["メンバー", "誰が関わっているか。人名の表記ゆれを解決する名簿でもあります"],
|
|
8827
|
+
["ルール", "この案件で継続的に守る約束事(例:「本番リリースは金曜禁止」)"],
|
|
8828
|
+
].forEach(function (row) {
|
|
8829
|
+
what.appendChild(el("li", {}, [
|
|
8830
|
+
el("strong", { text: row[0] }),
|
|
8831
|
+
el("span", { text: ": " + row[1] }),
|
|
8832
|
+
]));
|
|
8833
|
+
});
|
|
8834
|
+
sec.appendChild(what);
|
|
8835
|
+
sec.appendChild(el("p", { class: "about-note", text:
|
|
8836
|
+
"自動で追加されたものには「AI生成・未確認」と表示されます。内容を確認して、そのままでよければ「確認済みにする」を押してください。直したいところがあれば「AIで編集」から修正できます。どちらの場合も、その印は外れます。" }));
|
|
8837
|
+
|
|
8838
|
+
sec.appendChild(el("blockquote", { class: "about-quote", text:
|
|
8839
|
+
"要件・仕様・議事録などの個別ドキュメントはここに置きません。正本は普段お使いのツール(Backlog・Google Drive・GitHub 等)にあり、その写しが自動で同期されます。この仕組みのために新しく書く場所は増えません。" }));
|
|
8840
|
+
return sec;
|
|
8643
8841
|
}
|
|
8644
8842
|
|
|
8645
8843
|
/**
|
|
@@ -9490,6 +9688,46 @@ ${deriveCapTile.toString()}
|
|
|
9490
9688
|
]);
|
|
9491
9689
|
var nameInput = el("input", { type: "text", class: "modal-name", placeholder: "投入者として記録されます" });
|
|
9492
9690
|
modal.appendChild(fieldRow("ファイル(必須)", dropzone));
|
|
9691
|
+
|
|
9692
|
+
// ---- 会議名と会議日(文字起こしのみ・#277)----
|
|
9693
|
+
//
|
|
9694
|
+
// **どの会議の・いつの回か**が決まらないと、送ったファイルは置き場所を持てない。
|
|
9695
|
+
// 以前は名前も日付もサーバ側の推測に任せていたので、同じ会議が表記ゆれで2つの
|
|
9696
|
+
// ディレクトリに割れたり、別の日の回として入ったりした。ここで人に確定させる。
|
|
9697
|
+
var meetingList = isT && SITE.meetings ? SITE.meetings : [];
|
|
9698
|
+
var MEETING_NEW = "__new__";
|
|
9699
|
+
var BACKSLASH = String.fromCharCode(92);
|
|
9700
|
+
var meetingSelect = null;
|
|
9701
|
+
var meetingNewInput = null;
|
|
9702
|
+
var meetingError = null;
|
|
9703
|
+
var dupWarn = null;
|
|
9704
|
+
// ユーザーが会議を自分で選んだか。**選んだ後はファイル名からの推測で上書きしない**
|
|
9705
|
+
var meetingTouched = false;
|
|
9706
|
+
if (isT) {
|
|
9707
|
+
var meetingBox = el("div", { class: "meeting-field" });
|
|
9708
|
+
if (meetingList.length) {
|
|
9709
|
+
meetingSelect = el("select", { class: "modal-name meeting-select" });
|
|
9710
|
+
meetingSelect.appendChild(el("option", { value: "", text: "会議を選択してください" }));
|
|
9711
|
+
meetingList.forEach(function (m) {
|
|
9712
|
+
meetingSelect.appendChild(el("option", { value: m.path, text: m.path }));
|
|
9713
|
+
});
|
|
9714
|
+
meetingSelect.appendChild(el("option", { value: MEETING_NEW, text: "+ 新しい会議名を入力" }));
|
|
9715
|
+
meetingBox.appendChild(meetingSelect);
|
|
9716
|
+
}
|
|
9717
|
+
meetingNewInput = el("input", { type: "text", class: "modal-name meeting-new", placeholder: "新しい会議名(例: デイリースクラム)" });
|
|
9718
|
+
// 選択肢がある案件では、明示的に「新しい会議名」を選ぶまで隠す。
|
|
9719
|
+
// 選択肢が1件も無い案件(初回投入)は入力欄だけを出す
|
|
9720
|
+
if (meetingSelect) meetingNewInput.hidden = true;
|
|
9721
|
+
meetingBox.appendChild(meetingNewInput);
|
|
9722
|
+
meetingError = el("div", { class: "modal-status modal-status-error meeting-error" });
|
|
9723
|
+
meetingError.hidden = true;
|
|
9724
|
+
meetingBox.appendChild(meetingError);
|
|
9725
|
+
modal.appendChild(fieldRow("会議名(必須)", meetingBox));
|
|
9726
|
+
dupWarn = el("div", { class: "modal-warn meeting-dup-warn" });
|
|
9727
|
+
dupWarn.hidden = true;
|
|
9728
|
+
modal.appendChild(dupWarn);
|
|
9729
|
+
}
|
|
9730
|
+
|
|
9493
9731
|
modal.appendChild(fieldRow("お名前(必須)", nameInput));
|
|
9494
9732
|
var status = el("div", { class: "modal-status" });
|
|
9495
9733
|
var cancelBtn = el("button", { class: "modal-cancel", text: "閉じる" });
|
|
@@ -9501,13 +9739,166 @@ ${deriveCapTile.toString()}
|
|
|
9501
9739
|
showModal(overlay, modal);
|
|
9502
9740
|
|
|
9503
9741
|
function showStatus(msg, isError) { status.className = "modal-status" + (isError ? " modal-status-error" : ""); status.textContent = msg; }
|
|
9504
|
-
|
|
9742
|
+
|
|
9743
|
+
// ---- 会議名の確定 ----
|
|
9744
|
+
function isFreeMeeting() { return !meetingSelect || meetingSelect.value === MEETING_NEW; }
|
|
9745
|
+
/**
|
|
9746
|
+
* 自由入力の会議名を整える。
|
|
9747
|
+
*
|
|
9748
|
+
* **NFC に寄せる。** macOS の Finder から拾った名前は濁点が分解された形(NFD)で
|
|
9749
|
+
* 来ることがあり、見た目が同じなのに別ディレクトリになる。
|
|
9750
|
+
* **既存の選択肢には触らない**——あちらはビルド時にリポジトリから読んだ実物なので、
|
|
9751
|
+
* 画面で書き換えると、その会議に入らなくなる。
|
|
9752
|
+
*/
|
|
9753
|
+
function normalizeMeeting(s) {
|
|
9754
|
+
var v = String(s == null ? "" : s);
|
|
9755
|
+
if (v.normalize) v = v.normalize("NFC");
|
|
9756
|
+
return v.trim();
|
|
9757
|
+
}
|
|
9758
|
+
function meetingValue() {
|
|
9759
|
+
if (!isFreeMeeting()) return meetingSelect.value;
|
|
9760
|
+
return meetingNewInput ? normalizeMeeting(meetingNewInput.value) : "";
|
|
9761
|
+
}
|
|
9762
|
+
/** 会議名が送れない理由(送れるなら空文字) */
|
|
9763
|
+
function meetingProblem() {
|
|
9764
|
+
var v = meetingValue();
|
|
9765
|
+
if (!v) return "会議名を選んでください。";
|
|
9766
|
+
if (!isFreeMeeting()) return "";
|
|
9767
|
+
if (v === "." || v === "..") return "会議名に . や .. は使えません。";
|
|
9768
|
+
// 先頭ドットは、作れても以後の選択肢に出ない(既存会議の列挙が隠しディレクトリを
|
|
9769
|
+
// 見ないため)。次の投入者が同じ会議を選べず表記ゆれの二重化になるので、最初から断る
|
|
9770
|
+
if (v.charAt(0) === ".") return "会議名を . から始めることはできません。";
|
|
9771
|
+
if (v.indexOf("/") !== -1 || v.indexOf(BACKSLASH) !== -1) {
|
|
9772
|
+
return "会議名にスラッシュや円記号(パスの区切り)は使えません。1つの階層の名前にしてください。";
|
|
9773
|
+
}
|
|
9774
|
+
return "";
|
|
9775
|
+
}
|
|
9776
|
+
function refreshMeetingError() {
|
|
9777
|
+
if (!meetingError) return;
|
|
9778
|
+
// **開いた直後に赤くしない。** 未入力は「まだ書いていない」だけで、間違いではない。
|
|
9779
|
+
// 打った内容が使えないときだけ理由を出す(未入力は送信ボタンが無効なことで伝わる)
|
|
9780
|
+
var typed = isFreeMeeting() && meetingNewInput && normalizeMeeting(meetingNewInput.value);
|
|
9781
|
+
var msg = typed ? meetingProblem() : "";
|
|
9782
|
+
meetingError.textContent = msg;
|
|
9783
|
+
meetingError.hidden = !msg;
|
|
9784
|
+
}
|
|
9785
|
+
/**
|
|
9786
|
+
* ファイル名に既存の会議名が入っていたら、その会議を選ぶ。
|
|
9787
|
+
* 一致が複数あるときは長いほうを採る(「定例」と「開発定例」なら後者が意図に近い)。
|
|
9788
|
+
*/
|
|
9789
|
+
function autoPickMeeting(names) {
|
|
9790
|
+
if (!meetingSelect || meetingTouched || meetingSelect.value) return;
|
|
9791
|
+
var best = null;
|
|
9792
|
+
meetingList.forEach(function (m) {
|
|
9793
|
+
var seg = String(m.path).split("/").pop();
|
|
9794
|
+
if (!seg) return;
|
|
9795
|
+
for (var i = 0; i < names.length; i++) {
|
|
9796
|
+
if (String(names[i]).indexOf(seg) !== -1) {
|
|
9797
|
+
if (!best || seg.length > best.seg.length) best = { seg: seg, path: m.path };
|
|
9798
|
+
return;
|
|
9799
|
+
}
|
|
9800
|
+
}
|
|
9801
|
+
});
|
|
9802
|
+
if (best) meetingSelect.value = best.path;
|
|
9803
|
+
}
|
|
9804
|
+
|
|
9805
|
+
// ---- 会議日(ファイルごと)----
|
|
9806
|
+
function pad2(n) { return ("0" + n).slice(-2); }
|
|
9807
|
+
function todayIso() {
|
|
9808
|
+
var d = new Date();
|
|
9809
|
+
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
|
|
9810
|
+
}
|
|
9811
|
+
function validYmd(y, m, d) {
|
|
9812
|
+
var mm = Number(m), dd = Number(d);
|
|
9813
|
+
return Number(y) >= 2000 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
|
|
9814
|
+
}
|
|
9815
|
+
/**
|
|
9816
|
+
* ファイル名から会議日を推す(推せなければ null)。
|
|
9817
|
+
* 区切りつき(2026-01-20 / 2026年1月20日)→ 8桁の連続(20260120)の順に試す。
|
|
9818
|
+
*
|
|
9819
|
+
* **区切りつきを先に見る。** フラット置き時代の投入分は「投入日8桁-元名」の形で、
|
|
9820
|
+
* 元名の側に本当の会議日が区切りつきで入っていることがある
|
|
9821
|
+
* (実例: 20260819-2026-01-20__デイリースクラム….txt)。8桁を先に採ると
|
|
9822
|
+
* 投入日のほうを会議日として推してしまい、しかも「推定成功」の見た目になる。
|
|
9823
|
+
*/
|
|
9824
|
+
function guessMeetingDate(name) {
|
|
9825
|
+
// **変数を s・p・ext・src・internal と名付けない。** fleet-passthrough の走査は
|
|
9826
|
+
// その名前に続くプロパティを「画面が読む fleet のフィールド」とみなすので、
|
|
9827
|
+
// 文字列メソッドを呼んだだけで誤検知になる
|
|
9828
|
+
var nm = String(name || "");
|
|
9829
|
+
var sp = nm.match(/(20\\d\\d)[-_./年]\\s?(\\d{1,2})[-_./月]\\s?(\\d{1,2})[日]?/);
|
|
9830
|
+
if (sp && validYmd(sp[1], sp[2], sp[3])) return sp[1] + "-" + pad2(Number(sp[2])) + "-" + pad2(Number(sp[3]));
|
|
9831
|
+
var m8 = nm.match(/20\\d{6}/);
|
|
9832
|
+
if (m8 && validYmd(m8[0].slice(0, 4), m8[0].slice(4, 6), m8[0].slice(6, 8))) {
|
|
9833
|
+
return m8[0].slice(0, 4) + "-" + m8[0].slice(4, 6) + "-" + m8[0].slice(6, 8);
|
|
9834
|
+
}
|
|
9835
|
+
return null;
|
|
9836
|
+
}
|
|
9837
|
+
function compactDate(iso) { return String(iso || "").split("-").join(""); }
|
|
9838
|
+
|
|
9839
|
+
// ---- すでに議事録がある日 ----
|
|
9840
|
+
function selectedMeeting() {
|
|
9841
|
+
var v = meetingValue();
|
|
9842
|
+
if (!v) return null;
|
|
9843
|
+
for (var i = 0; i < meetingList.length; i++) if (meetingList[i].path === v) return meetingList[i];
|
|
9844
|
+
return null;
|
|
9845
|
+
}
|
|
9846
|
+
function dupEntries() {
|
|
9847
|
+
var m = selectedMeeting();
|
|
9848
|
+
if (!m || !m.minuteDates || !m.minuteDates.length) return [];
|
|
9849
|
+
return selectedFiles.filter(function (e) {
|
|
9850
|
+
return e.date && m.minuteDates.indexOf(compactDate(e.date)) !== -1;
|
|
9851
|
+
});
|
|
9852
|
+
}
|
|
9853
|
+
function refreshDupWarn() {
|
|
9854
|
+
if (!dupWarn) return;
|
|
9855
|
+
var dups = dupEntries();
|
|
9856
|
+
dupWarn.hidden = !dups.length;
|
|
9857
|
+
dupWarn.textContent = dups.length
|
|
9858
|
+
? "⚠ この日付には既に議事録があります。ファイルは保存されますが、議事録の自動生成は行われません: "
|
|
9859
|
+
+ dups.map(function (e) { return e.file.name; }).join(", ")
|
|
9860
|
+
: "";
|
|
9861
|
+
}
|
|
9862
|
+
|
|
9863
|
+
function update() {
|
|
9864
|
+
refreshMeetingError();
|
|
9865
|
+
refreshDupWarn();
|
|
9866
|
+
var ok = !!(nameInput.value.trim() && selectedFiles.length);
|
|
9867
|
+
// 文字起こしは会議名が確定し、各ファイルの日付が入っているときだけ送れる
|
|
9868
|
+
if (isT) {
|
|
9869
|
+
ok = ok && !meetingProblem() && selectedFiles.every(function (e) { return !!e.date; });
|
|
9870
|
+
}
|
|
9871
|
+
submitBtn.disabled = !ok;
|
|
9872
|
+
}
|
|
9505
9873
|
function renderFiles() {
|
|
9506
9874
|
fileListEl.textContent = "";
|
|
9507
|
-
selectedFiles.forEach(function (
|
|
9875
|
+
selectedFiles.forEach(function (entry, idx) {
|
|
9876
|
+
var chip = el("span", { class: "file-chip" + (isT && !entry.guessed ? " is-unguessed" : "") });
|
|
9877
|
+
chip.appendChild(el("span", { text: entry.file.name }));
|
|
9878
|
+
if (isT) {
|
|
9879
|
+
// 推せなかったファイルは当日を仮置きしてある。**そのまま送られないよう印を出す**
|
|
9880
|
+
var note = entry.guessed ? null : el("span", { class: "file-chip-guess", text: "日付を確認" });
|
|
9881
|
+
var dateInput = el("input", { type: "date", class: "file-chip-date", "aria-label": entry.file.name + " の会議日" });
|
|
9882
|
+
dateInput.value = entry.date;
|
|
9883
|
+
// **クリックがドロップゾーンへ抜けないようにする。** 抜けるとファイル選択が
|
|
9884
|
+
// 開き、日付を直そうとしただけでダイアログが出る
|
|
9885
|
+
dateInput.addEventListener("click", function (e) { e.stopPropagation(); });
|
|
9886
|
+
dateInput.addEventListener("change", function () {
|
|
9887
|
+
entry.date = dateInput.value;
|
|
9888
|
+
entry.guessed = true; // 人が触った日付は「確認済み」——注意を出し続けない
|
|
9889
|
+
// **ここで renderFiles() を呼ばない。** チップごと作り直すと入力欄が
|
|
9890
|
+
// 差し替わり、日付を打っている途中でフォーカスが飛ぶ
|
|
9891
|
+
chip.className = "file-chip";
|
|
9892
|
+
if (note && note.parentNode) note.parentNode.removeChild(note);
|
|
9893
|
+
update();
|
|
9894
|
+
});
|
|
9895
|
+
chip.appendChild(dateInput);
|
|
9896
|
+
if (note) chip.appendChild(note);
|
|
9897
|
+
}
|
|
9508
9898
|
var x = el("button", { class: "file-chip-x", type: "button", text: "×" });
|
|
9509
9899
|
x.addEventListener("click", function (e) { e.stopPropagation(); selectedFiles.splice(idx, 1); renderFiles(); update(); });
|
|
9510
|
-
|
|
9900
|
+
chip.appendChild(x);
|
|
9901
|
+
fileListEl.appendChild(chip);
|
|
9511
9902
|
});
|
|
9512
9903
|
}
|
|
9513
9904
|
function isAllowed(f) {
|
|
@@ -9519,9 +9910,16 @@ ${deriveCapTile.toString()}
|
|
|
9519
9910
|
}
|
|
9520
9911
|
function addFiles(list) {
|
|
9521
9912
|
var rejected = [];
|
|
9913
|
+
var addedNames = [];
|
|
9522
9914
|
Array.prototype.slice.call(list || []).forEach(function (f) {
|
|
9523
|
-
if (isAllowed(f))
|
|
9915
|
+
if (!isAllowed(f)) { rejected.push(f.name || "(名前なし)"); return; }
|
|
9916
|
+
var guess = isT ? guessMeetingDate(f.name) : null;
|
|
9917
|
+
// 推せなければ当日を仮置きする。**空のままにしない**——日付が空の入力欄は
|
|
9918
|
+
// 「入れなくてよい」に見えるが、実際は送れない(送信ボタンが無効なだけ)
|
|
9919
|
+
selectedFiles.push({ file: f, date: isT ? (guess || todayIso()) : "", guessed: !!guess });
|
|
9920
|
+
addedNames.push(f.name || "");
|
|
9524
9921
|
});
|
|
9922
|
+
if (isT && addedNames.length) autoPickMeeting(addedNames);
|
|
9525
9923
|
renderFiles();
|
|
9526
9924
|
update();
|
|
9527
9925
|
if (rejected.length) showStatus("⚠ 対応していない形式のため追加できませんでした: " + rejected.join(", ") + "(対応形式: " + allowedLabel + ")", true);
|
|
@@ -9533,24 +9931,41 @@ ${deriveCapTile.toString()}
|
|
|
9533
9931
|
dropzone.addEventListener("dragleave", function () { dropzone.classList.remove("dragover"); });
|
|
9534
9932
|
dropzone.addEventListener("drop", function (e) { e.preventDefault(); dropzone.classList.remove("dragover"); addFiles(e.dataTransfer.files); });
|
|
9535
9933
|
nameInput.addEventListener("input", update);
|
|
9934
|
+
if (meetingSelect) {
|
|
9935
|
+
meetingSelect.addEventListener("change", function () {
|
|
9936
|
+
meetingTouched = true;
|
|
9937
|
+
meetingNewInput.hidden = meetingSelect.value !== MEETING_NEW;
|
|
9938
|
+
// 選択肢に戻したら打ちかけの名前は捨てる(隠れた入力が送られるのを防ぐ)
|
|
9939
|
+
if (meetingSelect.value !== MEETING_NEW) meetingNewInput.value = "";
|
|
9940
|
+
update();
|
|
9941
|
+
});
|
|
9942
|
+
}
|
|
9943
|
+
if (meetingNewInput) meetingNewInput.addEventListener("input", update);
|
|
9536
9944
|
|
|
9537
9945
|
// 閉じる経路(Escape・背景クリック)は makeModal 側に用意されている
|
|
9538
9946
|
cancelBtn.addEventListener("click", function () { mk.close(); });
|
|
9539
|
-
submitBtn.addEventListener("click", function () {
|
|
9947
|
+
submitBtn.addEventListener("click", function () {
|
|
9948
|
+
// 全ファイルが「議事録のある日」なら、成功メッセージも「生成はされない」側に寄せる。
|
|
9949
|
+
// ✓ の直後に「数分後に議事録が自動生成され…」と出すと、待っても出ない
|
|
9950
|
+
var allDup = isT && selectedFiles.length > 0 && dupEntries().length === selectedFiles.length;
|
|
9951
|
+
submitIntake(kind, selectedFiles, nameInput.value, status, submitBtn, isT ? meetingValue() : null, allDup);
|
|
9952
|
+
});
|
|
9540
9953
|
}
|
|
9541
|
-
async function submitIntake(kind,
|
|
9954
|
+
async function submitIntake(kind, entries, submitter, status, submitBtn, meeting, allDup) {
|
|
9542
9955
|
var intake = SITE.intake;
|
|
9956
|
+
var isT = kind === "transcript";
|
|
9543
9957
|
function setStatus(msg, isError) { status.className = "modal-status" + (isError ? " modal-status-error" : ""); status.textContent = msg; }
|
|
9544
9958
|
if (!submitter || !submitter.trim()) { setStatus("⚠ お名前を入力してください。", true); return; }
|
|
9545
|
-
if (!
|
|
9959
|
+
if (!entries || !entries.length) { setStatus("⚠ ファイルを選択してください。", true); return; }
|
|
9960
|
+
if (isT && !meeting) { setStatus("⚠ 会議名を選んでください。", true); return; }
|
|
9546
9961
|
submitBtn.disabled = true;
|
|
9547
9962
|
var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
|
|
9548
9963
|
function fail(msg) { setStatus("✗ " + msg, true); submitBtn.disabled = false; }
|
|
9549
9964
|
try {
|
|
9550
9965
|
var uploaded = [];
|
|
9551
|
-
for (var i = 0; i <
|
|
9552
|
-
var f =
|
|
9553
|
-
setStatus("アップロード中… (" + (i + 1) + "/" +
|
|
9966
|
+
for (var i = 0; i < entries.length; i++) {
|
|
9967
|
+
var f = entries[i].file;
|
|
9968
|
+
setStatus("アップロード中… (" + (i + 1) + "/" + entries.length + ") " + f.name, false);
|
|
9554
9969
|
var pres;
|
|
9555
9970
|
try {
|
|
9556
9971
|
pres = await fetch(pfx + "op=presign&t=" + encodeURIComponent(intake.token), {
|
|
@@ -9564,20 +9979,27 @@ ${deriveCapTile.toString()}
|
|
|
9564
9979
|
put = await fetch(pres.j.url, { method: "PUT", headers: { "content-type": f.type || "application/octet-stream" }, body: f });
|
|
9565
9980
|
} catch (net2) { return fail("ファイルのアップロードに失敗しました(通信エラー)。もう一度お試しください。"); }
|
|
9566
9981
|
if (!put.ok) return fail("ファイルのアップロードに失敗しました(" + put.status + ")。ファイルのサイズ・形式をご確認ください。");
|
|
9567
|
-
|
|
9982
|
+
var up = { key: pres.j.key, filename: f.name };
|
|
9983
|
+
// 会議日は YYYYMMDD で送る(そのまま日付ディレクトリの名前になる)
|
|
9984
|
+
if (isT) up.date = String(entries[i].date || "").split("-").join("");
|
|
9985
|
+
uploaded.push(up);
|
|
9568
9986
|
}
|
|
9569
9987
|
setStatus("取り込み中…", false);
|
|
9570
9988
|
var sub;
|
|
9571
9989
|
try {
|
|
9990
|
+
var payload = { projectKey: intake.projectKey, kind: kind, files: uploaded, submitter: submitter.trim() };
|
|
9991
|
+
if (isT) payload.meeting = meeting;
|
|
9572
9992
|
sub = await fetch(pfx + "op=submit&t=" + encodeURIComponent(intake.token), {
|
|
9573
9993
|
method: "POST", headers: { "content-type": "application/json" },
|
|
9574
|
-
body: JSON.stringify(
|
|
9994
|
+
body: JSON.stringify(payload),
|
|
9575
9995
|
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); });
|
|
9576
9996
|
} catch (net3) { return fail("サーバーに接続できませんでした。もう一度お試しください。"); }
|
|
9577
9997
|
if (!sub.ok) return fail((sub.j && sub.j.message) || "取り込みに失敗しました。時間をおいて再度お試しください。");
|
|
9578
9998
|
status.className = "modal-status";
|
|
9579
9999
|
status.textContent = "";
|
|
9580
|
-
status.appendChild(el("div", { class: "modal-ok", text:
|
|
10000
|
+
status.appendChild(el("div", { class: "modal-ok", text: allDup
|
|
10001
|
+
? "✓ 送信しました。保存されました(この日付には既に議事録があるため、議事録の自動生成は行われません)"
|
|
10002
|
+
: "✓ 送信しました。数分後に" + (isT ? "議事録が自動生成され" : "内容がMarkdownに変換されて") + "反映されます。" }));
|
|
9581
10003
|
var paths = (sub.j && sub.j.committed) || [];
|
|
9582
10004
|
if (paths.length && sub.j.repo) {
|
|
9583
10005
|
var br = SITE.branch || "main";
|
package/package.json
CHANGED