您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Upload a CSV and auto-fill IMOS account distribution lines (description + amount). Adds rows in <=20 chunks.
// ==UserScript== // @name Auto MPESA – IMOS One-Time Payment Helper // @namespace benf.auto.mpesa // @version 1.0.1 // @description Upload a CSV and auto-fill IMOS account distribution lines (description + amount). Adds rows in <=20 chunks. // @match https://imos.churchofjesuschrist.org/payments/* // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; /***************** * Small helpers * *****************/ const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); function notify(msg, type = 'info', t = 3500) { let box = document.getElementById('auto-mpesa-toast'); if (!box) { box = document.createElement('div'); box.id = 'auto-mpesa-toast'; Object.assign(box.style, { position: 'fixed', right: '16px', bottom: '16px', padding: '10px 14px', background: '#111', color: '#fff', borderRadius: '10px', zIndex: 999999, fontSize: '13px', boxShadow: '0 6px 20px rgba(0,0,0,0.25)', maxWidth: '60vw' }); document.body.appendChild(box); } box.style.background = (type === 'error') ? '#b00020' : (type === 'success' ? '#196e3d' : '#111'); box.textContent = msg; clearTimeout(box._t); box._t = setTimeout(() => { box.remove(); }, t); } // Set input.value the "Angular-safe" way and emit events function setInputValue(input, value) { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' })); input.dispatchEvent(new Event('blur', { bubbles: true })); } // Normalize header keys (remove accents/spaces/punct, lowercase) function normKey(s) { return String(s || '') .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9]/g, '') .toLowerCase(); } // Detect CSV columns by flexible names function detectColumns(headers) { const norms = headers.map(normKey); const wantAmount = ['mznvalor', 'amount', 'mzn', 'valor', 'amountmzn', "Valor MZN (cost)", 'montante', 'valoramount'].map(normKey); const wantDesc = ['lineitem', 'Line Item', 'memo', 'descricao', 'item', 'details'].map(normKey); const findIdx = (wantList) => { for (const w of wantList) { const idx = norms.indexOf(w); if (idx !== -1) return idx; } return -1; }; return { amountIdx: findIdx(wantAmount), descIdx: findIdx(wantDesc) }; } // Safe MZN parser: handles "1.234,56", "1,234.56", "1234,56", "1234.56", "1 234,56", etc. function toTwoDecimalAmount(raw) { if (raw == null) return ''; let s = String(raw).trim(); // Remove currency symbols and spaces s = s.replace(/[^\d,.\-]/g, ''); if (s.includes(',') && s.includes('.')) { // Decide decimal by last occurrence if (s.lastIndexOf(',') > s.lastIndexOf('.')) { // comma decimal s = s.replace(/\./g, '').replace(',', '.'); } else { // dot decimal s = s.replace(/,/g, ''); } } else if (s.includes(',') && !s.includes('.')) { // comma decimal s = s.replace(',', '.'); } // else already dot decimal or integer const num = parseFloat(s); if (Number.isNaN(num)) return ''; return num.toFixed(2); } function sumTaxaColumn(headers, data) { const idx = headers.findIndex(h => normKey(h) === 'taxa'); if (idx === -1) return null; // no TAXA column let total = 0; for (const row of data) { const raw = (row[idx] || '').toString().trim().replace(',', '.'); const num = parseFloat(raw); if (!isNaN(num)) total += num; } return total; } // Lightweight CSV parser (handles quotes, commas, newlines) function parseCSV(text) { // Remove BOM if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); const rows = []; let i = 0, cur = '', cell = '', inQuotes = false; function pushCell() { cur += cell; cell = ''; } function pushField(r) { r.push(cur); cur = ''; } function pushRow(r) { rows.push(r); } let r = []; while (i < text.length) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i += 2; continue; } // escaped quote else { inQuotes = false; i++; continue; } } else { cell += ch; i++; continue; } } else { if (ch === '"') { inQuotes = true; i++; continue; } if (ch === ',') { pushCell(); pushField(r); i++; continue; } if (ch === '\r') { i++; continue; } if (ch === '\n') { pushCell(); pushField(r); pushRow(r); r = []; i++; continue; } cell += ch; i++; } } // flush last cell/row pushCell(); pushField(r); if (r.length > 1 || (r.length === 1 && r[0] !== '')) pushRow(r); if (!rows.length) return { headers: [], data: [] }; const headers = rows[0]; const data = rows.slice(1).filter(row => row.some(v => String(v).trim() !== '')); return { headers, data }; } function getTableRoot() { // Withholding vs non-withholding IDs (site misspells "withholding" as "witholding") return document.querySelector('#one-time-payment-account-distribution-no-witholding, #one-time-payment-account-distribution-witholding'); } function getLineBodies() { const table = getTableRoot(); if (!table) return []; return Array.from(table.querySelectorAll('tbody[ng-repeat="line in accountDistribution.service.payment.distributionList"]')); } async function waitForElm(selector, timeoutMs = 20000) { const existing = document.querySelector(selector); if (existing) return existing; return new Promise((resolve, reject) => { const obs = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { obs.disconnect(); resolve(el); } }); obs.observe(document.documentElement || document.body, { childList: true, subtree: true }); setTimeout(() => { obs.disconnect(); reject(new Error('Timeout waiting for ' + selector)); }, timeoutMs); }); } async function addRowsUntil(targetCount) { const inputSel = 'span.add-number-lines-input-wrapper input[ng-model="accountDistribution.service.payment.numLinesToAdd"]'; const addBtnSel = 'imos-button[type="action add"] button'; const input = await waitForElm(inputSel); const addBtn = await waitForElm(addBtnSel); let current = getLineBodies().length; if (current >= targetCount) { console.log('[Auto MPESA] Already have', current, 'rows; target', targetCount); return; } let remaining = targetCount - current; console.log('[Auto MPESA] Need to add', remaining, 'rows'); while (remaining > 0) { const chunk = Math.min(20, remaining); setInputValue(input, String(chunk)); addBtn.click(); console.log('[Auto MPESA] Clicked Add for', chunk); // Wait until rows increase const targetAfter = current + chunk; // Poll up to 10 seconds for rows to appear const t0 = Date.now(); while (getLineBodies().length < targetAfter && (Date.now() - t0) < 10000) { await sleep(200); } current = getLineBodies().length; remaining = targetCount - current; console.log('[Auto MPESA] Row count now', current, '; remaining', remaining); await sleep(150); // slight breather } } function ensureButton() { if (document.getElementById('auto-mpesa-btn')) return; const btn = document.createElement('button'); btn.id = 'auto-mpesa-btn'; btn.textContent = 'Auto MPESA'; Object.assign(btn.style, { position: 'fixed', left: '16px', bottom: '16px', zIndex: 999999, background: '#0a84ff', color: '#fff', border: 'none', borderRadius: '12px', padding: '10px 14px', fontSize: '14px', boxShadow: '0 6px 20px rgba(0,0,0,0.25)', cursor: 'pointer' }); btn.addEventListener('mouseenter', () => btn.style.filter = 'brightness(1.05)'); btn.addEventListener('mouseleave', () => btn.style.filter = ''); btn.addEventListener('click', openModal); document.body.appendChild(btn); } function openModal() { if (document.getElementById('auto-mpesa-modal')) { document.getElementById('auto-mpesa-modal').remove(); } const overlay = document.createElement('div'); overlay.id = 'auto-mpesa-modal'; Object.assign(overlay.style, { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999999 }); const panel = document.createElement('div'); Object.assign(panel.style, { position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', background: '#fff', padding: '20px', borderRadius: '14px', width: 'min(520px, 92vw)', boxShadow: '0 10px 40px rgba(0,0,0,.25)', fontFamily: 'system-ui, Roboto, Segoe UI, Arial' }); const h = document.createElement('div'); h.textContent = 'Auto MPESA — Upload CSV'; Object.assign(h.style, { fontSize: '18px', fontWeight: 700, marginBottom: '10px' }); const p = document.createElement('div'); p.innerHTML = 'Select a CSV with headers including <code>MZN Valor</code> and <code>Line Item</code>.'; Object.assign(p.style, { fontSize: '13px', marginBottom: '14px' }); const input = document.createElement('input'); input.type = 'file'; input.accept = '.csv,text/csv'; input.style.marginBottom = '12px'; const hint = document.createElement('div'); hint.textContent = 'We will add N + 1 rows and fill the first N rows.'; Object.assign(hint.style, { fontSize: '12px', color: '#444', marginBottom: '12px' }); const row = document.createElement('div'); Object.assign(row.style, { display: 'flex', gap: '10px', justifyContent: 'flex-end' }); const cancel = document.createElement('button'); cancel.textContent = 'Cancel'; Object.assign(cancel.style, commonBtn('#e5e7eb', '#111')); cancel.addEventListener('click', () => overlay.remove()); const go = document.createElement('button'); go.textContent = 'Process CSV'; Object.assign(go.style, commonBtn('#0a84ff', '#fff')); go.addEventListener('click', async () => { if (!input.files || !input.files[0]) { notify('Please choose a CSV file.', 'error'); return; } overlay.remove(); const file = input.files[0]; const text = await file.text(); await processCSVText(text); }); row.append(cancel, go); panel.append(h, p, input, hint, row); overlay.append(panel); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } function commonBtn(bg, fg) { return { background: bg, color: fg, border: 'none', borderRadius: '10px', padding: '8px 12px', cursor: 'pointer', fontSize: '14px' }; } /******************** * Core CSV -> Form * ********************/ async function processCSVText(text) { try { const { headers, data } = parseCSV(text); if (!headers.length) { notify('CSV appears empty or unparseable.', 'error'); return; } function showColumnSelector(headers) { return new Promise((resolve) => { const overlay = document.createElement('div'); Object.assign(overlay.style, { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999999 }); const panel = document.createElement('div'); Object.assign(panel.style, { position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', background: '#fff', padding: '20px', borderRadius: '14px', width: 'min(420px, 92vw)', boxShadow: '0 10px 40px rgba(0,0,0,.25)', fontFamily: 'system-ui, Roboto, Segoe UI, Arial' }); const title = document.createElement('div'); title.textContent = 'Select CSV Columns'; Object.assign(title.style, { fontSize: '18px', fontWeight: 700, marginBottom: '14px' }); function makeSelect(labelText) { const label = document.createElement('label'); label.textContent = labelText; Object.assign(label.style, { display: 'block', marginBottom: '6px', fontSize: '13px' }); const select = document.createElement('select'); headers.forEach(h => { const opt = document.createElement('option'); opt.value = h; opt.textContent = h; select.appendChild(opt); }); Object.assign(select.style, { width: '100%', marginBottom: '12px', padding: '4px' }); label.appendChild(select); panel.appendChild(label); return select; } const lineItemSel = makeSelect('Line Item column:'); const costSel = makeSelect('Cost column:'); const feeSel = makeSelect('Fee column (for TAX total):'); const btn = document.createElement('button'); btn.textContent = 'Continue'; Object.assign(btn.style, { background: '#0a84ff', color: '#fff', border: 'none', borderRadius: '8px', padding: '8px 12px', cursor: 'pointer' }); btn.addEventListener('click', () => { const cols = { lineItem: lineItemSel.value, cost: costSel.value, fee: feeSel.value }; overlay.remove(); resolve(cols); }); panel.appendChild(btn); overlay.appendChild(panel); document.body.appendChild(overlay); }); } const selectedCols = await showColumnSelector(headers); const lineIdx = headers.indexOf(selectedCols.lineItem); const costIdx = headers.indexOf(selectedCols.cost); const feeIdx = headers.indexOf(selectedCols.fee); // Build normalized records const records = data.map(row => ({ description: (row[lineIdx] || '').toString().trim(), amount: toTwoDecimalAmount(row[costIdx]) })); let taxaTotal = 0; for (const row of data) { const raw = (row[feeIdx] || '').toString().trim(); const num = parseFloat(raw.replace(',', '.')); if (!isNaN(num)) taxaTotal += num; } if (!records.length) { notify('No non-empty rows found after parsing.', 'error'); return; } const targetLines = records.length + 1; // keep last one empty notify(`Preparing ${targetLines} total lines…`); await addRowsUntil(targetLines); // Required: wait 2 seconds before filling await sleep(2000); const bodies = getLineBodies(); if (bodies.length < targetLines) { notify(`Only ${bodies.length} lines available; expected ${targetLines}.`, 'error'); return; } // Fill the first N rows let filled = 0; for (let i = 0; i < records.length; i++) { const rowBody = bodies[i]; const descInput = rowBody.querySelector('input[ng-model="line.distributionDescription"]'); const amtInput = rowBody.querySelector('input[ng-model="line.amountDisplayedToUser"]'); if (!descInput || !amtInput) { console.warn('[Auto MPESA] Could not find inputs in row', i + 1, rowBody); continue; } // Truncate description to maxlength if needed let desc = records[i].description || ''; const maxLen = parseInt(descInput.getAttribute('maxlength') || '30', 10); if (desc.length > maxLen) { console.warn(`[Auto MPESA] Row ${i + 1} description truncated to ${maxLen} chars.`); desc = desc.slice(0, maxLen); } const amt = records[i].amount || ''; if (amt === '') { console.warn(`[Auto MPESA] Row ${i + 1} has empty/invalid amount; leaving blank.`); } setInputValue(descInput, desc); await sleep(30); setInputValue(amtInput, amt); await sleep(30); filled++; } notify(`Filled ${filled}/${records.length} rows. Last row left blank.`, 'success', 5000); console.log('[Auto MPESA] Done. Filled:', filled, 'of', records.length); if (taxaTotal !== null) { const lastRow = bodies[records.length]; // the +1 row const descInput = lastRow.querySelector('input[ng-model="line.distributionDescription"]'); const amtInput = lastRow.querySelector('input[ng-model="line.amountDisplayedToUser"]'); if (descInput && amtInput) { setInputValue(descInput, 'TAX'); await sleep(30); setInputValue(amtInput, taxaTotal.toFixed(2)); await sleep(30); notify(`Added TAX row with total ${taxaTotal.toFixed(2)}.`, 'success'); } } } catch (err) { console.error('[Auto MPESA] Error:', err); notify('Auto MPESA encountered an error. See console for details.', 'error', 6000); } } /******************** * Bootstrapping * ********************/ function init() { if (document.documentElement.hasAttribute('data-auto-mpesa-init')) return; document.documentElement.setAttribute('data-auto-mpesa-init', '1'); // Keep watching for the target area; button is global/fixed anyway ensureButton(); // If SPA route changes, still keep button; nothing else to do const mo = new MutationObserver(() => { ensureButton(); }); mo.observe(document.body, { childList: true, subtree: true }); console.log('[Auto MPESA] Initialized'); } // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } // Expose for manual retry if needed window.__AutoMPESA = { processCSVText, addRowsUntil }; })();