Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95943f4e0e | ||
|
|
929f702cc6 | ||
|
|
c37622fdba | ||
|
|
febab50f7e | ||
|
|
e75b3ead10 | ||
|
|
682820ae43 | ||
|
|
c1d1d046b6 | ||
|
|
38a3307cd4 | ||
|
|
c3c42d5988 | ||
|
|
e16189153c |
@@ -8,3 +8,6 @@ __pycache__/
|
|||||||
|
|
||||||
# Redmine 連携の設定 (API アクセスキーが入る)
|
# Redmine 連携の設定 (API アクセスキーが入る)
|
||||||
redmine.json
|
redmine.json
|
||||||
|
|
||||||
|
# n8n 連携の設定 (合言葉が入る)
|
||||||
|
n8n.json
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッ
|
|||||||
## 構成
|
## 構成
|
||||||
|
|
||||||
```
|
```
|
||||||
chktime.py サーバ + jsonl 解析 + Redmine 連携(これ1本で完結)
|
chktime.py サーバ + jsonl 解析 + Redmine 連携 + n8n 連携(これ1本で完結)
|
||||||
web/index.html 画面の骨組み
|
web/index.html 画面の骨組み
|
||||||
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
|
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
|
||||||
web/style.css スタイル(ダークテーマ固定)
|
web/style.css スタイル(ダークテーマ固定)
|
||||||
|
web/n8n.js 「n8n へ送る」タブ(app.js の後に読む)
|
||||||
run.bat コンソール表示で起動
|
run.bat コンソール表示で起動
|
||||||
stop.cmd 起動中の chktime を終了
|
stop.cmd 起動中の chktime を終了
|
||||||
redmine.json Redmine 連携の設定(自動生成・git 管理外)
|
redmine.json Redmine 連携の設定(自動生成・git 管理外)
|
||||||
|
n8n.json n8n 連携の設定(合言葉を含む・git 管理外)
|
||||||
|
tests/ unittest(python -m unittest discover -s tests -v)
|
||||||
```
|
```
|
||||||
|
|
||||||
`chktime.py` の中身は上から
|
`chktime.py` の中身は上から
|
||||||
@@ -21,7 +24,7 @@ redmine.json Redmine 連携の設定(自動生成・git 管理外)
|
|||||||
## 守ること
|
## 守ること
|
||||||
|
|
||||||
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
|
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
|
||||||
- **外部通信は Redmine 連携だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。
|
- **外部通信は Redmine 連携と n8n への送信だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。
|
||||||
新しい通信先を増やすときは、この行を書き換えるところから始める。
|
新しい通信先を増やすときは、この行を書き換えるところから始める。
|
||||||
- **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない
|
- **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない
|
||||||
(`/api/redmine/config` はキーの有無だけ返す)。
|
(`/api/redmine/config` はキーの有無だけ返す)。
|
||||||
@@ -55,7 +58,7 @@ python chktime.py --port 8799 --no-browser
|
|||||||
|
|
||||||
いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。
|
いずれも `&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/draft` | 下書きを作る(`source=tasks` / `source=git`) |
|
||||||
| `POST /api/redmine/issues` | 起票をジョブとして開始。返り値の `id` で進捗を見る |
|
| `POST /api/redmine/issues` | 起票をジョブとして開始。返り値の `id` で進捗を見る |
|
||||||
| `GET /api/redmine/job?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` | 選んだ区間を送る。時刻はサーバーがログから計算し直す |
|
||||||
|
|
||||||
## 解析の前提
|
## 解析の前提
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Claude Code の **タスク(セッション)ごとのチャットのやり
|
|||||||
「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。
|
「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。
|
||||||
追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。
|
追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。
|
||||||
|
|
||||||
外部への通信は **[Redmine へ起票](#redmine-へ起票)** を使ったときだけです。それ以外は
|
外部への通信は **[Redmine へ起票](#redmine-へ起票)** と **[n8n へ送る](#n8n-へ送る)** を使ったときだけです。
|
||||||
ローカルの jsonl を読むだけで、どこにも接続しません。
|
それ以外はローカルの jsonl を読むだけで、どこにも接続しません。
|
||||||
|
|
||||||
## 起動
|
## 起動
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ python chktime.py
|
|||||||
| `--dir` | `~/.claude/projects` | ログの置き場所 |
|
| `--dir` | `~/.claude/projects` | ログの置き場所 |
|
||||||
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
|
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
|
||||||
| `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル |
|
| `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル |
|
||||||
|
| `--n8n-config` | `n8n.json` | n8n 連携の設定ファイル |
|
||||||
| `--no-browser` | – | ブラウザを自動で開かない |
|
| `--no-browser` | – | ブラウザを自動で開かない |
|
||||||
|
|
||||||
## 画面
|
## 画面
|
||||||
@@ -112,7 +113,7 @@ python chktime.py
|
|||||||
## Redmine へ起票
|
## Redmine へ起票
|
||||||
|
|
||||||
ヘッダの **Redmine へ起票** で、作業の記録を Redmine のチケットにまとめて登録できます。
|
ヘッダの **Redmine へ起票** で、作業の記録を Redmine のチケットにまとめて登録できます。
|
||||||
**このアプリで唯一、外部へ通信する機能**です。使わなければ一切接続しません。
|
外部へ通信する機能です(ほかに [n8n へ送る](#n8n-へ送る) があります)。使わなければ一切接続しません。
|
||||||
|
|
||||||
### 準備
|
### 準備
|
||||||
|
|
||||||
@@ -141,6 +142,36 @@ git 履歴のまとめ方は **日ごと / 日 × 種別(`feat:` `fix:` `docs:
|
|||||||
- 起票は 1 件ずつ間隔を空けて流すので、**数十件だと数分かかります**。進捗と結果(チケット番号/失敗理由)はダイアログに出ます。実行中もダイアログは閉じられます。
|
- 起票は 1 件ずつ間隔を空けて流すので、**数十件だと数分かかります**。進捗と結果(チケット番号/失敗理由)はダイアログに出ます。実行中もダイアログは閉じられます。
|
||||||
- Redmine は**作成時の状態指定を無視する**ため、状態を指定した場合は作成直後に更新をもう 1 回投げて付け直します(1 件あたり 2 回アクセスします)。
|
- 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/index.html 画面
|
||||||
web/app.js 表示ロジック
|
web/app.js 表示ロジック
|
||||||
|
web/n8n.js 「n8n へ送る」タブ
|
||||||
web/style.css スタイル
|
web/style.css スタイル
|
||||||
redmine.json Redmine 連携の設定(APIキーを含む。git 管理外・自動生成)
|
redmine.json Redmine 連携の設定(APIキーを含む。git 管理外・自動生成)
|
||||||
|
n8n.json n8n 連携の設定(合言葉を含む。git 管理外)
|
||||||
|
tests/ unittest(python -m unittest discover -s tests -v)
|
||||||
```
|
```
|
||||||
|
|||||||
+422
-3
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import errno
|
import errno
|
||||||
|
import http.client
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -32,7 +33,7 @@ import webbrowser
|
|||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
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
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
WEB_DIR = APP_DIR / "web"
|
WEB_DIR = APP_DIR / "web"
|
||||||
@@ -144,6 +145,7 @@ def new_turn(index, kind, text, ts):
|
|||||||
"sidechain": 0,
|
"sidechain": 0,
|
||||||
"interrupted": False,
|
"interrupted": False,
|
||||||
"models": [],
|
"models": [],
|
||||||
|
"openTools": 0, # 結果がまだ返っていないツール呼び出しの数 (進行中の判定に使う)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +229,9 @@ class SessionParser:
|
|||||||
match = TS_RE.search(raw)
|
match = TS_RE.search(raw)
|
||||||
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
||||||
self.counts["toolResult"] += 1
|
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:
|
if ts is not None:
|
||||||
self.bump(ts)
|
self.bump(ts)
|
||||||
self.extend_last(ts)
|
self.extend_last(ts)
|
||||||
@@ -302,6 +307,7 @@ class SessionParser:
|
|||||||
name = block.get("name") or "?"
|
name = block.get("name") or "?"
|
||||||
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
||||||
turn["toolCalls"] += 1
|
turn["toolCalls"] += 1
|
||||||
|
turn["openTools"] += 1
|
||||||
return
|
return
|
||||||
|
|
||||||
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
||||||
@@ -925,6 +931,23 @@ class Redmine:
|
|||||||
self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}})
|
self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}})
|
||||||
return issue_id
|
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):
|
def load_redmine_config(path):
|
||||||
try:
|
try:
|
||||||
@@ -1191,6 +1214,374 @@ def to_fields(draft, defaults):
|
|||||||
return fields
|
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
|
# ---------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
|
|
||||||
@@ -1302,24 +1693,40 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
try:
|
try:
|
||||||
if route.startswith("/api/"):
|
if route.startswith("/api/"):
|
||||||
|
if self.foreign_request():
|
||||||
|
return self.send_json({"error": "この画面以外からの要求は受け付けません"}, 403)
|
||||||
return self.api(route, query)
|
return self.api(route, query)
|
||||||
return self.static(route)
|
return self.static(route)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
self.send_json({"error": f"not found: {exc}"}, 404)
|
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)
|
self.send_json({"error": str(exc)}, 400)
|
||||||
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
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):
|
def do_POST(self):
|
||||||
route = urlparse(self.path).path
|
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:
|
try:
|
||||||
if route.startswith("/api/"):
|
if route.startswith("/api/"):
|
||||||
return self.api_post(route, self.read_json())
|
return self.api_post(route, self.read_json())
|
||||||
raise FileNotFoundError(route)
|
raise FileNotFoundError(route)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
self.send_json({"error": f"not found: {exc}"}, 404)
|
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)
|
self.send_json({"error": str(exc)}, 400)
|
||||||
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
||||||
@@ -1336,6 +1743,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def api_post(self, route, payload):
|
def api_post(self, route, payload):
|
||||||
redmine = self.server.redmine
|
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":
|
if route == "/api/redmine/config":
|
||||||
return self.send_json(redmine.update(payload))
|
return self.send_json(redmine.update(payload))
|
||||||
if route == "/api/redmine/draft":
|
if route == "/api/redmine/draft":
|
||||||
@@ -1389,6 +1800,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if query.get("format", [""])[0] == "csv":
|
if query.get("format", [""])[0] == "csv":
|
||||||
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
|
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
|
||||||
return self.send_json(session)
|
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":
|
if route == "/api/redmine/config":
|
||||||
return self.send_json(self.server.redmine.public())
|
return self.send_json(self.server.redmine.public())
|
||||||
if route == "/api/redmine/meta":
|
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("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
|
||||||
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
|
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
|
||||||
parser.add_argument("--redmine-config", default=str(REDMINE_CONFIG), help="Redmine 連携の設定ファイル")
|
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")
|
parser.add_argument("--no-browser", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1510,6 +1928,7 @@ def main():
|
|||||||
return
|
return
|
||||||
server.store = Store(root)
|
server.store = Store(root)
|
||||||
server.redmine = RedmineHolder(args.redmine_config)
|
server.redmine = RedmineHolder(args.redmine_config)
|
||||||
|
server.n8n = N8nHolder(args.n8n_config)
|
||||||
server.jobs = Jobs()
|
server.jobs = Jobs()
|
||||||
server.watcher = Watcher(root, args.interval)
|
server.watcher = Watcher(root, args.interval)
|
||||||
server.watcher.start()
|
server.watcher.start()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,14 @@ Claude Code での作業を、手で「作業開始・終了」を記録しな
|
|||||||
- ターンの起点は既存の規則どおり (人間の発言だけ。サブエージェント・再開時のメタ行は起点にしない)。
|
- ターンの起点は既存の規則どおり (人間の発言だけ。サブエージェント・再開時のメタ行は起点にしない)。
|
||||||
- `date` は区間の開始時刻のローカル日付 (日本時間)。日をまたぐ区間も開始日の記録。
|
- `date` は区間の開始時刻のローカル日付 (日本時間)。日をまたぐ区間も開始日の記録。
|
||||||
- 30 分と 5 分は設定 (`n8n.json` の `gapMinutes` / `minMinutes`) で変えられる。画面から変更できる。
|
- 30 分と 5 分は設定 (`n8n.json` の `gapMinutes` / `minMinutes`) で変えられる。画面から変更できる。
|
||||||
- セッションの最後の区間で、最後のターンがまだ応答中 (ログの最終更新から `gapMinutes` 経っていない、
|
- セッションの最後の区間は、次のどちらかなら「進行中」とし、送れない (最終レビューで改訂):
|
||||||
または最後のターンに応答完了が無い) のものは「進行中」とし、送れない。
|
- ログの最終更新から `gapMinutes` 経っていない
|
||||||
|
- 最後のターンが応答を待っている: 人の発言 (prompt / resume) に返事が無い、またはツール呼び出しの結果がまだ返っていない
|
||||||
|
(ローカルのコマンド `/exit` などは返事が来ないので待ちにしない。中断されたターンも待ちにしない)
|
||||||
|
ただしログが 24 時間更新されていなければ、待ちのままでも進行中にしない。
|
||||||
|
- 会話を続けると前の履歴が新しいセッションのファイルに同じ時刻のまま写される。開始時刻 (ミリ秒) が同じ区間は写しとみなして
|
||||||
|
1 件にし (長く続いている方、同着なら古いファイル)、外した写しの `source` を送信済みの判定に使う。
|
||||||
|
- 送信済みの区間が、送った後に伸び縮みしていたら「送信後に変化」と知らせる (n8n 側の行は自動では直さない)。
|
||||||
- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。同じログからは毎回同じ値になる。
|
- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。同じログからは毎回同じ値になる。
|
||||||
|
|
||||||
区間の計算は、HTTP やファイル読み込みから切り離した純粋な関数 (ターンの列と設定値 → 区間の列) にする。
|
区間の計算は、HTTP やファイル読み込みから切り離した純粋な関数 (ターンの列と設定値 → 区間の列) にする。
|
||||||
@@ -100,7 +106,7 @@ Name `X-WT-Token`) を作り、chktime の設定画面にも同じ値を入れ
|
|||||||
| `GET/POST /api/n8n/config` | 設定の取得 (合言葉は返さない) / 保存 |
|
| `GET/POST /api/n8n/config` | 設定の取得 (合言葉は返さない) / 保存 |
|
||||||
| `GET /api/n8n/blocks?from=&to=&project=` | 期間の区間を計算し、n8n の `wt-import-rows` の結果で「送信済み / 削除済み / 重なり」を付けて返す。n8n に届かなくても区間は返し、`n8nError` を付ける |
|
| `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` に送る。結果をそのまま返す |
|
| `POST /api/n8n/send` | `{items:[{source, task, memo}]}` を受け、時刻はサーバーが `source` から区間を計算し直して n8n の `wt-import` に送る。結果をそのまま返す |
|
||||||
| `GET /api/redmine/issues?project_id=&q=` | 既存の Redmine 連携で未完了チケットを検索 (チケット選択用) |
|
| `GET /api/redmine/search?q=` | 既存の Redmine 連携で未完了チケットを検索 (チケット選択用)。`/api/redmine/issues` は起票 (POST) が使っているので別の名前にする |
|
||||||
|
|
||||||
重なりの判定: 区間 [開始, 終了) と、`work_log` の行 (進行中は終了 = 今) または一覧の他の区間が
|
重なりの判定: 区間 [開始, 終了) と、`work_log` の行 (進行中は終了 = 今) または一覧の他の区間が
|
||||||
1 秒でも重なれば「重なりあり」。同じ `source` の行 (送信済みの自分) は除く。
|
1 秒でも重なれば「重なりあり」。同じ `source` の行 (送信済みの自分) は除く。
|
||||||
|
|||||||
@@ -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, "<command-name>/exit</command-name>")],
|
||||||
|
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()
|
||||||
+10
-1
@@ -144,6 +144,7 @@ async function selectProject(id) {
|
|||||||
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
|
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
|
||||||
if (state.view === 'daily') loadDaily();
|
if (state.view === 'daily') loadDaily();
|
||||||
else if (state.view === 'gantt') loadGantt();
|
else if (state.view === 'gantt') loadGantt();
|
||||||
|
else if (state.view === 'n8n') loadN8n();
|
||||||
await reloadSessions();
|
await reloadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +318,7 @@ function syncTabs() {
|
|||||||
$('#tab-turns').classList.toggle('sel', state.view === 'turns');
|
$('#tab-turns').classList.toggle('sel', state.view === 'turns');
|
||||||
$('#tab-daily').classList.toggle('sel', state.view === 'daily');
|
$('#tab-daily').classList.toggle('sel', state.view === 'daily');
|
||||||
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
|
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
|
||||||
|
$('#tab-n8n').classList.toggle('sel', state.view === 'n8n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ganttQuery(extra) {
|
function ganttQuery(extra) {
|
||||||
@@ -343,6 +345,10 @@ function dailyQuery(extra) {
|
|||||||
|
|
||||||
function updateCsv() {
|
function updateCsv() {
|
||||||
const link = $('#csv-turns');
|
const link = $('#csv-turns');
|
||||||
|
if (state.view === 'n8n') {
|
||||||
|
link.removeAttribute('href');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state.view === 'daily') {
|
if (state.view === 'daily') {
|
||||||
link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
|
link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
|
||||||
link.title = '日別集計を CSV 出力';
|
link.title = '日別集計を CSV 出力';
|
||||||
@@ -363,6 +369,7 @@ function setView(view) {
|
|||||||
updateCsv();
|
updateCsv();
|
||||||
if (view === 'daily') loadDaily();
|
if (view === 'daily') loadDaily();
|
||||||
else if (view === 'gantt') loadGantt();
|
else if (view === 'gantt') loadGantt();
|
||||||
|
else if (view === 'n8n') loadN8n();
|
||||||
else renderDetail();
|
else renderDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1028,7 @@ async function refreshFollow() {
|
|||||||
await reloadSessions();
|
await reloadSessions();
|
||||||
if (state.view === 'daily') await loadDaily({ silent: true });
|
if (state.view === 'daily') await loadDaily({ silent: true });
|
||||||
else if (state.view === 'gantt') await loadGantt({ silent: true });
|
else if (state.view === 'gantt') await loadGantt({ silent: true });
|
||||||
|
else if (state.view === 'n8n') { /* 編集中の入力を消さないよう、自動追従では読み直さない */ }
|
||||||
else await reloadDetail();
|
else await reloadDetail();
|
||||||
list.scrollTop = listTop;
|
list.scrollTop = listTop;
|
||||||
// やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚)
|
// やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚)
|
||||||
@@ -1472,6 +1480,7 @@ $('#relative').onchange = (e) => {
|
|||||||
$('#tab-turns').onclick = () => setView('turns');
|
$('#tab-turns').onclick = () => setView('turns');
|
||||||
$('#tab-daily').onclick = () => setView('daily');
|
$('#tab-daily').onclick = () => setView('daily');
|
||||||
$('#tab-gantt').onclick = () => setView('gantt');
|
$('#tab-gantt').onclick = () => setView('gantt');
|
||||||
|
$('#tab-n8n').onclick = () => setView('n8n');
|
||||||
$('#redmine').onclick = openRedmine;
|
$('#redmine').onclick = openRedmine;
|
||||||
$('#rm-close').onclick = closeRedmine;
|
$('#rm-close').onclick = closeRedmine;
|
||||||
$('#rm-run').onclick = runRedmine;
|
$('#rm-run').onclick = runRedmine;
|
||||||
@@ -1490,7 +1499,7 @@ $('#reload').onclick = async () => {
|
|||||||
|
|
||||||
let resizeTimer = null;
|
let resizeTimer = null;
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (state.view === 'turns') return;
|
if (state.view === 'turns' || state.view === 'n8n') return;
|
||||||
clearTimeout(resizeTimer);
|
clearTimeout(resizeTimer);
|
||||||
resizeTimer = setTimeout(() => {
|
resizeTimer = setTimeout(() => {
|
||||||
if (state.view === 'daily' && state.daily) renderDaily();
|
if (state.view === 'daily' && state.daily) renderDaily();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<button class="tab sel" id="tab-turns">やりとり</button>
|
<button class="tab sel" id="tab-turns">やりとり</button>
|
||||||
<button class="tab" id="tab-daily">日別集計</button>
|
<button class="tab" id="tab-daily">日別集計</button>
|
||||||
<button class="tab" id="tab-gantt">ガント</button>
|
<button class="tab" id="tab-gantt">ガント</button>
|
||||||
|
<button class="tab" id="tab-n8n" title="Claude の作業区間を n8n の work_log に送る">n8n へ送る</button>
|
||||||
</span>
|
</span>
|
||||||
<a id="csv-turns" class="btn" href="#" download>CSV</a>
|
<a id="csv-turns" class="btn" href="#" download>CSV</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,5 +68,6 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
<script src="n8n.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+279
@@ -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 = '<p class="loading">区間を計算中…</p>';
|
||||||
|
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 = `<p class="err">${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function n8Status(b) {
|
||||||
|
const marks = [];
|
||||||
|
if (b.sent) marks.push('<span class="n8-mark sent">送信済み</span>');
|
||||||
|
if (b.changedSinceSent) marks.push('<span class="n8-mark warn" title="送った時点では区間が終わっていなかった。work_log の終了時刻は work-tracker の編集画面で直す">送信後に変化</span>');
|
||||||
|
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
|
||||||
|
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
|
||||||
|
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(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
|
||||||
|
}
|
||||||
|
return marks.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function n8Settings() {
|
||||||
|
const c = n8.cfg || {};
|
||||||
|
return `<div class="n8-settings">
|
||||||
|
<h4>接続設定 <span class="note">${esc(c.configPath || '')}</span></h4>
|
||||||
|
<label>URL <input id="n8-url" value="${esc(c.url || '')}" placeholder="https://n8n.yfox.jp"></label>
|
||||||
|
<label>合言葉 <input id="n8-token" type="password" placeholder="${c.hasToken ? '設定済み (変えるときだけ入力)' : '未設定'}"></label>
|
||||||
|
<label class="chk"><input type="checkbox" id="n8-verify" ${c.verifySsl ? 'checked' : ''}> 証明書を検証する</label>
|
||||||
|
<label>区切る空き (分) <input id="n8-gap" type="number" min="1" max="240" value="${c.gapMinutes ?? 30}"></label>
|
||||||
|
<label>最短 (分) <input id="n8-min" type="number" min="0" max="240" value="${c.minMinutes ?? 5}"></label>
|
||||||
|
<button id="n8-save">保存</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function n8ResultHtml() {
|
||||||
|
const r = n8.result;
|
||||||
|
if (!r) return '';
|
||||||
|
const errs = (r.errors || []).map((e) => `<li>${esc(e.source)}: ${esc(e.error)}</li>`).join('');
|
||||||
|
const skips = (r.skipped || []).map((s) => `<li>${esc(s.source)}: ${esc(s.reason)}</li>`).join('');
|
||||||
|
return `<div class="n8-result ${r.ok ? 'ok' : 'ng'}">
|
||||||
|
追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件
|
||||||
|
${skips ? `<ul>${skips}</ul>` : ''}${errs ? `<ul class="err-inline">${errs}</ul>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function n8PickHtml() {
|
||||||
|
if (!n8.pick) return '';
|
||||||
|
const rows = n8.issues
|
||||||
|
.map((i) => `<li><button class="n8-issue" data-id="${i.id}">#${i.id}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
|
||||||
|
.join('');
|
||||||
|
return `<div class="n8-pick">
|
||||||
|
<h4>Redmine のチケットを選ぶ</h4>
|
||||||
|
<input id="n8-q" placeholder="件名の一部、または番号"> <button id="n8-find">検索</button> <button id="n8-pick-close">閉じる</button>
|
||||||
|
<span class="note">${esc(n8.pickMsg)}</span>
|
||||||
|
<ul>${rows}</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<tr data-src="${esc(b.source)}" class="${b.sent ? 'n8-sent' : ''}">
|
||||||
|
<td><input type="checkbox" class="n8-on" ${n8.checked.has(b.source) && n8CanSend(b) ? 'checked' : ''} ${n8CanSend(b) ? '' : 'disabled'}></td>
|
||||||
|
<td class="time">${esc(b.date)}</td>
|
||||||
|
<td class="time">${fmtTime(b.start)}–${fmtTime(b.end)}</td>
|
||||||
|
<td class="num">${b.minutes} 分</td>
|
||||||
|
<td class="n8-pj" title="${esc(b.projectPath)}">${esc(shortPath(b.projectPath))}</td>
|
||||||
|
<td><input class="n8-task" value="${esc(e.task)}"> <input class="n8-memo" value="${esc(e.memo)}" placeholder="メモ"></td>
|
||||||
|
<td><button class="n8-choose">選ぶ</button></td>
|
||||||
|
<td>${n8Status(b)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
$('#detail-body').innerHTML = `<div class="n8">
|
||||||
|
<div class="n8-bar">
|
||||||
|
<label>期間 <input type="date" id="n8-from" value="${esc(n8.from)}"> – <input type="date" id="n8-to" value="${esc(n8.to)}"></label>
|
||||||
|
<button id="n8-reload">読み直す</button>
|
||||||
|
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
|
||||||
|
<button id="n8-send" class="go" ${d.n8nError || n8.busy || !on.length ? 'disabled' : ''}>選んだ ${on.length} 件を送る</button>
|
||||||
|
</div>
|
||||||
|
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
|
||||||
|
${n8.settings ? n8Settings() : ''}
|
||||||
|
${n8ResultHtml()}
|
||||||
|
${n8PickHtml()}
|
||||||
|
<p class="note">区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件</p>
|
||||||
|
<table id="n8n-table"><thead><tr><th>送る</th><th>日付</th><th>時刻</th><th>時間</th><th>プロジェクト</th><th>タスク名 / メモ</th><th>チケット</th><th>状態</th></tr></thead>
|
||||||
|
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
|
||||||
|
</div>`;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -395,3 +395,27 @@ table { width: 100%; border-collapse: collapse; }
|
|||||||
.gantt svg rect.t-work { fill: var(--work); }
|
.gantt svg rect.t-work { fill: var(--work); }
|
||||||
.gantt svg rect.t-none { fill: var(--idle); }
|
.gantt svg rect.t-none { fill: var(--idle); }
|
||||||
#turns tr.hi td { background: rgba(122, 162, 247, .18); }
|
#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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user