diff --git a/.gitignore b/.gitignore index ac05e0d..db20333 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ __pycache__/ # Redmine 連携の設定 (API アクセスキーが入る) redmine.json + +# n8n 連携の設定 (合言葉が入る) +n8n.json diff --git a/CLAUDE.md b/CLAUDE.md index e51da96..2a1812d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,13 +6,16 @@ Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッ ## 構成 ``` -chktime.py サーバ + jsonl 解析 + Redmine 連携(これ1本で完結) +chktime.py サーバ + jsonl 解析 + Redmine 連携 + n8n 連携(これ1本で完結) web/index.html 画面の骨組み web/app.js 表示ロジック(フレームワークなし・素の DOM 操作) web/style.css スタイル(ダークテーマ固定) +web/n8n.js 「n8n へ送る」タブ(app.js の後に読む) run.bat コンソール表示で起動 stop.cmd 起動中の chktime を終了 redmine.json Redmine 連携の設定(自動生成・git 管理外) +n8n.json n8n 連携の設定(合言葉を含む・git 管理外) +tests/ unittest(python -m unittest discover -s tests -v) ``` `chktime.py` の中身は上から @@ -21,7 +24,7 @@ redmine.json Redmine 連携の設定(自動生成・git 管理外) ## 守ること - **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。 -- **外部通信は Redmine 連携だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。 +- **外部通信は Redmine 連携と n8n への送信だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。 新しい通信先を増やすときは、この行を書き換えるところから始める。 - **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない (`/api/redmine/config` はキーの有無だけ返す)。 @@ -55,7 +58,7 @@ python chktime.py --port 8799 --no-browser いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。 -Redmine 連携(POST を使う唯一の系統): +Redmine 連携: | ルート | 内容 | | --- | --- | @@ -64,6 +67,15 @@ Redmine 連携(POST を使う唯一の系統): | `POST /api/redmine/draft` | 下書きを作る(`source=tasks` / `source=git`) | | `POST /api/redmine/issues` | 起票をジョブとして開始。返り値の `id` で進捗を見る | | `GET /api/redmine/job?id=` | 起票ジョブの進捗と結果 | +| `GET /api/redmine/search?q=` | チケット選択用の検索(未完了のみ。数字だけなら番号で引く) | + +n8n 連携(work_log への取り込み。送り先は n8n の `work-tracker--import`): + +| ルート | 内容 | +| --- | --- | +| `GET/POST /api/n8n/config` | 接続設定の取得(合言葉は返さない)/ 保存 | +| `GET /api/n8n/blocks?from=&to=&project=` | 作業区間の一覧。n8n の送信済み・削除済み・重なりを付ける | +| `POST /api/n8n/send` | 選んだ区間を送る。時刻はサーバーがログから計算し直す | ## 解析の前提 diff --git a/README.md b/README.md index 69d6fa6..5152a00 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Claude Code の **タスク(セッション)ごとのチャットのやり 「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。 追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。 -外部への通信は **[Redmine へ起票](#redmine-へ起票)** を使ったときだけです。それ以外は -ローカルの jsonl を読むだけで、どこにも接続しません。 +外部への通信は **[Redmine へ起票](#redmine-へ起票)** と **[n8n へ送る](#n8n-へ送る)** を使ったときだけです。 +それ以外はローカルの jsonl を読むだけで、どこにも接続しません。 ## 起動 @@ -34,6 +34,7 @@ python chktime.py | `--dir` | `~/.claude/projects` | ログの置き場所 | | `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 | | `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル | +| `--n8n-config` | `n8n.json` | n8n 連携の設定ファイル | | `--no-browser` | – | ブラウザを自動で開かない | ## 画面 @@ -112,7 +113,7 @@ python chktime.py ## Redmine へ起票 ヘッダの **Redmine へ起票** で、作業の記録を Redmine のチケットにまとめて登録できます。 -**このアプリで唯一、外部へ通信する機能**です。使わなければ一切接続しません。 +外部へ通信する機能です(ほかに [n8n へ送る](#n8n-へ送る) があります)。使わなければ一切接続しません。 ### 準備 @@ -141,6 +142,36 @@ git 履歴のまとめ方は **日ごと / 日 × 種別(`feat:` `fix:` `docs: - 起票は 1 件ずつ間隔を空けて流すので、**数十件だと数分かかります**。進捗と結果(チケット番号/失敗理由)はダイアログに出ます。実行中もダイアログは閉じられます。 - Redmine は**作成時の状態指定を無視する**ため、状態を指定した場合は作成直後に更新をもう 1 回投げて付け直します(1 件あたり 2 回アクセスします)。 +## n8n へ送る + +右ペインの **n8n へ送る** タブで、Claude のセッションから作った「作業区間」を +n8n の work-tracker(`work_log`)に送れます。手で作業開始・終了を記録しなくても済むようにするための機能です。 + +- **作業区間**: 同じセッションの中で、応答が終わってから次の発言までの空きが 30 分未満ならつなぎ、 + 30 分以上で区切ります。5 分未満の区間は出しません(どちらも接続設定で変更可)。 + 最後の区間は、ログ更新から 30 分以内か、応答やツールの結果を待っている間は「進行中」で、送れません + (`/exit` などのローカルのコマンドは待ちに数えず、24 時間更新の無いログは進行中にしません)。 + 一晩中動かし続けたセッションは 1 つの長い区間になるので、送る前に時間を確かめてください。 +- **続きのセッション**: 会話を続けると前の履歴が新しいセッションのファイルに同じ時刻のまま写されます。 + 開始時刻が同じ区間は写しとみなし、いちばん後まで続いているものだけを出します。写し元で送った区間も送信済みになります。 +- **送信後に変化**: 送った時点で区間が終わっていなかった場合(長いツール実行の途中など)に付きます。 + n8n 側の終了時刻は自動では直さないので、work-tracker の編集画面で直してください。 +- **タスク名**: 初期値は `[作業フォルダ名] セッションのタイトル`。「選ぶ」で Redmine のチケットを検索して選ぶと + `[Redmine のプロジェクト名] #番号 題名` になり、work-tracker の Redmine 工数同期とガントの実績に載ります。 + 決めた名前はセッションごとにブラウザが覚え、同じセッションの他の区間にも入ります(切り替え可)。 +- **重なり**: すでに work_log にある記録や、一覧の他の区間と時間が重なる区間には印が付き、最初は送らない状態です。 +- **二重登録の防止**: 区間ごとの `source`(`chktime:<セッションID>:<開始時刻>`)を work_log に保存し、 + n8n 側で同じ `source` は追加しません。送信済みの区間は選べません。 + ただし 2 つの画面から同時に送ると二重に入ることがあります(確認と追加が一度の処理ではないため)。 +- **時刻**: 送るときはブラウザの値を使わず、サーバーがログから区間を計算し直します。 +- **他のサイトからの要求**: API は 127.0.0.1 / localhost 宛てで、同じ画面から来た要求だけを受け付けます + (POST は `Content-Type: application/json` 必須)。URL を別のホストに変えると、保存済みの合言葉は消えます。 + 合言葉は空白を含まない半角英数字・記号にしてください。 +- **設定**: `n8n.json`(git 管理外。`--n8n-config` で場所を変更可)。タブの「接続設定」からも変えられます。 + `{"url": "https://n8n.yfox.jp", "token": "<合言葉>", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}` + 合言葉は n8n の Header Auth 認証情報(ヘッダー `X-WT-Token`)と同じ値。画面とログには出しません。 +- 送り先は n8n のワークフロー `work-tracker--import`(`/webhook/wt-import-rows` と `/webhook/wt-import`)。 + ## 解析の仕様(ざっくり) - ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。 @@ -153,9 +184,12 @@ git 履歴のまとめ方は **日ごと / 日 × 種別(`feat:` `fix:` `docs: ## ファイル ``` -chktime.py サーバ + jsonl 解析 + Redmine 連携 +chktime.py サーバ + jsonl 解析 + Redmine 連携 + n8n 連携 web/index.html 画面 web/app.js 表示ロジック +web/n8n.js 「n8n へ送る」タブ web/style.css スタイル redmine.json Redmine 連携の設定(APIキーを含む。git 管理外・自動生成) +n8n.json n8n 連携の設定(合言葉を含む。git 管理外) +tests/ unittest(python -m unittest discover -s tests -v) ``` diff --git a/chktime.py b/chktime.py index 6cb23d3..ef626b1 100644 --- a/chktime.py +++ b/chktime.py @@ -16,6 +16,7 @@ from __future__ import annotations import argparse import csv import errno +import http.client import io import json import os @@ -32,7 +33,7 @@ import webbrowser from datetime import date, datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, quote, urlparse APP_DIR = Path(__file__).resolve().parent WEB_DIR = APP_DIR / "web" @@ -144,6 +145,7 @@ def new_turn(index, kind, text, ts): "sidechain": 0, "interrupted": False, "models": [], + "openTools": 0, # 結果がまだ返っていないツール呼び出しの数 (進行中の判定に使う) } @@ -227,6 +229,9 @@ class SessionParser: match = TS_RE.search(raw) ts = parse_ts(match.group(1).decode("utf-8")) if match else None self.counts["toolResult"] += 1 + if self.turns and b'"isSidechain":true' not in raw: + last = self.turns[-1] + last["openTools"] = max(0, last["openTools"] - raw.count(b'"type":"tool_result"')) if ts is not None: self.bump(ts) self.extend_last(ts) @@ -302,6 +307,7 @@ class SessionParser: name = block.get("name") or "?" turn["tools"][name] = turn["tools"].get(name, 0) + 1 turn["toolCalls"] += 1 + turn["openTools"] += 1 return # その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。 @@ -925,6 +931,23 @@ class Redmine: self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}}) return issue_id + def search(self, text, limit=30): + """未完了チケットを件名で探す (n8n へ送るときのチケット選択用)。数字だけなら番号で引く。""" + text = (text or "").strip() + if text.isdigit(): + try: + issue = self.call(f"/issues/{text}.json").get("issue") + except RedmineError: + issue = None + items = [issue] if issue else [] + else: + path = f"/issues.json?status_id=open&sort=updated_on:desc&limit={int(limit)}" + if text: + path += "&f[]=subject&op[subject]=~&v[subject][]=" + quote(text) + items = self.call(path).get("issues") or [] + return [{"id": i.get("id"), "subject": i.get("subject") or "", + "project": (i.get("project") or {}).get("name") or ""} for i in items] + def load_redmine_config(path): try: @@ -1191,6 +1214,374 @@ def to_fields(draft, defaults): return fields +# ---------------------------------------------------------------- n8n 連携 (work_log への取り込み) + +N8N_CONFIG = APP_DIR / "n8n.json" +N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る +N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない +N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限 +N8N_STALE_SEC = 24 * 3600 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない + + +class N8nError(Exception): + """n8n 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。""" + + +def block_source(session_id, start_epoch): + """作業区間の識別子。同じログからは毎回同じ値になる (二重登録を防ぐ鍵)。""" + return f"chktime:{session_id}:{int(round(start_epoch * 1000))}" + + +def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None): + """1 セッションのターンを作業区間に分ける。 + + 前のターンの応答完了から次の発言までの空きが gap_min 分未満ならつなぎ、 + gap_min 分以上なら区間を切る。min_min 分未満の区間は捨てる (進行中は残す)。 + 最後の区間は、応答が返っていない / ログの更新から gap_min 分経っていない + なら進行中 (送れない) とする。 + """ + now = time.time() if now is None else now + gap = gap_min * 60.0 + groups = [] + for turn in session["turns"]: + ts = turn["ts"] + if ts is None: + continue + end = turn["endTs"] if turn["endTs"] is not None else ts + if groups and ts - groups[-1]["end"] < gap: + group = groups[-1] + group["end"] = max(group["end"], end) + group["turns"] += 1 + group["last"] = turn + else: + groups.append({"start": ts, "end": end, "turns": 1, "last": turn}) + + out = [] + quiet = now - (session.get("mtime") or (groups[-1]["end"] if groups else now)) + for index, group in enumerate(groups): + last = group["last"] + # 応答を待っているのは、人の発言 (prompt / resume) に返事が無いときと、ツールの結果を待っているとき。 + # ローカルのコマンド (/exit など) には返事が来ないので待ちにしない。 + no_reply = last["firstReplyTs"] is None and last["kind"] in ("prompt", "resume") + waiting = not last["interrupted"] and (no_reply or last.get("openTools", 0) > 0) + in_progress = index == len(groups) - 1 and quiet < N8N_STALE_SEC and (waiting or quiet < gap) + minutes = (group["end"] - group["start"]) / 60.0 + if minutes < min_min and not in_progress: + continue + out.append( + { + "source": block_source(session["id"], group["start"]), + "sessionId": session["id"], + "date": local_date(group["start"]), + "start": iso(group["start"]), + "end": iso(group["end"]), + "startEpoch": group["start"], + "endEpoch": group["end"], + "minutes": int(round(minutes)), + "turns": group["turns"], + "inProgress": in_progress, + } + ) + return out + + +def default_task_name(folder, title): + """タスク名の初期値: [作業フォルダ名] セッションのタイトル。""" + name = Path(str(folder).replace("\\", "/")).name if folder else "" + title = one_line(title, 200) + return f"[{name}] {title}" if name else title + + +def collect_blocks(store, project_id, start, end, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None): + """期間 [start, end] (date, ローカル) に始まる作業区間を集める。project_id が空なら全プロジェクト。""" + lo = datetime.combine(start, datetime.min.time()).timestamp() + hi = datetime.combine(end + timedelta(days=1), datetime.min.time()).timestamp() + projects = store.projects() + if project_id: + projects = [p for p in projects if p["id"] == project_id] + if not projects: + raise FileNotFoundError(project_id) + out = [] + for project in projects: + for path in store.session_files(project["id"]): + if path.stat().st_mtime < lo: + continue # 期間より前に書き終わったログは開かない + session = store.session(path) + if session["startEpoch"] is None or session["startEpoch"] >= hi: + continue + folder = session.get("cwd") or project["path"] + for block in make_blocks(session, gap_min, min_min, now): + if not lo <= block["startEpoch"] < hi: + continue + block.update( + { + "project": project["id"], + "projectPath": folder, + "title": session["title"], + "taskName": default_task_name(folder, session["title"]), + "_mtime": session.get("mtime") or 0, + } + ) + out.append(block) + # 会話を続けると新しいセッションのファイルに前の履歴が同じ時刻のまま写されるので、 + # 開始時刻 (ミリ秒) が同じ区間は写しとみなして 1 件にする。いちばん後まで続いているものを残し、 + # 同着なら古いファイルのものを残す。外した写しの source は aliases に持ち、送信済みの判定に使う。 + groups = {} + for block in out: + groups.setdefault(int(round(block["startEpoch"] * 1000)), []).append(block) + result = [] + for copies in groups.values(): + copies.sort(key=lambda b: (-b["endEpoch"], b["_mtime"])) + keep = copies[0] + keep["aliases"] = [b["source"] for b in copies[1:]] + keep["aliasSessions"] = [b["sessionId"] for b in copies[1:]] + result.append(keep) + for block in out: + block.pop("_mtime", None) + return sorted(result, key=lambda b: b["startEpoch"]) + + +def mark_blocks(blocks, rows, deleted_sources, now=None): + """n8n の work_log の行と突き合わせて、送信済み / 削除済み / 重なりを付ける。 + + 重なりは [開始, 終了) が少しでも重なること (接しているだけは重ならない)。 + 相手は work_log の行 (進行中は終了 = now) と一覧の他の区間。同じ source は除く。 + """ + now = time.time() if now is None else now + sent = {r.get("source"): r for r in rows if r.get("source")} + deleted = set(deleted_sources or ()) + spans = [] + for row in rows: + start = parse_ts(row.get("start_time")) + if start is None: + continue + end = parse_ts(row.get("end_time")) or now + spans.append((start, end, {"kind": "row", "id": row.get("id"), "source": row.get("source") or "", + "task": row.get("task") or "", "start": iso(start), "end": iso(end)})) + for block in blocks: + spans.append((block["startEpoch"], block["endEpoch"], {"kind": "block", "source": block["source"], + "task": block.get("taskName") or "", + "start": block["start"], "end": block["end"]})) + for block in blocks: + own = {block["source"], *block.get("aliases", ())} + row = next((sent[s] for s in own if s in sent), None) + block["sent"] = row is not None + block["deleted"] = not block["sent"] and bool(own & deleted) + # 送信後に区間が伸び縮みした (送った時点では終わっていなかった) ことを知らせる + row_end = parse_ts(row.get("end_time")) if row else None + block["changedSinceSent"] = row_end is not None and abs(row_end - block["endEpoch"]) > 60 + block["overlaps"] = [ + info for start, end, info in spans + if info["source"] not in own and start < block["endEpoch"] and block["startEpoch"] < end + ] + return blocks + + +class N8n: + """n8n の work-tracker--import を呼ぶ最小のクライアント (標準ライブラリのみ)。 + + LAN からは n8n.yfox.jp の証明書名が一致しないので、verify=False なら検証を外す。 + 合言葉は X-WT-Token ヘッダーで送り、ログにも例外の文言にも出さない。 + """ + + def __init__(self, url=None, token=None, verify=False): + self.url = (url or "").rstrip("/") + self.token = token or "" + self.ctx = None + if not verify: + self.ctx = ssl.create_default_context() + self.ctx.check_hostname = False + self.ctx.verify_mode = ssl.CERT_NONE + + def call(self, path, method="GET", payload=None): + if not self.url: + raise N8nError("n8n の URL が未設定です") + if not self.token: + raise N8nError("合言葉が未設定です") + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None + req = urllib.request.Request(self.url + path, data=body, method=method) + req.add_header("X-WT-Token", self.token) + req.add_header("Accept", "application/json") + if body is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res: + raw = res.read() + except urllib.error.HTTPError as exc: + with exc: + if exc.code == 403: + raise N8nError("合言葉が違います (HTTP 403)") from None + detail = one_line(exc.read().decode("utf-8", "replace"), 200) + raise N8nError(f"HTTP {exc.code}: {detail or exc.reason}") from None + except urllib.error.URLError as exc: + raise N8nError(f"接続できません: {exc.reason}") from None + except OSError as exc: + raise N8nError(f"接続できません: {exc}") from None + except (ValueError, http.client.HTTPException) as exc: + # 送れない文字の合言葉 (UnicodeEncodeError) など。文言に合言葉が入りうるので型名だけ出す + raise N8nError(f"通信に失敗しました ({type(exc).__name__})") from None + try: + data = json.loads(raw or b"{}") + except ValueError: + raise N8nError("JSON として読めない応答が返りました") from None + if isinstance(data, dict) and data.get("ok") is False and data.get("error"): + raise N8nError(str(data["error"])) + return data if isinstance(data, dict) else {} + + def rows(self, start, end): + return self.call(f"/webhook/wt-import-rows?from={start.isoformat()}&to={end.isoformat()}") + + def import_rows(self, rows): + return self.call("/webhook/wt-import", "POST", {"rows": rows}) + + +class N8nHolder: + """n8n 連携の設定ファイルと、そこから作ったクライアントを保持する。合言葉は UI へ返さない。""" + + LIMITS = {"gapMinutes": (1, 240), "minMinutes": (0, 240)} + + def __init__(self, path): + self.path = Path(path) + self.lock = threading.Lock() + self.cfg = load_redmine_config(self.path) # 中身は汎用の JSON 読み込み + self.client = self._build() + + def _build(self): + return N8n(self.cfg.get("url"), self.cfg.get("token"), self.cfg.get("verifySsl", False)) + + def settings(self): + with self.lock: + return int(self.cfg.get("gapMinutes", N8N_GAP_MIN)), int(self.cfg.get("minMinutes", N8N_MIN_MIN)) + + def public(self): + with self.lock: + cfg = self.cfg + return { + "url": cfg.get("url", ""), + "hasToken": bool(cfg.get("token")), + "verifySsl": bool(cfg.get("verifySsl", False)), + "gapMinutes": int(cfg.get("gapMinutes", N8N_GAP_MIN)), + "minMinutes": int(cfg.get("minMinutes", N8N_MIN_MIN)), + "configPath": str(self.path), + } + + def update(self, patch): + """URL・合言葉・区間の設定を保存する。token が空文字なら既存の合言葉を残す。""" + with self.lock: + cfg = dict(self.cfg) + if "url" in patch: + old_host = urlparse(cfg.get("url") or "").hostname + cfg["url"] = str(patch["url"] or "").strip() + if urlparse(cfg["url"]).hostname != old_host and not patch.get("token"): + cfg.pop("token", None) # 送り先が変わったら、前の合言葉は送らない + if "verifySsl" in patch: + cfg["verifySsl"] = bool(patch["verifySsl"]) + for key, (low, high) in self.LIMITS.items(): + if key in patch: + try: + value = int(patch[key]) + except (TypeError, ValueError): + raise N8nError(f"{key} は整数で指定してください") from None + if not low <= value <= high: + raise N8nError(f"{key} は {low}〜{high} で指定してください") + cfg[key] = value + if patch.get("clearToken"): + cfg.pop("token", None) + elif patch.get("token"): + token = str(patch["token"]).strip() + if not all(33 <= ord(c) < 127 for c in token): + raise N8nError("合言葉は空白を含まない半角英数字・記号にしてください") + cfg["token"] = token + self.cfg = cfg + self.client = self._build() + try: + self.path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + except OSError as exc: + raise N8nError(f"設定を保存できません: {exc}") from None + return self.public() + + +SOURCE_RE = re.compile(r"^chktime:([^:]+):(\d+)$") + + +def n8n_range(query): + """?from= / ?to= の解釈。既定は今日までの 7 日。逆順と N8N_MAX_DAYS 日超はエラー。""" + end = parse_date(query.get("to", [""])[0]) or date.today() + start = parse_date(query.get("from", [""])[0]) or end - timedelta(days=6) + if start > end: + raise N8nError("開始日が終了日より後です") + if (end - start).days + 1 > N8N_MAX_DAYS: + raise N8nError(f"期間は {N8N_MAX_DAYS} 日以内にしてください") + return start, end + + +def n8n_blocks(store, holder, query, now=None): + """期間の作業区間に、n8n 側の送信済み・削除済み・重なりを付けて返す。 + + n8n に届かなくても区間は返し、n8nError に理由を入れる (送信済みは分からないので UI は送らせない)。 + """ + start, end = n8n_range(query) + gap, minimum = holder.settings() + blocks = collect_blocks(store, query.get("project", [""])[0], start, end, gap, minimum, now) + rows, deleted, error = [], [], None + try: + data = holder.client.rows(start, end) + rows = data.get("rows") or [] + deleted = data.get("deletedSources") or [] + except N8nError as exc: + error = str(exc) + mark_blocks(blocks, rows, deleted, now) + return {"from": start.isoformat(), "to": end.isoformat(), "gapMinutes": gap, "minMinutes": minimum, + "blocks": blocks, "n8nError": error} + + +def n8n_send(store, holder, payload, now=None): + """選ばれた区間を n8n の work_log に送る。時刻はブラウザの値を使わずログから計算し直す。""" + items = payload.get("items") or [] + if not items: + raise N8nError("送る区間がありません") + if len(items) > 200: + raise N8nError("一度に送れるのは 200 件までです") + gap, minimum = holder.settings() + rows, errors, cache = [], [], {} + for item in items: + source = str(item.get("source") or "") + task = " ".join(str(item.get("task") or "").split()) + match = SOURCE_RE.match(source) + if not match: + errors.append({"source": source, "error": "source が不正です"}) + continue + if not task: + errors.append({"source": source, "error": "タスク名が空です"}) + continue + key = (str(item.get("project") or ""), match.group(1)) + if key not in cache: + try: + cache[key] = make_blocks(store.one(*key), gap, minimum, now) + except FileNotFoundError: + cache[key] = None + if cache[key] is None: + errors.append({"source": source, "error": "セッションが見つかりません"}) + continue + block = next((b for b in cache[key] if b["source"] == source), None) + if block is None: + errors.append({"source": source, "error": "区間が見つかりません (ログか設定が変わりました)"}) + continue + if block["inProgress"]: + errors.append({"source": source, "error": "進行中の区間は送れません"}) + continue + rows.append({"source": source, "date": block["date"], "start_time": block["start"], + "end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")}) + result = {"ok": False, "inserted": [], "skipped": [], "errors": errors} + if rows: + data = holder.client.import_rows(rows) + result["inserted"] = data.get("inserted") or [] + result["skipped"] = data.get("skipped") or [] + result["errors"] = errors + (data.get("errors") or []) + result["ok"] = not result["errors"] + return result + + # ---------------------------------------------------------------- HTTP @@ -1302,24 +1693,40 @@ class Handler(BaseHTTPRequestHandler): query = parse_qs(parsed.query) try: if route.startswith("/api/"): + if self.foreign_request(): + return self.send_json({"error": "この画面以外からの要求は受け付けません"}, 403) return self.api(route, query) return self.static(route) except FileNotFoundError as exc: self.send_json({"error": f"not found: {exc}"}, 404) - except RedmineError as exc: + except (RedmineError, N8nError) as exc: self.send_json({"error": str(exc)}, 400) except Exception as exc: # noqa: BLE001 - UI にエラーを返す self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500) + LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]") + + def foreign_request(self): + """他のサイトや別名のホスト経由の要求を断る (設定の書き換えと合言葉の持ち出しを防ぐ)。""" + host_header = (self.headers.get("Host") or "").lower() + if host_header.rsplit(":", 1)[0] not in self.LOCAL_HOSTS: + return True + origin = self.headers.get("Origin") + return bool(origin) and urlparse(origin).netloc.lower() != host_header + def do_POST(self): route = urlparse(self.path).path + if self.foreign_request(): + return self.send_json({"error": "この画面以外からの要求は受け付けません"}, 403) + if not (self.headers.get("Content-Type") or "").lower().startswith("application/json"): + return self.send_json({"error": "Content-Type は application/json にしてください"}, 415) try: if route.startswith("/api/"): return self.api_post(route, self.read_json()) raise FileNotFoundError(route) except FileNotFoundError as exc: self.send_json({"error": f"not found: {exc}"}, 404) - except (RedmineError, ValueError) as exc: + except (RedmineError, N8nError, ValueError) as exc: self.send_json({"error": str(exc)}, 400) except Exception as exc: # noqa: BLE001 - UI にエラーを返す self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500) @@ -1336,6 +1743,10 @@ class Handler(BaseHTTPRequestHandler): def api_post(self, route, payload): redmine = self.server.redmine + if route == "/api/n8n/config": + return self.send_json(self.server.n8n.update(payload)) + if route == "/api/n8n/send": + return self.send_json(n8n_send(self.server.store, self.server.n8n, payload)) if route == "/api/redmine/config": return self.send_json(redmine.update(payload)) if route == "/api/redmine/draft": @@ -1389,6 +1800,12 @@ class Handler(BaseHTTPRequestHandler): if query.get("format", [""])[0] == "csv": return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv") return self.send_json(session) + if route == "/api/n8n/config": + return self.send_json(self.server.n8n.public()) + if route == "/api/n8n/blocks": + return self.send_json(n8n_blocks(store, self.server.n8n, query)) + if route == "/api/redmine/search": + return self.send_json({"issues": self.server.redmine.client.search(query.get("q", [""])[0])}) if route == "/api/redmine/config": return self.send_json(self.server.redmine.public()) if route == "/api/redmine/meta": @@ -1490,6 +1907,7 @@ def main(): parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ") parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)") parser.add_argument("--redmine-config", default=str(REDMINE_CONFIG), help="Redmine 連携の設定ファイル") + parser.add_argument("--n8n-config", default=str(N8N_CONFIG), help="n8n 連携の設定ファイル") parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() @@ -1510,6 +1928,7 @@ def main(): return server.store = Store(root) server.redmine = RedmineHolder(args.redmine_config) + server.n8n = N8nHolder(args.n8n_config) server.jobs = Jobs() server.watcher = Watcher(root, args.interval) server.watcher.start() diff --git a/docs/superpowers/plans/2026-10-12-n8n-worklog-import.md b/docs/superpowers/plans/2026-10-12-n8n-worklog-import.md new file mode 100644 index 0000000..68a0a3b --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-n8n-worklog-import.md @@ -0,0 +1,1711 @@ +# chktime → n8n work_log 取り込み Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** chktime の画面で Claude のセッションから作った作業区間を選び、n8n の `work_log` に二重登録なしで送れるようにする。 + +**Architecture:** n8n に受け口のワークフロー `work-tracker--import` (Header Auth 付き Webhook 2 本) を作り、`work_log` / `work_log_deleted` に `source` 列を足す。chktime は区間の計算・重なりの判定・n8n への送信を `chktime.py` の新しい節「n8n 連携」に持ち、画面は新しいファイル `web/n8n.js` に置いて右ペインのタブ「n8n へ送る」から開く。時刻はブラウザの値を信用せず、送信時にサーバーが `source` から区間を計算し直す。 + +**Tech Stack:** Python 3 標準ライブラリのみ (unittest, urllib, http.server)、素の JavaScript、n8n (Webhook / Code / Data table / IF / Respond to Webhook ノード)、n8n-api MCP (`mcp__n8n-api__*`)。 + +**Spec:** `docs/superpowers/specs/2026-10-12-n8n-worklog-import-design.md` + +## Global Constraints + +- chktime は **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。 +- 外部通信は **Redmine 連携と n8n への送信だけ**。 +- 合言葉 (`token`) はリポジトリ・ログ・画面・コマンド出力に出さない。`n8n.json` は `.gitignore` に入れる。`/api/n8n/config` は `hasToken` だけ返す。 +- 合言葉は利用者が決めて入力する。実装者 (Claude) は作らず、入力もしない。 +- 会話ログ (`~/.claude/projects/**/*.jsonl`) は読み取り専用。テストは一時フォルダに作った jsonl で行う。 +- 検証用サーバーは `python chktime.py --port 8799 --no-browser`。常用の 8787 と `stop.cmd` には触れない (`stop.cmd` は全 chktime を落とす)。 +- `.bat` / `.cmd` は ASCII のみ。コメント・コミットメッセージ・UI 文言は日本語。 +- 区間: 空き **30 分未満でつなぐ / 30 分以上で切る**、**5 分未満は外す (5 分ちょうどは残す)**。設定 `gapMinutes` (1〜240) / `minMinutes` (0〜240)。 +- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。 +- n8n 受け口: 期間 **最大 62 日**、1 回 **最大 200 件**、区間 **24 時間以内**、`task` **500 文字以内 (空不可)**、`memo` **2000 文字以内**、`source` **500 文字以内で `chktime:` 始まり**。 +- 重なり: `[開始, 終了)` が少しでも重なれば重なり。接しているだけは重ならない。同じ `source` は相手から除く。進行中の work_log 行は終了 = 今。 +- n8n の本番データに触れるテストは **2000-01-01 付けのダミー**だけで行い、終わったら Data table を直接 (MCP の `deleteRows`) 消す。work-tracker--delete の Webhook は使わない。 +- 既存のコミットされていない `CLAUDE.md` の変更 (ナレッジ保管庫の節) は利用者のもの。自分の変更だけを `git add -p` でステージする。 +- コミットメッセージの末尾に `Co-Authored-By: Claude Opus 5.5 `。 + +## Review Focus + +- 前のターンの応答中に次の発言を送った (並行) セッション: 区間は切れずにつながるべき → Task 3 `test_parallel_prompt_keeps_block` +- 最後のターンが中断されて応答が無いまま放置されたセッション: 進行中ではなく送れる区間になるべき → Task 3 `test_interrupted_last_turn_is_not_in_progress` +- タスク名が空白だけで送られた: n8n に送らず「タスク名が空です」を返すべき → Task 6 `test_send_rejects_blank_task` +- n8n に届かない (停止・合言葉違い): 区間の一覧は返り、送信済みの印は付かず `n8nError` に理由が入るべき → Task 6 `test_blocks_when_n8n_down` +- 日をまたぐ区間: 開始日の 1 件として、開始日を含む期間だけに出るべき → Task 4 `test_midnight_block_belongs_to_start_date` + +## File Structure + +| ファイル | 役割 | +|---|---| +| `chktime.py` (変更) | 新しい節「n8n 連携」: 定数、`N8nError`、`block_source`、`make_blocks`、`default_task_name`、`collect_blocks`、`mark_blocks`、`N8n`、`N8nHolder`、`n8n_range`、`n8n_blocks`、`n8n_send`。Redmine 節に `Redmine.search`。HTTP 節にルート追加。`main()` に `--n8n-config` | +| `tests/test_n8n.py` (新規) | 上記の unittest (純粋関数・偽の n8n サーバー・一時フォルダの jsonl) | +| `web/n8n.js` (新規) | 「n8n へ送る」タブの描画と操作 | +| `web/index.html` (変更) | タブボタンと `` の次の行に: + +```html + +``` + +- [ ] **Step 2: app.js に分岐を足す** + +`syncTabs` の最後に: +```js + $('#tab-n8n').classList.toggle('sel', state.view === 'n8n'); +``` +`updateCsv` の `if (state.view === 'daily') {` の前に: +```js + if (state.view === 'n8n') { + link.removeAttribute('href'); + return; + } +``` +`setView` の `else if (view === 'gantt') loadGantt();` の次に: +```js + else if (view === 'n8n') loadN8n(); +``` +`selectProject` の `else if (state.view === 'gantt') loadGantt();` の次に: +```js + else if (state.view === 'n8n') loadN8n(); +``` +`refreshFollow` の `else if (state.view === 'gantt') await loadGantt({ silent: true });` の次に (編集中の入力を消さないよう、自動追従では読み直さない): +```js + else if (state.view === 'n8n') { /* 自動追従では読み直さない */ } +``` +resize の `if (state.view === 'turns') return;` を: +```js + if (state.view === 'turns' || state.view === 'n8n') return; +``` +タブの onclick (`$('#tab-gantt').onclick = ...` の次) に: +```js +$('#tab-n8n').onclick = () => setView('n8n'); +``` + +- [ ] **Step 3: web/n8n.js を書く** + +```js +'use strict'; +// 「n8n へ送る」タブ: Claude のセッションから作った作業区間を選び、n8n の work_log に送る。 +// app.js の後に読み込む ($ / state / api / post / esc / fmtTime / shortPath を使う)。 + +const N8N_NAMES_KEY = 'chktime.n8n.names'; // セッション ID -> {task, memo} (ブラウザに覚える) + +const n8 = { + cfg: null, // /api/n8n/config (合言葉は含まれない) + from: '', + to: '', + data: null, // /api/n8n/blocks + checked: new Set(), // 送る区間の source + edits: new Map(), // source -> {task, memo} + sameSession: true, // 同じセッションの他の区間にも入れる + settings: false, + busy: false, + result: null, // 直前の送信結果 + pick: null, // チケットを選んでいる区間の source + issues: [], + pickMsg: '', +}; + +function n8LoadNames() { + try { + return JSON.parse(localStorage.getItem(N8N_NAMES_KEY) || '{}') || {}; + } catch (e) { + return {}; + } +} + +function n8SaveName(sessionId, value) { + try { + const all = n8LoadNames(); + all[sessionId] = value; + localStorage.setItem(N8N_NAMES_KEY, JSON.stringify(all)); + } catch (e) { + /* 保存できなくても動く */ + } +} + +const n8CanSend = (b) => !b.sent && !b.inProgress; +const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length; + +function n8Query() { + const q = new URLSearchParams(); + if (state.project) q.set('project', state.project); + if (n8.from) q.set('from', n8.from); + if (n8.to) q.set('to', n8.to); + return q.toString(); +} + +async function loadN8n({ silent = false } = {}) { + $('#detail-title').textContent = 'n8n へ送る'; + if (!silent) $('#detail-body').innerHTML = '

