fdcc94102a
* fix: remove gnubok_categorize_with_receipt tool The tool requires Claude to generate the entire base64-encoded file as tool arguments token-by-token, which is extremely slow for large PDFs. Receipt attachment should happen via the widget (where JavaScript handles encoding) or through the web app. The receipt matcher widget and gnubok_receipt_matcher tool remain — they provide the intended UX for matching receipts to transactions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update widget to call gnubok_categorize_transaction The widget was still calling the removed gnubok_categorize_with_receipt tool. Updated to call gnubok_categorize_transaction instead (text-only, no file upload). Receipt attachment via the widget is deferred to a future iteration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
/**
|
|
* Receipt Matcher Widget — MCP Apps inline HTML
|
|
*
|
|
* Self-contained HTML document rendered in an iframe by MCP Apps hosts
|
|
* (Claude Desktop, etc.). Communicates exclusively via postMessage
|
|
* (JSON-RPC 2.0 over the MCP Apps protocol). No fetch() calls.
|
|
*/
|
|
|
|
export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
|
|
<html lang="sv">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Kvittomatchning — gnubok</title>
|
|
<style>
|
|
:root {
|
|
--bg: #fafafa;
|
|
--surface: #ffffff;
|
|
--border: rgba(0,0,0,0.1);
|
|
--text: #1a1a1a;
|
|
--text-muted: #6b6b6b;
|
|
--success: #5a7a5a;
|
|
--success-bg: rgba(90,122,90,0.08);
|
|
--error: #b35a3a;
|
|
--error-bg: rgba(179,90,58,0.08);
|
|
--accent: #3b3b3b;
|
|
--drop-bg: rgba(0,0,0,0.03);
|
|
--drop-active: rgba(90,122,90,0.12);
|
|
}
|
|
.dark {
|
|
--bg: #161616;
|
|
--surface: #1e1e1e;
|
|
--border: rgba(255,255,255,0.1);
|
|
--text: #e5e5e5;
|
|
--text-muted: #999;
|
|
--success: #7aab7a;
|
|
--success-bg: rgba(122,171,122,0.1);
|
|
--error: #d4816a;
|
|
--error-bg: rgba(212,129,106,0.1);
|
|
--accent: #ccc;
|
|
--drop-bg: rgba(255,255,255,0.03);
|
|
--drop-active: rgba(122,171,122,0.12);
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
padding: 12px;
|
|
}
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
margin-bottom: 12px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.header h1 { font-size: 15px; font-weight: 600; }
|
|
.counter { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
|
.loading { text-align: center; padding: 32px; color: var(--text-muted); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th {
|
|
text-align: left; font-weight: 500; font-size: 11px;
|
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
color: var(--text-muted); padding: 6px 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
td { padding: 8px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
tr.booked { background: var(--success-bg); }
|
|
tr.error-row { background: var(--error-bg); }
|
|
.amount { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
|
.amount.negative { color: var(--error); }
|
|
.amount.positive { color: var(--success); }
|
|
select {
|
|
font-size: 12px; padding: 4px 6px; border: 1px solid var(--border);
|
|
border-radius: 4px; background: var(--surface); color: var(--text);
|
|
max-width: 140px;
|
|
}
|
|
.drop-zone {
|
|
border: 1px dashed var(--border); border-radius: 6px;
|
|
padding: 8px 12px; text-align: center; cursor: pointer;
|
|
color: var(--text-muted); font-size: 11px;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
min-width: 120px;
|
|
}
|
|
.drop-zone.active { background: var(--drop-active); border-color: var(--success); }
|
|
.drop-zone.has-file { border-style: solid; border-color: var(--success); color: var(--success); }
|
|
.drop-zone.disabled { opacity: 0.5; pointer-events: none; }
|
|
.check { color: var(--success); font-weight: 600; }
|
|
.error-msg { color: var(--error); font-size: 11px; margin-top: 2px; }
|
|
.booking { font-size: 11px; color: var(--text-muted); }
|
|
.empty { text-align: center; padding: 32px; color: var(--text-muted); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Kvittomatchning</h1>
|
|
<span class="counter" id="counter"></span>
|
|
</div>
|
|
<div id="content"><div class="loading">Laddar transaktioner\u2026</div></div>
|
|
|
|
<script>
|
|
(function() {
|
|
// ── MCP Apps Bridge ──
|
|
let rpcId = 1;
|
|
const pending = new Map();
|
|
let transactions = [];
|
|
let categories = [];
|
|
let vatTreatments = [];
|
|
let booked = 0;
|
|
|
|
function sendRequest(method, params) {
|
|
const id = rpcId++;
|
|
return new Promise((resolve, reject) => {
|
|
pending.set(id, { resolve, reject });
|
|
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
|
|
});
|
|
}
|
|
|
|
function sendNotification(method, params) {
|
|
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
|
|
}
|
|
|
|
function callTool(name, args) {
|
|
return sendRequest('tools/call', { name: name, arguments: args });
|
|
}
|
|
|
|
window.addEventListener('message', function(e) {
|
|
const msg = e.data;
|
|
if (!msg || msg.jsonrpc !== '2.0') return;
|
|
|
|
// Response to our request
|
|
if (msg.id != null && pending.has(msg.id)) {
|
|
const { resolve, reject } = pending.get(msg.id);
|
|
pending.delete(msg.id);
|
|
if (msg.error) reject(msg.error);
|
|
else resolve(msg.result);
|
|
return;
|
|
}
|
|
|
|
// Notifications from host
|
|
if (msg.method === 'ui/notifications/tool-result') {
|
|
const sc = msg.params?.structuredContent;
|
|
if (sc) {
|
|
transactions = sc.transactions || [];
|
|
categories = sc.categories || [];
|
|
vatTreatments = sc.vat_treatments || [];
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.method === 'ui/notifications/tool-input') return; // loading state already shown
|
|
if (msg.method === 'ui/notifications/host-context-changed') {
|
|
applyTheme(msg.params);
|
|
return;
|
|
}
|
|
});
|
|
|
|
function applyTheme(ctx) {
|
|
if (!ctx) return;
|
|
if (ctx.theme === 'dark') document.documentElement.classList.add('dark');
|
|
else document.documentElement.classList.remove('dark');
|
|
}
|
|
|
|
// ── Initialize ──
|
|
sendRequest('ui/initialize', {
|
|
name: 'gnubok-receipt-matcher',
|
|
version: '1.0.0'
|
|
}).then(function(res) {
|
|
if (res && res.hostContext) applyTheme(res.hostContext);
|
|
sendNotification('ui/notifications/initialized');
|
|
}).catch(function() {
|
|
sendNotification('ui/notifications/initialized');
|
|
});
|
|
|
|
// ── Render ──
|
|
function render() {
|
|
const el = document.getElementById('content');
|
|
const counterEl = document.getElementById('counter');
|
|
counterEl.textContent = booked + ' av ' + transactions.length + ' bokf\\u00f6rda';
|
|
|
|
if (!transactions.length) {
|
|
el.innerHTML = '<div class="empty">Inga okategoriserade transaktioner.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr>' +
|
|
'<th>Datum</th><th>Beskrivning</th><th class="amount">Belopp</th>' +
|
|
'<th>Kategori</th><th>Moms</th><th>Kvitto</th>' +
|
|
'</tr></thead><tbody>';
|
|
|
|
transactions.forEach(function(tx, i) {
|
|
const isBooked = tx._booked;
|
|
const hasError = tx._error;
|
|
const cls = isBooked ? 'booked' : (hasError ? 'error-row' : '');
|
|
const amt = Number(tx.amount);
|
|
const amtClass = amt < 0 ? 'negative' : 'positive';
|
|
const formatted = amt.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' kr';
|
|
|
|
html += '<tr class="' + cls + '" data-idx="' + i + '">';
|
|
html += '<td>' + esc(tx.date || '') + '</td>';
|
|
html += '<td>' + esc(tx.description || tx.merchant_name || '') + '</td>';
|
|
html += '<td class="amount ' + amtClass + '">' + formatted + '</td>';
|
|
|
|
if (isBooked) {
|
|
html += '<td colspan="3"><span class="check">\\u2713 Bokf\\u00f6rd</span></td>';
|
|
} else if (tx._booking) {
|
|
html += '<td colspan="3"><span class="booking">Bokar\\u2026</span></td>';
|
|
} else {
|
|
html += '<td><select id="cat-' + i + '">' + categoryOptions(tx) + '</select></td>';
|
|
html += '<td><select id="vat-' + i + '">' + vatOptions() + '</select></td>';
|
|
html += '<td>' + dropZone(i) + '</td>';
|
|
}
|
|
|
|
if (hasError) {
|
|
html += '</tr><tr class="error-row"><td colspan="6"><span class="error-msg">' + esc(tx._error) + '</span></td>';
|
|
}
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
el.innerHTML = html;
|
|
|
|
// Bind drop zones
|
|
document.querySelectorAll('.drop-zone').forEach(function(dz) {
|
|
const idx = parseInt(dz.dataset.idx);
|
|
dz.addEventListener('dragover', function(e) { e.preventDefault(); dz.classList.add('active'); });
|
|
dz.addEventListener('dragleave', function() { dz.classList.remove('active'); });
|
|
dz.addEventListener('drop', function(e) {
|
|
e.preventDefault();
|
|
dz.classList.remove('active');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleFile(idx, file);
|
|
});
|
|
dz.addEventListener('click', function() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/*,application/pdf';
|
|
input.onchange = function() { if (input.files[0]) handleFile(idx, input.files[0]); };
|
|
input.click();
|
|
});
|
|
});
|
|
}
|
|
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
|
|
function categoryOptions(tx) {
|
|
const cats = categories.length ? categories : [
|
|
'income_services','income_products','income_other',
|
|
'expense_equipment','expense_software','expense_travel','expense_office',
|
|
'expense_marketing','expense_professional_services','expense_education',
|
|
'expense_representation','expense_consumables','expense_vehicle',
|
|
'expense_telecom','expense_bank_fees','expense_card_fees',
|
|
'expense_currency_exchange','expense_other','private'
|
|
];
|
|
const labels = {
|
|
income_services:'Int\\u00e4kt tj\\u00e4nster', income_products:'Int\\u00e4kt varor', income_other:'Int\\u00e4kt \\u00f6vrigt',
|
|
expense_equipment:'Utrustning', expense_software:'Programvara', expense_travel:'Resor',
|
|
expense_office:'Kontor', expense_marketing:'Marknadsf\\u00f6ring',
|
|
expense_professional_services:'Konsulter', expense_education:'Utbildning',
|
|
expense_representation:'Representation', expense_consumables:'F\\u00f6rbrukning',
|
|
expense_vehicle:'Fordon', expense_telecom:'Telekom', expense_bank_fees:'Bankavgifter',
|
|
expense_card_fees:'Kortavgifter', expense_currency_exchange:'Valutav\\u00e4xling',
|
|
expense_other:'\\u00d6vrig kostnad', private:'Privat'
|
|
};
|
|
const guess = (tx.amount < 0) ? 'expense_other' : 'income_services';
|
|
return cats.map(function(c) {
|
|
return '<option value="' + c + '"' + (c === guess ? ' selected' : '') + '>' + (labels[c] || c) + '</option>';
|
|
}).join('');
|
|
}
|
|
|
|
function vatOptions() {
|
|
const vats = vatTreatments.length ? vatTreatments : ['standard_25','reduced_12','reduced_6','reverse_charge','export','exempt'];
|
|
const labels = { standard_25:'25%', reduced_12:'12%', reduced_6:'6%', reverse_charge:'Omv\\u00e4nd', export:'Export', exempt:'Undantagen' };
|
|
return vats.map(function(v) {
|
|
return '<option value="' + v + '"' + (v === 'standard_25' ? ' selected' : '') + '>' + (labels[v] || v) + '</option>';
|
|
}).join('');
|
|
}
|
|
|
|
function dropZone(idx) {
|
|
const tx = transactions[idx];
|
|
if (tx._file) return '<div class="drop-zone has-file disabled" data-idx="' + idx + '">' + esc(tx._file) + '</div>';
|
|
return '<div class="drop-zone" data-idx="' + idx + '">Sl\\u00e4pp kvitto</div>';
|
|
}
|
|
|
|
// ── File handling ──
|
|
function handleFile(idx, file) {
|
|
if (!file.type.match(/^image\\/|application\\/pdf/)) {
|
|
transactions[idx]._error = 'Filtypen st\\u00f6ds inte. Anv\\u00e4nd bild eller PDF.';
|
|
render();
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function() {
|
|
if (file.type.startsWith('image/')) {
|
|
resizeImage(reader.result, function(dataUri) {
|
|
transactions[idx]._file = file.name;
|
|
transactions[idx]._dataUri = dataUri;
|
|
transactions[idx]._mimeType = 'image/jpeg';
|
|
render();
|
|
bookTransaction(idx);
|
|
});
|
|
} else {
|
|
// PDF — send as-is (no resize)
|
|
transactions[idx]._file = file.name;
|
|
transactions[idx]._dataUri = reader.result;
|
|
transactions[idx]._mimeType = file.type;
|
|
render();
|
|
bookTransaction(idx);
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function resizeImage(dataUri, cb) {
|
|
const img = new Image();
|
|
img.onload = function() {
|
|
const MAX = 1600;
|
|
let w = img.width, h = img.height;
|
|
if (w > MAX || h > MAX) {
|
|
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
|
|
else { w = Math.round(w * MAX / h); h = MAX; }
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w; canvas.height = h;
|
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
cb(canvas.toDataURL('image/jpeg', 0.85));
|
|
};
|
|
img.src = dataUri;
|
|
}
|
|
|
|
// ── Booking ──
|
|
function bookTransaction(idx) {
|
|
const tx = transactions[idx];
|
|
const catEl = document.getElementById('cat-' + idx);
|
|
const vatEl = document.getElementById('vat-' + idx);
|
|
if (!catEl || !vatEl) return;
|
|
|
|
tx._booking = true;
|
|
tx._error = null;
|
|
render();
|
|
|
|
callTool('gnubok_categorize_transaction', {
|
|
transaction_id: tx.id,
|
|
category: catEl.value,
|
|
vat_treatment: vatEl.value
|
|
}).then(function(res) {
|
|
tx._booking = false;
|
|
// The tool result comes back as content[0].text (JSON string)
|
|
let result;
|
|
if (res && res.structuredContent) {
|
|
result = res.structuredContent;
|
|
} else if (res && res.content && res.content[0]) {
|
|
try { result = JSON.parse(res.content[0].text); } catch(e) { result = {}; }
|
|
}
|
|
if (result && result.error) {
|
|
tx._error = result.error;
|
|
} else {
|
|
tx._booked = true;
|
|
booked++;
|
|
}
|
|
render();
|
|
sendNotification('ui/updateContext', {
|
|
content: [{ type: 'text', text: 'Bokf\\u00f6rt ' + booked + ' av ' + transactions.length + ' transaktioner.' }]
|
|
});
|
|
}).catch(function(err) {
|
|
tx._booking = false;
|
|
tx._error = (err && err.message) || 'Kunde inte boka transaktionen.';
|
|
render();
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`;
|