| name | gkill-client-kftl |
| description | KFTL(メモ帳)の約束。タブ(kftl-tabs.ts / use-kftl-tabs.ts)、保存マーカーの beforeinput/input 対、複数ウィンドウの二重送信防止と送信タブの排他、メモ帳ダイアログの複数枚化(KFTLDialogHost・slot 採番)、KFTL パーサ(TS/Go の2実装)を扱う。src/client/classes/kftl/・kftl-tabs.ts・use-kftl-tabs.ts・use-kftl-view.ts・kftl-view.vue・mkfl-view.vue・kftl-dialog.vue・use-kftl-dialog-host.ts・src/server/gkill/api/kftl/ を編集するとき必読。「メモ帳が二重登録される」「別のタブへ保存された」「行ラベルが消える」「タブをクリックしただけで保存が走る」の調査でも必読。 |
KFTL(メモ帳)の不変条件
対象: src/client/classes/kftl/** / kftl-tabs.ts / use-kftl-tabs.ts / use-kftl-view.ts / kftl-view.vue / mkfl-view.vue / kftl-dialog.vue / use-kftl-dialog-host.ts / src/server/gkill/api/kftl/**
このファイルは全文が、実際に起きた事故の再発防止である。該当作業では飛ばさずに読むこと。
多くは「例外もエラーも出さずに静かに壊れる」種類で、破っても目の前ではエラーにならない。
gkill/api/kftl/ — KFTL custom text format parser (single package, no sub-packages). Supports both Japanese (。!?、ーー etc.) and ASCII (#!?,-- ~~ /mi /mood /expense /num /url /start /end /timeis /end? /endt /endt?) prefixes
classes/kftl/ — KFTL parser (50 statement types; the Go side has 47). Accepts the same Japanese/ASCII prefixes as the Go parser; ASCII constants and match/strip helpers centralized in kftl-prefixes.ts
Go 側のエラーは行ごとに返し、入力ミスとサーバ障害を分ける(2026-08-24、ADR-0080)。kftl_statement.go は3フェーズ直列で、行をリクエストへ適用するフェーズまでは1バイトも書かない。だからそこは最初の1件で止めず全行を評価して errors.Join で束ねる(利用者が1往復で全部直せる。TS 側は元からそうなっていて Go だけが遅れていた)。実行フェーズは書き込みが起きるので最初の失敗で止める。振り分けは KFTLInputError と errors.As で、入力ミスは ERR000416(400)・サーバ障害は ERR000351(500)。打ち間違いを 500 で返さないこと —— ステータスを見る層からサーバ障害と区別できなくなる。応答の created[] は書き込みが成功した直後にリクエスト側が控えたもので、requestMap を事前に列挙して作ってはいけない(本文が空の kmemo / Mi / Nlog は何も書かずに成功し、打刻の終了は既存レコードの更新なので嘘になる)。失敗してもそこまでに書けたぶんを載せる(KFTL は DB トランザクションを使わないので部分保存が残る)。多言語キーは各失敗に1対1で7言語ぶん既にあるので新設しない。api/kftl/ に .go を新規追加しない(verify_docs がファイル数を数えている)。
KFTL(メモ帳)のタブ(2026-08-16)。kftl-view.vue がタブのホストで、/kftl ページ・各画面のメモ帳ダイアログ(kftl-dialog.vue)・打刻メモ帳(mkfl-view.vue)の3系統すべてに効く。純関数は classes/kftl-tabs.ts、状態は classes/use-kftl-tabs.ts。守るべき約束:
v-window を使わず、アクティブなタブ1枚だけを描画する。 非表示の textarea は clientWidth が0になり、kftl-statement-line.ts の 1 + parseInt(text_width / 0) が NaN(Infinity ではない)を返して行ラベルが丸ごと消える
- タブの一覧と中身はモジュールシングルトン、いま映しているタブはビューごと。 メモ帳ダイアログは複数枚開けるので、インスタンスごとに配列を持つと片方の古い配列で localStorage を丸ごと上書きしてもう片方のタブが消える(単一文字列だった頃は「上書き」で済んでいた)。逆に
active_tab_id までストアに置くと、全ウィンドウが同じタブを映して並べて見られない。ストアが持つのは last_active_tab_id(次に開くウィンドウの初期表示)だけ。単体テストは reset_kftl_tabs_for_test() + localStorage.clear() を beforeEach で呼ぶこと
- タブが消えたらビューは追随する。 別のウィンドウがタブを閉じたり保存で閉じたりすると、そのタブを映していたビューの
active_tab_id が宙に浮く。watch(() => tabs.map(t => t.id)) で新旧を比べ、旧インデックスへクランプして隣のタブへ移す(放置すると本文が空文字を返し続ける)
- 送信対象タブは
do_submit(target_tab_id, ...) の引数で渡す。 do_submit() は未知タグ確認・未知板名確認でいったん抜けて応答を待つので、1回の保存で2〜3回呼ばれる。gkill のフローティングダイアログは非モーダル(App.vue の .gkill-float-scrim が pointer-events: none)なので、確認中でも背後のタブバーは押せる。持ち越し用の submit_target_tab_id を引数ではなく直接読ませてはいけない ―― 確認ダイアログを Escape / ブラウザバックで閉じられると古い値が残り、次の保存が別のタブへ誤配送される。新しい送信(submit())は必ずアクティブなタブを渡す
- タブ操作のロックは
is_submitting || show_confirm_unknown_tag_dialog。 is_requested_submit は設定の読み込みが終わるまで true なので鍵に使えない(起動直後にタブを追加できなくなる)。板名確認をロック条件に入れてもいけない ―― unknown_mi_boards はブラウザバックで閉じても空にならないので永久ロックになる(タグ確認は useDialogHistoryStack がどの閉じ方でも false にするので安全)
- 不正行の判定は送信対象タブから引き直す。 表示用の
invalid_line_numbers はアクティブタブのもので、しかも await をまたいで遅れて着地する。これを送信の可否に使うと、タブを切り替えた直後の保存が「おかしな行があります」で止まる
- 保存マーカーの判定は
beforeinput で控えた本文と input 時点の本文を比べ、「確定したマーカー行が増えたか」で行う(count_save_marker_lines)。watch(本文の変化)に置いてはいけない。 watch は flush: 'post' で中間の値を観測しないうえ、同じ input イベントのリスナー間でマイクロタスクが走るので @input より先に新しい本文を見てしまう(IMEでは必ず起きる)。「末尾がマーカーか」で見るのも不可。理由の詳細と却下案は ADR-0037。「確定した」= その行の後ろに改行がある、なので を打った時点では走らない。守るテストは の「KFTLの保存マーカー」節と の「IMEで確定してから改行しても自動で保存される」(。 は打鍵ごとにイベントループが回るので中間の本文を必ず観測してしまい、常に緑になる)
メモ帳ダイアログの複数枚化(2026-08-16)。5画面(rykv / mi / dashboard / saihate / plaing)は <KFTLDialogHost> を1個置くだけで、+メニューを選ぶたびにウィンドウが増える。呼び出し側は従来どおり kftl_dialog.value?.show() のまま(show() の意味が「開く or 再フォーカス」から「1枚増やす」に変わった)。配列の持ち方は rykv-dialog-host と同じ。守るべき約束:
useFloatingDialog のキーはウィンドウごとに分ける。 キーは ${key}:pos / :size / :transparent の保存先そのものなので、同じキーで複数枚出すと位置とサイズを奪い合う。スロット番号(空いている最小の番号)で kftl-dialog / kftl-dialog-2 … と分け、centerOffset に slot_index * 28 px を渡してずらす(全ダイアログが centerMode: "always" なので、ずらさないとピクセル単位で完全に重なる)
- z-index は「開いているダイアログの並び順」から出す。単調増加のカウンタにしてはいけない ―― Vuetify の overlay(メニュー / ツールチップ)が 2400 なので、上へ伸ばし続けるとダイアログの中のメニューが下へ潜る。伸びるのは同時に開いている枚数ぶんだけ
- 前面化は自分と子孫をまとめて上げる。 確認ダイアログは
Teleport to="body" で親の兄弟になるので、素朴に前面化すると親をクリックしただけで確認が後ろへ隠れる。親子は provide/inject で持つ(コンポーネント木は Teleport をまたいでも保たれる)
- バックと Escape が閉じるのは「見た目の最前面」。
use-dialog-history-stack.ts の stack は積んだ順なので、前面化で見た目とずれる。ずれたままだと奥のダイアログが閉じる。前面化のたびに raise_dialog_history_entries() で履歴エントリも同じ順へ並べ替える。2つのコンポーザブルは同じコンポーネントの setup で呼ばれるので、getCurrentInstance() を鍵にして結んでいる(stack の長さは変わらないので履歴の深さ計算は無傷)
closed は useDialogHistoryStack(is_show_dialog, { onClosed }) から出す。×・Escape・ブラウザバックのどれでも1回だけ上がるので、ホストはこれ1本で一覧から外せる
- ホストは
v-for でルートが複数になる。呼び出し側が渡してくる Kyou 系の中継束のうちメモ帳が出さないイベントは行き場が無いので、defineOptions({ inheritAttrs: false }) で黙って捨てる(従来と同じ挙動)
- E2E で
clickFabButton() を使ってはいけない ―― 先に dismissFloatingDialogs() を呼ぶので、開いているメモ帳ウィンドウを閉じてしまい枚数が増えない
- 守るテスト:
floating-dialog-z-order.test.ts / kftl-dialog-host.test.ts / e2e/kftl-multi-dialog.spec.ts
書式ミスは黙って通さない
プレフィックスの判定は完全一致(kftl_factory.go の generateDefaultConstructor)で、値は次の行に書く。外したときは書き込みの前に行別エラーへ倒すこと(ADR-0081)。
- 単独プレフィックス(次に値の行が無い)は
requireNextLineText で弾く。放置すると /mood 単独が気分値 0(最低)の記録を黙って1件書き、/num 単独が空の数値記録を書く。他は無言で0件になる
- プレフィックス+同じ行の引数(
/mood 8)は prefixWrittenWithArgument で弾く。完全一致判定なので本文へ落ち、気分記録のつもりが本文「/mood 8」のメモ1件になっていた
- 判定は長いプレフィックスから見る(短い側からだと
/end? が「/end に引数 ?」に化ける)。タグ(。/#)と関連時刻(?/?)は前方一致で受理する設計なので対象に入れない —— 入れると # 見出し や ? 始まりの英文が壊れる
- 検査は
ApplyThisLineToRequestMap のフェーズで行う。まだ1バイトも書いていないので全行を評価して束ねられる。DoRequest まで持ち越すと前の行は既に書かれている
- 打ち間違いは
newKFTLInputError を使う。fmt.Errorf のままだと ERR000351(HTTP 500)+英語の生文言になる。メッセージIDは既存の i18n キーを探してから足すこと(キー追加は7言語 + Go の embed コピー + 件数を書いた資料4箇所に波及する)
api/kftl/ に .go を新規追加しない。 verify_docs.mjs がファイル数を数えて api/README.md と突き合わせる
KFTL の実行フェーズの失敗でも、原因が「利用者が直せる状態」なら newKFTLInputError に載せる。 fmt.Errorf のままだと kftl_statement.go の errors.As に引っかからず、ERR000351(HTTP 500)の「メモ帳のテキストの記録に失敗しました」だけが返って行番号も理由も出ない。2026-08-25 の実利用レビューは ~~(リポストタスク)がこれで3回とも同じ文言で落ち、原因を MCP 経路の不具合と誤診した(実際は繋いだアカウントに mirekyou 型の rep が1件も無く、Web UI からでも同じく失敗する状態だった)。MessageID を空にしないこと —— 空だと formatKFTLInputErrorMessage が Cause の英文をそのまま応答へ載せ、利用者IDと端末名が漏れる(ADR-0046)。境界と却下案は ADR-0082。
~~ は既存レコードをタスク化できない。 対象IDは ctx.ThisStatementLineTargetID =同じ送信テキストの直前の行が採番したUUIDで、gkill に既にある記録を指す構文は無い。ツール説明にそう書き戻さないこと。
KFTL 経由で書いた記録は create_app="gkill_kftl" / create_device=<サーバのdevice>。 MCP から書いても同じで、手打ちのメモ帳と区別する欄が無い(gkill_add_* は gkill_mcp_readwrite / mcp)。create_apps:["gkill_mcp_readwrite"] を「MCP で作った記録の探し方」と案内しないこと。KFTL は DB トランザクションではないので、失敗した送信を再送するときは idempotency_key を同じ値で渡す(受け口は SubmitKFTLTextRequest。渡さないと孤児レコードが積む)。
関連スキル
詳しい設計と却下案(ADR)