@takagaki/cortex-decisions-viewer 0.12.9 → 0.12.11

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 CHANGED
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.readFleetSources = readFleetSources;
40
40
  exports.readGoldProposals = readGoldProposals;
41
41
  exports.buildHomeGrounding = buildHomeGrounding;
42
+ exports.collectMeetings = collectMeetings;
42
43
  exports.findBacklogDir = findBacklogDir;
43
44
  exports.build = build;
44
45
  exports.createRefResolver = createRefResolver;
@@ -797,6 +798,79 @@ async function findDirByMarker(repoRoot, marker, fallback) {
797
798
  }
798
799
  return fallback;
799
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
+ }
800
874
  /** exporter が作る区画名(engine の `BACKLOG_SECTIONS` と同じ固定の名前) */
801
875
  const BACKLOG_SECTIONS = ["issues", "documents", "wiki"];
802
876
  /**
@@ -1543,6 +1617,9 @@ async function build(opts) {
1543
1617
  computeLayout(graph); // 各ノードに x/y を書き込む(クライアントはこれを初期位置に使いウォームアップ省略)
1544
1618
  // データソースと自動化(fleet-status.json 由来)。無ければ undefined でセクション非表示。
1545
1619
  const fleet = await readFleetSources(repoRoot);
1620
+ // すでにある会議(投入モーダルの「会議名」の選択肢)。intake の有無に関わらず載せる——
1621
+ // 出し分けは表示側の仕事で、ここで絞ると「投入の口を後から付けたら選択肢が空」になる
1622
+ const meetings = await collectMeetings(repoRoot);
1546
1623
  const site = {
1547
1624
  title: opts.title,
1548
1625
  generatedAt: opts.generatedAt,
@@ -1564,6 +1641,7 @@ async function build(opts) {
1564
1641
  projectKey: opts.projectKey,
1565
1642
  }
1566
1643
  : undefined,
1644
+ meetings,
1567
1645
  // **URLとトークンが両方そろったときだけ出す。** 片方だけでは貼れるURLを作れないので、
1568
1646
  // 手順だけが出て「専用URLが空」という一番混乱する形になる(intake と同じ扱い)
1569
1647
  backlogWebhook: opts.webhookUrl && opts.webhookToken
package/dist/render.js CHANGED
@@ -512,8 +512,22 @@ function sanitizeStateNow(v) {
512
512
  return out.length ? out : null;
513
513
  }
514
514
  /**
515
- * ストアの「届くか」を行に重ねる。**まとめ表より新しいときだけ**——古い確認結果で
516
- * 新しい実測を潰さない(時刻の比較は ISO 文字列の辞書順で足りる)。
515
+ * ストアの「届くか」を行に重ねる。**まとめ表の時刻とは比べない。**
516
+ * fleet-status.json chat / dev の gate・needed を焼き込まない(観測の一本化・工程1。
517
+ * 生成側の明示許可リストに無い)ので、この関数が触る行ではストアの行が
518
+ * **常に唯一かつ最良の答え**である。比べる相手がそもそも居ない。
519
+ *
520
+ * 🔴 **かつて在った鮮度ガード(`hit.at <= generatedAt` なら捨てる)は外した(#280)。**
521
+ * fleet-status.json は push のたびに再生成されるので、夜間の観測はほぼ常に「古い」側へ
522
+ * 回り、恒久的に捨てられていた——画面が「登録状況を確認中です」のまま戻らなくなる。
523
+ * 新旧の勝ち負けはサーバ側(DynamoDB の SK と、書き込み時の条件式)が解決済みで、
524
+ * 画面で二重に判定しない。ISO の秒精度とミリ秒精度を辞書順で比べると新旧が逆転する
525
+ * ハザード(`infra/lambda/shared/connection-state-keys.mjs` に記述あり)も、
526
+ * 比較をやめたことで消えている。
527
+ *
528
+ * ⚠ 工程1より前の cortex-engine が作った fleet-status.json には gate が焼き込まれて
529
+ * いることがあるが、push のたびに再生成される設計上それが残るのは休眠案件だけで、
530
+ * 休眠案件ではストアの行が唯一かつ最新の観測なので実害は無い。
517
531
  *
518
532
  * 対象はチャットと開発の行(段B。夜間チェックがこの2つを書く)。
519
533
  *
@@ -529,7 +543,7 @@ function sanitizeStateNow(v) {
529
543
  * 分からない状態に戻る。上書きするときは空でも必ず載せる——夜間の古い needed が
530
544
  * 新しい判定の横に残ると、直したはずのスコープ名がいつまでも出る
531
545
  */
