Files
chktime/web/n8n.js
T
yoshikazuandClaude Opus 5.5 bcdd469d7a n8n 連携: 後回しにした小さな問題 4 件を修正
- タスク名・チェックの変更で画面全体を作り直さない (直後のクリックが失われていた)
- チケット選択の番号もエスケープする
- 長さ 0 分・24 時間超の区間は送れない理由を付け、画面でも送信でも外す
- items に dict でない項目があっても 500 にせず、その項目のエラーにする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:20:21 +09:00

298 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 「n8n へ送る」タブ: Claude のセッションから作った作業区間を選び、n8n の work_log に送る。
// app.js の後に読み込む ($ / state / api / post / esc / fmtTime / shortPath を使う)。
const N8N_NAMES_KEY = 'chktime.n8n.names'; // セッション ID -> {task, memo} (ブラウザに覚える)
const n8 = {
cfg: null, // /api/n8n/config (合言葉は含まれない)
from: '',
to: '',
data: null, // /api/n8n/blocks
checked: new Set(), // 送る区間の source
edits: new Map(), // source -> {task, memo}
sameSession: true, // 同じセッションの他の区間にも入れる
settings: false,
busy: false,
result: null, // 直前の送信結果
pick: null, // チケットを選んでいる区間の source
issues: [],
pickMsg: '',
};
function n8LoadNames() {
try {
return JSON.parse(localStorage.getItem(N8N_NAMES_KEY) || '{}') || {};
} catch (e) {
return {};
}
}
function n8SaveName(sessionId, value) {
try {
const all = n8LoadNames();
all[sessionId] = value;
localStorage.setItem(N8N_NAMES_KEY, JSON.stringify(all));
} catch (e) {
/* 保存できなくても動く */
}
}
const n8CanSend = (b) => !b.sent && !b.inProgress && !b.problem;
const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length;
function n8Query() {
const q = new URLSearchParams();
if (state.project) q.set('project', state.project);
if (n8.from) q.set('from', n8.from);
if (n8.to) q.set('to', n8.to);
return q.toString();
}
async function loadN8n({ silent = false } = {}) {
$('#detail-title').textContent = 'n8n へ送る';
if (!silent) $('#detail-body').innerHTML = '<p class="loading">区間を計算中…</p>';
try {
n8.cfg = await api('/api/n8n/config');
if (!n8.cfg.url || !n8.cfg.hasToken) n8.settings = true;
n8.data = await api('/api/n8n/blocks?' + n8Query());
n8.from = n8.data.from;
n8.to = n8.data.to;
const names = n8LoadNames();
n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source));
for (const b of n8.data.blocks) {
if (n8.edits.has(b.source)) continue;
// 続きのセッションに写された区間は、写し元のセッションで覚えた名前も使う
const saved = [b.sessionId, ...(b.aliasSessions || [])].map((id) => names[id]).find(Boolean) || {};
n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' });
}
renderN8n();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
function n8Status(b) {
const marks = [];
if (b.sent) marks.push('<span class="n8-mark sent">送信済み</span>');
if (b.changedSinceSent) marks.push('<span class="n8-mark warn" title="送った時点では区間が終わっていなかった。work_log の終了時刻は work-tracker の編集画面で直す">送信後に変化</span>');
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
if (b.problem) marks.push(`<span class="n8-mark warn" title="n8n が受け付けないので送れません">${esc(b.problem)}</span>`);
if ((b.overlaps || []).length) {
const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n');
marks.push(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
}
return marks.join(' ');
}
function n8Settings() {
const c = n8.cfg || {};
return `<div class="n8-settings">
<h4>接続設定 <span class="note">${esc(c.configPath || '')}</span></h4>
<label>URL <input id="n8-url" value="${esc(c.url || '')}" placeholder="https://n8n.yfox.jp"></label>
<label>合言葉 <input id="n8-token" type="password" placeholder="${c.hasToken ? '設定済み (変えるときだけ入力)' : '未設定'}"></label>
<label class="chk"><input type="checkbox" id="n8-verify" ${c.verifySsl ? 'checked' : ''}> 証明書を検証する</label>
<label>区切る空き (分) <input id="n8-gap" type="number" min="1" max="240" value="${c.gapMinutes ?? 30}"></label>
<label>最短 (分) <input id="n8-min" type="number" min="0" max="240" value="${c.minMinutes ?? 5}"></label>
<button id="n8-save">保存</button>
</div>`;
}
function n8ResultHtml() {
const r = n8.result;
if (!r) return '';
const errs = (r.errors || []).map((e) => `<li>${esc(e.source)}: ${esc(e.error)}</li>`).join('');
const skips = (r.skipped || []).map((s) => `<li>${esc(s.source)}: ${esc(s.reason)}</li>`).join('');
return `<div class="n8-result ${r.ok ? 'ok' : 'ng'}">
追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件
${skips ? `<ul>${skips}</ul>` : ''}${errs ? `<ul class="err-inline">${errs}</ul>` : ''}
</div>`;
}
function n8PickHtml() {
if (!n8.pick) return '';
const rows = n8.issues
.map((i) => `<li><button class="n8-issue" data-id="${esc(i.id)}">#${esc(i.id)}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
.join('');
return `<div class="n8-pick">
<h4>Redmine のチケットを選ぶ</h4>
<input id="n8-q" placeholder="件名の一部、または番号"> <button id="n8-find">検索</button> <button id="n8-pick-close">閉じる</button>
<span class="note">${esc(n8.pickMsg)}</span>
<ul>${rows}</ul>
</div>`;
}
function renderN8n() {
const d = n8.data;
if (!d) return;
const blocks = d.blocks;
const on = blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b));
const rows = blocks
.map((b) => {
const e = n8.edits.get(b.source) || { task: b.taskName, memo: '' };
return `<tr data-src="${esc(b.source)}" class="${b.sent ? 'n8-sent' : ''}">
<td><input type="checkbox" class="n8-on" ${n8.checked.has(b.source) && n8CanSend(b) ? 'checked' : ''} ${n8CanSend(b) ? '' : 'disabled'}></td>
<td class="time">${esc(b.date)}</td>
<td class="time">${fmtTime(b.start)}–${fmtTime(b.end)}</td>
<td class="num">${b.minutes} 分</td>
<td class="n8-pj" title="${esc(b.projectPath)}">${esc(shortPath(b.projectPath))}</td>
<td><input class="n8-task" value="${esc(e.task)}"> <input class="n8-memo" value="${esc(e.memo)}" placeholder="メモ"></td>
<td><button class="n8-choose">選ぶ</button></td>
<td>${n8Status(b)}</td>
</tr>`;
})
.join('');
$('#detail-body').innerHTML = `<div class="n8">
<div class="n8-bar">
<label>期間 <input type="date" id="n8-from" value="${esc(n8.from)}"> – <input type="date" id="n8-to" value="${esc(n8.to)}"></label>
<button id="n8-reload">読み直す</button>
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
<span class="spacer"></span>
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
<button id="n8-send" class="go"></button>
</div>
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
${n8.settings ? n8Settings() : ''}
${n8ResultHtml()}
${n8PickHtml()}
<p class="note">区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件</p>
<table id="n8n-table"><thead><tr><th>送る</th><th>日付</th><th>時刻</th><th>時間</th><th>プロジェクト</th><th>タスク名 / メモ</th><th>チケット</th><th>状態</th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
</div>`;
n8Wire();
n8SyncSend();
}
/** 送信ボタンの件数と押せるかどうかだけを更新する (ボタン自体は作り直さない)。 */
function n8SyncSend() {
const btn = $('#n8-send');
if (!btn || !n8.data) return;
const on = n8.data.blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)).length;
btn.textContent = `選んだ ${on} 件を送る`;
btn.disabled = Boolean(n8.data.n8nError) || n8.busy || !on;
}
function n8Block(source) {
return n8.data.blocks.find((b) => b.source === source);
}
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。
* 画面は作り直さず、変わった行の入力欄だけ書き換える (作り直すと直後のクリックが失われるため)。 */
function n8SetEdit(source, patch) {
const b = n8Block(source);
const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b];
for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch });
n8SaveName(b.sessionId, n8.edits.get(source));
const changed = new Set(targets.map((t) => t.source));
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
if (!changed.has(tr.dataset.src)) return;
const e = n8.edits.get(tr.dataset.src);
if ('task' in patch) tr.querySelector('.n8-task').value = e.task;
if ('memo' in patch) tr.querySelector('.n8-memo').value = e.memo;
});
}
function n8Wire() {
$('#n8-reload').onclick = () => {
n8.from = $('#n8-from').value;
n8.to = $('#n8-to').value;
loadN8n();
};
$('#n8-same').onchange = (e) => {
n8.sameSession = e.target.checked;
};
$('#n8-toggle').onclick = () => {
n8.settings = !n8.settings;
renderN8n();
};
$('#n8-send').onclick = n8Send;
if (n8.settings) $('#n8-save').onclick = n8SaveSettings;
if (n8.pick) {
$('#n8-find').onclick = n8Find;
$('#n8-q').onkeydown = (e) => {
if (e.key === 'Enter') n8Find();
};
$('#n8-pick-close').onclick = () => {
n8.pick = null;
renderN8n();
};
document.querySelectorAll('.n8-issue').forEach((btn) => {
btn.onclick = () => {
const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id);
n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` });
n8.pick = null;
renderN8n(); // 選択の枠を閉じるので作り直す
};
});
}
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
const source = tr.dataset.src;
tr.querySelector('.n8-on').onchange = (e) => {
if (e.target.checked) n8.checked.add(source);
else n8.checked.delete(source);
n8SyncSend();
};
tr.querySelector('.n8-task').onchange = (e) => {
n8SetEdit(source, { task: e.target.value });
};
tr.querySelector('.n8-memo').onchange = (e) => {
n8SetEdit(source, { memo: e.target.value });
};
tr.querySelector('.n8-choose').onclick = () => {
n8.pick = source;
n8.issues = [];
n8.pickMsg = '';
renderN8n();
};
});
}
async function n8Find() {
n8.pickMsg = '検索中…';
const q = $('#n8-q').value;
renderN8n();
try {
n8.issues = (await api('/api/redmine/search?q=' + encodeURIComponent(q))).issues;
n8.pickMsg = n8.issues.length ? '' : '見つかりません';
} catch (e) {
n8.pickMsg = e.message;
}
renderN8n();
$('#n8-q').value = q;
}
async function n8SaveSettings() {
const patch = {
url: $('#n8-url').value,
verifySsl: $('#n8-verify').checked,
gapMinutes: Number($('#n8-gap').value),
minMinutes: Number($('#n8-min').value),
};
const token = $('#n8-token').value;
if (token) patch.token = token;
try {
n8.cfg = await post('/api/n8n/config', patch);
n8.settings = false;
loadN8n();
} catch (e) {
alert('保存できません: ' + e.message);
}
}
async function n8Send() {
const items = n8.data.blocks
.filter((b) => n8.checked.has(b.source) && n8CanSend(b))
.map((b) => ({ project: b.project, source: b.source, ...(n8.edits.get(b.source) || { task: b.taskName, memo: '' }) }));
if (!items.length) return;
n8.busy = true;
renderN8n();
try {
n8.result = await post('/api/n8n/send', { items });
} catch (e) {
n8.result = { ok: false, inserted: [], skipped: [], errors: [{ source: '', error: e.message }] };
}
n8.busy = false;
await loadN8n({ silent: true });
}