区間を計算中…

'; + try { + n8.cfg = await api('/api/n8n/config'); + if (!n8.cfg.url || !n8.cfg.hasToken) n8.settings = true; + n8.data = await api('/api/n8n/blocks?' + n8Query()); + n8.from = n8.data.from; + n8.to = n8.data.to; + const names = n8LoadNames(); + n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source)); + for (const b of n8.data.blocks) { + if (n8.edits.has(b.source)) continue; + const saved = names[b.sessionId] || {}; + n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' }); + } + renderN8n(); + } catch (e) { + $('#detail-body').innerHTML = `

${esc(e.message)}

`; + } +} + +function n8Status(b) { + const marks = []; + if (b.sent) marks.push('送信済み'); + if (b.deleted) marks.push('削除済み'); + if (b.inProgress) marks.push('進行中'); + if ((b.overlaps || []).length) { + const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n'); + marks.push(`重なり ${b.overlaps.length}`); + } + return marks.join(' '); +} + +function n8Settings() { + const c = n8.cfg || {}; + return `
+

接続設定 ${esc(c.configPath || '')}

+ + + + + + +
`; +} + +function n8ResultHtml() { + const r = n8.result; + if (!r) return ''; + const errs = (r.errors || []).map((e) => `
  • ${esc(e.source)}: ${esc(e.error)}
  • `).join(''); + const skips = (r.skipped || []).map((s) => `
  • ${esc(s.source)}: ${esc(s.reason)}
  • `).join(''); + return `
    + 追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件 + ${skips ? `` : ''}${errs ? `` : ''} +
    `; +} + +function n8PickHtml() { + if (!n8.pick) return ''; + const rows = n8.issues + .map((i) => `
  • [${esc(i.project)}] ${esc(i.subject)}
  • `) + .join(''); + return `
    +

    Redmine のチケットを選ぶ

    + + ${esc(n8.pickMsg)} + +
    `; +} + +function renderN8n() { + const d = n8.data; + if (!d) return; + const blocks = d.blocks; + const on = blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)); + const rows = blocks + .map((b) => { + const e = n8.edits.get(b.source) || { task: b.taskName, memo: '' }; + return ` + + ${esc(b.date)} + ${fmtTime(b.start)}–${fmtTime(b.end)} + ${b.minutes} 分 + ${esc(shortPath(b.projectPath))} + + + ${n8Status(b)} + `; + }) + .join(''); + $('#detail-body').innerHTML = `
    +
    + + + + + + +
    + ${d.n8nError ? `

    n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)

    ` : ''} + ${n8.settings ? n8Settings() : ''} + ${n8ResultHtml()} + ${n8PickHtml()} +

    区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件

    + + ${rows || ''}
    送る日付時刻時間プロジェクトタスク名 / メモチケット状態
    この期間の区間はありません
    +
    `; + n8Wire(); +} + +function n8Block(source) { + return n8.data.blocks.find((b) => b.source === source); +} + +/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */ +function n8SetEdit(source, patch) { + const b = n8Block(source); + const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b]; + for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch }); + n8SaveName(b.sessionId, n8.edits.get(source)); +} + +function n8Wire() { + $('#n8-reload').onclick = () => { + n8.from = $('#n8-from').value; + n8.to = $('#n8-to').value; + loadN8n(); + }; + $('#n8-same').onchange = (e) => { + n8.sameSession = e.target.checked; + }; + $('#n8-toggle').onclick = () => { + n8.settings = !n8.settings; + renderN8n(); + }; + $('#n8-send').onclick = n8Send; + if (n8.settings) $('#n8-save').onclick = n8SaveSettings; + if (n8.pick) { + $('#n8-find').onclick = n8Find; + $('#n8-q').onkeydown = (e) => { + if (e.key === 'Enter') n8Find(); + }; + $('#n8-pick-close').onclick = () => { + n8.pick = null; + renderN8n(); + }; + document.querySelectorAll('.n8-issue').forEach((btn) => { + btn.onclick = () => { + const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id); + n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` }); + n8.pick = null; + renderN8n(); + }; + }); + } + document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => { + const source = tr.dataset.src; + tr.querySelector('.n8-on').onchange = (e) => { + if (e.target.checked) n8.checked.add(source); + else n8.checked.delete(source); + renderN8n(); + }; + tr.querySelector('.n8-task').onchange = (e) => { + n8SetEdit(source, { task: e.target.value }); + renderN8n(); + }; + tr.querySelector('.n8-memo').onchange = (e) => { + n8SetEdit(source, { memo: e.target.value }); + }; + tr.querySelector('.n8-choose').onclick = () => { + n8.pick = source; + n8.issues = []; + n8.pickMsg = ''; + renderN8n(); + }; + }); +} + +async function n8Find() { + n8.pickMsg = '検索中…'; + const q = $('#n8-q').value; + renderN8n(); + try { + n8.issues = (await api('/api/redmine/search?q=' + encodeURIComponent(q))).issues; + n8.pickMsg = n8.issues.length ? '' : '見つかりません'; + } catch (e) { + n8.pickMsg = e.message; + } + renderN8n(); + $('#n8-q').value = q; +} + +async function n8SaveSettings() { + const patch = { + url: $('#n8-url').value, + verifySsl: $('#n8-verify').checked, + gapMinutes: Number($('#n8-gap').value), + minMinutes: Number($('#n8-min').value), + }; + const token = $('#n8-token').value; + if (token) patch.token = token; + try { + n8.cfg = await post('/api/n8n/config', patch); + n8.settings = false; + loadN8n(); + } catch (e) { + alert('保存できません: ' + e.message); + } +} + +async function n8Send() { + const items = n8.data.blocks + .filter((b) => n8.checked.has(b.source) && n8CanSend(b)) + .map((b) => ({ project: b.project, source: b.source, ...(n8.edits.get(b.source) || { task: b.taskName, memo: '' }) })); + if (!items.length) return; + n8.busy = true; + renderN8n(); + try { + n8.result = await post('/api/n8n/send', { items }); + } catch (e) { + n8.result = { ok: false, inserted: [], skipped: [], errors: [{ source: '', error: e.message }] }; + } + n8.busy = false; + await loadN8n({ silent: true }); +} +``` + +- [ ] **Step 4: スタイルを足す** (`web/style.css` の末尾) + +```css +/* n8n へ送る */ +.n8 { padding: 8px 12px; } +.n8-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; } +.n8-bar .spacer { flex: 1; } +.n8 .go { border-color: var(--work); color: var(--work); } +.n8 .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; } +.n8 .note { color: var(--dim); font-size: 11px; } +.n8 .err-inline { color: #f7768e; font-size: 11px; } +.n8-settings, .n8-pick, .n8-result { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 8px 0; } +.n8-settings label { display: inline-flex; gap: 4px; align-items: center; margin-right: 12px; } +.n8-result.ok { border-color: var(--work); } +.n8-result.ng { border-color: #f7768e; } +.n8-pick ul { list-style: none; padding: 0; margin: 6px 0 0; max-height: 200px; overflow: auto; } +#n8n-table th { text-align: left; font-size: 11px; color: var(--dim); padding: 4px 6px; border-bottom: 1px solid var(--line); } +#n8n-table td { padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; } +#n8n-table .time, #n8n-table .num { white-space: nowrap; font-variant-numeric: tabular-nums; } +#n8n-table .num { text-align: right; } +#n8n-table .n8-task { width: 22em; } +#n8n-table .n8-memo { width: 10em; } +#n8n-table tr.n8-sent td { color: var(--dim); } +.n8-mark { display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 10.5px; border: 1px solid var(--line); } +.n8-mark.sent { color: var(--work); border-color: var(--work); } +.n8-mark.warn { color: var(--warn); border-color: var(--warn); } +``` + +- [ ] **Step 5: 検証用サーバーで画面を確かめる** + +1. `n8n.json` (利用者が Task 2 で作ったもの) を使い、`python chktime.py --port 8799 --no-browser` をバックグラウンドで起動。 +2. 組み込みブラウザで `http://127.0.0.1:8799/` を開き、「n8n へ送る」タブを押す。 +3. 確かめること (`read_page` / `javascript_tool` で): + - 直近 7 日の区間の表が出て、件数の表示と行数が合う + - 送信済み・進行中の行のチェックが無効、重なりのある行は最初チェックが外れている + - タスク名を変えると、「同じセッションの区間にも入れる」が on のとき同じセッションの他の行も変わる。ページを読み直しても残る + - 「接続設定」で合言葉の欄が空で、placeholder が「設定済み…」になっている (値は出ない) + - 「選ぶ」→ 件名で検索 → 結果をクリックすると、タスク名が `[プロジェクト] #番号 題名` になる + - 期間を逆にして「読み直す」と、エラーの文言が出る + - **「送る」は押さない** (実データを送るのは Task 8) +4. 8799 番のプロセスだけを止める。 + +- [ ] **Step 6: コミット** + +```bash +cd /c/debug/chktime && git add web/n8n.js web/index.html web/app.js web/style.css && git commit -m "n8n 連携: 作業区間を選んで送る「n8n へ送る」タブを追加 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: 実際の区間を 1 件送る確認と文書 + +**Files:** +- Modify: `README.md`、`CLAUDE.md` (自分の変更だけステージする) +- 保管庫: `C:\debug\obsidian\10_プロジェクト\chktime\ナレッジ\`、`C:\debug\obsidian\00_共通ナレッジ\運用・手順\n8n(n8n.yfox.jp)のMCP接続.md` + +**Interfaces:** +- Consumes: Task 1〜7 のすべて。 + +- [ ] **Step 1: 送る区間を利用者に選んでもらう (ここで止まって待つ)** + +8799 の画面で一覧を見せ、本当に `work_log` に入れてよい区間を 1 件選んでもらう。タスク名も利用者に決めてもらう。 + +- [ ] **Step 2: 送って確かめる** + +1. 選ばれた 1 件だけチェックして「送る」→ 結果が `追加 1 件`。 +2. もう一度「読み直す」→ その行が「送信済み」になり、チェックできない。 +3. `curl -sk https://n8n.yfox.jp/webhook/home | grep -o '<タスク名の一部>'` でダッシュボードに出ること。 +4. 日報: `curl -sk -X POST -H "Content-Type: application/json" -d '{"date":"<その区間の日付>","tasks":[]}' https://n8n.yfox.jp/webhook/work-report-final | grep -o '<タスク名の一部>'` +5. 同じ区間をもう一度送れないこと (チェックが無効) を画面で確かめる。 + +- [ ] **Step 3: README.md に使い方を足す** + +「Redmine へ起票」の節の後に、次の節を足す: + +```markdown +## n8n へ送る + +右ペインの **n8n へ送る** タブで、Claude のセッションから作った「作業区間」を +n8n の work-tracker (`work_log`) に送れます。手で作業開始・終了を記録しなくても済むようにするための機能です。 + +- **作業区間**: 同じセッションの中で、応答が終わってから次の発言までの空きが 30 分未満ならつなぎ、 + 30 分以上で区切ります。5 分未満の区間は出しません (どちらも接続設定で変更可)。 + まだ応答中・ログ更新から 30 分以内の最後の区間は「進行中」で、送れません。 +- **タスク名**: 初期値は `[作業フォルダ名] セッションのタイトル`。「選ぶ」で Redmine のチケットを検索して選ぶと + `[Redmine のプロジェクト名] #番号 題名` になり、work-tracker の Redmine 工数同期とガントの実績に載ります。 + 決めた名前はセッションごとにブラウザが覚え、同じセッションの他の区間にも入ります (切り替え可)。 +- **重なり**: すでに work_log にある記録や、一覧の他の区間と時間が重なる区間には印が付き、最初は送らない状態です。 +- **二重登録の防止**: 区間ごとの `source` (`chktime:<セッションID>:<開始時刻>`) を work_log に保存し、 + n8n 側で同じ `source` は追加しません。送信済みの区間は選べません。 + ただし 2 つの画面から同時に送ると二重に入ることがあります (確認と追加が一度の処理ではないため)。 +- **設定**: `n8n.json` (git 管理外。`--n8n-config` で場所を変更可)。 + `{"url": "https://n8n.yfox.jp", "token": "<合言葉>", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}` + 合言葉は n8n の Header Auth 認証情報 (`X-WT-Token`) と同じ値。画面とログには出しません。 +- 送り先は n8n のワークフロー `work-tracker--import` (`/webhook/wt-import-rows` と `/webhook/wt-import`)。 +``` + +起動オプションの表に `| --n8n-config | n8n.json | n8n 連携の設定ファイル |` を足し、冒頭の「外部への通信は…」の文を「外部への通信は Redmine へ起票と n8n へ送るを使ったときだけです」に変える。 + +- [ ] **Step 4: CLAUDE.md を直す (自分の変更だけステージ)** + +- 「外部通信は Redmine 連携だけ」の行を次に変える: + `- **外部通信は Redmine 連携と n8n への送信だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。` +- 構成の表に `web/n8n.js 「n8n へ送る」タブ` と `n8n.json n8n 連携の設定(自動生成・git 管理外)` と `tests/ unittest (python -m unittest discover -s tests -v)` を足す。 +- 「n8n 連携」の表を API の節に足す: + +```markdown +n8n 連携(work_log への取り込み): + +| ルート | 内容 | +| --- | --- | +| `GET/POST /api/n8n/config` | 接続設定の取得(合言葉は返さない)/ 保存 | +| `GET /api/n8n/blocks?from=&to=&project=` | 作業区間の一覧。n8n の送信済み・削除済み・重なりを付ける | +| `POST /api/n8n/send` | 選んだ区間を送る。時刻はサーバーがログから計算し直す | +| `GET /api/redmine/search?q=` | チケット選択用の検索(未完了のみ) | +``` + +ステージは `git add -p CLAUDE.md` で、今回足した行の hunk だけ `y` にする (利用者の「ナレッジ保管庫」の節は `n`)。対話できない環境では、`git diff CLAUDE.md > <スクラッチパッド>/claude.diff` から自分の hunk だけのパッチを作って `git apply --cached` する。 + +- [ ] **Step 5: テストを全部流してコミット** + +Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v` +Expected: 28 件すべて PASS。 + +```bash +cd /c/debug/chktime && git add README.md && git commit -m "n8n へ送る機能の使い方と通信の決まりを文書に追加 + +Co-Authored-By: Claude Opus 5.5 " +``` +(`CLAUDE.md` は Step 4 でステージ済みの分だけが一緒に入る。`git show --stat HEAD` で確認) + +- [ ] **Step 6: 保管庫に記録する** + +- `10_プロジェクト\chktime\ナレッジ\n8nへ作業区間を送る仕組み.md` (ナレッジのテンプレート): 区間の作り方、`source` の形、n8n の受け口 2 本と合言葉 (Header Auth `X-WT-Token`)、同時送信の注意、テストの流し方。 +- chktime の `_index.md` のナレッジに 1 行追加。chktime の `CLAUDE.md` の「このプロジェクトの記録」表にも 1 行 (利用者の未コミットの節なので、追記は利用者に伝えるだけにして、ファイルの編集はしない)。 +- `00_共通ナレッジ\運用・手順\n8n(n8n.yfox.jp)のMCP接続.md` の注意点に: データテーブルの列追加は公開 API ではできず、n8n-mcp の `addColumn` に `N8N_MCP_ACCESS_TOKEN` が要ること (Task 1 で実際にどうなったかを書く)。 + +- [ ] **Step 7: 仕上げ** + +`superpowers:finishing-a-development-branch` に従って、ブランチの扱い (Gitea への push と PR) を利用者と決める。 diff --git a/docs/superpowers/specs/2026-10-12-n8n-worklog-import-design.md b/docs/superpowers/specs/2026-10-12-n8n-worklog-import-design.md new file mode 100644 index 0000000..db8f320 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-n8n-worklog-import-design.md @@ -0,0 +1,173 @@ +# chktime から n8n の work_log へ作業区間を送る — 設計 + +日付: 2026-10-12 +対象: chktime (このリポジトリ) と n8n (https://n8n.yfox.jp) の work-tracker + +## 目的 + +Claude Code での作業を、手で「作業開始・終了」を記録しなくても n8n の `work_log` に入るようにする。 +入った記録は、手で記録したものと同じく、日報・月報・ガント・ダッシュボード・Redmine 工数同期 +(work-tracker--redmine-sync) で使われる。 + +### 成功の条件 + +- chktime の画面で期間を選ぶと、Claude のセッションから作った「作業区間」が一覧で出る。 +- 区間ごとにタスク名 (Redmine のチケットを選べる) を決め、選んだものだけを `work_log` に送れる。 +- 同じ区間を二度送っても、`work_log` に二重に入らない。 +- すでにある記録 (手で記録したもの・他の区間) と時間が重なる区間は、画面で分かり、最初は送らない状態になっている。 +- chktime に渡す権限は「work_log への取り込み」だけ。n8n の API キーは chktime に持たせない。 + +### 前提と決めたこと + +| 項目 | 決定 | +|---|---| +| 目的 | 記録の手間を減らす | +| 時間の数え方 | 連続した区間ごと (空き 30 分以上で切る) | +| タスク名 | 送る前に chktime の画面で決める | +| 重なり | 画面で知らせ、最初は送らない。送るかは利用者が決める | +| 送り方 | 案 A: n8n に受け口のワークフローを作り、chktime から呼ぶ | + +## 1. 区間の作り方 (chktime) + +材料は chktime の既存の「ターン」(人間の発言 1 回と、それに対する応答一式)。 + +- 同じセッションの中で、前のターンの応答完了から次の発言までの空きが **30 分未満** ならつなぐ。 + 30 分以上なら区間を切る。 + - 開始 = 区間の最初の発言の時刻、終了 = 区間の最後のターンの応答完了の時刻。 +- **5 分未満** の区間は一覧から外す。 +- ターンの起点は既存の規則どおり (人間の発言だけ。サブエージェント・再開時のメタ行は起点にしない)。 +- `date` は区間の開始時刻のローカル日付 (日本時間)。日をまたぐ区間も開始日の記録。 +- 30 分と 5 分は設定 (`n8n.json` の `gapMinutes` / `minMinutes`) で変えられる。画面から変更できる。 +- セッションの最後の区間は、次のどちらかなら「進行中」とし、送れない (最終レビューで改訂): + - ログの最終更新から `gapMinutes` 経っていない + - 最後のターンが応答を待っている: 人の発言 (prompt / resume) に返事が無い、またはツール呼び出しの結果がまだ返っていない + (ローカルのコマンド `/exit` などは返事が来ないので待ちにしない。中断されたターンも待ちにしない) + ただしログが 24 時間更新されていなければ、待ちのままでも進行中にしない。 +- 会話を続けると前の履歴が新しいセッションのファイルに同じ時刻のまま写される。開始時刻 (ミリ秒) が同じ区間は写しとみなして + 1 件にし (長く続いている方、同着なら古いファイル)、外した写しの `source` を送信済みの判定に使う。 +- 送信済みの区間が、送った後に伸び縮みしていたら「送信後に変化」と知らせる (n8n 側の行は自動では直さない)。 +- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。同じログからは毎回同じ値になる。 + +区間の計算は、HTTP やファイル読み込みから切り離した純粋な関数 (ターンの列と設定値 → 区間の列) にする。 + +## 2. n8n 側 + +### データテーブル + +- `work_log` に `source` (string) の列を足す。chktime から入れた行だけ値が入り、手で記録した行は空。 + 既存のワークフローはこの列を書かないので影響しない。 +- `work_log_deleted` にも `source` を足し、work-tracker--delete の `Backup Row` で一緒にコピーする。 + +### ワークフロー `work-tracker--import` + +両方の Webhook に Header Auth の認証情報を付ける (ヘッダー `X-WT-Token`)。違えば n8n が 403 を返す。 + +**`GET /webhook/wt-import-rows?from=YYYY-MM-DD&to=YYYY-MM-DD`** + +- `from` / `to` を確かめる (形式、`from <= to`、最大 62 日)。不正なら `{ok:false, error}`。 +- `work_log` から `date` が `from の前日` 〜 `to` の行を返す + (前日を含めるのは、日をまたぐ記録との重なりを見るため)。 +- 返す値: + `{ok:true, rows:[{id, date, start_time, end_time, task, status, source}], deletedSources:[...]}` + `deletedSources` は同じ期間の `work_log_deleted` の `source` (空でないもの)。 + +**`POST /webhook/wt-import`** + +- 本文: `{rows:[{source, date, start_time, end_time, task, memo}]}`、1 回に最大 200 件。 +- 1 行ずつ確かめる: + - `source` が `chktime:` で始まる (500 文字以内) + - `date` が `YYYY-MM-DD`、`start_time` / `end_time` が ISO 8601 で、終了 > 開始、長さ 24 時間以内 + - `task` が空でない (500 文字以内)、`memo` は 2000 文字以内 +- `work_log` に同じ `source` がある行はスキップ。 +- 新しい行は `status: "done"`、`duration_min` = 終了 − 開始 (分、四捨五入、丸めなし) で追加。 +- 返す値: `{ok, inserted:[{source, id}], skipped:[{source, reason}], errors:[{source, error}]}`。 + `ok` は errors が 0 件のとき true。 + +### 認証情報 + +合言葉は利用者が決める。利用者が n8n の画面で Header Auth の認証情報 (例: 名前 `wt-import token`、 +Name `X-WT-Token`) を作り、chktime の設定画面にも同じ値を入れる。Claude は合言葉を作らず、入力もしない。 +認証情報の id は一覧 (中身を含まない) から取ってワークフローに設定する。 + +## 3. chktime 側 + +### 設定 + +- `n8n.json` (git 管理外、`.gitignore` に追加。場所は `--n8n-config` で変更可): + `{"url": "https://n8n.yfox.jp", "token": "...", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}` +- `GET /api/n8n/config` は `token` の中身を返さず、`hasToken` だけ返す。`POST /api/n8n/config` で保存。 + Redmine 設定 (`RedmineHolder`) と同じ作り。 +- `verifySsl` の既定は false (LAN からは n8n.yfox.jp の証明書名が一致しないため)。 + +### サーバーの API (chktime 内) + +| ルート | 内容 | +|---|---| +| `GET/POST /api/n8n/config` | 設定の取得 (合言葉は返さない) / 保存 | +| `GET /api/n8n/blocks?from=&to=&project=` | 期間の区間を計算し、n8n の `wt-import-rows` の結果で「送信済み / 削除済み / 重なり」を付けて返す。n8n に届かなくても区間は返し、`n8nError` を付ける | +| `POST /api/n8n/send` | `{items:[{source, task, memo}]}` を受け、時刻はサーバーが `source` から区間を計算し直して n8n の `wt-import` に送る。結果をそのまま返す | +| `GET /api/redmine/search?q=` | 既存の Redmine 連携で未完了チケットを検索 (チケット選択用)。`/api/redmine/issues` は起票 (POST) が使っているので別の名前にする | + +重なりの判定: 区間 [開始, 終了) と、`work_log` の行 (進行中は終了 = 今) または一覧の他の区間が +1 秒でも重なれば「重なりあり」。同じ `source` の行 (送信済みの自分) は除く。 + +### 画面 + +- 右ペインのタブに「n8n へ送る」を足す。左で選んでいるプロジェクト (または すべて) が対象。 +- 期間: 初期値は直近 7 日。日付入力で指定もできる。 +- 一覧の 1 行 = 1 区間。列: 送る (チェック) / 日付 / 開始〜終了 / 時間 / プロジェクト / タスク名 (編集可) / チケット (選ぶ) / 状態。 +- タスク名の初期値は `[作業フォルダ名] セッションのタイトル`。チケットを選ぶと + `[Redmine のプロジェクト名] #番号 題名` にする (rich-start と同じ形。工数同期とガントの実績に載る)。 +- タスク名・チケットは、同じセッションの他の区間にも入れる (切り替え可)。セッションごとに + ブラウザの localStorage に覚える (読み書きは try/catch で囲み、使えなくても動く)。 +- 状態の印: 送信済み / 削除済み / 重なりあり (カーソルで相手を表示) / 進行中。 +- 送るチェックの初期値: 問題の無い区間だけ on。送信済み・削除済み・重なりあり・進行中は off + (送信済みと進行中はチェックできない)。 +- 「選んだ区間を送る」で送信し、追加・スキップ・エラーの件数と内訳を表示して一覧を読み込み直す。 +- 設定 (URL・合言葉・証明書の検証・30 分・5 分) は Redmine 設定と同じ形のダイアログ。 + +## 4. エラーの扱い + +- n8n に届かない / 403 / 5xx: 一覧は出し、上部に原因を表示して送信ボタンを無効にする。送信時の失敗は n8n の応答をそのまま表示。 +- 一部の行だけ失敗: 行ごとの結果を表示。`source` で二重登録を防ぐので、再送で失敗分だけ入る。 +- 同時に 2 回送った場合: n8n 側の「確認 → 追加」は一括の処理ではないので、二重に入る可能性がある。 + 使用者は 1 人なので許容し、README に書く。 +- 合言葉はログにも画面にも出さない。`n8n.json` は git に入れない。 + +## 5. テスト + +### chktime + +- 区間の計算を標準ライブラリの `unittest` で確かめる (`tests/test_blocks.py`)。 + 入力は手で組んだターンの列、またはスクラッチパッドに作ったテスト用 jsonl (実ログは書き換えない)。 + - 空き 29 分はつながり、30 分で切れる + - 5 分未満は除外、5 分ちょうどは残る + - 進行中の区間 + - サブエージェントの行を起点にしない + - 日をまたぐ区間の `date` は開始日 + - `source` が同じ入力で毎回同じ + - 重なりの判定 (接しているだけは重ならない、1 秒重なれば重なる、自分の `source` は除く) +- サーバーは検証用に `python chktime.py --port 8799 --no-browser` で起動する (常用の 8787 には触れない)。 + +### n8n + +- 2000-01-01 付けのダミー (`source: chktime:test:...`) で: 追加される / 再送でスキップ / 合言葉違いで 403 / + 不正値でエラー / `wt-import-rows` に出る / 削除すると `deletedSources` に出る。 +- 終わったらダミーを `work_log` と `work_log_deleted` から直接削除する (削除 Webhook は使わない)。 + +### 全体 + +- 8799 の chktime から、利用者が選んだ実際の区間を 1 件送り、ダッシュボードと日報に出ることを確かめる。 + +## 6. 文書 + +- `CLAUDE.md`: 「外部通信は Redmine 連携だけ」を「Redmine 連携と n8n への送信だけ」に。API の表に n8n の系統を足す。 +- `README.md`: 「n8n へ送る」の使い方、設定、同時送信の注意。 +- `.gitignore`: `n8n.json`。 +- 保管庫: chktime と n8n のノートに追記。 + +## 対象外 + +- 自動で定期的に送ること (送るのは利用者が画面で選んだときだけ)。 +- 送信後に chktime から `work_log` の行を編集・削除すること (編集・削除は既存の work-tracker の画面で行う)。 +- 他の Webhook への認証の追加 (今回の 2 本だけ。広げるのは別の作業)。 diff --git a/tests/test_n8n.py b/tests/test_n8n.py new file mode 100644 index 0000000..822f76f --- /dev/null +++ b/tests/test_n8n.py @@ -0,0 +1,578 @@ +"""n8n 連携 (作業区間の計算・重なり・送信) のテスト。標準ライブラリの unittest だけを使う。 + + python -m unittest discover -s tests -v +""" + +import json +import sys +import tempfile +import threading +import time +import unittest +from datetime import date, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import chktime # noqa: E402 + +MIN = 60.0 +BASE = datetime(2026, 1, 5, 9, 0).timestamp() # ローカル時刻 2026-01-05 09:00 + + +def turn(start_min, end_min, kind="prompt", reply=True, interrupted=False): + """BASE からの分で 1 ターンを作る。reply=False なら応答なし。""" + ts = BASE + start_min * MIN + end = BASE + end_min * MIN + return {"ts": ts, "endTs": end if reply else ts, "firstReplyTs": ts + 1 if reply else None, + "kind": kind, "interrupted": interrupted} + + +def session(*turns, sid="s1", mtime=None): + last_end = max(t["endTs"] for t in turns) if turns else BASE + return {"id": sid, "turns": list(turns), "mtime": mtime if mtime is not None else last_end} + + +LATER = BASE + 24 * 3600 # 「今」: 最後のログから十分経っている + + +class MakeBlocksTest(unittest.TestCase): + def test_gap_29_minutes_keeps_block(self): + blocks = chktime.make_blocks(session(turn(0, 10), turn(39, 50)), now=LATER) + self.assertEqual(len(blocks), 1) + self.assertEqual(blocks[0]["minutes"], 50) + self.assertEqual(blocks[0]["turns"], 2) + + def test_gap_30_minutes_splits_block(self): + blocks = chktime.make_blocks(session(turn(0, 10), turn(40, 50)), now=LATER) + self.assertEqual([b["minutes"] for b in blocks], [10, 10]) + + def test_parallel_prompt_keeps_block(self): + # 前の応答 (0〜20 分) の途中の 5 分に次の発言 + blocks = chktime.make_blocks(session(turn(0, 20), turn(5, 30)), now=LATER) + self.assertEqual(len(blocks), 1) + self.assertEqual(blocks[0]["minutes"], 30) + + def test_short_block_dropped_but_exactly_min_kept(self): + short = chktime.make_blocks(session(turn(0, 4.9)), now=LATER) + exact = chktime.make_blocks(session(turn(0, 5)), now=LATER) + self.assertEqual(short, []) + self.assertEqual(len(exact), 1) + + def test_last_block_without_reply_is_in_progress(self): + blocks = chktime.make_blocks(session(turn(0, 10), turn(60, 60, reply=False)), now=LATER) + self.assertEqual([b["inProgress"] for b in blocks], [False, True]) + + def test_recent_log_marks_last_block_in_progress(self): + s = session(turn(0, 10)) + blocks = chktime.make_blocks(s, now=s["mtime"] + 29 * MIN) + self.assertTrue(blocks[0]["inProgress"]) + blocks = chktime.make_blocks(s, now=s["mtime"] + 30 * MIN) + self.assertFalse(blocks[0]["inProgress"]) + + def test_interrupted_last_turn_is_not_in_progress(self): + blocks = chktime.make_blocks(session(turn(0, 10, reply=False, interrupted=True), turn(1, 10)), now=LATER) + self.assertFalse(blocks[0]["inProgress"]) + only = chktime.make_blocks(session(turn(0, 10), turn(11, 11, reply=False, interrupted=True)), now=LATER) + self.assertFalse(only[-1]["inProgress"]) + + def test_source_is_stable(self): + a = chktime.make_blocks(session(turn(0, 10)), now=LATER) + b = chktime.make_blocks(session(turn(0, 10)), now=LATER + 999) + self.assertEqual(a[0]["source"], b[0]["source"]) + self.assertEqual(a[0]["source"], f"chktime:s1:{int(round(BASE * 1000))}") + + def test_custom_gap_and_min(self): + blocks = chktime.make_blocks(session(turn(0, 3), turn(14, 17)), gap_min=10, min_min=3, now=LATER) + self.assertEqual([b["minutes"] for b in blocks], [3, 3]) + + def test_date_is_local_start_date(self): + blocks = chktime.make_blocks(session(turn(0, 10)), now=LATER) + self.assertEqual(blocks[0]["date"], "2026-01-05") + + +def jsonl_line(obj): + return json.dumps(obj, ensure_ascii=False) + "\n" + + +def write_session(root, project_dir, sid, events, cwd): + """events: [(分, 'user'|'assistant'|'sidechain', 本文)] を jsonl にする。""" + folder = Path(root) / project_dir + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{sid}.jsonl" + with path.open("w", encoding="utf-8") as fh: + for minute, kind, text in events: + ts = chktime.iso(BASE + minute * MIN) + if kind == "assistant": + fh.write(jsonl_line({"type": "assistant", "timestamp": ts, "sessionId": sid, "cwd": cwd, + "message": {"model": "m", "content": [{"type": "text", "text": text}]}})) + else: + fh.write(jsonl_line({"type": "user", "timestamp": ts, "sessionId": sid, "cwd": cwd, + "isSidechain": kind == "sidechain", "message": {"role": "user", "content": text}})) + end = BASE + max(m for m, _, _ in events) * MIN + import os + os.utime(path, (end, end)) + return path + + +class CollectAndMarkTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.store = chktime.Store(self.root) + + def tearDown(self): + self.tmp.cleanup() + + def test_collect_blocks_names_and_range(self): + write_session(self.root, "C--debug-demo", "s1", + [(0, "user", "設計して"), (10, "assistant", "ok"), (60, "user", "続き"), (70, "assistant", "ok")], + r"C:\debug\demo") + blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER) + self.assertEqual(len(blocks), 2) + self.assertEqual(blocks[0]["taskName"], "[demo] 設計して") + self.assertEqual(blocks[0]["project"], "C--debug-demo") + none = chktime.collect_blocks(self.store, "", date(2026, 1, 6), date(2026, 1, 6), 30, 5, now=LATER) + self.assertEqual(none, []) + + def test_sidechain_does_not_start_turn(self): + write_session(self.root, "C--debug-demo", "s2", + [(0, "user", "調べて"), (10, "assistant", "ok"), (45, "sidechain", "sub"), (80, "user", "次"), (90, "assistant", "ok")], + r"C:\debug\demo") + blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER) + # サブエージェントの行は直前のターンを 45 分まで伸ばすだけ。80 分の発言はその 35 分後なので別の区間。 + self.assertEqual([b["minutes"] for b in blocks], [45, 10]) + + def test_midnight_block_belongs_to_start_date(self): + start = datetime(2026, 1, 5, 23, 50).timestamp() + minutes = (start - BASE) / MIN + write_session(self.root, "C--debug-demo", "s3", + [(minutes, "user", "夜"), (minutes + 20, "assistant", "ok")], r"C:\debug\demo") + day1 = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER + 86400) + day2 = chktime.collect_blocks(self.store, "", date(2026, 1, 6), date(2026, 1, 6), 30, 5, now=LATER + 86400) + self.assertEqual([b["date"] for b in day1], ["2026-01-05"]) + self.assertEqual(day2, []) + + def test_continued_session_copies_are_listed_once(self): + # 会話を続けると、新しいセッションのファイルに前の履歴が同じ時刻のまま写される + old = [(0, "user", "設計して"), (10, "assistant", "ok")] + write_session(self.root, "C--debug-demo", "old", old, r"C:\debug\demo") + write_session(self.root, "C--debug-demo", "new", + old + [(20, "user", "続き"), (25, "assistant", "ok"), (90, "user", "次"), (100, "assistant", "ok")], + r"C:\debug\demo") + blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER) + self.assertEqual([(b["sessionId"], b["minutes"]) for b in blocks], [("new", 25), ("new", 10)]) + + def test_mark_blocks(self): + b1 = {"source": "chktime:a:1", "startEpoch": BASE, "endEpoch": BASE + 600, "start": "", "end": "", "taskName": "A"} + b2 = {"source": "chktime:b:2", "startEpoch": BASE + 600, "endEpoch": BASE + 1200, "start": "", "end": "", "taskName": "B"} + b3 = {"source": "chktime:c:3", "startEpoch": BASE + 1199, "endEpoch": BASE + 1800, "start": "", "end": "", "taskName": "C"} + rows = [ + {"id": 1, "source": "chktime:a:1", "start_time": chktime.iso(BASE), "end_time": chktime.iso(BASE + 600), "task": "A"}, + {"id": 2, "source": "", "start_time": chktime.iso(BASE + 1700), "end_time": None, "task": "手で記録"}, + ] + chktime.mark_blocks([b1, b2, b3], rows, ["chktime:b:2"], now=BASE + 3600) + self.assertTrue(b1["sent"]) + self.assertEqual(b1["overlaps"], []) # 自分の行は除く。b2 とは接しているだけ + self.assertFalse(b2["sent"]) + self.assertTrue(b2["deleted"]) + self.assertEqual([o["source"] for o in b2["overlaps"]], ["chktime:c:3"]) # 1 秒重なる + self.assertEqual(sorted(o["kind"] for o in b3["overlaps"]), ["block", "row"]) # 進行中の行 (終了 = 今) とも重なる + + +class FakeN8n: + """wt-import の受け口を真似る偽のサーバー。受け取った要求を記録する。""" + + TOKEN = "test-token" + + def __init__(self): + self.requests = [] + fake = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def reply(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + fake.requests.append(("GET", self.path, self.headers.get("X-WT-Token"))) + if self.headers.get("X-WT-Token") != FakeN8n.TOKEN: + return self.reply(403, {"message": "Authorization data is wrong!"}) + if "from=2099" in self.path: + return self.reply(200, {"ok": False, "error": "from が to より後です"}) + self.reply(200, {"ok": True, "rows": [{"id": 9, "source": "chktime:s1:1", "start_time": None}], "deletedSources": []}) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(length)) + fake.requests.append(("POST", self.path, body)) + if self.headers.get("X-WT-Token") != FakeN8n.TOKEN: + return self.reply(403, {}) + inserted = [{"source": r["source"], "id": 100 + i} for i, r in enumerate(body["rows"])] + self.reply(200, {"ok": True, "inserted": inserted, "skipped": [], "errors": []}) + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def close(self): + self.server.shutdown() + self.server.server_close() + + +class N8nClientTest(unittest.TestCase): + def setUp(self): + self.fake = FakeN8n() + + def tearDown(self): + self.fake.close() + + def test_rows_sends_token_and_range(self): + client = chktime.N8n(self.fake.url, FakeN8n.TOKEN) + data = client.rows(date(2026, 1, 1), date(2026, 1, 7)) + self.assertEqual(data["rows"][0]["id"], 9) + method, path, token = self.fake.requests[-1] + self.assertEqual(path, "/webhook/wt-import-rows?from=2026-01-01&to=2026-01-07") + self.assertEqual(token, FakeN8n.TOKEN) + + def test_wrong_token_is_403_message(self): + client = chktime.N8n(self.fake.url, "wrong") + with self.assertRaisesRegex(chktime.N8nError, "合言葉が違います"): + client.rows(date(2026, 1, 1), date(2026, 1, 1)) + + def test_error_payload_raises(self): + client = chktime.N8n(self.fake.url, FakeN8n.TOKEN) + with self.assertRaisesRegex(chktime.N8nError, "from が to より後です"): + client.rows(date(2099, 1, 2), date(2099, 1, 1)) + + def test_unreachable(self): + client = chktime.N8n("http://127.0.0.1:9", FakeN8n.TOKEN) + with self.assertRaisesRegex(chktime.N8nError, "接続できません"): + client.rows(date(2026, 1, 1), date(2026, 1, 1)) + + def test_missing_settings(self): + with self.assertRaisesRegex(chktime.N8nError, "URL が未設定"): + chktime.N8n("", "x").rows(date(2026, 1, 1), date(2026, 1, 1)) + with self.assertRaisesRegex(chktime.N8nError, "合言葉が未設定"): + chktime.N8n(self.fake.url, "").rows(date(2026, 1, 1), date(2026, 1, 1)) + + +class N8nHolderTest(unittest.TestCase): + def test_public_hides_token_and_update_keeps_it(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "n8n.json" + holder = chktime.N8nHolder(path) + self.assertEqual(holder.settings(), (30, 5)) + pub = holder.update({"url": "https://n8n.example/", "token": "secret", "gapMinutes": 20, "minMinutes": 3}) + self.assertTrue(pub["hasToken"]) + self.assertNotIn("secret", json.dumps(pub)) + self.assertEqual(holder.settings(), (20, 3)) + holder.update({"token": ""}) # 空なら既存を残す + self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["token"], "secret") + self.assertEqual(holder.client.url, "https://n8n.example") + + def test_rejects_bad_minutes(self): + with tempfile.TemporaryDirectory() as tmp: + holder = chktime.N8nHolder(Path(tmp) / "n8n.json") + for patch in ({"gapMinutes": 0}, {"gapMinutes": 241}, {"minMinutes": -1}, {"gapMinutes": "abc"}): + with self.assertRaises(chktime.N8nError): + holder.update(patch) + + +class ApiFunctionsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "projects" + self.store = chktime.Store(self.root) + write_session(self.root, "C--debug-demo", "s1", + [(0, "user", "設計して"), (10, "assistant", "ok")], r"C:\debug\demo") + self.fake = FakeN8n() + self.holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json") + self.holder.update({"url": self.fake.url, "token": FakeN8n.TOKEN}) + self.query = {"from": ["2026-01-05"], "to": ["2026-01-05"], "project": [""]} + + def tearDown(self): + self.fake.close() + self.tmp.cleanup() + + def source(self): + return f"chktime:s1:{int(round(BASE * 1000))}" + + def test_range_defaults_and_limits(self): + start, end = chktime.n8n_range({}) + self.assertEqual((end - start).days, 6) + with self.assertRaises(chktime.N8nError): + chktime.n8n_range({"from": ["2026-01-05"], "to": ["2026-01-04"]}) + with self.assertRaises(chktime.N8nError): + chktime.n8n_range({"from": ["2026-01-01"], "to": ["2026-03-31"]}) + + def test_blocks_marks_with_n8n_rows(self): + data = chktime.n8n_blocks(self.store, self.holder, self.query, now=LATER) + self.assertIsNone(data["n8nError"]) + self.assertEqual(len(data["blocks"]), 1) + self.assertFalse(data["blocks"][0]["sent"]) + + def test_blocks_when_n8n_down(self): + self.holder.update({"url": "http://127.0.0.1:9"}) + data = chktime.n8n_blocks(self.store, self.holder, self.query, now=LATER) + self.assertIn("接続できません", data["n8nError"]) + self.assertEqual(len(data["blocks"]), 1) + self.assertFalse(data["blocks"][0]["sent"]) + + def test_send_recomputes_times_on_server(self): + item = {"project": "C--debug-demo", "source": self.source(), "task": "[demo] #12 設計", "memo": "m", + "start_time": "1999-01-01T00:00:00Z"} # ブラウザの時刻は使わない + result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER) + self.assertTrue(result["ok"]) + method, path, body = self.fake.requests[-1] + row = body["rows"][0] + self.assertEqual(path, "/webhook/wt-import") + self.assertEqual(row["start_time"], chktime.iso(BASE)) + self.assertEqual(row["end_time"], chktime.iso(BASE + 10 * MIN)) + self.assertEqual(row["date"], "2026-01-05") + self.assertEqual(row["task"], "[demo] #12 設計") + + def test_send_rejects_blank_task(self): + item = {"project": "C--debug-demo", "source": self.source(), "task": " "} + result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER) + self.assertFalse(result["ok"]) + self.assertEqual(result["errors"][0]["error"], "タスク名が空です") + self.assertEqual([r for r in self.fake.requests if r[0] == "POST"], []) + + def test_send_rejects_unknown_and_in_progress(self): + bad = {"project": "C--debug-demo", "source": "chktime:s1:123", "task": "x"} + gone = {"project": "C--debug-demo", "source": "chktime:nope:1", "task": "x"} + result = chktime.n8n_send(self.store, self.holder, {"items": [bad, gone]}, now=LATER) + self.assertEqual([e["error"] for e in result["errors"]], + ["区間が見つかりません (ログか設定が変わりました)", "セッションが見つかりません"]) + live = {"project": "C--debug-demo", "source": self.source(), "task": "x"} + result = chktime.n8n_send(self.store, self.holder, {"items": [live]}, now=BASE + 11 * MIN) + self.assertEqual(result["errors"][0]["error"], "進行中の区間は送れません") + + def test_send_requires_items(self): + with self.assertRaises(chktime.N8nError): + chktime.n8n_send(self.store, self.holder, {"items": []}, now=LATER) + + +class RedmineSearchTest(unittest.TestCase): + """チケット選択用の検索。偽の Redmine に対して要求の形と結果の変換を確かめる。""" + + def setUp(self): + self.paths = [] + test = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + test.paths.append(self.path) + if self.path.startswith("/issues/404.json"): + self.send_response(404) + self.end_headers() + return + if self.path.startswith("/issues/"): + payload = {"issue": {"id": 7, "subject": "七", "project": {"name": "P"}}} + else: + payload = {"issues": [{"id": 3, "subject": "設計", "project": {"name": "ImgChk"}}]} + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.client = chktime.Redmine(f"http://127.0.0.1:{self.server.server_address[1]}", "key", interval=0) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def test_search_by_subject(self): + self.assertEqual(self.client.search("設 計"), [{"id": 3, "subject": "設計", "project": "ImgChk"}]) + self.assertIn("status_id=open", self.paths[-1]) + self.assertIn("op[subject]=~", self.paths[-1]) + self.assertIn("v[subject][]=%E8%A8%AD%20%E8%A8%88", self.paths[-1]) + + def test_search_by_number_and_missing_number(self): + self.assertEqual(self.client.search("7"), [{"id": 7, "subject": "七", "project": "P"}]) + self.assertEqual(self.client.search("404"), []) + + +def write_raw(root, project_dir, sid, lines, mtime): + """jsonl の行 (dict) をそのまま書く。mtime を指定する。""" + import os + folder = Path(root) / project_dir + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{sid}.jsonl" + with path.open("w", encoding="utf-8") as fh: + for obj in lines: + # 本物のログと同じく区切りの空白なしで書く (tool_result 行の高速判定がこの形を前提にしている) + fh.write(json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n") + os.utime(path, (mtime, mtime)) + return path + + +def ts(minute): + return chktime.iso(BASE + minute * MIN) + + +def user_line(minute, text, sid="p1", sidechain=False): + return {"type": "user", "timestamp": ts(minute), "sessionId": sid, "cwd": r"C:\debug\demo", + "isSidechain": sidechain, "message": {"role": "user", "content": text}} + + +def reply_line(minute, sid="p1", tool=None): + content = [{"type": "tool_use", "id": "t1", "name": tool, "input": {}}] if tool else [{"type": "text", "text": "ok"}] + return {"type": "assistant", "timestamp": ts(minute), "sessionId": sid, "message": {"model": "m", "content": content}} + + +def tool_result_line(minute, sid="p1"): + return {"type": "user", "timestamp": ts(minute), "sessionId": sid, + "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "done"}]}} + + +class FinalReviewFixTest(unittest.TestCase): + """最終レビューで見つかった問題の再現テスト。""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "projects" + self.store = chktime.Store(self.root) + + def tearDown(self): + self.tmp.cleanup() + + def blocks(self, now): + return chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=now) + + # Important 1: 最後がローカルのコマンド (/exit など) だと永遠に進行中になっていた + def test_trailing_local_command_is_not_in_progress(self): + write_raw(self.root, "C--debug-demo", "p1", + [user_line(0, "作業して"), reply_line(20), user_line(21, "/exit")], + BASE + 21 * MIN) + self.assertEqual([b["inProgress"] for b in self.blocks(LATER)], [False]) + + def test_prompt_without_reply_long_ago_is_not_in_progress(self): + write_raw(self.root, "C--debug-demo", "p1", [user_line(0, "作業して"), reply_line(20), user_line(21, "次")], + BASE + 21 * MIN) + self.assertEqual([b["inProgress"] for b in self.blocks(BASE + 21 * MIN + 25 * 3600)], [False]) + self.assertEqual([b["inProgress"] for b in self.blocks(BASE + 21 * MIN + 2 * 3600)], [True]) + + # Important 2: ツールの結果を待っている間に 30 分静かだと、途中で切れた区間が送れていた + def test_open_tool_call_keeps_block_in_progress(self): + write_raw(self.root, "C--debug-demo", "p1", [user_line(0, "調べて"), reply_line(5, tool="Agent")], + BASE + 5 * MIN) + self.assertEqual([b["inProgress"] for b in self.blocks(BASE + 40 * MIN)], [True]) + write_raw(self.root, "C--debug-demo", "p1", + [user_line(0, "調べて"), reply_line(5, tool="Agent"), tool_result_line(45), reply_line(46)], + BASE + 46 * MIN) + self.store.clear() + self.assertEqual([(b["inProgress"], b["minutes"]) for b in self.blocks(BASE + 100 * MIN)], [(False, 46)]) + + def test_sent_block_that_grew_is_flagged(self): + block = {"source": "chktime:a:1", "startEpoch": BASE, "endEpoch": BASE + 46 * MIN, "start": ts(0), "end": ts(46), + "taskName": "A"} + rows = [{"id": 1, "source": "chktime:a:1", "start_time": ts(0), "end_time": ts(5), "task": "A"}] + chktime.mark_blocks([block], rows, [], now=LATER) + self.assertTrue(block["sent"]) + self.assertTrue(block["changedSinceSent"]) + + # Important 3: 写しの同着で、送信済みの区間が未送信に見えていた + def test_copy_with_same_end_keeps_sent_state(self): + lines_old = [user_line(0, "設計して", sid="aaa"), reply_line(10, sid="aaa")] + lines_new = [user_line(0, "設計して", sid="zzz"), reply_line(10, sid="zzz"), user_line(60, "続き", sid="zzz"), + reply_line(70, sid="zzz")] + write_raw(self.root, "C--debug-demo", "zzz", lines_new, BASE + 70 * MIN) + write_raw(self.root, "C--debug-demo", "aaa", lines_old, BASE + 10 * MIN) + blocks = self.blocks(LATER) + first = blocks[0] + self.assertEqual(len(blocks), 2) + sent_source = f"chktime:aaa:{int(round(BASE * 1000))}" + self.assertIn(sent_source, [first["source"]] + first["aliases"]) + rows = [{"id": 1, "source": sent_source, "start_time": ts(0), "end_time": ts(10), "task": "A"}] + chktime.mark_blocks(blocks, rows, [], now=LATER) + self.assertTrue(first["sent"]) + self.assertEqual(first["overlaps"], []) + + # Important 5: 送れない文字の合言葉で一覧ごと 500 になっていた + def test_non_ascii_token_is_n8n_error_without_token_text(self): + client = chktime.N8n("http://127.0.0.1:9", "合言葉です") + with self.assertRaises(chktime.N8nError) as ctx: + client.rows(date(2026, 1, 1), date(2026, 1, 1)) + self.assertNotIn("合言葉です", str(ctx.exception)) + + def test_holder_rejects_non_ascii_token(self): + holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json") + with self.assertRaises(chktime.N8nError): + holder.update({"token": "合言葉"}) + + # Important 4: URL だけ変えると、保存済みの合言葉が別のサーバーへ送られていた + def test_changing_url_host_clears_token(self): + holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json") + holder.update({"url": "https://n8n.example", "token": "secret"}) + self.assertFalse(holder.update({"url": "https://attacker.example"})["hasToken"]) + holder.update({"url": "https://n8n.example", "token": "secret"}) + self.assertTrue(holder.update({"url": "https://n8n.example/"})["hasToken"]) + self.assertTrue(holder.update({"url": "https://other.example", "token": "secret2"})["hasToken"]) + + +class PostGuardTest(unittest.TestCase): + """Important 4: 他のサイトから POST で設定を書き換えられないこと (Content-Type と Origin)。""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + server = chktime.Server(("127.0.0.1", 0), chktime.Handler) + server.store = chktime.Store(Path(self.tmp.name)) + server.redmine = chktime.RedmineHolder(Path(self.tmp.name) / "redmine.json") + server.n8n = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json") + server.jobs = chktime.Jobs() + self.server = server + self.port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.tmp.cleanup() + + def post(self, body, ctype="application/json", origin=None, host=None): + import http.client + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + headers = {"Content-Type": ctype} + if origin: + headers["Origin"] = origin + if host: + headers["Host"] = host + conn.request("POST", "/api/n8n/config", body=json.dumps(body).encode(), headers=headers) + res = conn.getresponse() + status = res.status + res.read() + conn.close() + return status + + def test_rejects_simple_cross_site_post(self): + self.assertEqual(self.post({"url": "https://attacker.example"}, ctype="text/plain"), 415) + self.assertEqual(self.server.n8n.public()["url"], "") + + def test_rejects_foreign_origin_and_host(self): + self.assertEqual(self.post({"url": "https://a.example"}, origin="https://evil.example"), 403) + self.assertEqual(self.post({"url": "https://a.example"}, host="evil.example"), 403) + self.assertEqual(self.server.n8n.public()["url"], "") + + def test_accepts_same_origin_json(self): + origin = f"http://127.0.0.1:{self.port}" + self.assertEqual(self.post({"url": "https://n8n.example"}, origin=origin), 200) + self.assertEqual(self.server.n8n.public()["url"], "https://n8n.example") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.js b/web/app.js index 71c9ba8..d74773c 100644 --- a/web/app.js +++ b/web/app.js @@ -144,6 +144,7 @@ async function selectProject(id) { $('#session-table tbody').innerHTML = '解析中…'; if (state.view === 'daily') loadDaily(); else if (state.view === 'gantt') loadGantt(); + else if (state.view === 'n8n') loadN8n(); await reloadSessions(); } @@ -317,6 +318,7 @@ function syncTabs() { $('#tab-turns').classList.toggle('sel', state.view === 'turns'); $('#tab-daily').classList.toggle('sel', state.view === 'daily'); $('#tab-gantt').classList.toggle('sel', state.view === 'gantt'); + $('#tab-n8n').classList.toggle('sel', state.view === 'n8n'); } function ganttQuery(extra) { @@ -343,6 +345,10 @@ function dailyQuery(extra) { function updateCsv() { const link = $('#csv-turns'); + if (state.view === 'n8n') { + link.removeAttribute('href'); + return; + } if (state.view === 'daily') { link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.title = '日別集計を CSV 出力'; @@ -363,6 +369,7 @@ function setView(view) { updateCsv(); if (view === 'daily') loadDaily(); else if (view === 'gantt') loadGantt(); + else if (view === 'n8n') loadN8n(); else renderDetail(); } @@ -1021,6 +1028,7 @@ async function refreshFollow() { await reloadSessions(); if (state.view === 'daily') await loadDaily({ silent: true }); else if (state.view === 'gantt') await loadGantt({ silent: true }); + else if (state.view === 'n8n') { /* 編集中の入力を消さないよう、自動追従では読み直さない */ } else await reloadDetail(); list.scrollTop = listTop; // やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚) @@ -1472,6 +1480,7 @@ $('#relative').onchange = (e) => { $('#tab-turns').onclick = () => setView('turns'); $('#tab-daily').onclick = () => setView('daily'); $('#tab-gantt').onclick = () => setView('gantt'); +$('#tab-n8n').onclick = () => setView('n8n'); $('#redmine').onclick = openRedmine; $('#rm-close').onclick = closeRedmine; $('#rm-run').onclick = runRedmine; @@ -1490,7 +1499,7 @@ $('#reload').onclick = async () => { let resizeTimer = null; window.addEventListener('resize', () => { - if (state.view === 'turns') return; + if (state.view === 'turns' || state.view === 'n8n') return; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (state.view === 'daily' && state.daily) renderDaily(); diff --git a/web/index.html b/web/index.html index da6b98e..421f78f 100644 --- a/web/index.html +++ b/web/index.html @@ -57,6 +57,7 @@ + CSV @@ -67,5 +68,6 @@ + diff --git a/web/n8n.js b/web/n8n.js new file mode 100644 index 0000000..77fa95a --- /dev/null +++ b/web/n8n.js @@ -0,0 +1,279 @@ +'use strict'; +// 「n8n へ送る」タブ: Claude のセッションから作った作業区間を選び、n8n の work_log に送る。 +// app.js の後に読み込む ($ / state / api / post / esc / fmtTime / shortPath を使う)。 + +const N8N_NAMES_KEY = 'chktime.n8n.names'; // セッション ID -> {task, memo} (ブラウザに覚える) + +const n8 = { + cfg: null, // /api/n8n/config (合言葉は含まれない) + from: '', + to: '', + data: null, // /api/n8n/blocks + checked: new Set(), // 送る区間の source + edits: new Map(), // source -> {task, memo} + sameSession: true, // 同じセッションの他の区間にも入れる + settings: false, + busy: false, + result: null, // 直前の送信結果 + pick: null, // チケットを選んでいる区間の source + issues: [], + pickMsg: '', +}; + +function n8LoadNames() { + try { + return JSON.parse(localStorage.getItem(N8N_NAMES_KEY) || '{}') || {}; + } catch (e) { + return {}; + } +} + +function n8SaveName(sessionId, value) { + try { + const all = n8LoadNames(); + all[sessionId] = value; + localStorage.setItem(N8N_NAMES_KEY, JSON.stringify(all)); + } catch (e) { + /* 保存できなくても動く */ + } +} + +const n8CanSend = (b) => !b.sent && !b.inProgress; +const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length; + +function n8Query() { + const q = new URLSearchParams(); + if (state.project) q.set('project', state.project); + if (n8.from) q.set('from', n8.from); + if (n8.to) q.set('to', n8.to); + return q.toString(); +} + +async function loadN8n({ silent = false } = {}) { + $('#detail-title').textContent = 'n8n へ送る'; + if (!silent) $('#detail-body').innerHTML = '

    区間を計算中…

    '; + try { + n8.cfg = await api('/api/n8n/config'); + if (!n8.cfg.url || !n8.cfg.hasToken) n8.settings = true; + n8.data = await api('/api/n8n/blocks?' + n8Query()); + n8.from = n8.data.from; + n8.to = n8.data.to; + const names = n8LoadNames(); + n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source)); + for (const b of n8.data.blocks) { + if (n8.edits.has(b.source)) continue; + // 続きのセッションに写された区間は、写し元のセッションで覚えた名前も使う + const saved = [b.sessionId, ...(b.aliasSessions || [])].map((id) => names[id]).find(Boolean) || {}; + n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' }); + } + renderN8n(); + } catch (e) { + $('#detail-body').innerHTML = `

    ${esc(e.message)}

    `; + } +} + +function n8Status(b) { + const marks = []; + if (b.sent) marks.push('送信済み'); + if (b.changedSinceSent) marks.push('送信後に変化'); + if (b.deleted) marks.push('削除済み'); + if (b.inProgress) marks.push('進行中'); + if ((b.overlaps || []).length) { + const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n'); + marks.push(`重なり ${b.overlaps.length}`); + } + return marks.join(' '); +} + +function n8Settings() { + const c = n8.cfg || {}; + return `
    +

    接続設定 ${esc(c.configPath || '')}

    + + + + + + +
    `; +} + +function n8ResultHtml() { + const r = n8.result; + if (!r) return ''; + const errs = (r.errors || []).map((e) => `
  • ${esc(e.source)}: ${esc(e.error)}
  • `).join(''); + const skips = (r.skipped || []).map((s) => `
  • ${esc(s.source)}: ${esc(s.reason)}
  • `).join(''); + return `
    + 追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件 + ${skips ? `
      ${skips}
    ` : ''}${errs ? `
      ${errs}
    ` : ''} +
    `; +} + +function n8PickHtml() { + if (!n8.pick) return ''; + const rows = n8.issues + .map((i) => `
  • [${esc(i.project)}] ${esc(i.subject)}
  • `) + .join(''); + return `
    +

    Redmine のチケットを選ぶ

    + + ${esc(n8.pickMsg)} +
      ${rows}
    +
    `; +} + +function renderN8n() { + const d = n8.data; + if (!d) return; + const blocks = d.blocks; + const on = blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)); + const rows = blocks + .map((b) => { + const e = n8.edits.get(b.source) || { task: b.taskName, memo: '' }; + return ` + + ${esc(b.date)} + ${fmtTime(b.start)}–${fmtTime(b.end)} + ${b.minutes} 分 + ${esc(shortPath(b.projectPath))} + + + ${n8Status(b)} + `; + }) + .join(''); + $('#detail-body').innerHTML = `
    +
    + + + + + + +
    + ${d.n8nError ? `

    n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)

    ` : ''} + ${n8.settings ? n8Settings() : ''} + ${n8ResultHtml()} + ${n8PickHtml()} +

    区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件

    + + ${rows || ''}
    送る日付時刻時間プロジェクトタスク名 / メモチケット状態
    この期間の区間はありません
    +
    `; + n8Wire(); +} + +function n8Block(source) { + return n8.data.blocks.find((b) => b.source === source); +} + +/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */ +function n8SetEdit(source, patch) { + const b = n8Block(source); + const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b]; + for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch }); + n8SaveName(b.sessionId, n8.edits.get(source)); +} + +function n8Wire() { + $('#n8-reload').onclick = () => { + n8.from = $('#n8-from').value; + n8.to = $('#n8-to').value; + loadN8n(); + }; + $('#n8-same').onchange = (e) => { + n8.sameSession = e.target.checked; + }; + $('#n8-toggle').onclick = () => { + n8.settings = !n8.settings; + renderN8n(); + }; + $('#n8-send').onclick = n8Send; + if (n8.settings) $('#n8-save').onclick = n8SaveSettings; + if (n8.pick) { + $('#n8-find').onclick = n8Find; + $('#n8-q').onkeydown = (e) => { + if (e.key === 'Enter') n8Find(); + }; + $('#n8-pick-close').onclick = () => { + n8.pick = null; + renderN8n(); + }; + document.querySelectorAll('.n8-issue').forEach((btn) => { + btn.onclick = () => { + const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id); + n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` }); + n8.pick = null; + renderN8n(); + }; + }); + } + document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => { + const source = tr.dataset.src; + tr.querySelector('.n8-on').onchange = (e) => { + if (e.target.checked) n8.checked.add(source); + else n8.checked.delete(source); + renderN8n(); + }; + tr.querySelector('.n8-task').onchange = (e) => { + n8SetEdit(source, { task: e.target.value }); + renderN8n(); + }; + tr.querySelector('.n8-memo').onchange = (e) => { + n8SetEdit(source, { memo: e.target.value }); + }; + tr.querySelector('.n8-choose').onclick = () => { + n8.pick = source; + n8.issues = []; + n8.pickMsg = ''; + renderN8n(); + }; + }); +} + +async function n8Find() { + n8.pickMsg = '検索中…'; + const q = $('#n8-q').value; + renderN8n(); + try { + n8.issues = (await api('/api/redmine/search?q=' + encodeURIComponent(q))).issues; + n8.pickMsg = n8.issues.length ? '' : '見つかりません'; + } catch (e) { + n8.pickMsg = e.message; + } + renderN8n(); + $('#n8-q').value = q; +} + +async function n8SaveSettings() { + const patch = { + url: $('#n8-url').value, + verifySsl: $('#n8-verify').checked, + gapMinutes: Number($('#n8-gap').value), + minMinutes: Number($('#n8-min').value), + }; + const token = $('#n8-token').value; + if (token) patch.token = token; + try { + n8.cfg = await post('/api/n8n/config', patch); + n8.settings = false; + loadN8n(); + } catch (e) { + alert('保存できません: ' + e.message); + } +} + +async function n8Send() { + const items = n8.data.blocks + .filter((b) => n8.checked.has(b.source) && n8CanSend(b)) + .map((b) => ({ project: b.project, source: b.source, ...(n8.edits.get(b.source) || { task: b.taskName, memo: '' }) })); + if (!items.length) return; + n8.busy = true; + renderN8n(); + try { + n8.result = await post('/api/n8n/send', { items }); + } catch (e) { + n8.result = { ok: false, inserted: [], skipped: [], errors: [{ source: '', error: e.message }] }; + } + n8.busy = false; + await loadN8n({ silent: true }); +} diff --git a/web/style.css b/web/style.css index 09ee935..33bd461 100644 --- a/web/style.css +++ b/web/style.css @@ -395,3 +395,27 @@ table { width: 100%; border-collapse: collapse; } .gantt svg rect.t-work { fill: var(--work); } .gantt svg rect.t-none { fill: var(--idle); } #turns tr.hi td { background: rgba(122, 162, 247, .18); } + +/* n8n へ送る */ +.n8 { padding: 8px 12px; } +.n8-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; } +.n8-bar .spacer { flex: 1; } +.n8 .go { border-color: var(--work); color: var(--work); } +.n8 .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; } +.n8 .note { color: var(--dim); font-size: 11px; } +.n8 .err-inline { color: #f7768e; font-size: 11px; } +.n8-settings, .n8-pick, .n8-result { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 8px 0; } +.n8-settings label { display: inline-flex; gap: 4px; align-items: center; margin-right: 12px; } +.n8-result.ok { border-color: var(--work); } +.n8-result.ng { border-color: #f7768e; } +.n8-pick ul { list-style: none; padding: 0; margin: 6px 0 0; max-height: 200px; overflow: auto; } +#n8n-table th { text-align: left; font-size: 11px; color: var(--dim); padding: 4px 6px; border-bottom: 1px solid var(--line); } +#n8n-table td { padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; } +#n8n-table .time, #n8n-table .num { white-space: nowrap; font-variant-numeric: tabular-nums; } +#n8n-table .num { text-align: right; } +#n8n-table .n8-task { width: 22em; } +#n8n-table .n8-memo { width: 10em; } +#n8n-table tr.n8-sent td { color: var(--dim); } +.n8-mark { display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 10.5px; border: 1px solid var(--line); } +.n8-mark.sent { color: var(--work); border-color: var(--work); } +.n8-mark.warn { color: var(--warn); border-color: var(--warn); }