532
- function overlayStateOnFleet(view, rows, generatedAt) {
546
+ function overlayStateOnFleet(view, rows) {
533
547
  const v = view || {};
534
548
  if (!rows || !rows.length || !Array.isArray(v.externalSources))
535
549
  return v;
@@ -553,8 +567,6 @@ function overlayStateOnFleet(view, rows, generatedAt) {
553
567
  hit = byKey["dev\t" + String(s.ref)];
554
568
  if (!hit)
555
569
  return s;
556
- if (generatedAt && hit.at <= generatedAt)
557
- return s;
558
570
  changed = true;
559
571
  return Object.assign({}, s, {
560
572
  gate: hit.status,
@@ -2740,6 +2752,21 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
2740
2752
  .file-chip > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
2741
2753
  .file-chip-x { border: none; background: none; cursor: pointer; color: var(--muted); font-size: 16px; line-height: 1; padding: 0 4px; }
2742
2754
  .file-chip-x:hover { color: #dc2626; }
2755
+ /* 会議日(#277)。ファイル名から推せなかったチップは黄色にして「当日を仮置きした」ことを見せる */
2756
+ .file-chip-date {
2757
+ border: 1px solid var(--border); border-radius: 6px; padding: 1px 5px; font-size: 11.5px;
2758
+ color: var(--text); background: var(--surface); font-family: inherit;
2759
+ }
2760
+ .file-chip.is-unguessed { background: #fffbeb; border-color: #fcd34d; color: #92400e; }
2761
+ .file-chip-guess { font-size: 11px; font-weight: 700; color: #92400e; white-space: nowrap; }
2762
+ /* 会議名の欄(選択肢+新規入力+入力エラー) */
2763
+ .meeting-field { display: flex; flex-direction: column; gap: 6px; }
2764
+ .meeting-error { min-height: 0; }
2765
+ /* 「その日はもう議事録がある」の注意。送信は妨げないので、エラー(赤)とは色を変える */
2766
+ .modal-warn {
2767
+ background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px;
2768
+ padding: 8px 12px; font-size: 12.5px; color: #92400e; line-height: 1.7;
2769
+ }
2743
2770
  .summary-box { background: var(--accent-weak); border: 1px solid #dce6fb; border-radius: var(--radius); padding: 12px 16px; color: #1e3a8a; }
2744
2771
  .deciders { display: flex; flex-wrap: wrap; gap: 8px; }
2745
2772
  .refs { margin: 0; padding-left: 18px; }
@@ -4723,12 +4750,11 @@ ${deriveCapTile.toString()}
4723
4750
  function fleetVal(key) {
4724
4751
  if ((LIVE_REGISTERED || LIVE_STATE) && MERGE_KEYS[key]) {
4725
4752
  // 登録の追加 → 状態の上書き、の順で1回だけ合成する。
4726
- // 状態ストアの答えは、まとめ表より新しいときだけ勝つ(overlayStateOnFleet が判定)
4753
+ // 状態ストアの答えは常に勝つ(まとめ表は gate を焼き込まない。overlayStateOnFleet の項)
4727
4754
  if (!MERGED) {
4728
4755
  MERGED = overlayStateOnFleet(
4729
4756
  mergeRegisteredFleet(rawView(), LIVE_REGISTERED),
4730
4757
  LIVE_STATE,
4731
- String(rawFleetVal("generatedAt") || ""),
4732
4758
  );
4733
4759
  }
4734
4760
  return MERGED[key];
@@ -4862,7 +4888,6 @@ ${deriveCapTile.toString()}
4862
4888
  var ov = overlayStateOnFleet(
4863
4889
  { externalSources: rawFleetVal("externalSources") || [] },
4864
4890
  LIVE_STATE,
4865
- String(rawFleetVal("generatedAt") || ""),
4866
4891
  );
4867
4892
  return {
4868
4893
  // 🔴 **能力の状態は正本(capabilitiesNow)を通す(工程1.5)。** タイルの語は
@@ -9673,6 +9698,46 @@ ${deriveCapTile.toString()}
9673
9698
  ]);
9674
9699
  var nameInput = el("input", { type: "text", class: "modal-name", placeholder: "投入者として記録されます" });
9675
9700
  modal.appendChild(fieldRow("ファイル(必須)", dropzone));
9701
+
9702
+ // ---- 会議名と会議日(文字起こしのみ・#277)----
9703
+ //
9704
+ // **どの会議の・いつの回か**が決まらないと、送ったファイルは置き場所を持てない。
9705
+ // 以前は名前も日付もサーバ側の推測に任せていたので、同じ会議が表記ゆれで2つの
9706
+ // ディレクトリに割れたり、別の日の回として入ったりした。ここで人に確定させる。
9707
+ var meetingList = isT && SITE.meetings ? SITE.meetings : [];
9708
+ var MEETING_NEW = "__new__";
9709
+ var BACKSLASH = String.fromCharCode(92);
9710
+ var meetingSelect = null;
9711
+ var meetingNewInput = null;
9712
+ var meetingError = null;
9713
+ var dupWarn = null;
9714
+ // ユーザーが会議を自分で選んだか。**選んだ後はファイル名からの推測で上書きしない**
9715
+ var meetingTouched = false;
9716
+ if (isT) {
9717
+ var meetingBox = el("div", { class: "meeting-field" });
9718
+ if (meetingList.length) {
9719
+ meetingSelect = el("select", { class: "modal-name meeting-select" });
9720
+ meetingSelect.appendChild(el("option", { value: "", text: "会議を選択してください" }));
9721
+ meetingList.forEach(function (m) {
9722
+ meetingSelect.appendChild(el("option", { value: m.path, text: m.path }));
9723
+ });
9724
+ meetingSelect.appendChild(el("option", { value: MEETING_NEW, text: "+ 新しい会議名を入力" }));
9725
+ meetingBox.appendChild(meetingSelect);
9726
+ }
9727
+ meetingNewInput = el("input", { type: "text", class: "modal-name meeting-new", placeholder: "新しい会議名(例: デイリースクラム)" });
9728
+ // 選択肢がある案件では、明示的に「新しい会議名」を選ぶまで隠す。
9729
+ // 選択肢が1件も無い案件(初回投入)は入力欄だけを出す
9730
+ if (meetingSelect) meetingNewInput.hidden = true;
9731
+ meetingBox.appendChild(meetingNewInput);
9732
+ meetingError = el("div", { class: "modal-status modal-status-error meeting-error" });
9733
+ meetingError.hidden = true;
9734
+ meetingBox.appendChild(meetingError);
9735
+ modal.appendChild(fieldRow("会議名(必須)", meetingBox));
9736
+ dupWarn = el("div", { class: "modal-warn meeting-dup-warn" });
9737
+ dupWarn.hidden = true;
9738
+ modal.appendChild(dupWarn);
9739
+ }
9740
+
9676
9741
  modal.appendChild(fieldRow("お名前(必須)", nameInput));
9677
9742
  var status = el("div", { class: "modal-status" });
9678
9743
  var cancelBtn = el("button", { class: "modal-cancel", text: "閉じる" });
@@ -9684,13 +9749,166 @@ ${deriveCapTile.toString()}
9684
9749
  showModal(overlay, modal);
9685
9750
 
9686
9751
  function showStatus(msg, isError) { status.className = "modal-status" + (isError ? " modal-status-error" : ""); status.textContent = msg; }
9687
- function update() { submitBtn.disabled = !(nameInput.value.trim() && selectedFiles.length); }
9752
+
9753
+ // ---- 会議名の確定 ----
9754
+ function isFreeMeeting() { return !meetingSelect || meetingSelect.value === MEETING_NEW; }
9755
+ /**
9756
+ * 自由入力の会議名を整える。
9757
+ *
9758
+ * **NFC に寄せる。** macOS の Finder から拾った名前は濁点が分解された形(NFD)で
9759
+ * 来ることがあり、見た目が同じなのに別ディレクトリになる。
9760
+ * **既存の選択肢には触らない**——あちらはビルド時にリポジトリから読んだ実物なので、
9761
+ * 画面で書き換えると、その会議に入らなくなる。
9762
+ */
9763
+ function normalizeMeeting(s) {
9764
+ var v = String(s == null ? "" : s);
9765
+ if (v.normalize) v = v.normalize("NFC");
9766
+ return v.trim();
9767
+ }
9768
+ function meetingValue() {
9769
+ if (!isFreeMeeting()) return meetingSelect.value;
9770
+ return meetingNewInput ? normalizeMeeting(meetingNewInput.value) : "";
9771
+ }
9772
+ /** 会議名が送れない理由(送れるなら空文字) */
9773
+ function meetingProblem() {
9774
+ var v = meetingValue();
9775
+ if (!v) return "会議名を選んでください。";
9776
+ if (!isFreeMeeting()) return "";
9777
+ if (v === "." || v === "..") return "会議名に . や .. は使えません。";
9778
+ // 先頭ドットは、作れても以後の選択肢に出ない(既存会議の列挙が隠しディレクトリを
9779
+ // 見ないため)。次の投入者が同じ会議を選べず表記ゆれの二重化になるので、最初から断る
9780
+ if (v.charAt(0) === ".") return "会議名を . から始めることはできません。";
9781
+ if (v.indexOf("/") !== -1 || v.indexOf(BACKSLASH) !== -1) {
9782
+ return "会議名にスラッシュや円記号(パスの区切り)は使えません。1つの階層の名前にしてください。";
9783
+ }
9784
+ return "";
9785
+ }
9786
+ function refreshMeetingError() {
9787
+ if (!meetingError) return;
9788
+ // **開いた直後に赤くしない。** 未入力は「まだ書いていない」だけで、間違いではない。
9789
+ // 打った内容が使えないときだけ理由を出す(未入力は送信ボタンが無効なことで伝わる)
9790
+ var typed = isFreeMeeting() && meetingNewInput && normalizeMeeting(meetingNewInput.value);
9791
+ var msg = typed ? meetingProblem() : "";
9792
+ meetingError.textContent = msg;
9793
+ meetingError.hidden = !msg;
9794
+ }
9795
+ /**
9796
+ * ファイル名に既存の会議名が入っていたら、その会議を選ぶ。
9797
+ * 一致が複数あるときは長いほうを採る(「定例」と「開発定例」なら後者が意図に近い)。
9798
+ */
9799
+ function autoPickMeeting(names) {
9800
+ if (!meetingSelect || meetingTouched || meetingSelect.value) return;
9801
+ var best = null;
9802
+ meetingList.forEach(function (m) {
9803
+ var seg = String(m.path).split("/").pop();
9804
+ if (!seg) return;
9805
+ for (var i = 0; i < names.length; i++) {
9806
+ if (String(names[i]).indexOf(seg) !== -1) {
9807
+ if (!best || seg.length > best.seg.length) best = { seg: seg, path: m.path };
9808
+ return;
9809
+ }
9810
+ }
9811
+ });
9812
+ if (best) meetingSelect.value = best.path;
9813
+ }
9814
+
9815
+ // ---- 会議日(ファイルごと)----
9816
+ function pad2(n) { return ("0" + n).slice(-2); }
9817
+ function todayIso() {
9818
+ var d = new Date();
9819
+ return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
9820
+ }
9821
+ function validYmd(y, m, d) {
9822
+ var mm = Number(m), dd = Number(d);
9823
+ return Number(y) >= 2000 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
9824
+ }
9825
+ /**
9826
+ * ファイル名から会議日を推す(推せなければ null)。
9827
+ * 区切りつき(2026-01-20 / 2026年1月20日)→ 8桁の連続(20260120)の順に試す。
9828
+ *
9829
+ * **区切りつきを先に見る。** フラット置き時代の投入分は「投入日8桁-元名」の形で、
9830
+ * 元名の側に本当の会議日が区切りつきで入っていることがある
9831
+ * (実例: 20260819-2026-01-20__デイリースクラム….txt)。8桁を先に採ると
9832
+ * 投入日のほうを会議日として推してしまい、しかも「推定成功」の見た目になる。
9833
+ */
9834
+ function guessMeetingDate(name) {
9835
+ // **変数を s・p・ext・src・internal と名付けない。** fleet-passthrough の走査は
9836
+ // その名前に続くプロパティを「画面が読む fleet のフィールド」とみなすので、
9837
+ // 文字列メソッドを呼んだだけで誤検知になる
9838
+ var nm = String(name || "");
9839
+ var sp = nm.match(/(20\\d\\d)[-_./年]\\s?(\\d{1,2})[-_./月]\\s?(\\d{1,2})[日]?/);
9840
+ if (sp && validYmd(sp[1], sp[2], sp[3])) return sp[1] + "-" + pad2(Number(sp[2])) + "-" + pad2(Number(sp[3]));
9841
+ var m8 = nm.match(/20\\d{6}/);
9842
+ if (m8 && validYmd(m8[0].slice(0, 4), m8[0].slice(4, 6), m8[0].slice(6, 8))) {
9843
+ return m8[0].slice(0, 4) + "-" + m8[0].slice(4, 6) + "-" + m8[0].slice(6, 8);
9844
+ }
9845
+ return null;
9846
+ }
9847
+ function compactDate(iso) { return String(iso || "").split("-").join(""); }
9848
+
9849
+ // ---- すでに議事録がある日 ----
9850
+ function selectedMeeting() {
9851
+ var v = meetingValue();
9852
+ if (!v) return null;
9853
+ for (var i = 0; i < meetingList.length; i++) if (meetingList[i].path === v) return meetingList[i];
9854
+ return null;
9855
+ }
9856
+ function dupEntries() {
9857
+ var m = selectedMeeting();
9858
+ if (!m || !m.minuteDates || !m.minuteDates.length) return [];
9859
+ return selectedFiles.filter(function (e) {
9860
+ return e.date && m.minuteDates.indexOf(compactDate(e.date)) !== -1;
9861
+ });
9862
+ }
9863
+ function refreshDupWarn() {
9864
+ if (!dupWarn) return;
9865
+ var dups = dupEntries();
9866
+ dupWarn.hidden = !dups.length;
9867
+ dupWarn.textContent = dups.length
9868
+ ? "⚠ この日付には既に議事録があります。ファイルは保存されますが、議事録の自動生成は行われません: "
9869
+ + dups.map(function (e) { return e.file.name; }).join(", ")
9870
+ : "";
9871
+ }
9872
+
9873
+ function update() {
9874
+ refreshMeetingError();
9875
+ refreshDupWarn();
9876
+ var ok = !!(nameInput.value.trim() && selectedFiles.length);
9877
+ // 文字起こしは会議名が確定し、各ファイルの日付が入っているときだけ送れる
9878
+ if (isT) {
9879
+ ok = ok && !meetingProblem() && selectedFiles.every(function (e) { return !!e.date; });
9880
+ }
9881
+ submitBtn.disabled = !ok;
9882
+ }
9688
9883
  function renderFiles() {
9689
9884
  fileListEl.textContent = "";
9690
- selectedFiles.forEach(function (f, idx) {
9885
+ selectedFiles.forEach(function (entry, idx) {
9886
+ var chip = el("span", { class: "file-chip" + (isT && !entry.guessed ? " is-unguessed" : "") });
9887
+ chip.appendChild(el("span", { text: entry.file.name }));
9888
+ if (isT) {
9889
+ // 推せなかったファイルは当日を仮置きしてある。**そのまま送られないよう印を出す**
9890
+ var note = entry.guessed ? null : el("span", { class: "file-chip-guess", text: "日付を確認" });
9891
+ var dateInput = el("input", { type: "date", class: "file-chip-date", "aria-label": entry.file.name + " の会議日" });
9892
+ dateInput.value = entry.date;
9893
+ // **クリックがドロップゾーンへ抜けないようにする。** 抜けるとファイル選択が
9894
+ // 開き、日付を直そうとしただけでダイアログが出る
9895
+ dateInput.addEventListener("click", function (e) { e.stopPropagation(); });
9896
+ dateInput.addEventListener("change", function () {
9897
+ entry.date = dateInput.value;
9898
+ entry.guessed = true; // 人が触った日付は「確認済み」——注意を出し続けない
9899
+ // **ここで renderFiles() を呼ばない。** チップごと作り直すと入力欄が
9900
+ // 差し替わり、日付を打っている途中でフォーカスが飛ぶ
9901
+ chip.className = "file-chip";
9902
+ if (note && note.parentNode) note.parentNode.removeChild(note);
9903
+ update();
9904
+ });
9905
+ chip.appendChild(dateInput);
9906
+ if (note) chip.appendChild(note);
9907
+ }
9691
9908
  var x = el("button", { class: "file-chip-x", type: "button", text: "×" });
9692
9909
  x.addEventListener("click", function (e) { e.stopPropagation(); selectedFiles.splice(idx, 1); renderFiles(); update(); });
9693
- fileListEl.appendChild(el("span", { class: "file-chip" }, [el("span", { text: f.name }), x]));
9910
+ chip.appendChild(x);
9911
+ fileListEl.appendChild(chip);
9694
9912
  });
9695
9913
  }
9696
9914
  function isAllowed(f) {
@@ -9702,9 +9920,16 @@ ${deriveCapTile.toString()}
9702
9920
  }
9703
9921
  function addFiles(list) {
9704
9922
  var rejected = [];
9923
+ var addedNames = [];
9705
9924
  Array.prototype.slice.call(list || []).forEach(function (f) {
9706
- if (isAllowed(f)) selectedFiles.push(f); else rejected.push(f.name || "(名前なし)");
9925
+ if (!isAllowed(f)) { rejected.push(f.name || "(名前なし)"); return; }
9926
+ var guess = isT ? guessMeetingDate(f.name) : null;
9927
+ // 推せなければ当日を仮置きする。**空のままにしない**——日付が空の入力欄は
9928
+ // 「入れなくてよい」に見えるが、実際は送れない(送信ボタンが無効なだけ)
9929
+ selectedFiles.push({ file: f, date: isT ? (guess || todayIso()) : "", guessed: !!guess });
9930
+ addedNames.push(f.name || "");
9707
9931
  });
9932
+ if (isT && addedNames.length) autoPickMeeting(addedNames);
9708
9933
  renderFiles();
9709
9934
  update();
9710
9935
  if (rejected.length) showStatus("⚠ 対応していない形式のため追加できませんでした: " + rejected.join(", ") + "(対応形式: " + allowedLabel + ")", true);
@@ -9716,24 +9941,41 @@ ${deriveCapTile.toString()}
9716
9941
  dropzone.addEventListener("dragleave", function () { dropzone.classList.remove("dragover"); });
9717
9942
  dropzone.addEventListener("drop", function (e) { e.preventDefault(); dropzone.classList.remove("dragover"); addFiles(e.dataTransfer.files); });
9718
9943
  nameInput.addEventListener("input", update);
9944
+ if (meetingSelect) {
9945
+ meetingSelect.addEventListener("change", function () {
9946
+ meetingTouched = true;
9947
+ meetingNewInput.hidden = meetingSelect.value !== MEETING_NEW;
9948
+ // 選択肢に戻したら打ちかけの名前は捨てる(隠れた入力が送られるのを防ぐ)
9949
+ if (meetingSelect.value !== MEETING_NEW) meetingNewInput.value = "";
9950
+ update();
9951
+ });
9952
+ }
9953
+ if (meetingNewInput) meetingNewInput.addEventListener("input", update);
9719
9954
 
9720
9955
  // 閉じる経路(Escape・背景クリック)は makeModal 側に用意されている
9721
9956
  cancelBtn.addEventListener("click", function () { mk.close(); });
9722
- submitBtn.addEventListener("click", function () { submitIntake(kind, selectedFiles, nameInput.value, status, submitBtn); });
9957
+ submitBtn.addEventListener("click", function () {
9958
+ // 全ファイルが「議事録のある日」なら、成功メッセージも「生成はされない」側に寄せる。
9959
+ // ✓ の直後に「数分後に議事録が自動生成され…」と出すと、待っても出ない
9960
+ var allDup = isT && selectedFiles.length > 0 && dupEntries().length === selectedFiles.length;
9961
+ submitIntake(kind, selectedFiles, nameInput.value, status, submitBtn, isT ? meetingValue() : null, allDup);
9962
+ });
9723
9963
  }
9724
- async function submitIntake(kind, files, submitter, status, submitBtn) {
9964
+ async function submitIntake(kind, entries, submitter, status, submitBtn, meeting, allDup) {
9725
9965
  var intake = SITE.intake;
9966
+ var isT = kind === "transcript";
9726
9967
  function setStatus(msg, isError) { status.className = "modal-status" + (isError ? " modal-status-error" : ""); status.textContent = msg; }
9727
9968
  if (!submitter || !submitter.trim()) { setStatus("⚠ お名前を入力してください。", true); return; }
9728
- if (!files || !files.length) { setStatus("⚠ ファイルを選択してください。", true); return; }
9969
+ if (!entries || !entries.length) { setStatus("⚠ ファイルを選択してください。", true); return; }
9970
+ if (isT && !meeting) { setStatus("⚠ 会議名を選んでください。", true); return; }
9729
9971
  submitBtn.disabled = true;
9730
9972
  var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
9731
9973
  function fail(msg) { setStatus("✗ " + msg, true); submitBtn.disabled = false; }
9732
9974
  try {
9733
9975
  var uploaded = [];
9734
- for (var i = 0; i < files.length; i++) {
9735
- var f = files[i];
9736
- setStatus("アップロード中… (" + (i + 1) + "/" + files.length + ") " + f.name, false);
9976
+ for (var i = 0; i < entries.length; i++) {
9977
+ var f = entries[i].file;
9978
+ setStatus("アップロード中… (" + (i + 1) + "/" + entries.length + ") " + f.name, false);
9737
9979
  var pres;
9738
9980
  try {
9739
9981
  pres = await fetch(pfx + "op=presign&t=" + encodeURIComponent(intake.token), {
@@ -9747,20 +9989,27 @@ ${deriveCapTile.toString()}
9747
9989
  put = await fetch(pres.j.url, { method: "PUT", headers: { "content-type": f.type || "application/octet-stream" }, body: f });
9748
9990
  } catch (net2) { return fail("ファイルのアップロードに失敗しました(通信エラー)。もう一度お試しください。"); }
9749
9991
  if (!put.ok) return fail("ファイルのアップロードに失敗しました(" + put.status + ")。ファイルのサイズ・形式をご確認ください。");
9750
- uploaded.push({ key: pres.j.key, filename: f.name });
9992
+ var up = { key: pres.j.key, filename: f.name };
9993
+ // 会議日は YYYYMMDD で送る(そのまま日付ディレクトリの名前になる)
9994
+ if (isT) up.date = String(entries[i].date || "").split("-").join("");
9995
+ uploaded.push(up);
9751
9996
  }
9752
9997
  setStatus("取り込み中…", false);
9753
9998
  var sub;
9754
9999
  try {
10000
+ var payload = { projectKey: intake.projectKey, kind: kind, files: uploaded, submitter: submitter.trim() };
10001
+ if (isT) payload.meeting = meeting;
9755
10002
  sub = await fetch(pfx + "op=submit&t=" + encodeURIComponent(intake.token), {
9756
10003
  method: "POST", headers: { "content-type": "application/json" },
9757
- body: JSON.stringify({ projectKey: intake.projectKey, kind: kind, files: uploaded, submitter: submitter.trim() }),
10004
+ body: JSON.stringify(payload),
9758
10005
  }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); });
9759
10006
  } catch (net3) { return fail("サーバーに接続できませんでした。もう一度お試しください。"); }
9760
10007
  if (!sub.ok) return fail((sub.j && sub.j.message) || "取り込みに失敗しました。時間をおいて再度お試しください。");
9761
10008
  status.className = "modal-status";
9762
10009
  status.textContent = "";
9763
- status.appendChild(el("div", { class: "modal-ok", text: "✓ 送信しました。数分後に" + (kind === "transcript" ? "議事録が自動生成され" : "内容がMarkdownに変換されて") + "反映されます。" }));
10010
+ status.appendChild(el("div", { class: "modal-ok", text: allDup
10011
+ ? "✓ 送信しました。保存されました(この日付には既に議事録があるため、議事録の自動生成は行われません)"
10012
+ : "✓ 送信しました。数分後に" + (isT ? "議事録が自動生成され" : "内容がMarkdownに変換されて") + "反映されます。" }));
9764
10013
  var paths = (sub.j && sub.j.committed) || [];
9765
10014
  if (paths.length && sub.j.repo) {
9766
10015
  var br = SITE.branch || "main";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,