設計書のチケット検索 API を /api/redmine/search に改名 (/api/redmine/issues は起票の POST が使っているため)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1712 lines
92 KiB
Markdown
1712 lines
92 KiB
Markdown
# chktime → n8n work_log 取り込み Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** chktime の画面で Claude のセッションから作った作業区間を選び、n8n の `work_log` に二重登録なしで送れるようにする。
|
||
|
||
**Architecture:** n8n に受け口のワークフロー `work-tracker--import` (Header Auth 付き Webhook 2 本) を作り、`work_log` / `work_log_deleted` に `source` 列を足す。chktime は区間の計算・重なりの判定・n8n への送信を `chktime.py` の新しい節「n8n 連携」に持ち、画面は新しいファイル `web/n8n.js` に置いて右ペインのタブ「n8n へ送る」から開く。時刻はブラウザの値を信用せず、送信時にサーバーが `source` から区間を計算し直す。
|
||
|
||
**Tech Stack:** Python 3 標準ライブラリのみ (unittest, urllib, http.server)、素の JavaScript、n8n (Webhook / Code / Data table / IF / Respond to Webhook ノード)、n8n-api MCP (`mcp__n8n-api__*`)。
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-12-n8n-worklog-import-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- chktime は **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
|
||
- 外部通信は **Redmine 連携と n8n への送信だけ**。
|
||
- 合言葉 (`token`) はリポジトリ・ログ・画面・コマンド出力に出さない。`n8n.json` は `.gitignore` に入れる。`/api/n8n/config` は `hasToken` だけ返す。
|
||
- 合言葉は利用者が決めて入力する。実装者 (Claude) は作らず、入力もしない。
|
||
- 会話ログ (`~/.claude/projects/**/*.jsonl`) は読み取り専用。テストは一時フォルダに作った jsonl で行う。
|
||
- 検証用サーバーは `python chktime.py --port 8799 --no-browser`。常用の 8787 と `stop.cmd` には触れない (`stop.cmd` は全 chktime を落とす)。
|
||
- `.bat` / `.cmd` は ASCII のみ。コメント・コミットメッセージ・UI 文言は日本語。
|
||
- 区間: 空き **30 分未満でつなぐ / 30 分以上で切る**、**5 分未満は外す (5 分ちょうどは残す)**。設定 `gapMinutes` (1〜240) / `minMinutes` (0〜240)。
|
||
- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。
|
||
- n8n 受け口: 期間 **最大 62 日**、1 回 **最大 200 件**、区間 **24 時間以内**、`task` **500 文字以内 (空不可)**、`memo` **2000 文字以内**、`source` **500 文字以内で `chktime:` 始まり**。
|
||
- 重なり: `[開始, 終了)` が少しでも重なれば重なり。接しているだけは重ならない。同じ `source` は相手から除く。進行中の work_log 行は終了 = 今。
|
||
- n8n の本番データに触れるテストは **2000-01-01 付けのダミー**だけで行い、終わったら Data table を直接 (MCP の `deleteRows`) 消す。work-tracker--delete の Webhook は使わない。
|
||
- 既存のコミットされていない `CLAUDE.md` の変更 (ナレッジ保管庫の節) は利用者のもの。自分の変更だけを `git add -p` でステージする。
|
||
- コミットメッセージの末尾に `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`。
|
||
|
||
## Review Focus
|
||
|
||
- 前のターンの応答中に次の発言を送った (並行) セッション: 区間は切れずにつながるべき → Task 3 `test_parallel_prompt_keeps_block`
|
||
- 最後のターンが中断されて応答が無いまま放置されたセッション: 進行中ではなく送れる区間になるべき → Task 3 `test_interrupted_last_turn_is_not_in_progress`
|
||
- タスク名が空白だけで送られた: n8n に送らず「タスク名が空です」を返すべき → Task 6 `test_send_rejects_blank_task`
|
||
- n8n に届かない (停止・合言葉違い): 区間の一覧は返り、送信済みの印は付かず `n8nError` に理由が入るべき → Task 6 `test_blocks_when_n8n_down`
|
||
- 日をまたぐ区間: 開始日の 1 件として、開始日を含む期間だけに出るべき → Task 4 `test_midnight_block_belongs_to_start_date`
|
||
|
||
## File Structure
|
||
|
||
| ファイル | 役割 |
|
||
|---|---|
|
||
| `chktime.py` (変更) | 新しい節「n8n 連携」: 定数、`N8nError`、`block_source`、`make_blocks`、`default_task_name`、`collect_blocks`、`mark_blocks`、`N8n`、`N8nHolder`、`n8n_range`、`n8n_blocks`、`n8n_send`。Redmine 節に `Redmine.search`。HTTP 節にルート追加。`main()` に `--n8n-config` |
|
||
| `tests/test_n8n.py` (新規) | 上記の unittest (純粋関数・偽の n8n サーバー・一時フォルダの jsonl) |
|
||
| `web/n8n.js` (新規) | 「n8n へ送る」タブの描画と操作 |
|
||
| `web/index.html` (変更) | タブボタンと `<script src="n8n.js">` |
|
||
| `web/app.js` (変更) | `syncTabs` / `setView` / `selectProject` / `refreshFollow` / `updateCsv` / resize に n8n の分岐 |
|
||
| `web/style.css` (変更) | `#n8n-table` などの最小のスタイル |
|
||
| `.gitignore` / `README.md` / `CLAUDE.md` (変更) | `n8n.json`、使い方、通信の決まりと API 表 |
|
||
| n8n: `work_log` / `work_log_deleted` | `source` (string) 列を追加 |
|
||
| n8n: `work-tracker--import` (新規) | Webhook 2 本 |
|
||
| n8n: `work-tracker--delete` (変更) | `Backup Row` が `source` もコピー |
|
||
|
||
テストの実行: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
|
||
---
|
||
|
||
### Task 1: n8n のデータテーブルに source 列を足し、削除時のコピーにも含める
|
||
|
||
**Files:**
|
||
- n8n データテーブル `work_log` (id `l41sKVXXWr0QFMBE`)、`work_log_deleted` (id `6Pwm6IdAkotvx8rD`)
|
||
- n8n ワークフロー `work-tracker--delete` (id `1z73HfzmdqHkX2TD`) のノード `Backup Row`
|
||
- 一時スクリプト: スクラッチパッド `add_columns.js` (リポジトリには入れない)
|
||
|
||
**Interfaces:**
|
||
- Produces: 両テーブルの `source` (string) 列。Task 2 の受け口と Task 4 以降の chktime が使う。
|
||
|
||
列の追加は公開 API ではできず、n8n-mcp の `addColumn` が `N8N_MCP_ACCESS_TOKEN` を必要とする。登録済みの `n8n-api` サーバーにはこの値を渡していないので、一時スクリプトで n8n-mcp を直接起動して呼ぶ (値は表示しない)。
|
||
|
||
- [ ] **Step 1: 列が無いことを確かめる**
|
||
|
||
`mcp__n8n-api__n8n_manage_datatable` を `action: "getTable"`、`tableId: "l41sKVXXWr0QFMBE"` で呼び、`columns` に `source` が無いことを確認する。`6Pwm6IdAkotvx8rD` も同様。
|
||
|
||
- [ ] **Step 2: 一時スクリプトを書く** (スクラッチパッドに `add_columns.js`)
|
||
|
||
```js
|
||
// n8n-mcp を直接起動して、2 つのデータテーブルに source 列を足す。トークンは表示しない。
|
||
const { spawn } = require('child_process');
|
||
const fs = require('fs');
|
||
const base = 'C:/Users/debug/.claude/mcp';
|
||
const apiKey = fs.readFileSync(`${base}/n8n-api/api-key.txt`, 'utf8').split(/\r?\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('#'));
|
||
const mcpLine = fs.readFileSync(`${base}/n8n/headers.txt`, 'utf8').split(/\r?\n/).find((s) => /^Authorization:/i.test(s));
|
||
const mcpToken = mcpLine.replace(/^Authorization:\s*Bearer\s+/i, '').trim();
|
||
const p = spawn(process.execPath, [`${base}/n8n-api/node_modules/n8n-mcp/dist/mcp/stdio-wrapper.js`], {
|
||
env: { ...process.env, MCP_MODE: 'stdio', LOG_LEVEL: 'error', DISABLE_CONSOLE_OUTPUT: 'true',
|
||
N8N_API_URL: 'https://n8n.yfox.jp', N8N_API_KEY: apiKey, N8N_MCP_ACCESS_TOKEN: mcpToken,
|
||
N8N_MCP_TELEMETRY_DISABLED: 'true', WEBHOOK_SECURITY_MODE: 'permissive', NODE_TLS_REJECT_UNAUTHORIZED: '0' },
|
||
});
|
||
const tables = ['l41sKVXXWr0QFMBE', '6Pwm6IdAkotvx8rD'];
|
||
let buf = '';
|
||
p.stdout.on('data', (d) => {
|
||
buf += d;
|
||
let i;
|
||
while ((i = buf.indexOf('\n')) >= 0) {
|
||
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
||
try { const m = JSON.parse(line); if (m.id >= 10) console.log(m.id, JSON.stringify(m.result || m.error).slice(0, 400)); } catch (e) { /* 他の行は無視 */ }
|
||
}
|
||
});
|
||
const send = (o) => p.stdin.write(JSON.stringify(o) + '\n');
|
||
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'add-columns', version: '0' } } });
|
||
setTimeout(() => {
|
||
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
||
tables.forEach((tableId, k) => send({ jsonrpc: '2.0', id: 10 + k, method: 'tools/call',
|
||
params: { name: 'n8n_manage_datatable', arguments: { action: 'addColumn', tableId, column: { name: 'source', type: 'string' } } } }));
|
||
}, 5000);
|
||
setTimeout(() => { p.kill(); process.exit(0); }, 40000);
|
||
```
|
||
|
||
- [ ] **Step 3: 実行する**
|
||
|
||
Run: `node "<スクラッチパッド>/add_columns.js" 2>&1 | grep -v -E "trace-warnings|NODE_TLS"`
|
||
Expected: `10` と `11` の行が出て、どちらにもエラーが無い。
|
||
|
||
失敗した (instance-level MCP が列の追加に対応していない等) 場合: 利用者に、n8n の画面の Data tables → `work_log` と `work_log_deleted` で、列 `source` (String) を足してもらう。
|
||
|
||
- [ ] **Step 4: 列ができたことを確かめる**
|
||
|
||
Step 1 と同じ `getTable` で、両方の `columns` に `{"name":"source","type":"string"}` があること。
|
||
|
||
- [ ] **Step 5: 削除時のコピーに source を足す**
|
||
|
||
先に `mcp__n8n-api__n8n_get_workflow` (`id: "1z73HfzmdqHkX2TD"`, `mode: "filtered"`, `nodeNames: ["Backup Row"]`) で現在の `columns.value` を確認し、`mcp__n8n-api__n8n_update_partial_workflow` で:
|
||
|
||
```json
|
||
{"id":"1z73HfzmdqHkX2TD","operations":[{"type":"updateNode","nodeName":"Backup Row","updates":{"parameters.columns":{"mappingMode":"defineBelow","value":{"orig_id":"={{ $json.id }}","date":"={{ $json.date }}","start_time":"={{ $json.start_time }}","end_time":"={{ $json.end_time }}","task":"={{ $json.task }}","memo":"={{ $json.memo }}","duration_min":"={{ $json.duration_min }}","status":"={{ $json.status }}","orig_created_at":"={{ $json.createdAt }}","deleted_at":"={{ $now.toISO() }}","source":"={{ $json.source }}"},"matchingColumns":[],"schema":[]}}}]}
|
||
```
|
||
|
||
- [ ] **Step 6: 削除のコピーを確かめる**
|
||
|
||
1. `n8n_manage_datatable` `insertRows` で `work_log` にダミー
|
||
`{"date":"2000-01-01","start_time":"2000-01-01T00:00:00.000Z","end_time":"2000-01-01T01:00:00.000Z","task":"[削除テスト用のダミー] Claude","memo":"","duration_min":60,"status":"done","source":"chktime:test:946684800000"}` を `returnType: "id"` で入れ、id を控える。
|
||
2. `curl -sk -X POST -H "Content-Type: application/json" -d '{"ids":[<id>]}' https://n8n.yfox.jp/webhook/work-delete`
|
||
Expected: `{"ok":true,"deleted_count":1,...}`
|
||
3. `getRows` (`tableId: "6Pwm6IdAkotvx8rD"`, filter `orig_id eq <id>`) で `source` が `chktime:test:946684800000` であること。
|
||
4. `deleteRows` で `work_log_deleted` のそのダミーを消す (filter `orig_id eq <id>`)。
|
||
|
||
(この確認だけは削除 Webhook 自体の検証なので Webhook を使う。対象は自分で入れたダミーだけ。)
|
||
|
||
---
|
||
|
||
### Task 2: n8n に受け口のワークフロー work-tracker--import を作る
|
||
|
||
**Files:**
|
||
- n8n ワークフロー `work-tracker--import` (新規)
|
||
- `.gitignore` (変更)
|
||
- 一時スクリプト: スクラッチパッド `wt_call.py` (リポジトリには入れない)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 の `source` 列。
|
||
- Produces (chktime の Task 5・6 が使う):
|
||
- `GET /webhook/wt-import-rows?from=YYYY-MM-DD&to=YYYY-MM-DD` (ヘッダー `X-WT-Token`) →
|
||
`{"ok":true,"rows":[{"id":int,"date":str,"start_time":str,"end_time":str|null,"task":str,"status":str,"source":str}],"deletedSources":[str]}`
|
||
不正な期間は `{"ok":false,"error":str}`。合言葉違いは HTTP 403。
|
||
- `POST /webhook/wt-import` 本文 `{"rows":[{"source","date","start_time","end_time","task","memo"}]}` →
|
||
`{"ok":bool,"inserted":[{"source":str,"id":int}],"skipped":[{"source":str,"reason":str}],"errors":[{"source":str,"error":str}]}`
|
||
|
||
- [ ] **Step 1: .gitignore に n8n.json を足してコミットする**
|
||
|
||
`.gitignore` の末尾に追加:
|
||
|
||
```
|
||
# n8n 連携の設定 (合言葉が入る)
|
||
n8n.json
|
||
```
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add .gitignore && git commit -m "n8n 連携の設定ファイルを git 管理外にする
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 2: 利用者に合言葉の登録を頼む (ここで止まって待つ)**
|
||
|
||
利用者に次を依頼し、終わったと言われるまで先に進まない:
|
||
|
||
1. n8n の画面で Credentials → Create → **Header Auth**。名前 `wt-import token`、Name `X-WT-Token`、Value は自分で決めた合言葉 (推測されにくい長い文字列)。
|
||
2. 同じ合言葉で chktime の設定ファイルを作る (自分のターミナルで):
|
||
|
||
```powershell
|
||
Set-Content -Encoding utf8NoBOM C:\debug\chktime\n8n.json '{"url":"https://n8n.yfox.jp","token":"<合言葉>","verifySsl":false,"gapMinutes":30,"minMinutes":5}'
|
||
```
|
||
|
||
- [ ] **Step 3: 認証情報の id を取る**
|
||
|
||
`mcp__n8n-api__n8n_manage_credentials` を `action: "list"` で呼び、名前 `wt-import token` (type `httpHeaderAuth`) の `id` を控える (以下 `<CRED_ID>`)。値は読まない。
|
||
`git status --short` に `n8n.json` が出ない (無視されている) ことも確かめる。
|
||
|
||
- [ ] **Step 4: 呼び出し用の一時スクリプトを書く** (スクラッチパッドに `wt_call.py`。合言葉は表示しない)
|
||
|
||
```python
|
||
"""wt-import の受け口を呼ぶ検証用スクリプト。合言葉は n8n.json から読み、表示しない。
|
||
|
||
使い方:
|
||
python -I wt_call.py rows 2000-01-01 2000-01-01
|
||
python -I wt_call.py import '<rows の JSON>'
|
||
python -I wt_call.py rows 2000-01-01 2000-01-01 --bad-token
|
||
"""
|
||
import json, ssl, sys, urllib.error, urllib.request
|
||
|
||
cfg = json.load(open(r"C:\debug\chktime\n8n.json", encoding="utf-8"))
|
||
token = "wrong-token" if "--bad-token" in sys.argv else cfg["token"]
|
||
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
|
||
args = [a for a in sys.argv[1:] if a != "--bad-token"]
|
||
if args[0] == "rows":
|
||
req = urllib.request.Request(f"{cfg['url']}/webhook/wt-import-rows?from={args[1]}&to={args[2]}")
|
||
else:
|
||
req = urllib.request.Request(f"{cfg['url']}/webhook/wt-import", data=json.dumps({"rows": json.loads(args[1])}).encode(), method="POST")
|
||
req.add_header("Content-Type", "application/json")
|
||
req.add_header("X-WT-Token", token)
|
||
try:
|
||
with urllib.request.urlopen(req, context=ctx, timeout=60) as res:
|
||
print(res.status, res.read().decode())
|
||
except urllib.error.HTTPError as e:
|
||
print(e.code, e.read().decode()[:300])
|
||
```
|
||
|
||
- [ ] **Step 5: ワークフローを作る**
|
||
|
||
`mcp__n8n-api__n8n_create_workflow` を次で呼ぶ (`<CRED_ID>` は Step 3 の値に置き換える):
|
||
|
||
```json
|
||
{
|
||
"name": "work-tracker--import",
|
||
"settings": {"executionOrder": "v1", "timezone": "Asia/Tokyo", "callerPolicy": "workflowsFromSameOwner"},
|
||
"nodes": [
|
||
{"id":"wr","name":"Webhook Rows","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[0,0],"webhookId":"wt-import-rows",
|
||
"parameters":{"httpMethod":"GET","path":"wt-import-rows","authentication":"headerAuth","responseMode":"responseNode","options":{}},
|
||
"credentials":{"httpHeaderAuth":{"id":"<CRED_ID>","name":"wt-import token"}}},
|
||
{"id":"cr","name":"Check Rows Range","type":"n8n-nodes-base.code","typeVersion":2,"position":[220,0],
|
||
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// 期間を確かめる。日をまたぐ記録との重なりを見るため、取得は from の前日から。\nconst q = $input.first().json.query || {};\nconst re = /^\\d{4}-\\d{2}-\\d{2}$/;\nconst f = String(q.from || ''), t = String(q.to || '');\nlet err = '';\nif (!re.test(f) || !re.test(t) || isNaN(Date.parse(f)) || isNaN(Date.parse(t))) err = 'from / to を YYYY-MM-DD で指定してください';\nelse if (f > t) err = 'from が to より後です';\nelse if ((Date.parse(t) - Date.parse(f)) / 86400000 + 1 > 62) err = '期間は 62 日以内にしてください';\nconst prev = err ? '' : new Date(Date.parse(f) - 86400000).toISOString().slice(0, 10);\nreturn [{ json: { ok: !err, error: err, from: prev, to: t } }];"}},
|
||
{"id":"ro","name":"Rows OK?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[440,0],
|
||
"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"loose","version":2},"conditions":[{"id":"r1","leftValue":"={{ $json.ok }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}}},
|
||
{"id":"gr","name":"Get Rows","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[660,-80],"alwaysOutputData":true,
|
||
"parameters":{"resource":"row","operation":"get","dataTableId":{"__rl":true,"value":"l41sKVXXWr0QFMBE","mode":"id","cachedResultName":"work_log"},"matchType":"allConditions","filters":{"conditions":[{"keyName":"date","condition":"gte","keyValue":"={{ $json.from }}"},{"keyName":"date","condition":"lte","keyValue":"={{ $json.to }}"}]},"returnAll":true,"orderBy":true,"orderByColumn":"start_time","orderByDirection":"ASC"}},
|
||
{"id":"gd","name":"Get Deleted","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[880,-80],"alwaysOutputData":true,"executeOnce":true,
|
||
"parameters":{"resource":"row","operation":"get","dataTableId":{"__rl":true,"value":"6Pwm6IdAkotvx8rD","mode":"id","cachedResultName":"work_log_deleted"},"matchType":"allConditions","filters":{"conditions":[{"keyName":"date","condition":"gte","keyValue":"={{ $('Check Rows Range').first().json.from }}"},{"keyName":"date","condition":"lte","keyValue":"={{ $('Check Rows Range').first().json.to }}"}]},"returnAll":true,"orderBy":false}},
|
||
{"id":"br","name":"Build Rows Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[1100,-80],
|
||
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"const rows = $('Get Rows').all().map(i => i.json).filter(r => r && r.id).map(r => ({ id: r.id, date: r.date, start_time: r.start_time, end_time: r.end_time, task: r.task || '', status: r.status || '', source: r.source || '' }));\nconst deletedSources = [...new Set($input.all().map(i => i.json && i.json.source).filter(Boolean))];\nreturn [{ json: { ok: true, rows, deletedSources } }];"}},
|
||
{"id":"rr","name":"Respond Rows","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.4,"position":[1320,-80],
|
||
"parameters":{"respondWith":"json","responseBody":"={{ $json }}","options":{}}},
|
||
{"id":"ri","name":"Respond Rows Invalid","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.4,"position":[660,120],
|
||
"parameters":{"respondWith":"json","responseBody":"={{ { ok: false, error: $json.error } }}","options":{}}},
|
||
|
||
{"id":"wi","name":"Webhook Import","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[0,400],"webhookId":"wt-import",
|
||
"parameters":{"httpMethod":"POST","path":"wt-import","authentication":"headerAuth","responseMode":"responseNode","options":{}},
|
||
"credentials":{"httpHeaderAuth":{"id":"<CRED_ID>","name":"wt-import token"}}},
|
||
{"id":"vi","name":"Validate Import","type":"n8n-nodes-base.code","typeVersion":2,"position":[220,400],
|
||
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// 受け取った区間を 1 行ずつ確かめる。通ったものだけ valid に入れる。\nconst body = $input.first().json.body || {};\nconst rows = Array.isArray(body.rows) ? body.rows : null;\nconst out = (valid, errors) => { const d = valid.map(v => v.date).sort(); return [{ json: { valid, errors, minDate: d[0] || '', maxDate: d[d.length - 1] || '' } }]; };\nif (!rows) return out([], [{ source: '', error: 'rows がありません' }]);\nif (rows.length > 200) return out([], [{ source: '', error: '一度に送れるのは 200 件までです' }]);\nconst isoRe = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$/;\nconst valid = [], errors = [];\nfor (const r of rows) {\n const source = String((r && r.source) || '');\n const fail = (m) => errors.push({ source, error: m });\n if (!source.startsWith('chktime:') || source.length > 500) { fail('source が不正です'); continue; }\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(String(r.date || ''))) { fail('date が不正です'); continue; }\n const s = Date.parse(r.start_time), e = Date.parse(r.end_time);\n if (!isoRe.test(String(r.start_time || '')) || !isoRe.test(String(r.end_time || '')) || isNaN(s) || isNaN(e)) { fail('start_time / end_time が不正です'); continue; }\n if (e <= s) { fail('end_time が start_time 以前です'); continue; }\n if (e - s > 24 * 3600000) { fail('区間が 24 時間を超えています'); continue; }\n const task = String(r.task == null ? '' : r.task).trim();\n if (!task || task.length > 500) { fail('task が空か 500 文字を超えています'); continue; }\n const memo = String(r.memo == null ? '' : r.memo);\n if (memo.length > 2000) { fail('memo が 2000 文字を超えています'); continue; }\n valid.push({ source, date: r.date, start_time: new Date(s).toISOString(), end_time: new Date(e).toISOString(), task, memo, status: 'done', duration_min: Math.round((e - s) / 60000) });\n}\nreturn out(valid, errors);"}},
|
||
{"id":"ge","name":"Get Existing","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[440,400],"alwaysOutputData":true,
|
||
"parameters":{"resource":"row","operation":"get","dataTableId":{"__rl":true,"value":"l41sKVXXWr0QFMBE","mode":"id","cachedResultName":"work_log"},"matchType":"allConditions","filters":{"conditions":[{"keyName":"date","condition":"gte","keyValue":"={{ $json.minDate }}"},{"keyName":"date","condition":"lte","keyValue":"={{ $json.maxDate }}"},{"keyName":"source","condition":"isNotEmpty"}]},"returnAll":true,"orderBy":false}},
|
||
{"id":"pi","name":"Plan Insert","type":"n8n-nodes-base.code","typeVersion":2,"position":[660,400],
|
||
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// すでに work_log にある source と、同じ送信の中の重複を除く。1 件も無ければ _none を返す。\nconst existing = new Set($input.all().map(i => i.json && i.json.source).filter(Boolean));\nconst seen = new Set();\nconst rows = [];\nfor (const v of $('Validate Import').first().json.valid) {\n if (existing.has(v.source) || seen.has(v.source)) continue;\n seen.add(v.source);\n rows.push({ json: v });\n}\nreturn rows.length ? rows : [{ json: { _none: true } }];"}},
|
||
{"id":"ai","name":"Any To Insert?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[880,400],
|
||
"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"loose","version":2},"conditions":[{"id":"a1","leftValue":"={{ !!$json.source }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}}},
|
||
{"id":"ir","name":"Insert Rows","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[1100,320],
|
||
"parameters":{"resource":"row","operation":"insert","dataTableId":{"__rl":true,"value":"l41sKVXXWr0QFMBE","mode":"id","cachedResultName":"work_log"},"columns":{"mappingMode":"defineBelow","value":{"date":"={{ $json.date }}","start_time":"={{ $json.start_time }}","end_time":"={{ $json.end_time }}","task":"={{ $json.task }}","memo":"={{ $json.memo }}","duration_min":"={{ $json.duration_min }}","status":"={{ $json.status }}","source":"={{ $json.source }}"},"matchingColumns":[],"schema":[]},"options":{}}},
|
||
{"id":"bi","name":"Build Import Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[1320,400],
|
||
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// Insert Rows (追加した行) か Any To Insert? の false 側 (_none) のどちらか一方から来る。\nconst v = $('Validate Import').first().json;\nconst existing = new Set($('Get Existing').all().map(i => i.json && i.json.source).filter(Boolean));\nconst inserted = $input.all().map(i => i.json).filter(r => r && r.source && r.id).map(r => ({ source: r.source, id: r.id }));\nconst done = new Set(inserted.map(r => r.source));\nconst skipped = [];\nconst seen = new Set();\nfor (const row of v.valid) {\n if (done.has(row.source) && !seen.has(row.source)) { seen.add(row.source); continue; }\n skipped.push({ source: row.source, reason: existing.has(row.source) ? 'すでに登録されています' : '同じ送信の中で重複しています' });\n}\nreturn [{ json: { ok: v.errors.length === 0, inserted, skipped, errors: v.errors } }];"}},
|
||
{"id":"rp","name":"Respond Import","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.4,"position":[1540,400],
|
||
"parameters":{"respondWith":"json","responseBody":"={{ $json }}","options":{}}}
|
||
],
|
||
"connections": {
|
||
"Webhook Rows": {"main": [[{"node":"Check Rows Range","type":"main","index":0}]]},
|
||
"Check Rows Range": {"main": [[{"node":"Rows OK?","type":"main","index":0}]]},
|
||
"Rows OK?": {"main": [[{"node":"Get Rows","type":"main","index":0}],[{"node":"Respond Rows Invalid","type":"main","index":0}]]},
|
||
"Get Rows": {"main": [[{"node":"Get Deleted","type":"main","index":0}]]},
|
||
"Get Deleted": {"main": [[{"node":"Build Rows Result","type":"main","index":0}]]},
|
||
"Build Rows Result": {"main": [[{"node":"Respond Rows","type":"main","index":0}]]},
|
||
"Webhook Import": {"main": [[{"node":"Validate Import","type":"main","index":0}]]},
|
||
"Validate Import": {"main": [[{"node":"Get Existing","type":"main","index":0}]]},
|
||
"Get Existing": {"main": [[{"node":"Plan Insert","type":"main","index":0}]]},
|
||
"Plan Insert": {"main": [[{"node":"Any To Insert?","type":"main","index":0}]]},
|
||
"Any To Insert?": {"main": [[{"node":"Insert Rows","type":"main","index":0}],[{"node":"Build Import Result","type":"main","index":0}]]},
|
||
"Insert Rows": {"main": [[{"node":"Build Import Result","type":"main","index":0}]]},
|
||
"Build Import Result": {"main": [[{"node":"Respond Import","type":"main","index":0}]]}
|
||
}
|
||
}
|
||
```
|
||
|
||
返ってきたワークフロー id を控え、`mcp__n8n-api__n8n_validate_workflow` でエラー 0 を確かめてから、`n8n_update_partial_workflow` の `{"type":"activateWorkflow"}` で有効にする。
|
||
|
||
- [ ] **Step 6: 受け口を確かめる (ダミーだけを使う)**
|
||
|
||
`S=<スクラッチパッド>/wt_call.py`、ダミー区間 `D='[{"source":"chktime:test:946684800000","date":"2000-01-01","start_time":"2000-01-01T00:00:00.000Z","end_time":"2000-01-01T01:00:00.000Z","task":"[取り込みテスト] Claude","memo":"テスト後に削除する"}]'`
|
||
|
||
| 実行 | Expected |
|
||
|---|---|
|
||
| `python -I $S rows 2000-01-01 2000-01-01 --bad-token` | `403` |
|
||
| `python -I $S rows 2000-01-02 2000-01-01` | `200 {"ok":false,"error":"from が to より後です"}` |
|
||
| `python -I $S rows 2000-01-01 2000-03-31` | `200 {"ok":false,"error":"期間は 62 日以内にしてください"}` |
|
||
| `python -I $S import "$D"` | `200`、`inserted` に `chktime:test:946684800000` と id が 1 件 |
|
||
| `python -I $S import "$D"` (2 回目) | `200`、`inserted` は空、`skipped` に `すでに登録されています` |
|
||
| `python -I $S import '[{"source":"x","date":"2000-01-01","start_time":"2000-01-01T00:00:00Z","end_time":"2000-01-01T01:00:00Z","task":"t"}]'` | `200`、`ok:false`、`errors` に `source が不正です` |
|
||
| `python -I $S import '[{"source":"chktime:test:1","date":"2000-01-01","start_time":"2000-01-01T02:00:00Z","end_time":"2000-01-01T01:00:00Z","task":"t"}]'` | `errors` に `end_time が start_time 以前です` |
|
||
| `python -I $S import '[{"source":"chktime:test:2","date":"2000-01-01","start_time":"2000-01-01T00:00:00Z","end_time":"2000-01-01T01:00:00Z","task":" "}]'` | `errors` に `task が空か 500 文字を超えています` |
|
||
| `python -I $S rows 2000-01-01 2000-01-01` | `rows` にダミー 1 件 (`source` 付き)、`deletedSources` は `[]` |
|
||
|
||
`inserted` に id が無い場合 (Insert Rows が行を返さない場合) は、`Build Import Result` で `$('Plan Insert').all()` と `$input.all()` を並び順で対応させて id を取るように直し、もう一度確かめる。
|
||
|
||
- [ ] **Step 7: ダミーを消す**
|
||
|
||
`n8n_manage_datatable` `deleteRows` (`tableId: "l41sKVXXWr0QFMBE"`, filter `source eq chktime:test:946684800000` かつ `date eq 2000-01-01`) で消し、`rows 2000-01-01 2000-01-01` が空になることを確かめる。
|
||
|
||
---
|
||
|
||
### Task 3: 区間の計算 make_blocks
|
||
|
||
**Files:**
|
||
- Modify: `chktime.py` (HTTP 節の直前、`to_fields` の後に新しい節を足す)
|
||
- Create: `tests/test_n8n.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `N8N_CONFIG: Path`、`N8N_GAP_MIN = 30`、`N8N_MIN_MIN = 5`、`N8N_MAX_DAYS = 62`
|
||
- `class N8nError(Exception)`
|
||
- `block_source(session_id: str, start_epoch: float) -> str`
|
||
- `make_blocks(session: dict, gap_min=30, min_min=5, now: float|None=None) -> list[dict]`
|
||
各 dict: `source, sessionId, date (YYYY-MM-DD ローカル), start, end (ISO UTC), startEpoch, endEpoch (float), minutes (int), turns (int), inProgress (bool)`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py`)
|
||
|
||
```python
|
||
"""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")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: 失敗を確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: 全テストが ERROR (`AttributeError: module 'chktime' has no attribute 'make_blocks'`)。
|
||
|
||
- [ ] **Step 3: 実装する** (`chktime.py` の `to_fields` 関数の後、`# ---------------------------------------------------------------- HTTP` の前)
|
||
|
||
```python
|
||
# ---------------------------------------------------------------- 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 と同じ上限
|
||
|
||
|
||
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 = []
|
||
for index, group in enumerate(groups):
|
||
last = group["last"]
|
||
waiting = last["firstReplyTs"] is None and not last["interrupted"]
|
||
recent = now - (session.get("mtime") or group["end"]) < gap
|
||
in_progress = index == len(groups) - 1 and (waiting or recent)
|
||
minutes = (group["end"] - group["start"]) / 60.0
|
||
if minutes < min_min and not in_progress:
|
||
continue
|
||
out.append(
|
||
{
|
||
"source": block_source(session["id"], group["start"]),
|
||
"sessionId": session["id"],
|
||
"date": local_date(group["start"]),
|
||
"start": iso(group["start"]),
|
||
"end": iso(group["end"]),
|
||
"startEpoch": group["start"],
|
||
"endEpoch": group["end"],
|
||
"minutes": int(round(minutes)),
|
||
"turns": group["turns"],
|
||
"inProgress": in_progress,
|
||
}
|
||
)
|
||
return out
|
||
```
|
||
|
||
注意: `test_interrupted_last_turn_is_not_in_progress` の 2 つ目は、中断のターンが前のターンに含まれる形 (応答の途中で中断) を想定している。chktime のパーサーは中断を新しいターンにせず直前のターンの `interrupted` を立てるので、テストの `turn(11, 11, reply=False, interrupted=True)` は「応答が無いまま中断されたターン」を表す。
|
||
|
||
- [ ] **Step 4: 通ることを確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: `MakeBlocksTest` の 10 件が PASS。
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: セッションを作業区間に分ける make_blocks を追加
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 期間の区間を集める collect_blocks と重なりの判定 mark_blocks
|
||
|
||
**Files:**
|
||
- Modify: `chktime.py` (Task 3 の節に追記)
|
||
- Modify: `tests/test_n8n.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `make_blocks`、`Store` (`projects()` / `session_files(id)` / `session(path)`)、`parse_ts`、`iso`、`one_line`
|
||
- Produces:
|
||
- `default_task_name(folder: str|None, title: str) -> str` … `[<フォルダ名>] <タイトル>`
|
||
- `collect_blocks(store, project_id: str, start: date, end: date, gap_min, min_min, now=None) -> list[dict]`
|
||
区間 dict に `project, projectPath, title, taskName` を足したもの。開始の古い順。
|
||
- `mark_blocks(blocks, rows: list[dict], deleted_sources: list[str], now=None) -> blocks`
|
||
各区間に `sent: bool, deleted: bool, overlaps: list[{kind:'row'|'block', id?, source, task, start, end}]` を足す。
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py` の `if __name__` の前に追加)
|
||
|
||
```python
|
||
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_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"]) # 進行中の行 (終了 = 今) とも重なる
|
||
```
|
||
|
||
- [ ] **Step 2: 失敗を確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: `CollectAndMarkTest` の 4 件が ERROR (`no attribute 'collect_blocks'` / `'mark_blocks'`)、`MakeBlocksTest` は PASS のまま。
|
||
|
||
- [ ] **Step 3: 実装する** (Task 3 の `make_blocks` の後に追加)
|
||
|
||
```python
|
||
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"]),
|
||
}
|
||
)
|
||
out.append(block)
|
||
out.sort(key=lambda b: b["startEpoch"])
|
||
return out
|
||
|
||
|
||
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") 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:
|
||
block["sent"] = block["source"] in sent
|
||
block["deleted"] = not block["sent"] and block["source"] in deleted
|
||
block["overlaps"] = [
|
||
info for start, end, info in spans
|
||
if info["source"] != block["source"] and start < block["endEpoch"] and block["startEpoch"] < end
|
||
]
|
||
return blocks
|
||
```
|
||
|
||
- [ ] **Step 4: 通ることを確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: 14 件すべて PASS。
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: 期間の作業区間を集め、送信済み・重なりを判定する
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: n8n クライアントと設定 (N8n / N8nHolder)
|
||
|
||
**Files:**
|
||
- Modify: `chktime.py` (n8n 節に追記)
|
||
- Modify: `tests/test_n8n.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 2 の受け口の形、`load_redmine_config` (JSON 設定の読み込み。名前は Redmine 用だが中身は汎用)
|
||
- Produces:
|
||
- `class N8n(url, token, verify=False)`: `.call(path, method="GET", payload=None) -> dict`、`.rows(start: date, end: date) -> dict`、`.import_rows(rows: list[dict]) -> dict`。失敗は `N8nError` (403 は `合言葉が違います (HTTP 403)`)。応答が `{"ok":false,"error":...}` なら `N8nError(error)`。
|
||
- `class N8nHolder(path)`: `.client: N8n`、`.settings() -> (gap_min:int, min_min:int)`、`.public() -> {url, hasToken, verifySsl, gapMinutes, minMinutes, configPath}`、`.update(patch) -> public()`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py` に追加)
|
||
|
||
```python
|
||
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)
|
||
```
|
||
|
||
- [ ] **Step 2: 失敗を確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: 新しい 7 件が ERROR (`no attribute 'N8n'` / `'N8nHolder'`)、既存の 14 件は PASS。
|
||
|
||
- [ ] **Step 3: 実装する** (`mark_blocks` の後に追加)
|
||
|
||
```python
|
||
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:
|
||
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
|
||
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:
|
||
cfg["url"] = str(patch["url"] or "").strip()
|
||
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"):
|
||
cfg["token"] = str(patch["token"]).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 N8nError(f"設定を保存できません: {exc}") from None
|
||
return self.public()
|
||
```
|
||
|
||
- [ ] **Step 4: 通ることを確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: 21 件すべて PASS。
|
||
|
||
- [ ] **Step 5: コミット**
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: 受け口を呼ぶクライアントと設定の保持を追加
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: API (/api/n8n/config・blocks・send、/api/redmine/search)
|
||
|
||
**Files:**
|
||
- Modify: `chktime.py` (n8n 節に `n8n_range` / `n8n_blocks` / `n8n_send`、`Redmine.search`、`Handler`、`main`、import)
|
||
- Modify: `tests/test_n8n.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `collect_blocks`、`mark_blocks`、`make_blocks`、`N8nHolder`、`Store.one(project, session_id)`
|
||
- Produces:
|
||
- `n8n_range(query: dict) -> (date, date)` (既定は今日までの 7 日。62 日超・逆順は `N8nError`)
|
||
- `n8n_blocks(store, holder, query, now=None) -> {from, to, gapMinutes, minMinutes, blocks, n8nError}`
|
||
- `n8n_send(store, holder, payload, now=None) -> {ok, inserted, skipped, errors}`
|
||
`payload = {"items":[{"project","source","task","memo"}]}`
|
||
- `Redmine.search(text: str, limit=30) -> list[{id, subject, project}]`
|
||
- ルート: `GET /api/n8n/config`、`POST /api/n8n/config`、`GET /api/n8n/blocks?from=&to=&project=`、`POST /api/n8n/send`、`GET /api/redmine/search?q=`
|
||
|
||
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py` に追加)
|
||
|
||
```python
|
||
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)
|
||
```
|
||
|
||
- [ ] **Step 2: 失敗を確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: `ApiFunctionsTest` の 7 件が ERROR (`no attribute 'n8n_range'` など)。
|
||
|
||
- [ ] **Step 3: 関数を実装する** (`N8nHolder` の後に追加)
|
||
|
||
```python
|
||
SOURCE_RE = re.compile(r"^chktime:([^:]+):(\d+)$")
|
||
|
||
|
||
def n8n_range(query):
|
||
"""?from= / ?to= の解釈。既定は今日までの 7 日。逆順と N8N_MAX_DAYS 日超はエラー。"""
|
||
end = parse_date(query.get("to", [""])[0]) or date.today()
|
||
start = parse_date(query.get("from", [""])[0]) or end - timedelta(days=6)
|
||
if start > end:
|
||
raise N8nError("開始日が終了日より後です")
|
||
if (end - start).days + 1 > N8N_MAX_DAYS:
|
||
raise N8nError(f"期間は {N8N_MAX_DAYS} 日以内にしてください")
|
||
return start, end
|
||
|
||
|
||
def n8n_blocks(store, holder, query, now=None):
|
||
"""期間の作業区間に、n8n 側の送信済み・削除済み・重なりを付けて返す。
|
||
|
||
n8n に届かなくても区間は返し、n8nError に理由を入れる (送信済みは分からないので UI は送らせない)。
|
||
"""
|
||
start, end = n8n_range(query)
|
||
gap, minimum = holder.settings()
|
||
blocks = collect_blocks(store, query.get("project", [""])[0], start, end, gap, minimum, now)
|
||
rows, deleted, error = [], [], None
|
||
try:
|
||
data = holder.client.rows(start, end)
|
||
rows = data.get("rows") or []
|
||
deleted = data.get("deletedSources") or []
|
||
except N8nError as exc:
|
||
error = str(exc)
|
||
mark_blocks(blocks, rows, deleted, now)
|
||
return {"from": start.isoformat(), "to": end.isoformat(), "gapMinutes": gap, "minMinutes": minimum,
|
||
"blocks": blocks, "n8nError": error}
|
||
|
||
|
||
def n8n_send(store, holder, payload, now=None):
|
||
"""選ばれた区間を n8n の work_log に送る。時刻はブラウザの値を使わずログから計算し直す。"""
|
||
items = payload.get("items") or []
|
||
if not items:
|
||
raise N8nError("送る区間がありません")
|
||
if len(items) > 200:
|
||
raise N8nError("一度に送れるのは 200 件までです")
|
||
gap, minimum = holder.settings()
|
||
rows, errors, cache = [], [], {}
|
||
for item in items:
|
||
source = str(item.get("source") or "")
|
||
task = " ".join(str(item.get("task") or "").split())
|
||
match = SOURCE_RE.match(source)
|
||
if not match:
|
||
errors.append({"source": source, "error": "source が不正です"})
|
||
continue
|
||
if not task:
|
||
errors.append({"source": source, "error": "タスク名が空です"})
|
||
continue
|
||
key = (str(item.get("project") or ""), match.group(1))
|
||
if key not in cache:
|
||
try:
|
||
cache[key] = make_blocks(store.one(*key), gap, minimum, now)
|
||
except FileNotFoundError:
|
||
cache[key] = None
|
||
if cache[key] is None:
|
||
errors.append({"source": source, "error": "セッションが見つかりません"})
|
||
continue
|
||
block = next((b for b in cache[key] if b["source"] == source), None)
|
||
if block is None:
|
||
errors.append({"source": source, "error": "区間が見つかりません (ログか設定が変わりました)"})
|
||
continue
|
||
if block["inProgress"]:
|
||
errors.append({"source": source, "error": "進行中の区間は送れません"})
|
||
continue
|
||
rows.append({"source": source, "date": block["date"], "start_time": block["start"],
|
||
"end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")})
|
||
result = {"ok": False, "inserted": [], "skipped": [], "errors": errors}
|
||
if rows:
|
||
data = holder.client.import_rows(rows)
|
||
result["inserted"] = data.get("inserted") or []
|
||
result["skipped"] = data.get("skipped") or []
|
||
result["errors"] = errors + (data.get("errors") or [])
|
||
result["ok"] = not result["errors"]
|
||
return result
|
||
```
|
||
|
||
- [ ] **Step 4: Redmine の検索を足す** (`class Redmine` の `create` の後。先頭の import を `from urllib.parse import parse_qs, quote, urlparse` に変える)
|
||
|
||
```python
|
||
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]
|
||
```
|
||
|
||
- [ ] **Step 5: ルートと起動オプションを足す**
|
||
|
||
`Handler.do_GET` と `do_POST` の例外処理に `N8nError` を足す:
|
||
|
||
```python
|
||
except (RedmineError, N8nError) as exc:
|
||
self.send_json({"error": str(exc)}, 400)
|
||
```
|
||
(`do_POST` は `except (RedmineError, N8nError, ValueError) as exc:`)
|
||
|
||
`Handler.api` の `if route == "/api/redmine/config":` の前に:
|
||
|
||
```python
|
||
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])})
|
||
```
|
||
|
||
`Handler.api_post` の先頭 (`redmine = ...` の後) に:
|
||
|
||
```python
|
||
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))
|
||
```
|
||
|
||
`main()` に:
|
||
|
||
```python
|
||
parser.add_argument("--n8n-config", default=str(N8N_CONFIG), help="n8n 連携の設定ファイル")
|
||
```
|
||
と、`server.redmine = RedmineHolder(args.redmine_config)` の次の行に:
|
||
|
||
```python
|
||
server.n8n = N8nHolder(args.n8n_config)
|
||
```
|
||
|
||
- [ ] **Step 6: 通ることを確かめる**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: 28 件すべて PASS。
|
||
|
||
- [ ] **Step 7: 検証用サーバーで API を確かめる**
|
||
|
||
1. 一時設定を作る (合言葉は入れない): スクラッチパッドに `n8n-test.json` = `{"url":"http://127.0.0.1:9","gapMinutes":30,"minMinutes":5}`
|
||
2. `cd /c/debug/chktime && python chktime.py --port 8799 --no-browser --n8n-config "<スクラッチパッド>/n8n-test.json"` をバックグラウンドで起動
|
||
3. `curl -s http://127.0.0.1:8799/api/n8n/config` → `hasToken: false`、`token` のキーが無い
|
||
4. `curl -s "http://127.0.0.1:8799/api/n8n/blocks?from=$(date +%F)&to=$(date +%F)"` → `blocks` 配列と `n8nError: "合言葉が未設定です"`
|
||
5. `curl -s "http://127.0.0.1:8799/api/n8n/blocks?from=2026-01-05&to=2026-01-01"` → HTTP 400 `{"error":"開始日が終了日より後です"}`
|
||
6. 8799 番のプロセスだけを止める (`stop.cmd` は使わない。`netstat -ano | grep 8799` の PID を `taskkill //PID <pid> //F`)
|
||
|
||
- [ ] **Step 8: コミット**
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: 区間の一覧・送信・設定の API と Redmine のチケット検索を追加
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 「n8n へ送る」タブの画面
|
||
|
||
**Files:**
|
||
- Create: `web/n8n.js`
|
||
- Modify: `web/index.html`、`web/app.js` (`syncTabs`、`setView`、`selectProject`、`updateCsv`、`refreshFollow`、resize、タブの onclick)、`web/style.css`
|
||
|
||
**Interfaces:**
|
||
- Consumes: app.js の `$`、`state`、`api(path)`、`post(path, payload)`、`esc`、`fmtTime(iso)`、`shortPath(path)`、`setView(view)`。API は Task 6。
|
||
- Produces: グローバル関数 `loadN8n({silent})` と `renderN8n()` (app.js から呼ぶ)。
|
||
|
||
- [ ] **Step 1: index.html にタブとスクリプトを足す**
|
||
|
||
`<button class="tab" id="tab-gantt">ガント</button>` の次の行に:
|
||
|
||
```html
|
||
<button class="tab" id="tab-n8n" title="Claude の作業区間を n8n の work_log に送る">n8n へ送る</button>
|
||
```
|
||
|
||
`<script src="app.js"></script>` の次の行に:
|
||
|
||
```html
|
||
<script src="n8n.js"></script>
|
||
```
|
||
|
||
- [ ] **Step 2: app.js に分岐を足す**
|
||
|
||
`syncTabs` の最後に:
|
||
```js
|
||
$('#tab-n8n').classList.toggle('sel', state.view === 'n8n');
|
||
```
|
||
`updateCsv` の `if (state.view === 'daily') {` の前に:
|
||
```js
|
||
if (state.view === 'n8n') {
|
||
link.removeAttribute('href');
|
||
return;
|
||
}
|
||
```
|
||
`setView` の `else if (view === 'gantt') loadGantt();` の次に:
|
||
```js
|
||
else if (view === 'n8n') loadN8n();
|
||
```
|
||
`selectProject` の `else if (state.view === 'gantt') loadGantt();` の次に:
|
||
```js
|
||
else if (state.view === 'n8n') loadN8n();
|
||
```
|
||
`refreshFollow` の `else if (state.view === 'gantt') await loadGantt({ silent: true });` の次に (編集中の入力を消さないよう、自動追従では読み直さない):
|
||
```js
|
||
else if (state.view === 'n8n') { /* 自動追従では読み直さない */ }
|
||
```
|
||
resize の `if (state.view === 'turns') return;` を:
|
||
```js
|
||
if (state.view === 'turns' || state.view === 'n8n') return;
|
||
```
|
||
タブの onclick (`$('#tab-gantt').onclick = ...` の次) に:
|
||
```js
|
||
$('#tab-n8n').onclick = () => setView('n8n');
|
||
```
|
||
|
||
- [ ] **Step 3: web/n8n.js を書く**
|
||
|
||
```js
|
||
'use strict';
|
||
// 「n8n へ送る」タブ: Claude のセッションから作った作業区間を選び、n8n の work_log に送る。
|
||
// app.js の後に読み込む ($ / state / api / post / esc / fmtTime / shortPath を使う)。
|
||
|
||
const N8N_NAMES_KEY = 'chktime.n8n.names'; // セッション ID -> {task, memo} (ブラウザに覚える)
|
||
|
||
const n8 = {
|
||
cfg: null, // /api/n8n/config (合言葉は含まれない)
|
||
from: '',
|
||
to: '',
|
||
data: null, // /api/n8n/blocks
|
||
checked: new Set(), // 送る区間の source
|
||
edits: new Map(), // source -> {task, memo}
|
||
sameSession: true, // 同じセッションの他の区間にも入れる
|
||
settings: false,
|
||
busy: false,
|
||
result: null, // 直前の送信結果
|
||
pick: null, // チケットを選んでいる区間の source
|
||
issues: [],
|
||
pickMsg: '',
|
||
};
|
||
|
||
function n8LoadNames() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(N8N_NAMES_KEY) || '{}') || {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function n8SaveName(sessionId, value) {
|
||
try {
|
||
const all = n8LoadNames();
|
||
all[sessionId] = value;
|
||
localStorage.setItem(N8N_NAMES_KEY, JSON.stringify(all));
|
||
} catch (e) {
|
||
/* 保存できなくても動く */
|
||
}
|
||
}
|
||
|
||
const n8CanSend = (b) => !b.sent && !b.inProgress;
|
||
const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length;
|
||
|
||
function n8Query() {
|
||
const q = new URLSearchParams();
|
||
if (state.project) q.set('project', state.project);
|
||
if (n8.from) q.set('from', n8.from);
|
||
if (n8.to) q.set('to', n8.to);
|
||
return q.toString();
|
||
}
|
||
|
||
async function loadN8n({ silent = false } = {}) {
|
||
$('#detail-title').textContent = 'n8n へ送る';
|
||
if (!silent) $('#detail-body').innerHTML = '<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 = names[b.sessionId] || {};
|
||
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.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
|
||
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
|
||
if ((b.overlaps || []).length) {
|
||
const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n');
|
||
marks.push(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
|
||
}
|
||
return marks.join(' ');
|
||
}
|
||
|
||
function n8Settings() {
|
||
const c = n8.cfg || {};
|
||
return `<div class="n8-settings">
|
||
<h4>接続設定 <span class="note">${esc(c.configPath || '')}</span></h4>
|
||
<label>URL <input id="n8-url" value="${esc(c.url || '')}" placeholder="https://n8n.yfox.jp"></label>
|
||
<label>合言葉 <input id="n8-token" type="password" placeholder="${c.hasToken ? '設定済み (変えるときだけ入力)' : '未設定'}"></label>
|
||
<label class="chk"><input type="checkbox" id="n8-verify" ${c.verifySsl ? 'checked' : ''}> 証明書を検証する</label>
|
||
<label>区切る空き (分) <input id="n8-gap" type="number" min="1" max="240" value="${c.gapMinutes ?? 30}"></label>
|
||
<label>最短 (分) <input id="n8-min" type="number" min="0" max="240" value="${c.minMinutes ?? 5}"></label>
|
||
<button id="n8-save">保存</button>
|
||
</div>`;
|
||
}
|
||
|
||
function n8ResultHtml() {
|
||
const r = n8.result;
|
||
if (!r) return '';
|
||
const errs = (r.errors || []).map((e) => `<li>${esc(e.source)}: ${esc(e.error)}</li>`).join('');
|
||
const skips = (r.skipped || []).map((s) => `<li>${esc(s.source)}: ${esc(s.reason)}</li>`).join('');
|
||
return `<div class="n8-result ${r.ok ? 'ok' : 'ng'}">
|
||
追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件
|
||
${skips ? `<ul>${skips}</ul>` : ''}${errs ? `<ul class="err-inline">${errs}</ul>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function n8PickHtml() {
|
||
if (!n8.pick) return '';
|
||
const rows = n8.issues
|
||
.map((i) => `<li><button class="n8-issue" data-id="${i.id}">#${i.id}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
|
||
.join('');
|
||
return `<div class="n8-pick">
|
||
<h4>Redmine のチケットを選ぶ</h4>
|
||
<input id="n8-q" placeholder="件名の一部、または番号"> <button id="n8-find">検索</button> <button id="n8-pick-close">閉じる</button>
|
||
<span class="note">${esc(n8.pickMsg)}</span>
|
||
<ul>${rows}</ul>
|
||
</div>`;
|
||
}
|
||
|
||
function renderN8n() {
|
||
const d = n8.data;
|
||
if (!d) return;
|
||
const blocks = d.blocks;
|
||
const on = blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b));
|
||
const rows = blocks
|
||
.map((b) => {
|
||
const e = n8.edits.get(b.source) || { task: b.taskName, memo: '' };
|
||
return `<tr data-src="${esc(b.source)}" class="${b.sent ? 'n8-sent' : ''}">
|
||
<td><input type="checkbox" class="n8-on" ${n8.checked.has(b.source) && n8CanSend(b) ? 'checked' : ''} ${n8CanSend(b) ? '' : 'disabled'}></td>
|
||
<td class="time">${esc(b.date)}</td>
|
||
<td class="time">${fmtTime(b.start)}–${fmtTime(b.end)}</td>
|
||
<td class="num">${b.minutes} 分</td>
|
||
<td class="n8-pj" title="${esc(b.projectPath)}">${esc(shortPath(b.projectPath))}</td>
|
||
<td><input class="n8-task" value="${esc(e.task)}"> <input class="n8-memo" value="${esc(e.memo)}" placeholder="メモ"></td>
|
||
<td><button class="n8-choose">選ぶ</button></td>
|
||
<td>${n8Status(b)}</td>
|
||
</tr>`;
|
||
})
|
||
.join('');
|
||
$('#detail-body').innerHTML = `<div class="n8">
|
||
<div class="n8-bar">
|
||
<label>期間 <input type="date" id="n8-from" value="${esc(n8.from)}"> – <input type="date" id="n8-to" value="${esc(n8.to)}"></label>
|
||
<button id="n8-reload">読み直す</button>
|
||
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
|
||
<span class="spacer"></span>
|
||
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
|
||
<button id="n8-send" class="go" ${d.n8nError || n8.busy || !on.length ? 'disabled' : ''}>選んだ ${on.length} 件を送る</button>
|
||
</div>
|
||
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
|
||
${n8.settings ? n8Settings() : ''}
|
||
${n8ResultHtml()}
|
||
${n8PickHtml()}
|
||
<p class="note">区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件</p>
|
||
<table id="n8n-table"><thead><tr><th>送る</th><th>日付</th><th>時刻</th><th>時間</th><th>プロジェクト</th><th>タスク名 / メモ</th><th>チケット</th><th>状態</th></tr></thead>
|
||
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
|
||
</div>`;
|
||
n8Wire();
|
||
}
|
||
|
||
function n8Block(source) {
|
||
return n8.data.blocks.find((b) => b.source === source);
|
||
}
|
||
|
||
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */
|
||
function n8SetEdit(source, patch) {
|
||
const b = n8Block(source);
|
||
const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b];
|
||
for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch });
|
||
n8SaveName(b.sessionId, n8.edits.get(source));
|
||
}
|
||
|
||
function n8Wire() {
|
||
$('#n8-reload').onclick = () => {
|
||
n8.from = $('#n8-from').value;
|
||
n8.to = $('#n8-to').value;
|
||
loadN8n();
|
||
};
|
||
$('#n8-same').onchange = (e) => {
|
||
n8.sameSession = e.target.checked;
|
||
};
|
||
$('#n8-toggle').onclick = () => {
|
||
n8.settings = !n8.settings;
|
||
renderN8n();
|
||
};
|
||
$('#n8-send').onclick = n8Send;
|
||
if (n8.settings) $('#n8-save').onclick = n8SaveSettings;
|
||
if (n8.pick) {
|
||
$('#n8-find').onclick = n8Find;
|
||
$('#n8-q').onkeydown = (e) => {
|
||
if (e.key === 'Enter') n8Find();
|
||
};
|
||
$('#n8-pick-close').onclick = () => {
|
||
n8.pick = null;
|
||
renderN8n();
|
||
};
|
||
document.querySelectorAll('.n8-issue').forEach((btn) => {
|
||
btn.onclick = () => {
|
||
const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id);
|
||
n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` });
|
||
n8.pick = null;
|
||
renderN8n();
|
||
};
|
||
});
|
||
}
|
||
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
|
||
const source = tr.dataset.src;
|
||
tr.querySelector('.n8-on').onchange = (e) => {
|
||
if (e.target.checked) n8.checked.add(source);
|
||
else n8.checked.delete(source);
|
||
renderN8n();
|
||
};
|
||
tr.querySelector('.n8-task').onchange = (e) => {
|
||
n8SetEdit(source, { task: e.target.value });
|
||
renderN8n();
|
||
};
|
||
tr.querySelector('.n8-memo').onchange = (e) => {
|
||
n8SetEdit(source, { memo: e.target.value });
|
||
};
|
||
tr.querySelector('.n8-choose').onclick = () => {
|
||
n8.pick = source;
|
||
n8.issues = [];
|
||
n8.pickMsg = '';
|
||
renderN8n();
|
||
};
|
||
});
|
||
}
|
||
|
||
async function n8Find() {
|
||
n8.pickMsg = '検索中…';
|
||
const q = $('#n8-q').value;
|
||
renderN8n();
|
||
try {
|
||
n8.issues = (await api('/api/redmine/search?q=' + encodeURIComponent(q))).issues;
|
||
n8.pickMsg = n8.issues.length ? '' : '見つかりません';
|
||
} catch (e) {
|
||
n8.pickMsg = e.message;
|
||
}
|
||
renderN8n();
|
||
$('#n8-q').value = q;
|
||
}
|
||
|
||
async function n8SaveSettings() {
|
||
const patch = {
|
||
url: $('#n8-url').value,
|
||
verifySsl: $('#n8-verify').checked,
|
||
gapMinutes: Number($('#n8-gap').value),
|
||
minMinutes: Number($('#n8-min').value),
|
||
};
|
||
const token = $('#n8-token').value;
|
||
if (token) patch.token = token;
|
||
try {
|
||
n8.cfg = await post('/api/n8n/config', patch);
|
||
n8.settings = false;
|
||
loadN8n();
|
||
} catch (e) {
|
||
alert('保存できません: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function n8Send() {
|
||
const items = n8.data.blocks
|
||
.filter((b) => n8.checked.has(b.source) && n8CanSend(b))
|
||
.map((b) => ({ project: b.project, source: b.source, ...(n8.edits.get(b.source) || { task: b.taskName, memo: '' }) }));
|
||
if (!items.length) return;
|
||
n8.busy = true;
|
||
renderN8n();
|
||
try {
|
||
n8.result = await post('/api/n8n/send', { items });
|
||
} catch (e) {
|
||
n8.result = { ok: false, inserted: [], skipped: [], errors: [{ source: '', error: e.message }] };
|
||
}
|
||
n8.busy = false;
|
||
await loadN8n({ silent: true });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: スタイルを足す** (`web/style.css` の末尾)
|
||
|
||
```css
|
||
/* n8n へ送る */
|
||
.n8 { padding: 8px 12px; }
|
||
.n8-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||
.n8-bar .spacer { flex: 1; }
|
||
.n8 .go { border-color: var(--work); color: var(--work); }
|
||
.n8 .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; }
|
||
.n8 .note { color: var(--dim); font-size: 11px; }
|
||
.n8 .err-inline { color: #f7768e; font-size: 11px; }
|
||
.n8-settings, .n8-pick, .n8-result { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 8px 0; }
|
||
.n8-settings label { display: inline-flex; gap: 4px; align-items: center; margin-right: 12px; }
|
||
.n8-result.ok { border-color: var(--work); }
|
||
.n8-result.ng { border-color: #f7768e; }
|
||
.n8-pick ul { list-style: none; padding: 0; margin: 6px 0 0; max-height: 200px; overflow: auto; }
|
||
#n8n-table th { text-align: left; font-size: 11px; color: var(--dim); padding: 4px 6px; border-bottom: 1px solid var(--line); }
|
||
#n8n-table td { padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||
#n8n-table .time, #n8n-table .num { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||
#n8n-table .num { text-align: right; }
|
||
#n8n-table .n8-task { width: 22em; }
|
||
#n8n-table .n8-memo { width: 10em; }
|
||
#n8n-table tr.n8-sent td { color: var(--dim); }
|
||
.n8-mark { display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 10.5px; border: 1px solid var(--line); }
|
||
.n8-mark.sent { color: var(--work); border-color: var(--work); }
|
||
.n8-mark.warn { color: var(--warn); border-color: var(--warn); }
|
||
```
|
||
|
||
- [ ] **Step 5: 検証用サーバーで画面を確かめる**
|
||
|
||
1. `n8n.json` (利用者が Task 2 で作ったもの) を使い、`python chktime.py --port 8799 --no-browser` をバックグラウンドで起動。
|
||
2. 組み込みブラウザで `http://127.0.0.1:8799/` を開き、「n8n へ送る」タブを押す。
|
||
3. 確かめること (`read_page` / `javascript_tool` で):
|
||
- 直近 7 日の区間の表が出て、件数の表示と行数が合う
|
||
- 送信済み・進行中の行のチェックが無効、重なりのある行は最初チェックが外れている
|
||
- タスク名を変えると、「同じセッションの区間にも入れる」が on のとき同じセッションの他の行も変わる。ページを読み直しても残る
|
||
- 「接続設定」で合言葉の欄が空で、placeholder が「設定済み…」になっている (値は出ない)
|
||
- 「選ぶ」→ 件名で検索 → 結果をクリックすると、タスク名が `[プロジェクト] #番号 題名` になる
|
||
- 期間を逆にして「読み直す」と、エラーの文言が出る
|
||
- **「送る」は押さない** (実データを送るのは Task 8)
|
||
4. 8799 番のプロセスだけを止める。
|
||
|
||
- [ ] **Step 6: コミット**
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add web/n8n.js web/index.html web/app.js web/style.css && git commit -m "n8n 連携: 作業区間を選んで送る「n8n へ送る」タブを追加
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 実際の区間を 1 件送る確認と文書
|
||
|
||
**Files:**
|
||
- Modify: `README.md`、`CLAUDE.md` (自分の変更だけステージする)
|
||
- 保管庫: `C:\debug\obsidian\10_プロジェクト\chktime\ナレッジ\`、`C:\debug\obsidian\00_共通ナレッジ\運用・手順\n8n(n8n.yfox.jp)のMCP接続.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1〜7 のすべて。
|
||
|
||
- [ ] **Step 1: 送る区間を利用者に選んでもらう (ここで止まって待つ)**
|
||
|
||
8799 の画面で一覧を見せ、本当に `work_log` に入れてよい区間を 1 件選んでもらう。タスク名も利用者に決めてもらう。
|
||
|
||
- [ ] **Step 2: 送って確かめる**
|
||
|
||
1. 選ばれた 1 件だけチェックして「送る」→ 結果が `追加 1 件`。
|
||
2. もう一度「読み直す」→ その行が「送信済み」になり、チェックできない。
|
||
3. `curl -sk https://n8n.yfox.jp/webhook/home | grep -o '<タスク名の一部>'` でダッシュボードに出ること。
|
||
4. 日報: `curl -sk -X POST -H "Content-Type: application/json" -d '{"date":"<その区間の日付>","tasks":[]}' https://n8n.yfox.jp/webhook/work-report-final | grep -o '<タスク名の一部>'`
|
||
5. 同じ区間をもう一度送れないこと (チェックが無効) を画面で確かめる。
|
||
|
||
- [ ] **Step 3: README.md に使い方を足す**
|
||
|
||
「Redmine へ起票」の節の後に、次の節を足す:
|
||
|
||
```markdown
|
||
## n8n へ送る
|
||
|
||
右ペインの **n8n へ送る** タブで、Claude のセッションから作った「作業区間」を
|
||
n8n の work-tracker (`work_log`) に送れます。手で作業開始・終了を記録しなくても済むようにするための機能です。
|
||
|
||
- **作業区間**: 同じセッションの中で、応答が終わってから次の発言までの空きが 30 分未満ならつなぎ、
|
||
30 分以上で区切ります。5 分未満の区間は出しません (どちらも接続設定で変更可)。
|
||
まだ応答中・ログ更新から 30 分以内の最後の区間は「進行中」で、送れません。
|
||
- **タスク名**: 初期値は `[作業フォルダ名] セッションのタイトル`。「選ぶ」で Redmine のチケットを検索して選ぶと
|
||
`[Redmine のプロジェクト名] #番号 題名` になり、work-tracker の Redmine 工数同期とガントの実績に載ります。
|
||
決めた名前はセッションごとにブラウザが覚え、同じセッションの他の区間にも入ります (切り替え可)。
|
||
- **重なり**: すでに work_log にある記録や、一覧の他の区間と時間が重なる区間には印が付き、最初は送らない状態です。
|
||
- **二重登録の防止**: 区間ごとの `source` (`chktime:<セッションID>:<開始時刻>`) を work_log に保存し、
|
||
n8n 側で同じ `source` は追加しません。送信済みの区間は選べません。
|
||
ただし 2 つの画面から同時に送ると二重に入ることがあります (確認と追加が一度の処理ではないため)。
|
||
- **設定**: `n8n.json` (git 管理外。`--n8n-config` で場所を変更可)。
|
||
`{"url": "https://n8n.yfox.jp", "token": "<合言葉>", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}`
|
||
合言葉は n8n の Header Auth 認証情報 (`X-WT-Token`) と同じ値。画面とログには出しません。
|
||
- 送り先は n8n のワークフロー `work-tracker--import` (`/webhook/wt-import-rows` と `/webhook/wt-import`)。
|
||
```
|
||
|
||
起動オプションの表に `| --n8n-config | n8n.json | n8n 連携の設定ファイル |` を足し、冒頭の「外部への通信は…」の文を「外部への通信は Redmine へ起票と n8n へ送るを使ったときだけです」に変える。
|
||
|
||
- [ ] **Step 4: CLAUDE.md を直す (自分の変更だけステージ)**
|
||
|
||
- 「外部通信は Redmine 連携だけ」の行を次に変える:
|
||
`- **外部通信は Redmine 連携と n8n への送信だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。`
|
||
- 構成の表に `web/n8n.js 「n8n へ送る」タブ` と `n8n.json n8n 連携の設定(自動生成・git 管理外)` と `tests/ unittest (python -m unittest discover -s tests -v)` を足す。
|
||
- 「n8n 連携」の表を API の節に足す:
|
||
|
||
```markdown
|
||
n8n 連携(work_log への取り込み):
|
||
|
||
| ルート | 内容 |
|
||
| --- | --- |
|
||
| `GET/POST /api/n8n/config` | 接続設定の取得(合言葉は返さない)/ 保存 |
|
||
| `GET /api/n8n/blocks?from=&to=&project=` | 作業区間の一覧。n8n の送信済み・削除済み・重なりを付ける |
|
||
| `POST /api/n8n/send` | 選んだ区間を送る。時刻はサーバーがログから計算し直す |
|
||
| `GET /api/redmine/search?q=` | チケット選択用の検索(未完了のみ) |
|
||
```
|
||
|
||
ステージは `git add -p CLAUDE.md` で、今回足した行の hunk だけ `y` にする (利用者の「ナレッジ保管庫」の節は `n`)。対話できない環境では、`git diff CLAUDE.md > <スクラッチパッド>/claude.diff` から自分の hunk だけのパッチを作って `git apply --cached` する。
|
||
|
||
- [ ] **Step 5: テストを全部流してコミット**
|
||
|
||
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
|
||
Expected: 28 件すべて PASS。
|
||
|
||
```bash
|
||
cd /c/debug/chktime && git add README.md && git commit -m "n8n へ送る機能の使い方と通信の決まりを文書に追加
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
(`CLAUDE.md` は Step 4 でステージ済みの分だけが一緒に入る。`git show --stat HEAD` で確認)
|
||
|
||
- [ ] **Step 6: 保管庫に記録する**
|
||
|
||
- `10_プロジェクト\chktime\ナレッジ\n8nへ作業区間を送る仕組み.md` (ナレッジのテンプレート): 区間の作り方、`source` の形、n8n の受け口 2 本と合言葉 (Header Auth `X-WT-Token`)、同時送信の注意、テストの流し方。
|
||
- chktime の `_index.md` のナレッジに 1 行追加。chktime の `CLAUDE.md` の「このプロジェクトの記録」表にも 1 行 (利用者の未コミットの節なので、追記は利用者に伝えるだけにして、ファイルの編集はしない)。
|
||
- `00_共通ナレッジ\運用・手順\n8n(n8n.yfox.jp)のMCP接続.md` の注意点に: データテーブルの列追加は公開 API ではできず、n8n-mcp の `addColumn` に `N8N_MCP_ACCESS_TOKEN` が要ること (Task 1 で実際にどうなったかを書く)。
|
||
|
||
- [ ] **Step 7: 仕上げ**
|
||
|
||
`superpowers:finishing-a-development-branch` に従って、ブランチの扱い (Gitea への push と PR) を利用者と決める。
|