diff --git a/chktime.py b/chktime.py index ef626b1..45bd472 100644 --- a/chktime.py +++ b/chktime.py @@ -1220,6 +1220,7 @@ 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 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない @@ -1268,6 +1269,12 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None): 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"]), @@ -1280,6 +1287,7 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None): "minutes": int(round(minutes)), "turns": group["turns"], "inProgress": in_progress, + "problem": problem, } ) return out @@ -1545,6 +1553,9 @@ def n8n_send(store, holder, payload, now=None): 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) @@ -1570,6 +1581,9 @@ def n8n_send(store, holder, payload, now=None): 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} diff --git a/tests/test_n8n.py b/tests/test_n8n.py index 822f76f..d590c0a 100644 --- a/tests/test_n8n.py +++ b/tests/test_n8n.py @@ -525,6 +525,50 @@ class FinalReviewFixTest(unittest.TestCase): 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)。""" diff --git a/web/n8n.js b/web/n8n.js index 77fa95a..55153d6 100644 --- a/web/n8n.js +++ b/web/n8n.js @@ -38,7 +38,7 @@ function n8SaveName(sessionId, value) { } } -const n8CanSend = (b) => !b.sent && !b.inProgress; +const n8CanSend = (b) => !b.sent && !b.inProgress && !b.problem; const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length; function n8Query() { @@ -78,6 +78,7 @@ function n8Status(b) { if (b.changedSinceSent) marks.push('送信後に変化'); if (b.deleted) marks.push('削除済み'); if (b.inProgress) marks.push('進行中'); + if (b.problem) marks.push(`${esc(b.problem)}`); if ((b.overlaps || []).length) { const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n'); marks.push(`重なり ${b.overlaps.length}`); @@ -112,7 +113,7 @@ function n8ResultHtml() { function n8PickHtml() { if (!n8.pick) return ''; const rows = n8.issues - .map((i) => `
  • [${esc(i.project)}] ${esc(i.subject)}
  • `) + .map((i) => `
  • [${esc(i.project)}] ${esc(i.subject)}
  • `) .join(''); return `

    Redmine のチケットを選ぶ

    @@ -149,7 +150,7 @@ function renderN8n() { - +
    ${d.n8nError ? `

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

    ` : ''} ${n8.settings ? n8Settings() : ''} @@ -160,18 +161,36 @@ function renderN8n() { ${rows || 'この期間の区間はありません'} `; 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() { @@ -203,7 +222,7 @@ function n8Wire() { 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(); + renderN8n(); // 選択の枠を閉じるので作り直す }; }); } @@ -212,11 +231,10 @@ function n8Wire() { tr.querySelector('.n8-on').onchange = (e) => { if (e.target.checked) n8.checked.add(source); else n8.checked.delete(source); - renderN8n(); + n8SyncSend(); }; 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 });