Merge pull request 'n8n へ送る: 後回しにした小さな問題 4 件を修正' (#3) from fix/n8n-minor into main

This commit was merged in pull request #3.
This commit is contained in:
2026-10-12 23:43:49 +00:00
3 changed files with 83 additions and 7 deletions
+14
View File
@@ -1220,6 +1220,7 @@ N8N_CONFIG = APP_DIR / "n8n.json"
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限 N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
N8N_MAX_BLOCK_SEC = 24 * 3600 # n8n の wt-import が受け付ける区間の長さの上限
N8N_STALE_SEC = 24 * 3600 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない 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 minutes = (group["end"] - group["start"]) / 60.0
if minutes < min_min and not in_progress: if minutes < min_min and not in_progress:
continue continue
# n8n の受け口が断る長さは、ここで送れない理由として付けておく
problem = None
if group["end"] <= group["start"]:
problem = "長さが 0 分です"
elif group["end"] - group["start"] > N8N_MAX_BLOCK_SEC:
problem = "24 時間を超えています"
out.append( out.append(
{ {
"source": block_source(session["id"], group["start"]), "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)), "minutes": int(round(minutes)),
"turns": group["turns"], "turns": group["turns"],
"inProgress": in_progress, "inProgress": in_progress,
"problem": problem,
} }
) )
return out return out
@@ -1545,6 +1553,9 @@ def n8n_send(store, holder, payload, now=None):
gap, minimum = holder.settings() gap, minimum = holder.settings()
rows, errors, cache = [], [], {} rows, errors, cache = [], [], {}
for item in items: for item in items:
if not isinstance(item, dict):
errors.append({"source": "", "error": "項目の形が不正です"})
continue
source = str(item.get("source") or "") source = str(item.get("source") or "")
task = " ".join(str(item.get("task") or "").split()) task = " ".join(str(item.get("task") or "").split())
match = SOURCE_RE.match(source) match = SOURCE_RE.match(source)
@@ -1570,6 +1581,9 @@ def n8n_send(store, holder, payload, now=None):
if block["inProgress"]: if block["inProgress"]:
errors.append({"source": source, "error": "進行中の区間は送れません"}) errors.append({"source": source, "error": "進行中の区間は送れません"})
continue continue
if block["problem"]:
errors.append({"source": source, "error": block["problem"]})
continue
rows.append({"source": source, "date": block["date"], "start_time": block["start"], rows.append({"source": source, "date": block["date"], "start_time": block["start"],
"end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")}) "end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")})
result = {"ok": False, "inserted": [], "skipped": [], "errors": errors} result = {"ok": False, "inserted": [], "skipped": [], "errors": errors}
+44
View File
@@ -525,6 +525,50 @@ class FinalReviewFixTest(unittest.TestCase):
self.assertTrue(holder.update({"url": "https://other.example", "token": "secret2"})["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): class PostGuardTest(unittest.TestCase):
"""Important 4: 他のサイトから POST で設定を書き換えられないこと (Content-Type と Origin)。""" """Important 4: 他のサイトから POST で設定を書き換えられないこと (Content-Type と Origin)。"""
+25 -7
View File
@@ -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; const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length;
function n8Query() { function n8Query() {
@@ -78,6 +78,7 @@ function n8Status(b) {
if (b.changedSinceSent) marks.push('<span class="n8-mark warn" title="送った時点では区間が終わっていなかった。work_log の終了時刻は work-tracker の編集画面で直す">送信後に変化</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.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.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) { 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'); 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>`); marks.push(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
@@ -112,7 +113,7 @@ function n8ResultHtml() {
function n8PickHtml() { function n8PickHtml() {
if (!n8.pick) return ''; if (!n8.pick) return '';
const rows = n8.issues 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>`) .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(''); .join('');
return `<div class="n8-pick"> return `<div class="n8-pick">
<h4>Redmine のチケットを選ぶ</h4> <h4>Redmine のチケットを選ぶ</h4>
@@ -149,7 +150,7 @@ function renderN8n() {
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label> <label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
<span class="spacer"></span> <span class="spacer"></span>
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button> <button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
<button id="n8-send" class="go" ${d.n8nError || n8.busy || !on.length ? 'disabled' : ''}>選んだ ${on.length} 件を送る</button> <button id="n8-send" class="go"></button>
</div> </div>
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''} ${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
${n8.settings ? n8Settings() : ''} ${n8.settings ? n8Settings() : ''}
@@ -160,18 +161,36 @@ function renderN8n() {
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table> <tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
</div>`; </div>`;
n8Wire(); 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) { function n8Block(source) {
return n8.data.blocks.find((b) => b.source === source); return n8.data.blocks.find((b) => b.source === source);
} }
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */ /** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。
* 画面は作り直さず、変わった行の入力欄だけ書き換える (作り直すと直後のクリックが失われるため)。 */
function n8SetEdit(source, patch) { function n8SetEdit(source, patch) {
const b = n8Block(source); const b = n8Block(source);
const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b]; 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 }); for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch });
n8SaveName(b.sessionId, n8.edits.get(source)); 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() { function n8Wire() {
@@ -203,7 +222,7 @@ function n8Wire() {
const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id); const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id);
n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` }); n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` });
n8.pick = null; n8.pick = null;
renderN8n(); renderN8n(); // 選択の枠を閉じるので作り直す
}; };
}); });
} }
@@ -212,11 +231,10 @@ function n8Wire() {
tr.querySelector('.n8-on').onchange = (e) => { tr.querySelector('.n8-on').onchange = (e) => {
if (e.target.checked) n8.checked.add(source); if (e.target.checked) n8.checked.add(source);
else n8.checked.delete(source); else n8.checked.delete(source);
renderN8n(); n8SyncSend();
}; };
tr.querySelector('.n8-task').onchange = (e) => { tr.querySelector('.n8-task').onchange = (e) => {
n8SetEdit(source, { task: e.target.value }); n8SetEdit(source, { task: e.target.value });
renderN8n();
}; };
tr.querySelector('.n8-memo').onchange = (e) => { tr.querySelector('.n8-memo').onchange = (e) => {
n8SetEdit(source, { memo: e.target.value }); n8SetEdit(source, { memo: e.target.value });