Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401462eed0 | ||
|
|
bcdd469d7a |
+14
@@ -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}
|
||||
|
||||
@@ -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)。"""
|
||||
|
||||
|
||||
+25
-7
@@ -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('<span class="n8-mark warn" title="送った時点では区間が終わっていなかった。work_log の終了時刻は work-tracker の編集画面で直す">送信後に変化</span>');
|
||||
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
|
||||
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
|
||||
if (b.problem) marks.push(`<span class="n8-mark warn" title="n8n が受け付けないので送れません">${esc(b.problem)}</span>`);
|
||||
if ((b.overlaps || []).length) {
|
||||
const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n');
|
||||
marks.push(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
|
||||
@@ -112,7 +113,7 @@ function n8ResultHtml() {
|
||||
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>`)
|
||||
.map((i) => `<li><button class="n8-issue" data-id="${esc(i.id)}">#${esc(i.id)}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
|
||||
.join('');
|
||||
return `<div class="n8-pick">
|
||||
<h4>Redmine のチケットを選ぶ</h4>
|
||||
@@ -149,7 +150,7 @@ function renderN8n() {
|
||||
<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>
|
||||
<button id="n8-send" class="go"></button>
|
||||
</div>
|
||||
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
|
||||
${n8.settings ? n8Settings() : ''}
|
||||
@@ -160,18 +161,36 @@ function renderN8n() {
|
||||
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
|
||||
</div>`;
|
||||
n8Wire();
|
||||
n8SyncSend();
|
||||
}
|
||||
|
||||
/** 送信ボタンの件数と押せるかどうかだけを更新する (ボタン自体は作り直さない)。 */
|
||||
function n8SyncSend() {
|
||||
const btn = $('#n8-send');
|
||||
if (!btn || !n8.data) return;
|
||||
const on = n8.data.blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)).length;
|
||||
btn.textContent = `選んだ ${on} 件を送る`;
|
||||
btn.disabled = Boolean(n8.data.n8nError) || n8.busy || !on;
|
||||
}
|
||||
|
||||
function n8Block(source) {
|
||||
return n8.data.blocks.find((b) => b.source === source);
|
||||
}
|
||||
|
||||
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */
|
||||
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。
|
||||
* 画面は作り直さず、変わった行の入力欄だけ書き換える (作り直すと直後のクリックが失われるため)。 */
|
||||
function n8SetEdit(source, patch) {
|
||||
const b = n8Block(source);
|
||||
const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b];
|
||||
for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch });
|
||||
n8SaveName(b.sessionId, n8.edits.get(source));
|
||||
const changed = new Set(targets.map((t) => t.source));
|
||||
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
|
||||
if (!changed.has(tr.dataset.src)) return;
|
||||
const e = n8.edits.get(tr.dataset.src);
|
||||
if ('task' in patch) tr.querySelector('.n8-task').value = e.task;
|
||||
if ('memo' in patch) tr.querySelector('.n8-memo').value = e.memo;
|
||||
});
|
||||
}
|
||||
|
||||
function n8Wire() {
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user