Compare commits

...
16 Commits
Author SHA1 Message Date
yoshikazu 401462eed0 Merge pull request 'n8n へ送る: 後回しにした小さな問題 4 件を修正' (#3) from fix/n8n-minor into main 2026-10-12 23:43:49 +00:00
yoshikazuandClaude Opus 5.5 bcdd469d7a n8n 連携: 後回しにした小さな問題 4 件を修正
- タスク名・チェックの変更で画面全体を作り直さない (直後のクリックが失われていた)
- チケット選択の番号もエスケープする
- 長さ 0 分・24 時間超の区間は送れない理由を付け、画面でも送信でも外す
- items に dict でない項目があっても 500 にせず、その項目のエラーにする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:20:21 +09:00
yoshikazu 9a50480491 Merge pull request 'Claude の作業区間を n8n の work_log へ送る「n8n へ送る」タブを追加' (#2) from n8n-worklog-import into main 2026-10-12 22:14:41 +00:00
yoshikazuandClaude Opus 5.5 95943f4e0e n8n 連携: 最終レビューの指摘を修正
- 進行中の判定: ローカルのコマンドを待ちにしない、ツールの結果待ちを進行中にする、
  24 時間更新の無いログは進行中にしない
- 写しの区間: 同着は古いファイルを残し、写し元の source でも送信済みにする。
  送信後に区間が変わったら知らせる
- 他のサイトからの API 要求を断る (Host / Origin / Content-Type)。
  URL のホストを変えたら合言葉を消す。合言葉は半角の表示文字に限る
- n8n との通信の想定外の例外 (送れない文字など) を N8nError にする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:36:20 +09:00
yoshikazuandClaude Opus 5.5 929f702cc6 n8n へ送る機能の使い方と通信の決まりを文書に追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:24:08 +09:00
yoshikazuandClaude Opus 5.5 c37622fdba n8n 連携: 続きのセッションに写された同じ区間を 1 件にまとめる
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:22:09 +09:00
yoshikazuandClaude Opus 5.5 febab50f7e n8n 連携の設定ファイルを git 管理外にする
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:18:59 +09:00
yoshikazuandClaude Opus 5.5 e75b3ead10 n8n 連携: 作業区間を選んで送る「n8n へ送る」タブを追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:18:40 +09:00
yoshikazuandClaude Opus 5.5 682820ae43 n8n 連携: 区間の一覧・送信・設定の API と Redmine のチケット検索を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:15:31 +09:00
yoshikazuandClaude Opus 5.5 c1d1d046b6 n8n 連携: 受け口を呼ぶクライアントと設定の保持を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:13:01 +09:00
yoshikazuandClaude Opus 5.5 38a3307cd4 n8n 連携: 期間の作業区間を集め、送信済み・重なりを判定する
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:12:11 +09:00
yoshikazuandClaude Opus 5.5 c3c42d5988 n8n 連携: セッションを作業区間に分ける make_blocks を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:11:28 +09:00
yoshikazuandClaude Opus 5.5 e16189153c n8n の work_log へ作業区間を送る機能の実装計画を追加
設計書のチケット検索 API を /api/redmine/search に改名
(/api/redmine/issues は起票の POST が使っているため)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:40:00 +09:00
yoshikazuandClaude Opus 5.5 765b8c4798 n8n の work_log へ作業区間を送る機能の設計書を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:02:49 +09:00
yoshikazu 7490e31076 Merge pull request 'Redmine へチケットを起票するボタンを追加' (#1) from redmine-issue-button into main 2026-09-02 06:03:03 +00:00
yoshikazuandClaude Opus 5 a458509014 Redmine へチケットを起票するボタンを追加
chktime のタスクと git 履歴から下書きを作り、まとめて Redmine へ登録できる
ようにした。外部通信はこの機能を使ったときだけで、使わなければ従来どおり
ローカルの jsonl を読むだけ。

- Redmine の REST API を叩く最小クライアント (標準ライブラリのみ)
- 下書きは「タスクから」(1タスク=1チケット) と「git 履歴から」
  (日ごと / 日 x 種別 / 期間一括) の 2 系統
- 起票は件数 x 間隔ぶん時間がかかるのでジョブにし、進捗だけ見に行く
- 作成時の status_id は Redmine 側で無視されるため、作成後に付け直す
- 連続アクセスに 403 を返すサーバがあるので 1 件ごとに間隔をあける (既定 7 秒)
- 自己署名証明書が普通なので、既定は証明書の検証なし
- API キーは redmine.json に置き、git 管理外にする

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:55:46 +09:00
11 changed files with 4343 additions and 7 deletions
+6
View File
@@ -5,3 +5,9 @@ __pycache__/
# Claude Code のローカル設定
.claude/settings.local.json
# Redmine 連携の設定 (API アクセスキーが入る)
redmine.json
# n8n 連携の設定 (合言葉が入る)
n8n.json
+44 -3
View File
@@ -6,20 +6,28 @@ Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッ
## 構成
```
chktime.py サーバ + jsonl 解析(これ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` の中身は上から「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ HTTP」の順。
`chktime.py` の中身は上から
「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ Redmine 連携 → HTTP」の順。
## 守ること
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
- **外部通信しない**。読むのはローカルの jsonl だけ。
- **外部通信は Redmine 連携と n8n への送信だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。
新しい通信先を増やすときは、この行を書き換えるところから始める。
- **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない
(`/api/redmine/config` はキーの有無だけ返す)。
- 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。
- `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。
- コメント・コミットメッセージ・UI の文言は日本語。
@@ -50,6 +58,25 @@ python chktime.py --port 8799 --no-browser
いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。
Redmine 連携:
| ルート | 内容 |
| --- | --- |
| `GET/POST /api/redmine/config` | 接続設定の取得(キーは返さない)/ 保存 |
| `GET /api/redmine/meta` | プロジェクト・トラッカー・ステータス・自分の情報 |
| `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` | 選んだ区間を送る。時刻はサーバーがログから計算し直す |
## 解析の前提
- ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。
@@ -70,3 +97,17 @@ python chktime.py --port 8799 --no-browser
ターン単位で共有している。
- `/api/timeline` は表示範囲より前から続くセッションの全体時間・往復数・ツール数も範囲内に切り詰め、
`clipped` を立てて返す(作業時間だけ切り詰めると数字が食い違うため)。
- Redmine 起票のダイアログは `rm` オブジェクトが状態を持ち、`renderRedmine()` が中身を作り直す。
HTML 属性に NUL は入れられない(パーサが置換する)ので、タスクの識別は `data-project` /
`data-id` の 2 属性で持つ。
## Redmine 連携の落とし穴
実際に踏んだもの。同じところで転ばないように。
- **作成時の `status_id` は無視される**(既定の New で入る)。状態を指定したいなら作成後に
`PUT /issues/<id>.json` で付け直す(`Redmine.create()` がやっている)。
- **連続アクセスに 403 を返すサーバがある**。7〜8 秒あければ通る。`Redmine.throttle()` が
クライアント単位で間隔を保つので、クライアントは作り直さず使い回す。
- **自己署名証明書**が普通なので、既定は検証なし(`verifySsl: false`)。
- 起票は件数×間隔ぶん時間がかかるので HTTP を掴んだままにしない。`Jobs` に投げて進捗を取りに来てもらう。
+73 -2
View File
@@ -4,7 +4,10 @@ Claude Code の **タスク(セッション)ごとのチャットのやり
`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(Claude Code の会話ログ)を解析して、
「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。
外部通信なし・追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。
追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。
外部への通信は **[Redmine へ起票](#redmine-へ起票)** と **[n8n へ送る](#n8n-へ送る)** を使ったときだけです。
それ以外はローカルの jsonl を読むだけで、どこにも接続しません。
## 起動
@@ -30,6 +33,8 @@ python chktime.py
| `--host` | 127.0.0.1 | 待ち受けアドレス |
| `--dir` | `~/.claude/projects` | ログの置き場所 |
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
| `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル |
| `--n8n-config` | `n8n.json` | n8n 連携の設定ファイル |
| `--no-browser` | – | ブラウザを自動で開かない |
## 画面
@@ -105,6 +110,68 @@ python chktime.py
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
- オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。
## Redmine へ起票
ヘッダの **Redmine へ起票** で、作業の記録を Redmine のチケットにまとめて登録できます。
外部へ通信する機能です(ほかに [n8n へ送る](#n8n-へ送る) があります)。使わなければ一切接続しません。
### 準備
ダイアログ右上の **設定** で、Redmine の URL と API アクセスキーを入れて保存します。
キーは Redmine の「個人設定 → API アクセスキー」で表示できます。
- 設定は `redmine.json`(`--redmine-config` で変更可)に保存されます。**キーが平文で入るので git 管理外**です。
- 社内 Redmine のような自己署名証明書のサーバでは「証明書を検証する」を**外して**ください。
- **間隔(秒)** は 1 件ごとに空ける待ち時間です。連続アクセスに 403 を返すサーバがあるため既定 7 秒。速いサーバなら短くできます。
### 起票する
**下書きを作る → 中身を確かめる → 起票する** の 3 段です。取得元は 2 つあります。
| 取得元 | 内容 |
| --- | --- |
| **タスクから** | 中央の一覧に出ているタスクから選び、1 タスク = 1 チケットにします。本文は期間・全体/作業時間・往復数・ツール呼び出し数と、やりとりの一覧(発言時刻・要約・応答時間、最大 60 件)。開始日/期日はタスクの開始・終了日 |
| **git 履歴から** | 指定したリポジトリのコミットをまとめてチケットにします。本文はコミットの一覧(日付・短縮ハッシュ・件名、最大 80 件) |
git 履歴のまとめ方は **日ごと / 日 × 種別(`feat:` `fix:` `docs:` …)/ 期間まとめて 1 件** から選びます。
分類は**件名の prefix と日付だけを見た機械的なもの**で、中身の判断はしません。期間は空にすると全履歴です。
下書きは件名をクリックすると本文を確認でき、チェックを外せば除外できます。
起票先のプロジェクト・トラッカー・ステータス・担当・進捗 100% はダイアログ上部で指定します(次回も同じ値で開きます)。
- 起票は 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`)。
## 解析の仕様(ざっくり)
- ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。
@@ -117,8 +184,12 @@ python chktime.py
## ファイル
```
chktime.py サーバ + jsonl 解析
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)
```
+880 -1
View File
@@ -16,19 +16,24 @@ from __future__ import annotations
import argparse
import csv
import errno
import http.client
import io
import json
import os
import re
import select
import socket
import ssl
import subprocess
import threading
import time
import urllib.error
import urllib.request
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"
@@ -140,6 +145,7 @@ def new_turn(index, kind, text, ts):
"sidechain": 0,
"interrupted": False,
"models": [],
"openTools": 0, # 結果がまだ返っていないツール呼び出しの数 (進行中の判定に使う)
}
@@ -223,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)
@@ -298,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) はセッション範囲にのみ反映。
@@ -797,6 +807,795 @@ def decode_project_dir(name):
return name.replace("-", "/")
# ---------------------------------------------------------------- Redmine 連携
REDMINE_CONFIG = APP_DIR / "redmine.json"
REDMINE_INTERVAL = 7.0 # 連続で叩くと 403 を返すサーバがあるので、呼び出しの間隔をあける
TURN_LIMIT = 60 # チケット本文に載せるやりとりの上限
COMMIT_LIMIT = 80 # チケット本文に載せるコミットの上限
GIT_SEP = "\x1f"
GIT_FMT = GIT_SEP.join(["%H", "%h", "%ad", "%an", "%s"])
COMMIT_TYPE_RE = re.compile(r"^(\w+)(?:\([^)]*\))?!?:\s")
TYPE_LABEL = {
"feat": "機能追加", "fix": "不具合修正", "docs": "書類", "test": "テスト",
"perf": "高速化", "refactor": "整理", "build": "ビルド", "chore": "雑務",
"release": "リリース", "style": "整形", "ci": "CI", "legal": "法務", "wip": "作業中",
}
class RedmineError(Exception):
"""Redmine 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
def fmt_local(epoch, time_only=False):
if epoch is None:
return "–"
stamp = datetime.fromtimestamp(epoch)
return stamp.strftime("%H:%M:%S") if time_only else stamp.strftime("%Y-%m-%d %H:%M")
def local_date(epoch):
return datetime.fromtimestamp(epoch).date().isoformat() if epoch else None
class Redmine:
"""Redmine の REST API を叩く最小のクライアント (標準ライブラリのみ)。
社内 Redmine は自己署名証明書のことが多いので、verify=False なら検証を外す。
また連続アクセスに 403 を返す設定のサーバがあるため、呼び出しの間隔をあける。
"""
def __init__(self, url=None, key=None, verify=False, interval=REDMINE_INTERVAL):
self.url = (url or "").rstrip("/")
self.key = key or ""
self.interval = max(0.0, float(interval or 0))
self.ctx = None
if not verify:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
self._last = 0.0
self._lock = threading.Lock()
def throttle(self):
"""前回の呼び出しから interval 秒あくまで待つ。"""
with self._lock:
gap = self.interval - (time.monotonic() - self._last)
if gap > 0:
time.sleep(gap)
self._last = time.monotonic()
def call(self, path, method="GET", payload=None):
if not self.url:
raise RedmineError("Redmine の URL が未設定です")
if not self.key:
raise RedmineError("API アクセスキーが未設定です")
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-Redmine-API-Key", self.key)
req.add_header("Accept", "application/json")
if body is not None:
req.add_header("Content-Type", "application/json")
self.throttle()
try:
with urllib.request.urlopen(req, timeout=30, context=self.ctx) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
raise RedmineError(f"HTTP {exc.code}: {detail or exc.reason}") from None
except urllib.error.URLError as exc:
raise RedmineError(f"接続できません: {exc.reason}") from None
except OSError as exc:
raise RedmineError(f"接続できません: {exc}") from None
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise RedmineError("JSON として読めない応答が返りました") from None
def projects(self):
out = []
offset = 0
while True:
data = self.call(f"/projects.json?limit=100&offset={offset}")
items = data.get("projects") or []
out += [{"id": p.get("id"), "identifier": p.get("identifier"), "name": p.get("name")} for p in items]
offset += len(items)
if not items or offset >= (data.get("total_count") or 0):
return out
def enums(self):
"""トラッカー / ステータス / 自分の情報。起票フォームの選択肢に使う。"""
trackers = self.call("/trackers.json").get("trackers") or []
statuses = self.call("/issue_statuses.json").get("issue_statuses") or []
me = (self.call("/users/current.json").get("user")) or {}
return {
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers],
"statuses": [{"id": s.get("id"), "name": s.get("name"), "closed": bool(s.get("is_closed"))} for s in statuses],
"user": {"id": me.get("id"), "login": me.get("login"),
"name": " ".join(filter(None, [me.get("lastname"), me.get("firstname")])).strip()},
}
def create(self, fields):
"""チケットを作る。
Redmine は作成時の status_id を既定値で上書きするので、指定があれば
作成後に PUT で付け直す。
"""
fields = dict(fields)
status = fields.pop("status_id", None)
data = self.call("/issues.json", "POST", {"issue": fields})
issue_id = (data.get("issue") or {}).get("id")
if issue_id and status:
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:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
class RedmineHolder:
"""設定ファイルと、そこから作ったクライアントを保持する。
呼び出し間隔の記憶をなくさないよう、設定が変わったときだけ作り直す。
API キーは UI へ返さない (有無だけ返す)。
"""
def __init__(self, path):
self.path = Path(path)
self.lock = threading.Lock()
self.cfg = load_redmine_config(self.path)
self.client = self._build()
def _build(self):
return Redmine(self.cfg.get("url"), self.cfg.get("key"),
self.cfg.get("verifySsl", False), self.cfg.get("interval", REDMINE_INTERVAL))
def public(self):
with self.lock:
cfg = self.cfg
return {
"url": cfg.get("url", ""),
"hasKey": bool(cfg.get("key")),
"verifySsl": bool(cfg.get("verifySsl", False)),
"interval": cfg.get("interval", REDMINE_INTERVAL),
"projectId": cfg.get("projectId"),
"trackerId": cfg.get("trackerId"),
"statusId": cfg.get("statusId"),
"assignedToId": cfg.get("assignedToId"),
"configPath": str(self.path),
}
def update(self, patch):
"""URL・キー・既定値を保存する。key が空文字なら既存のキーを残す。"""
with self.lock:
cfg = dict(self.cfg)
for key in ("url", "verifySsl", "interval", "projectId", "trackerId", "statusId", "assignedToId"):
if key in patch:
cfg[key] = patch[key]
if patch.get("clearKey"):
cfg.pop("key", None)
elif patch.get("key"):
cfg["key"] = str(patch["key"]).strip()
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 RedmineError(f"設定を保存できません: {exc}") from None
return self.public()
class Jobs:
"""起票をバックグラウンドで流し、UI からは進捗だけ見えるようにする。
1件ごとに間隔をあけるので数十件で数分かかる。HTTP を掴んだままにしないため、
開始だけ返して /api/redmine/job で進捗を取りに来てもらう。
"""
def __init__(self):
self._jobs = {}
self._lock = threading.Lock()
self._seq = 0
def start(self, client, drafts):
with self._lock:
self._seq += 1
job = {"id": f"j{self._seq}", "total": len(drafts), "done": 0, "state": "running", "results": []}
self._jobs[job["id"]] = job
threading.Thread(target=self._run, args=(job, client, drafts), daemon=True).start()
return job
def _run(self, job, client, drafts):
for draft in drafts:
subject = draft.get("subject") or "(件名なし)"
try:
issue_id = client.create(draft)
job["results"].append({"subject": subject, "issueId": issue_id, "ok": True})
except RedmineError as exc:
job["results"].append({"subject": subject, "error": str(exc), "ok": False})
job["done"] += 1
job["state"] = "done"
def get(self, job_id):
with self._lock:
job = self._jobs.get(job_id)
if job is None:
raise FileNotFoundError(job_id)
return job
# -- 下書きの組み立て -------------------------------------------------
def issue_from_session(session, project_path=None):
"""chktime のタスク 1 件をチケットの下書きにする。"""
start, end = session.get("startEpoch"), session.get("endEpoch")
head = [
f"* 期間: {fmt_local(start)} → {fmt_local(end)}",
f"* 全体: {fmt_dur(session['wallMs'])} / 作業時間: {fmt_dur(session['activeMs'])}",
f"* 往復: {session['turnCount']} · 応答メッセージ: {session['assistantMsgs']} · ツール呼び出し: {session['toolCalls']}",
]
if project_path:
head.append(f"* 作業ディレクトリ: {project_path}")
if session.get("gitBranch"):
head.append(f"* ブランチ: {session['gitBranch']}")
turns = [t for t in session.get("turns", []) if t.get("kind") in ("prompt", "command")]
rows = ["h3. やりとり", ""]
for turn in turns[:TURN_LIMIT]:
rows.append(f"* {fmt_local(turn['ts'], True)} #{turn['i']} "
f"{one_line(turn['prompt'], 120)} — {fmt_dur(turn['durMs'])}")
if len(turns) > TURN_LIMIT:
rows.append(f"* … 他 {len(turns) - TURN_LIMIT} 件")
if not turns:
rows.append("* (記録なし)")
return {
"subject": one_line(session.get("title") or session["id"], 240),
"description": "\n".join(head + [""] + rows + ["", "---", f"chktime から起票(タスク {session['id']})"]),
"start_date": local_date(start),
"due_date": local_date(end),
"source": {"kind": "task", "id": session["id"]},
}
def git_log(repo, since=None, until=None, limit=2000):
"""git 履歴を読む。リポジトリでなければ RedmineError。"""
path = Path(repo).expanduser()
if not path.is_dir():
raise RedmineError(f"フォルダが見つかりません: {repo}")
cmd = ["git", "-C", str(path), "log", "--no-merges", "--date=short", f"--pretty=format:{GIT_FMT}", f"-n{limit}"]
if since:
cmd.append(f"--since={since} 00:00:00")
if until:
cmd.append(f"--until={until} 23:59:59")
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60)
except FileNotFoundError:
raise RedmineError("git が見つかりません (PATH を確認してください)") from None
except (OSError, subprocess.SubprocessError) as exc:
raise RedmineError(f"git を実行できません: {exc}") from None
if proc.returncode:
detail = one_line(proc.stderr.decode("utf-8", "replace"), 200)
raise RedmineError(detail or f"git log に失敗しました: {path}")
out = []
for line in proc.stdout.decode("utf-8", "replace").splitlines():
parts = line.split(GIT_SEP)
if len(parts) == 5:
out.append({"hash": parts[0], "short": parts[1], "date": parts[2], "author": parts[3], "subject": parts[4]})
out.reverse() # 古い順
return out
def commit_type(subject):
match = COMMIT_TYPE_RE.match(subject or "")
return match.group(1).lower() if match else ""
def group_commits(commits, mode):
"""コミットを起票の単位にまとめる。mode: day=日ごと / type=日×種別 / all=一括。
分類は機械的に行う (件名の prefix と日付だけを見る)。
"""
groups = {}
order = []
for commit in commits:
if mode == "all":
key = ("all",)
elif mode == "type":
key = (commit["date"], commit_type(commit["subject"]))
else:
key = (commit["date"],)
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(commit)
out = []
for key in order:
items = groups[key]
dates = [c["date"] for c in items]
start, due = min(dates), max(dates)
if mode == "all":
span = start if start == due else f"{start} 〜 {due}"
subject = f"{span} の作業({len(items)} コミット)"
elif mode == "type":
kind = key[1]
label = TYPE_LABEL.get(kind, kind or "その他")
subject = f"{key[0]} {label}({len(items)} コミット)"
else:
subject = f"{key[0]} の作業({len(items)} コミット)"
out.append({"subject": subject, "start": start, "due": due, "commits": items})
return out
def issue_from_commits(group, repo):
"""コミットのまとまり 1 つをチケットの下書きにする。"""
items = group["commits"]
rows = ["h3. 対応したコミット", ""]
for commit in items[:COMMIT_LIMIT]:
rows.append(f"* {commit['date']} {commit['short']} {commit['subject']}")
if len(items) > COMMIT_LIMIT:
rows.append(f"* … 他 {len(items) - COMMIT_LIMIT} 件")
return {
"subject": one_line(group["subject"], 240),
"description": "\n".join(rows + ["", "---", f"git 履歴から起票(リポジトリ: {repo})"]),
"start_date": group["start"],
"due_date": group["due"],
"source": {"kind": "git", "id": items[0]["short"], "count": len(items)},
}
def build_drafts(store, payload):
"""UI から来た指定で下書きの一覧を作る。source: tasks / git。"""
source = payload.get("source")
if source == "tasks":
project = payload.get("project") or ""
ids = payload.get("ids") or []
if not ids:
raise RedmineError("タスクが選ばれていません")
paths = {p["id"]: p["path"] for p in store.projects()}
drafts = []
for item in ids:
pid, sid = (item.get("project") or project, item.get("id")) if isinstance(item, dict) else (project, item)
session = store.one(pid, sid)
drafts.append(issue_from_session(session, session.get("cwd") or paths.get(pid)))
return drafts
if source == "git":
repo = payload.get("repo") or ""
commits = git_log(repo, payload.get("from"), payload.get("to"))
if not commits:
raise RedmineError("その期間のコミットがありません")
return [issue_from_commits(g, repo) for g in group_commits(commits, payload.get("mode") or "day")]
raise RedmineError(f"不明な取得元です: {source}")
def to_fields(draft, defaults):
"""下書き + 既定値を Redmine の issue フィールドにする。"""
fields = {
"project_id": defaults.get("projectId"),
"subject": draft.get("subject") or "(件名なし)",
"description": draft.get("description") or "",
}
if not fields["project_id"]:
raise RedmineError("起票先のプロジェクトが選ばれていません")
for key, name in (("trackerId", "tracker_id"), ("statusId", "status_id"), ("assignedToId", "assigned_to_id")):
if defaults.get(key):
fields[name] = defaults[key]
for key in ("start_date", "due_date"):
if draft.get(key):
fields[key] = draft[key]
if defaults.get("doneRatio") is not None:
fields["done_ratio"] = defaults["doneRatio"]
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_MAX_BLOCK_SEC = 24 * 3600 # n8n の wt-import が受け付ける区間の長さの上限
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
# n8n の受け口が断る長さは、ここで送れない理由として付けておく
problem = None
if group["end"] <= group["start"]:
problem = "長さが 0 分です"
elif group["end"] - group["start"] > N8N_MAX_BLOCK_SEC:
problem = "24 時間を超えています"
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,
"problem": problem,
}
)
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:
if not isinstance(item, dict):
errors.append({"source": "", "error": "項目の形が不正です"})
continue
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
if block["problem"]:
errors.append({"source": source, "error": block["problem"]})
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
@@ -908,13 +1707,73 @@ 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, 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, 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)
def read_json(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
data = json.loads(self.rfile.read(length))
except ValueError:
raise ValueError("JSON として読めません") from None
return data if isinstance(data, dict) else {}
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":
return self.send_json({"drafts": build_drafts(self.server.store, payload)})
if route == "/api/redmine/issues":
drafts = payload.get("drafts") or []
if not drafts:
raise RedmineError("起票するものがありません")
defaults = payload.get("defaults") or {}
fields = [to_fields(d, defaults) for d in drafts]
return self.send_json(self.server.jobs.start(redmine.client, fields))
raise FileNotFoundError(route)
def api(self, route, query):
store = self.server.store
if route == "/api/projects":
@@ -955,6 +1814,21 @@ 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":
client = self.server.redmine.client
data = client.enums()
data["projects"] = client.projects()
return self.send_json(data)
if route == "/api/redmine/job":
return self.send_json(self.server.jobs.get(query.get("id", [""])[0]))
if route == "/api/reload":
store.clear()
return self.send_json({"ok": True})
@@ -1046,6 +1920,8 @@ def main():
parser.add_argument("--host", default="127.0.0.1")
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()
@@ -1065,6 +1941,9 @@ def main():
webbrowser.open(url)
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()
print(f"chktime: {root}")
File diff suppressed because it is too large Load Diff
@@ -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 本だけ。広げるのは別の作業)。
+622
View File
@@ -0,0 +1,622 @@
"""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 MinorFixTest(unittest.TestCase):
"""後回しにした小さな問題の再現テスト。"""
def test_zero_length_block_has_problem(self):
blocks = chktime.make_blocks(session(turn(0, 0)), min_min=0, now=LATER)
self.assertEqual(blocks[0]["problem"], "長さが 0 分です")
def test_over_24h_block_has_problem(self):
turns = [turn(m, m + 20) for m in range(0, 25 * 60, 40)] # 40 分ごとにつながって 25 時間
blocks = chktime.make_blocks(session(*turns), now=LATER + 2 * 86400)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0]["problem"], "24 時間を超えています")
def test_normal_block_has_no_problem(self):
self.assertIsNone(chktime.make_blocks(session(turn(0, 10)), now=LATER)[0]["problem"])
class MinorSendTest(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", "設計して"), (0, "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, "minMinutes": 0})
def tearDown(self):
self.fake.close()
self.tmp.cleanup()
def test_send_rejects_block_with_problem(self):
item = {"project": "C--debug-demo", "source": f"chktime:s1:{int(round(BASE * 1000))}", "task": "x"}
result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER)
self.assertEqual(result["errors"][0]["error"], "長さが 0 分です")
self.assertEqual([r for r in self.fake.requests if r[0] == "POST"], [])
def test_send_reports_non_dict_item(self):
result = chktime.n8n_send(self.store, self.holder, {"items": ["abc", 3]}, now=LATER)
self.assertFalse(result["ok"])
self.assertEqual([e["error"] for e in result["errors"]], ["項目の形が不正です", "項目の形が不正です"])
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()
+401 -1
View File
@@ -144,6 +144,7 @@ async function selectProject(id) {
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
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 と同じ感覚)
@@ -1080,6 +1088,388 @@ function stopFollow() {
followStatus('off', '');
}
// ---------------------------------------------------------------- Redmine 起票
// 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。
// サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。
const rm = {
cfg: null, // /api/redmine/config (キーは含まれない)
meta: null, // プロジェクト / トラッカー / ステータス
settings: false, // 接続設定を開いているか
source: 'tasks', // tasks=chktime のタスク / git=git 履歴
mode: 'day', // git のまとめ方: day / type / all
repo: '',
from: '',
to: '',
tasks: new Map(), // 起票対象のタスク: キー -> {project, id}
drafts: [],
off: new Set(), // 下書きのうち外したもの (index)
job: null,
timer: null,
busy: false,
};
async function post(path, payload) {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
return data;
}
/** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */
const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id });
const taskKey = (t) => `${t.project}/${t.id}`;
function rmMsg(text, kind) {
const el = $('#rm-msg');
el.className = kind === 'err' ? 'err-inline' : 'note';
el.textContent = text || '';
}
function rmConn() {
const el = $('#rm-conn');
if (!rm.cfg || !rm.cfg.url) {
el.className = 'fstate wait';
el.textContent = '未設定';
} else if (!rm.cfg.hasKey) {
el.className = 'fstate wait';
el.textContent = 'キー未設定';
} else {
el.className = 'fstate live';
el.textContent = rm.cfg.url;
}
}
async function openRedmine() {
$('#rm-back').classList.remove('hidden');
rmMsg('');
try {
rm.cfg = await api('/api/redmine/config');
rm.settings = !rm.cfg.url || !rm.cfg.hasKey;
if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : '');
if (!rm.tasks.size && state.session) {
const t = { project: state.sessionProject, id: state.session };
rm.tasks.set(taskKey(t), t);
}
renderRedmine();
if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta();
} catch (e) {
$('#rm-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
function closeRedmine() {
$('#rm-back').classList.add('hidden');
clearInterval(rm.timer);
rm.timer = null;
}
async function loadRedmineMeta() {
try {
rmMsg('Redmine に接続しています…');
rm.meta = await api('/api/redmine/meta');
rmMsg('');
} catch (e) {
rm.meta = null;
rmMsg(e.message, 'err');
}
renderRedmine();
}
function optionList(items, selected, label) {
return items
.map((it) => `<option value="${it.id}"${String(it.id) === String(selected ?? '') ? ' selected' : ''}>${esc(label(it))}</option>`)
.join('');
}
function renderRedmine() {
rmConn();
const cfg = rm.cfg || {};
const meta = rm.meta;
const settings = `
<div class="rm-sec">
<h4>接続 <button class="rng" id="rm-toggle">${rm.settings ? '隠す' : '設定'}</button></h4>
${rm.settings
? `<div class="rm-row"><label>URL</label>
<input type="text" id="rm-url" value="${esc(cfg.url || '')}" placeholder="https://192.168.3.150"></div>
<div class="rm-row"><label>APIキー</label>
<input type="password" id="rm-key" placeholder="${cfg.hasKey ? '保存済み(変更するときだけ入力)' : '個人設定 → APIアクセスキー'}"></div>
<div class="rm-row">
<label class="chk"><input type="checkbox" id="rm-verify"${cfg.verifySsl ? ' checked' : ''}> 証明書を検証する</label>
<label>間隔(秒)</label><input type="text" id="rm-int" value="${esc(cfg.interval ?? 7)}" style="flex:0 0 60px">
<button id="rm-save">保存</button>
</div>
<div class="note">設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。
自己署名証明書のサーバでは「証明書を検証する」を外してください。</div>`
: ''}
</div>`;
const target = meta
? `<div class="rm-sec">
<h4>起票先</h4>
<div class="rm-row">
<label>プロジェクト</label>
<select id="rm-proj"><option value="">選んでください</option>${optionList(meta.projects, cfg.projectId, (p) => p.name)}</select>
<label>トラッカー</label>
<select id="rm-tracker">${optionList(meta.trackers, cfg.trackerId, (t) => t.name)}</select>
<label>ステータス</label>
<select id="rm-status">${optionList(meta.statuses, cfg.statusId, (s) => s.name)}</select>
</div>
<div class="rm-row">
<label class="chk"><input type="checkbox" id="rm-assign"${cfg.assignedToId ? ' checked' : ''}> 自分(${esc(meta.user.name || meta.user.login || '')})を担当にする</label>
<label class="chk"><input type="checkbox" id="rm-done"> 進捗を 100% にする</label>
</div>
</div>`
: cfg.hasKey
? '<div class="rm-sec"><p class="note">Redmine の情報を取得できていません。接続設定を確認してください。</p></div>'
: '';
const tab = (v, label) => `<button class="rng${rm.source === v ? ' sel' : ''}" data-src="${v}">${label}</button>`;
const picker =
rm.source === 'tasks'
? `<div class="rm-row">
<span class="note">一覧に出ている ${state.sessions.length} 件から選びます</span>
<button class="rng" id="rm-all">全選択</button><button class="rng" id="rm-none">全解除</button>
</div>
<div class="rm-list">${
state.sessions.length
? state.sessions
.map((s) => {
const t = taskOf(s);
return `<div class="rm-item"><label class="h">
<input type="checkbox" class="rm-task" data-project="${esc(t.project)}" data-id="${esc(t.id)}"${rm.tasks.has(taskKey(t)) ? ' checked' : ''}>
<span class="t"><span class="subject">${esc(s.title)}</span>
<span class="meta">${fmtDateTime(s.start)} · ${s.turnCount} 往復 · 作業 ${fmtDur(s.activeMs)}</span></span>
</label></div>`;
})
.join('')
: '<div class="rm-item"><div class="h"><span class="note">タスクがありません</span></div></div>'
}</div>`
: `<div class="rm-row">
<label>リポジトリ</label><input type="text" id="rm-repo" value="${esc(rm.repo)}" placeholder="C:\\debug\\chktime">
</div>
<div class="rm-row">
<label>期間</label><input type="date" id="rm-from" value="${rm.from}"><span class="note">〜</span><input type="date" id="rm-to" value="${rm.to}">
<label>まとめ方</label>
<select id="rm-mode">
<option value="day"${rm.mode === 'day' ? ' selected' : ''}>日ごと</option>
<option value="type"${rm.mode === 'type' ? ' selected' : ''}>日 × 種別 (feat/fix/docs…)</option>
<option value="all"${rm.mode === 'all' ? ' selected' : ''}>期間まとめて 1 件</option>
</select>
</div>
<div class="note">分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。</div>`;
const source = `
<div class="rm-sec">
<h4>取得元 <span class="rngs" style="display:inline-flex;margin-left:6px">${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}</span></h4>
${picker}
<div class="rm-row" style="margin-top:8px"><button id="rm-draft">下書きを作る</button></div>
</div>`;
const drafts = rm.drafts.length
? `<div class="rm-sec">
<h4>下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)</h4>
<div class="rm-list">${rm.drafts
.map(
(d, i) => `<div class="rm-item${rm.off.has(i) ? ' off' : ''}" data-i="${i}">
<div class="h">
<input type="checkbox" class="rm-pick" data-i="${i}"${rm.off.has(i) ? '' : ' checked'}>
<span class="t">
<span class="subject">${esc(d.subject)}</span>
<span class="meta">${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''}
${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''}</span>
</span>
</div>
<div class="body">${esc(d.description)}</div>
</div>`
)
.join('')}</div>
</div>`
: '';
$('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : '');
wireRedmine();
syncRun();
}
function renderJob() {
const j = rm.job;
const pct = j.total ? Math.round((j.done / j.total) * 100) : 0;
const ok = j.results.filter((r) => r.ok).length;
const ng = j.results.length - ok;
return `<div class="rm-sec">
<h4>起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}</h4>
<div class="rm-prog"><i style="width:${pct}%"></i></div>
<div class="note">成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}</div>
<div class="rm-list" style="margin-top:6px">${j.results
.map(
(r) => `<div class="rm-res">
<span class="id${r.ok ? '' : ' ng'}">${r.ok ? '#' + r.issueId : '失敗'}</span>
<span class="s">${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}</span>
</div>`
)
.join('')}</div>
</div>`;
}
/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */
function wireRedmine() {
const on = (sel, ev, fn) => {
const el = $(sel);
if (el) el[ev] = fn;
};
on('#rm-toggle', 'onclick', () => {
rm.settings = !rm.settings;
renderRedmine();
});
on('#rm-save', 'onclick', async () => {
try {
rmMsg('保存しています…');
rm.cfg = await post('/api/redmine/config', {
url: $('#rm-url').value.trim(),
key: $('#rm-key').value.trim(),
verifySsl: $('#rm-verify').checked,
interval: Number($('#rm-int').value) || 7,
});
rm.settings = false;
rm.meta = null;
rmMsg('保存しました');
await loadRedmineMeta();
} catch (e) {
rmMsg(e.message, 'err');
}
});
document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => {
btn.onclick = () => {
rm.source = btn.dataset.src;
renderRedmine();
};
});
document.querySelectorAll('#rm-body .rm-task').forEach((box) => {
box.onchange = () => {
const t = { project: box.dataset.project, id: box.dataset.id };
if (box.checked) rm.tasks.set(taskKey(t), t);
else rm.tasks.delete(taskKey(t));
};
});
const pickAll = (want) => {
state.sessions.forEach((s) => {
const t = taskOf(s);
if (want) rm.tasks.set(taskKey(t), t);
else rm.tasks.delete(taskKey(t));
});
renderRedmine();
};
on('#rm-all', 'onclick', () => pickAll(true));
on('#rm-none', 'onclick', () => pickAll(false));
on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim()));
on('#rm-from', 'onchange', (e) => (rm.from = e.target.value));
on('#rm-to', 'onchange', (e) => (rm.to = e.target.value));
on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value));
on('#rm-draft', 'onclick', makeDrafts);
document.querySelectorAll('#rm-body .rm-pick').forEach((box) => {
box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように
box.onchange = () => {
const i = Number(box.dataset.i);
if (box.checked) rm.off.delete(i);
else rm.off.add(i);
renderRedmine();
};
});
document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => {
head.onclick = (e) => {
if (e.target.classList.contains('rm-pick')) return;
head.parentElement.classList.toggle('open');
};
});
['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun));
}
/** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */
function syncRun() {
const proj = $('#rm-proj');
$('#rm-run').disabled =
rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value);
}
async function makeDrafts() {
const payload =
rm.source === 'tasks'
? {
source: 'tasks',
ids: [...rm.tasks.values()],
}
: { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode };
try {
rmMsg('下書きを作っています…');
const data = await post('/api/redmine/draft', payload);
rm.drafts = data.drafts;
rm.off = new Set();
rm.job = null;
rmMsg(`${rm.drafts.length} 件の下書きを作りました`);
} catch (e) {
rm.drafts = [];
rmMsg(e.message, 'err');
}
renderRedmine();
}
async function runRedmine() {
const drafts = rm.drafts.filter((_, i) => !rm.off.has(i));
if (!drafts.length) return;
const defaults = {
projectId: Number($('#rm-proj').value) || null,
trackerId: Number($('#rm-tracker').value) || null,
statusId: Number($('#rm-status').value) || null,
assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null,
doneRatio: $('#rm-done').checked ? 100 : null,
};
const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1));
if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return;
try {
rm.busy = true;
rmMsg('起票しています…');
// 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す
post('/api/redmine/config', {
projectId: defaults.projectId, trackerId: defaults.trackerId,
statusId: defaults.statusId, assignedToId: defaults.assignedToId,
}).catch(() => {});
rm.job = await post('/api/redmine/issues', { drafts, defaults });
renderRedmine();
clearInterval(rm.timer);
rm.timer = setInterval(pollJob, 1000);
} catch (e) {
rm.busy = false;
rmMsg(e.message, 'err');
renderRedmine();
}
}
async function pollJob() {
if (!rm.job) return;
try {
rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`);
} catch {
return;
}
if (rm.job.state === 'done') {
clearInterval(rm.timer);
rm.timer = null;
rm.busy = false;
const ng = rm.job.results.filter((r) => !r.ok).length;
rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : '');
}
renderRedmine();
}
// ---------------------------------------------------------------- 起動
$('#filter').oninput = renderSessions;
@@ -1090,6 +1480,16 @@ $('#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;
$('#rm-back').onclick = (e) => {
if (e.target.id === 'rm-back') closeRedmine();
};
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine();
});
$('#reload').onclick = async () => {
await fetch('/api/reload');
const keep = { project: state.sessionProject, session: state.session, view: state.view };
@@ -1099,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();
+20
View File
@@ -14,9 +14,27 @@
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
<span id="follow-state" class="fstate"></span>
<button id="redmine" title="Redmine にチケットを起票する">Redmine へ起票</button>
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
</header>
<div id="rm-back" class="modal-back hidden">
<div class="modal">
<div class="modal-head">
<span>Redmine へ起票</span>
<span class="spacer"></span>
<span id="rm-conn" class="fstate"></span>
<button id="rm-close" title="閉じる">✕</button>
</div>
<div class="modal-body" id="rm-body"></div>
<div class="modal-foot">
<span id="rm-msg" class="note"></span>
<span class="spacer"></span>
<button id="rm-run" class="go">起票する</button>
</div>
</div>
</div>
<main>
<nav id="projects" class="pane">
<div class="pane-head">プロジェクト</div>
@@ -39,6 +57,7 @@
<button class="tab sel" id="tab-turns">やりとり</button>
<button class="tab" id="tab-daily">日別集計</button>
<button class="tab" id="tab-gantt">ガント</button>
<button class="tab" id="tab-n8n" title="Claude の作業区間を n8n の work_log に送る">n8n へ送る</button>
</span>
<a id="csv-turns" class="btn" href="#" download>CSV</a>
</div>
@@ -49,5 +68,6 @@
</main>
<script src="app.js"></script>
<script src="n8n.js"></script>
</body>
</html>
+297
View File
@@ -0,0 +1,297 @@
'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 && !b.problem;
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.problem) marks.push(`<span class="n8-mark warn" title="n8n が受け付けないので送れません">${esc(b.problem)}</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="${esc(i.id)}">#${esc(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"></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();
n8SyncSend();
}
/** 送信ボタンの件数と押せるかどうかだけを更新する (ボタン自体は作り直さない)。 */
function n8SyncSend() {
const btn = $('#n8-send');
if (!btn || !n8.data) return;
const on = n8.data.blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)).length;
btn.textContent = `選んだ ${on} 件を送る`;
btn.disabled = Boolean(n8.data.n8nError) || n8.busy || !on;
}
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));
const changed = new Set(targets.map((t) => t.source));
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
if (!changed.has(tr.dataset.src)) return;
const e = n8.edits.get(tr.dataset.src);
if ('task' in patch) tr.querySelector('.n8-task').value = e.task;
if ('memo' in patch) tr.querySelector('.n8-memo').value = e.memo;
});
}
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);
n8SyncSend();
};
tr.querySelector('.n8-task').onchange = (e) => {
n8SetEdit(source, { task: e.target.value });
};
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 });
}
+116
View File
@@ -297,9 +297,125 @@ table { width: 100%; border-collapse: collapse; }
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }
/* Redmine 起票ダイアログ */
.hidden { display: none !important; }
.modal-back {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.modal {
display: flex;
flex-direction: column;
width: min(880px, 94vw);
height: min(760px, 92vh);
background: var(--pane);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.modal-head, .modal-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--pane2);
border-bottom: 1px solid var(--line);
}
.modal-foot { border-bottom: none; border-top: 1px solid var(--line); }
.modal-head .spacer, .modal-foot .spacer { flex: 1; }
.modal-body { flex: 1; overflow: auto; padding: 12px 14px 20px; }
.modal .note { color: var(--dim); font-size: 11px; }
.modal .err-inline { color: #f7768e; font-size: 11px; }
.modal .go { border-color: var(--work); color: var(--work); }
.modal .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; }
.rm-sec { margin-bottom: 14px; }
.rm-sec > h4 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 500;
letter-spacing: .08em;
color: var(--dim);
text-transform: uppercase;
}
.rm-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.rm-row label { color: var(--dim); font-size: 11px; }
.modal input[type="text"], .modal input[type="password"], .modal input[type="date"], .modal select {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 5px;
color: var(--fg);
padding: 3px 8px;
font: inherit;
font-size: 12px;
color-scheme: dark;
}
.modal input[type="text"], .modal input[type="password"] { flex: 1 1 220px; min-width: 140px; }
.modal input:focus, .modal select:focus { outline: none; border-color: var(--accent); }
.rm-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.rm-item { border-bottom: 1px solid var(--line); }
.rm-item:last-child { border-bottom: none; }
.rm-item > .h { display: flex; align-items: flex-start; gap: 8px; padding: 6px 10px; cursor: pointer; }
.rm-item > .h:hover { background: var(--pane2); }
.rm-item .t { flex: 1; min-width: 0; }
.rm-item .subject { font-size: 12.5px; word-break: break-word; }
.rm-item .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
.rm-item .body {
display: none;
white-space: pre-wrap;
word-break: break-word;
font-size: 11px;
color: var(--dim);
background: var(--bg);
border-top: 1px solid var(--line);
padding: 8px 10px;
max-height: 260px;
overflow: auto;
}
.rm-item.open .body { display: block; }
.rm-item.off .subject, .rm-item.off .meta { opacity: .45; text-decoration: line-through; }
.rm-res { display: flex; gap: 8px; padding: 4px 10px; border-bottom: 1px solid var(--line); font-size: 12px; }
.rm-res:last-child { border-bottom: none; }
.rm-res .id { color: var(--work); font-variant-numeric: tabular-nums; white-space: nowrap; }
.rm-res .id.ng { color: #f7768e; }
.rm-res .s { flex: 1; min-width: 0; word-break: break-word; }
.rm-prog { height: 6px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; margin: 8px 0; }
.rm-prog i { display: block; height: 100%; background: var(--work); transition: width .2s; }
/* ガント (ターン単位) */
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
.gantt svg rect.t-wait { fill: var(--user); }
.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); }