'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 = '

区間を計算中…

'; 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 = `

${esc(e.message)}

`; } } function n8Status(b) { const marks = []; if (b.sent) marks.push('送信済み'); 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}`); } return marks.join(' '); } function n8Settings() { const c = n8.cfg || {}; return `

接続設定 ${esc(c.configPath || '')}

`; } function n8ResultHtml() { const r = n8.result; if (!r) return ''; const errs = (r.errors || []).map((e) => `
  • ${esc(e.source)}: ${esc(e.error)}
  • `).join(''); const skips = (r.skipped || []).map((s) => `
  • ${esc(s.source)}: ${esc(s.reason)}
  • `).join(''); return `
    追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件 ${skips ? `` : ''}${errs ? `` : ''}
    `; } function n8PickHtml() { if (!n8.pick) return ''; const rows = n8.issues .map((i) => `
  • [${esc(i.project)}] ${esc(i.subject)}
  • `) .join(''); return `

    Redmine のチケットを選ぶ

    ${esc(n8.pickMsg)}
    `; } 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 ` ${esc(b.date)} ${fmtTime(b.start)}–${fmtTime(b.end)} ${b.minutes} 分 ${esc(shortPath(b.projectPath))} ${n8Status(b)} `; }) .join(''); $('#detail-body').innerHTML = `
    ${d.n8nError ? `

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

    ` : ''} ${n8.settings ? n8Settings() : ''} ${n8ResultHtml()} ${n8PickHtml()}

    区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件

    ${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() { $('#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 }); }