Add Puter receipt file selection and report export

This commit is contained in:
404oops committed 2026-10-13 20:39:32 +02:00
1 parent b15cfe15b4
commit 02596cd0fe
3 files changed
+1362 -93

No files matched your search

@@ -1,21 +1,80 @@
<html>
<body>
<p>
<button id="pick">Pick a receipt image from Puter</button>
<button id="reopen">Open report</button>
</p>
<p id="status">Pick a PNG, JPEG, or WebP receipt image. Nothing is sent until you pick one.</p>
<img id="preview" alt="The picked receipt" width="320" hidden>
<ul id="problems"></ul>
<p>
<label>Merchant <input id="merchant"></label>
<label>Date <input id="date" placeholder="YYYY-MM-DD"></label>
<label>Currency <input id="currency" placeholder="EUR"></label>
<label>Total <input id="total" placeholder="12.40"></label>
</p>
<p>
<button id="approve">Approve expense</button>
<button id="save">Save report CSV to Puter</button>
</p>
<p id="report"></p>
<p>Saving corrections for later, discarding reviews, and recovering earlier saves are in the recipe.</p>
<script src="https://js.puter.com/v2/"></script>
<pre id="reply">Reading the sample receipt...</pre>
<script type="module">
// Draw a small synthetic receipt, so no real receipt is sent.
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 200;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.font = '18px monospace';
['Corner Cafe', '2026-09-30', 'Coffee 3.40', 'Sandwich 9.00', 'Total EUR 12.40']
.forEach((line, i) => ctx.fillText(line, 20, 36 + i * 34));
document.body.prepend(canvas);
const receiptImage = canvas.toDataURL('image/png');
// -- Recipe code, unchanged --
const IMAGE_TYPES = new Map([['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['webp', 'image/webp']]);
// Types storage can report for any file, accepted when the extension and the first bytes match.
const GENERIC_TYPES = ['', 'application/octet-stream'];
const IMAGE_SIGNATURES = {
'image/png': /^\x89PNG\r\n\x1A\n/,
'image/jpeg': /^\xFF\xD8\xFF/,
'image/webp': /^RIFF[\s\S]{4}WEBP/,
};
// Base64 adds a third, so this keeps the data URL under the cap of checkImage().
const MAX_IMAGE_BYTES = 3_700_000;
const NOT_AN_IMAGE = 'Pick one PNG, JPEG, or WebP image.';
const IMAGE_TOO_LARGE = 'Pick an image of at most 3,700,000 bytes. Scale a larger photo down first.';
function readAsDataUrl (blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error ?? new Error('The image could not be read.'));
reader.readAsDataURL(blob);
});
}
async function pickReceiptImage () {
const request = puter.ui.showOpenFilePicker({ multiple: false, accept: 'image/*' });
// A failure only rejects the picker's own promise. A desktop cancel settles undefinedOnCancel,
// and a website popup cancel resolves the picker's own promise with a cancel message.
const file = await Promise.race([request, request.undefinedOnCancel]);
if ( file === undefined || file?.msg === 'fileOpenCanceled' ) {
return null;
}
const type = IMAGE_TYPES.get(/\.([a-z]+)$/i.exec(String(file?.name))?.[1].toLowerCase());
if ( Array.isArray(file) || file?.isDir || typeof file?.read !== 'function' || type === undefined ) {
throw new Error(NOT_AN_IMAGE);
}
if ( typeof file.size === 'number' && file.size > MAX_IMAGE_BYTES ) {
throw new Error(IMAGE_TOO_LARGE);
}
const blob = await file.read();
if ( ! (blob instanceof Blob) ) {
throw new Error('The image could not be read.');
}
// The reported size can be stale, so the bytes actually read are checked too.
if ( blob.size > MAX_IMAGE_BYTES ) {
throw new Error(IMAGE_TOO_LARGE);
}
const head = String.fromCharCode(...new Uint8Array(await blob.slice(0, 12).arrayBuffer()));
if ( (blob.type !== type && ! GENERIC_TYPES.includes(blob.type)) || ! IMAGE_SIGNATURES[type].test(head) ) {
throw new Error(NOT_AN_IMAGE);
}
// A generic Blob type would end up in the data URL, so the image type is set first.
return readAsDataUrl(new Blob([blob], { type }));
}
const FIELDS = ['merchant', 'date', 'currency', 'total'];
const PROMPT = 'Read this receipt. Return the merchant name, the purchase date as YYYY-MM-DD '
@@ -42,13 +101,540 @@
return response?.message?.content ?? null;
}
// The reply comes from the AI, so it's shown as plain text.
const output = document.getElementById('reply');
try {
output.textContent = await requestExtraction(receiptImage) ?? 'The AI returned no reply text.';
} catch ( error ) {
output.textContent = `The request failed (${ error?.message ?? error?.error?.message ?? 'unknown error' }).`;
const IMAGE_PREFIX = /^data:image\/(?:jpeg|png|webp);base64,/;
const NOT_BASE64 = /[^A-Za-z0-9+/]/;
const MAX_IMAGE_LENGTH = 5_000_000;
// Plain length and character checks, since one regex over a multi-megabyte body can overflow the regex stack.
function checkImage (image) {
const prefix = typeof image === 'string' && image.length <= MAX_IMAGE_LENGTH ? IMAGE_PREFIX.exec(image) : null;
const body = prefix ? image.slice(prefix[0].length) : '';
const padding = body.endsWith('==') ? 2 : body.endsWith('=') ? 1 : 0;
if ( body.length === 0 || body.length % 4 !== 0 || NOT_BASE64.test(body.slice(0, body.length - padding)) ) {
throw new Error('Pass one JPEG, PNG, or WebP receipt as a base64 data URL of at most 5,000,000 characters.');
}
}
const MAX_REPLY_LENGTH = 2_000;
const MAX_MERCHANT_LENGTH = 100;
const RULES = {
merchant: `text of 1 to ${ MAX_MERCHANT_LENGTH } characters on one line`,
date: 'a real date written as YYYY-MM-DD',
currency: 'a three-letter code in capitals, such as EUR',
total: 'digits with an optional dot and up to 3 decimals, such as 12.40',
};
const CHECKS = {
merchant: value => value.trim() !== '' && value.length <= MAX_MERCHANT_LENGTH && ! /[\x00-\x1F\x7F]/.test(value),
date: isCalendarDate,
currency: value => /^[A-Z]{3}$/.test(value),
total: value => /^\d{1,12}(\.\d{1,3})?$/.test(value),
};
function isCalendarDate (value) {
if ( ! /^\d{4}-\d{2}-\d{2}$/.test(value) ) {
return false;
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
function isValid (name, value) {
return typeof value === 'string' && CHECKS[name](value);
}
function isRecord (value) {
return typeof value === 'object' && value !== null && ! Array.isArray(value);
}
function emptyFields () {
return Object.fromEntries(FIELDS.map(name => [name, null]));
}
function parseExtraction (content) {
const fields = emptyFields();
const problems = [];
let reply;
if ( typeof content === 'string' && content.length <= MAX_REPLY_LENGTH ) {
try {
reply = JSON.parse(content);
} catch {
reply = undefined;
}
}
if ( ! isRecord(reply) ) {
problems.push('The AI reply was too long or not a receipt, so no field was filled in.');
return { fields, problems };
}
for ( const name of FIELDS ) {
const value = reply[name] ?? null;
if ( value === null ) {
continue;
}
if ( isValid(name, value) ) {
fields[name] = value;
} else {
problems.push(`Ignored the suggested ${ name }. It must be ${ RULES[name] }.`);
}
}
return { fields, problems };
}
const REPORT_ID = 'expenseReport';
const SNAPSHOT_PREFIX = `${ REPORT_ID }:snapshot:`;
const COUNTER_KEY = `${ REPORT_ID }:order`;
// Wide enough for Number.MAX_SAFE_INTEGER, so snapshot keys sort in save-number order.
const ORDER_WIDTH = 16;
const SNAPSHOT_NAME = /^\d{16}:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DRAFT_VERSION = 1;
const MAX_EXPENSES = 300;
const MAX_REVIEWS = 20;
const MAX_PROBLEMS = 5;
const MAX_PROBLEM_LENGTH = 300;
const RECEIPT_ID = /^[0-9a-f]{64}$/;
const DAMAGED = 'The saved report is damaged, so it was left unchanged.';
let draft = null;
let draftKey = null;
let saving = Promise.resolve();
function copy (value) {
return JSON.parse(JSON.stringify(value));
}
function isReceiptId (value) {
return typeof value === 'string' && RECEIPT_ID.test(value);
}
function toReview (receiptId, fields, problems) {
return { receiptId, fields, unresolved: FIELDS.filter(name => fields[name] === null), problems };
}
function readExpense (item) {
if ( ! isRecord(item) || ! isReceiptId(item.receiptId) || ! FIELDS.every(name => isValid(name, item[name])) ) {
return null;
}
return { receiptId: item.receiptId, ...Object.fromEntries(FIELDS.map(name => [name, item[name]])) };
}
function readReview (item) {
if ( ! isRecord(item) || ! isReceiptId(item.receiptId) || ! isRecord(item.fields)
|| ! Array.isArray(item.problems) || item.problems.length > MAX_PROBLEMS ) {
return null;
}
const fields = Object.fromEntries(FIELDS.map(name => [name, item.fields[name]]));
const fieldsOk = FIELDS.every(name => fields[name] === null || isValid(name, fields[name]));
const problemsOk = item.problems.every(problem => typeof problem === 'string' && problem.length <= MAX_PROBLEM_LENGTH);
return fieldsOk && problemsOk ? toReview(item.receiptId, fields, [...item.problems]) : null;
}
function parseDraft (value) {
if ( value === null ) {
return { version: DRAFT_VERSION, revision: 0, expenses: [], reviews: [] };
}
if ( ! isRecord(value) || value.version !== DRAFT_VERSION ) {
throw new Error(`The saved report isn't a version ${ DRAFT_VERSION } report, so it was left unchanged.`);
}
const { revision, expenses, reviews } = value;
if ( ! Number.isSafeInteger(revision) || revision < 1
|| ! Array.isArray(expenses) || expenses.length > MAX_EXPENSES
|| ! Array.isArray(reviews) || reviews.length > MAX_REVIEWS ) {
throw new Error(DAMAGED);
}
const parsed = {
version: DRAFT_VERSION,
revision,
expenses: expenses.map(readExpense),
reviews: reviews.map(readReview),
};
const items = [...parsed.expenses, ...parsed.reviews];
if ( items.includes(null) || new Set(items.map(item => item.receiptId)).size !== items.length ) {
throw new Error(DAMAGED);
}
return parsed;
}
function requireOpen () {
if ( draft === null ) {
throw new Error('Open the report with openDraft() first.');
}
}
function snapshotKey (order) {
return `${ SNAPSHOT_PREFIX }${ String(order).padStart(ORDER_WIDTH, '0') }:${ crypto.randomUUID() }`;
}
function readSnapshot (item) {
if ( ! SNAPSHOT_NAME.test(item.key.slice(SNAPSHOT_PREFIX.length)) || item.value === null ) {
throw new Error(DAMAGED);
}
return parseDraft(item.value);
}
function isSavedAs (value, expected) {
try {
return JSON.stringify(parseDraft(value)) === expected;
} catch {
return false;
}
}
async function readLatest () {
const page = await puter.kv.list({
pattern: SNAPSHOT_PREFIX,
reverse: true,
limit: 1,
fetchUntilFull: true,
returnValues: true,
});
if ( page.items.length === 0 ) {
return { key: null, report: parseDraft(null) };
}
return { key: page.items[0].key, report: readSnapshot(page.items[0]) };
}
function queue (task) {
const run = saving.then(task);
saving = run.catch(() => {});
return run;
}
function openDraft () {
return queue(async () => {
const latest = await readLatest();
draft = latest.report;
draftKey = latest.key;
return copy(draft);
});
}
function commit (change) {
return queue(async () => {
requireOpen();
const next = copy(draft);
change(next);
const stored = await readLatest();
if ( stored.key !== draftKey || JSON.stringify(stored.report) !== JSON.stringify(draft) ) {
throw new Error('The report changed in another tab or device. Open it again before making changes.');
}
if ( draft.revision >= Number.MAX_SAFE_INTEGER ) {
throw new Error('The report has been saved too many times. Export it and start a new report.');
}
next.revision = draft.revision + 1;
const checked = parseDraft(next);
const expected = JSON.stringify(checked);
// The save number is reserved before the write, so a write that lands late can't rank above a later save.
let order;
try {
order = await puter.kv.incr(COUNTER_KEY);
} catch ( error ) {
throw new Error(`Nothing was saved (${ String(error?.message ?? 'the save could not start') }). Try again.`);
}
if ( ! Number.isSafeInteger(order) || order < 1 || order >= Number.MAX_SAFE_INTEGER ) {
throw new Error('The report\'s save counter is damaged, so nothing was saved. Start a new report.');
}
const key = snapshotKey(order);
let failure = null;
try {
if ( await puter.kv.set(key, checked) !== true ) {
failure = 'the save did not return true';
}
} catch ( error ) {
failure = String(error?.message ?? 'the save failed');
}
if ( failure !== null ) {
// A failed response doesn't prove nothing was stored, so read this save's own key back once.
const readBack = await puter.kv.get(key).catch(() => undefined);
if ( ! isSavedAs(readBack, expected) ) {
const found = readBack === null
? `The save was not confirmed (${ failure }), and reading it back found nothing yet.`
: `The report may or may not have been saved (${ failure }).`;
draft = null;
throw new Error(`${ found } Open it again with openDraft() before making changes.`);
}
}
draft = checked;
draftKey = key;
});
}
const busy = new Set();
let extracting = 0;
async function receiptIdOf (image) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(image));
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function lock (receiptId) {
if ( busy.has(receiptId) ) {
throw new Error('This receipt is still being processed.');
}
busy.add(receiptId);
}
function putReview (next, review) {
if ( next.expenses.some(expense => expense.receiptId === review.receiptId) ) {
throw new Error('This receipt is already in the report.');
}
const index = next.reviews.findIndex(item => item.receiptId === review.receiptId);
if ( index !== -1 ) {
next.reviews[index] = copy(review);
} else if ( next.reviews.length >= MAX_REVIEWS ) {
throw new Error('There are too many open reviews.');
} else {
next.reviews.push(copy(review));
}
}
async function extractReceipt (image, reply) {
checkImage(image);
const receiptId = await receiptIdOf(image);
requireOpen();
const saved = draft.reviews.find(review => review.receiptId === receiptId);
if ( saved ) {
return copy(saved);
}
if ( draft.expenses.some(expense => expense.receiptId === receiptId) ) {
throw new Error('This receipt is already in the report.');
}
if ( draft.expenses.length >= MAX_EXPENSES ) {
throw new Error(`A report can have at most ${ MAX_EXPENSES } expenses.`);
}
if ( draft.reviews.length + extracting >= MAX_REVIEWS ) {
throw new Error(`Approve or discard some open reviews first. At most ${ MAX_REVIEWS } can be open, counting receipts being read.`);
}
lock(receiptId);
extracting += 1;
try {
let review;
try {
const { fields, problems } = parseExtraction(reply === undefined ? await requestExtraction(image) : reply);
review = toReview(receiptId, fields, problems);
} catch ( error ) {
const reason = String(error?.message ?? error?.error?.message ?? 'the request failed').slice(0, 200);
review = toReview(receiptId, emptyFields(), [`No AI suggestions (${ reason }). Fill in each field from the receipt.`]);
}
try {
await commit(next => putReview(next, review));
} catch ( error ) {
return { ...copy(review), saveError: String(error?.message ?? 'unknown error') };
}
return copy(review);
} finally {
extracting -= 1;
busy.delete(receiptId);
}
}
async function approveExpense (receiptId, values) {
requireOpen();
if ( ! isReceiptId(receiptId) ) {
throw new Error('Approve a receipt by the receiptId of its review.');
}
const fields = Object.fromEntries(FIELDS.map(name => [name, values?.[name]]));
const invalid = FIELDS.filter(name => ! isValid(name, fields[name]));
if ( invalid.length > 0 ) {
throw new Error(invalid.map(name => `The ${ name } must be ${ RULES[name] }.`).join(' '));
}
const expense = { receiptId, ...fields };
lock(receiptId);
try {
await commit(next => {
if ( next.expenses.some(item => item.receiptId === receiptId) ) {
throw new Error('This receipt is already in the report.');
}
if ( next.expenses.length >= MAX_EXPENSES ) {
throw new Error(`A report can have at most ${ MAX_EXPENSES } expenses.`);
}
next.expenses.push(expense);
next.reviews = next.reviews.filter(review => review.receiptId !== receiptId);
});
} finally {
busy.delete(receiptId);
}
return copy(expense);
}
const FORMULA_LIKE = /^[\s\x00-\x1F\x7F\u200B-\u200D\u2060]*[=+\-@]|^[\x00-\x1F\x7F]/;
const PLAIN_NUMBER = /^[+-]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$/;
const CSV_COLUMNS = ['date', 'merchant', 'currency', 'total'];
function csvField (value) {
let text = String(value ?? '');
if ( FORMULA_LIKE.test(text) && ! PLAIN_NUMBER.test(text) ) {
text = `'${ text }`;
}
return `"${ text.replaceAll('"', '""') }"`;
}
function toCsv (rows, columns) {
const lines = [columns, ...rows.map(row => columns.map(column => row[column]))];
return `${ lines.map(fields => fields.map(csvField).join(',')).join('\r\n') }\r\n`;
}
function exportCsv () {
requireOpen();
return toCsv(draft.expenses, CSV_COLUMNS);
}
async function saveCsvFile (csv, suggestedName) {
const request = puter.ui.showSaveFilePicker(csv, suggestedName);
// A desktop cancel settles undefinedOnCancel, and a website popup cancel resolves with a cancel message.
const saved = await Promise.race([request, request.undefinedOnCancel]);
if ( saved === undefined || saved?.msg === 'fileSaveCanceled' ) {
return null;
}
// Only a result that names the saved file confirms the save.
if ( typeof saved?.name !== 'string' || saved.name === '' ) {
throw new Error('The save dialog did not return the saved file, so the save could not be confirmed.');
}
return saved;
}
// -- Review UI --
// AI suggestions only reach the page as input values and text.
const pickButton = document.getElementById('pick');
const reopenButton = document.getElementById('reopen');
const approveButton = document.getElementById('approve');
const saveButton = document.getElementById('save');
const status = document.getElementById('status');
const preview = document.getElementById('preview');
const problemList = document.getElementById('problems');
const reportSummary = document.getElementById('report');
const inputs = Object.fromEntries(FIELDS.map(name => [name, document.getElementById(name)]));
let review = null;
let working = false;
const messageOf = error => String(error?.message ?? error?.error?.message ?? error ?? 'unknown error');
function update () {
pickButton.disabled = working;
reopenButton.disabled = working || draft !== null;
approveButton.disabled = working || review === null;
saveButton.disabled = working || draft === null || draft.expenses.length === 0;
reportSummary.textContent = draft === null
? 'The report is not open.'
: `The report has ${ draft.expenses.length } approved expenses and ${ draft.reviews.length } open reviews.`;
}
function showReview (next, image) {
review = next;
preview.hidden = next === null;
if ( image !== undefined ) {
preview.src = image;
}
for ( const name of FIELDS ) {
inputs[name].value = next?.fields[name] ?? '';
}
const notes = next === null ? [] : [
...next.problems,
...(next.unresolved.length > 0 ? [`Fill in from the receipt: ${ next.unresolved.join(', ') }.`] : []),
];
problemList.replaceChildren(...notes.map(note => {
const item = document.createElement('li');
item.textContent = note;
return item;
}));
}
// Runs one button action at a time. The task starts right away, so a picker it opens still counts as a user action.
async function run (task) {
working = true;
update();
try {
await task();
} finally {
working = false;
update();
}
}
pickButton.addEventListener('click', () => run(async () => {
let image;
try {
image = await pickReceiptImage();
} catch ( error ) {
status.textContent = `The image was not used (${ messageOf(error) }). Nothing was sent.`;
return;
}
if ( image === null ) {
status.textContent = 'No image was picked, so nothing was sent.';
return;
}
try {
if ( draft === null ) {
await openDraft();
}
status.textContent = 'Reading the receipt...';
const next = await extractReceipt(image);
showReview(next, image);
status.textContent = next.saveError === undefined
? 'Check every field against the receipt, correct it, then approve.'
: `The review may not be saved. ${ next.saveError } Use Open report, then approve.`;
} catch ( error ) {
status.textContent = messageOf(error);
}
}));
approveButton.addEventListener('click', () => run(async () => {
if ( review === null ) {
return;
}
if ( draft === null ) {
status.textContent = 'Open the report again with Open report, then approve.';
return;
}
const values = Object.fromEntries(FIELDS.map(name => [name, inputs[name].value]));
try {
const expense = await approveExpense(review.receiptId, values);
showReview(null);
status.textContent = `Approved the expense from ${ expense.merchant }. Save the report CSV to keep a copy in Puter.`;
} catch ( error ) {
status.textContent = draft === null
? `${ messageOf(error) } Use Open report to load what was saved.`
: messageOf(error);
}
}));
reopenButton.addEventListener('click', () => run(async () => {
try {
await openDraft();
status.textContent = 'The report is open.';
} catch ( error ) {
status.textContent = `The report could not be opened (${ messageOf(error) }). Try again.`;
}
}));
saveButton.addEventListener('click', () => run(async () => {
if ( draft === null ) {
status.textContent = 'Open the report again with Open report, then save.';
return;
}
try {
const saved = await saveCsvFile(exportCsv(), 'expense-report.csv');
status.textContent = saved === null
? 'Save cancelled. No CSV file was saved.'
: `Saved the report as ${ saved.name }.`;
} catch ( error ) {
// A rejection can follow a completed write whose response was lost.
status.textContent = `Save could not be confirmed (${ messageOf(error) }). Check the destination before retrying.`;
}
}));
update();
</script>
</body>
</html>
@@ -6,23 +6,122 @@ order: 71
---
An expense report usually starts as a pile of receipt photos, and typing in each
merchant, date, and total by hand is slow. In this recipe,
[`puter.ai.chat()`](/AI/chat/) reads one receipt image and suggests four fields.
The user checks them in your app, and only approved expenses go into the report.
The report is saved with [`puter.kv`](/KV/), a key-value store in the user's
Puter account, so an unfinished report is still there when the user comes back.
The finished report is exported as a CSV file. The receipt image is sent to the
AI but never saved.
merchant, date, and total by hand is slow. In this recipe, the user picks a
receipt photo from their Puter account, [`puter.ai.chat()`](/AI/chat/) reads it
and suggests four fields, and the user checks them in your app. Only approved
expenses go into the report. The report is saved with [`puter.kv`](/KV/), a
key-value store in the user's Puter account, so an unfinished report is still
there when the user comes back. The finished report is saved as a CSV file in
the user's Puter account. The receipt image is sent to the AI but never saved.
## Pick a Receipt Image
The user picks the photo with
[`puter.ui.showOpenFilePicker()`](/UI/showOpenFilePicker/), which opens the
Puter file picker on the user's Desktop folder and resolves to an
[`FSItem`](/Objects/fsitem/) for the chosen file. The
[`read()`](/Objects/fsitem/#read) method of that item resolves to a `Blob` with
the file's contents. When the user closes the picker in the Puter desktop, the
promise from [`puter.ui.showOpenFilePicker()`](/UI/showOpenFilePicker/) stays
pending, and its `undefinedOnCancel` promise resolves to `undefined` instead.
When your app runs as a website, the picker opens in a popup window. Closing
that popup resolves the picker's own promise to an object whose `msg` is
`'fileOpenCanceled'`. The `pickReceiptImage()` function waits for whichever
settles first and treats both forms as a cancel, so it resolves to `null` when
the user cancels and rejects when the picker fails.
The `accept: 'image/*'` option only filters what the picker shows, so the
function checks the file itself. It accepts one PNG, JPEG, or WebP file of at
most 3,700,000 bytes, going by its extension, the type and size of the `Blob` it
reads, and the first bytes of the image. Storage can report any file as
`application/octet-stream`, so that type is accepted when the extension and the
first bytes match. The function then turns the image into a base64 data URL of
the right type with
[`FileReader.readAsDataURL()`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL).
The byte cap keeps the data URL under the 5,000,000-character cap in
[Check the Image](#check-the-image):
```js;build-an-expense-report-from-receipts
const IMAGE_TYPES = new Map([['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['webp', 'image/webp']]);
// Types storage can report for any file, accepted when the extension and the first bytes match.
const GENERIC_TYPES = ['', 'application/octet-stream'];
const IMAGE_SIGNATURES = {
'image/png': /^\x89PNG\r\n\x1A\n/,
'image/jpeg': /^\xFF\xD8\xFF/,
'image/webp': /^RIFF[\s\S]{4}WEBP/,
};
// Base64 adds a third, so this keeps the data URL under the cap of checkImage().
const MAX_IMAGE_BYTES = 3_700_000;
const NOT_AN_IMAGE = 'Pick one PNG, JPEG, or WebP image.';
const IMAGE_TOO_LARGE = 'Pick an image of at most 3,700,000 bytes. Scale a larger photo down first.';
function readAsDataUrl (blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error ?? new Error('The image could not be read.'));
reader.readAsDataURL(blob);
});
}
async function pickReceiptImage () {
const request = puter.ui.showOpenFilePicker({ multiple: false, accept: 'image/*' });
// A failure only rejects the picker's own promise. A desktop cancel settles undefinedOnCancel,
// and a website popup cancel resolves the picker's own promise with a cancel message.
const file = await Promise.race([request, request.undefinedOnCancel]);
if ( file === undefined || file?.msg === 'fileOpenCanceled' ) {
return null;
}
const type = IMAGE_TYPES.get(/\.([a-z]+)$/i.exec(String(file?.name))?.[1].toLowerCase());
if ( Array.isArray(file) || file?.isDir || typeof file?.read !== 'function' || type === undefined ) {
throw new Error(NOT_AN_IMAGE);
}
if ( typeof file.size === 'number' && file.size > MAX_IMAGE_BYTES ) {
throw new Error(IMAGE_TOO_LARGE);
}
const blob = await file.read();
if ( ! (blob instanceof Blob) ) {
throw new Error('The image could not be read.');
}
// The reported size can be stale, so the bytes actually read are checked too.
if ( blob.size > MAX_IMAGE_BYTES ) {
throw new Error(IMAGE_TOO_LARGE);
}
const head = String.fromCharCode(...new Uint8Array(await blob.slice(0, 12).arrayBuffer()));
if ( (blob.type !== type && ! GENERIC_TYPES.includes(blob.type)) || ! IMAGE_SIGNATURES[type].test(head) ) {
throw new Error(NOT_AN_IMAGE);
}
// A generic Blob type would end up in the data URL, so the image type is set first.
return readAsDataUrl(new Blob([blob], { type }));
}
```
Call `pickReceiptImage()` from your Add Receipt button's click handler, before
any other `await`, so the browser allows the picker popup. If it throws, show
the error and keep the reviews the user already has. The rest of this recipe
works on the picked image, so this code stops before anything is sent when the
user cancels. In a click handler, return instead of throwing:
```js
const receiptImage = await pickReceiptImage();
if ( receiptImage === null ) {
throw new Error('No receipt image was picked.');
}
```
To try the whole flow with a receipt image from your Puter account, run the
first code block in this section in the playground. It sends an image only after
you pick one, lets you correct and approve the fields, and saves the report CSV
to your Puter account.
## Read a Receipt
Your app passes each receipt as one base64 data URL. In a browser,
[`FileReader.readAsDataURL()`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL)
gives you one. In Node.js, put `data:image/jpeg;base64,` in front of the file's
base64 contents. The `requestExtraction()` function sends the image with the
prompt and returns the reply text, or `null` if there's none:
The `requestExtraction()` function sends one receipt as a base64 data URL with
the prompt, and returns the reply text, or `null` if there's none. In Node.js,
put `data:image/jpeg;base64,` in front of the file's base64 contents to get a
data URL:
```js;build-an-expense-report-from-receipts
```js
const FIELDS = ['merchant', 'date', 'currency', 'total'];
const PROMPT = 'Read this receipt. Return the merchant name, the purchase date as YYYY-MM-DD '
+ 'only if the printed date is unambiguous, the currency only if its three-letter code is '
@@ -49,8 +148,7 @@ async function requestExtraction (image) {
}
```
Send the receipt when the user adds it. Here `receiptImage` is the data URL from
your app's file picker or camera:
Send the receipt the user picked:
```js
const receiptReply = await requestExtraction(receiptImage);
@@ -66,9 +164,7 @@ If the AI reads a cafe receipt, leaves out the currency, and suggests the date
The [`outputFormat`](/AI/chat/) schema asks for the four fields as text or
`null`, and [`normalize: true`](/AI/chat/#response-normalization) makes the
reply text a string for every model. The other options are listed on
[`puter.ai.chat()`](/AI/chat/). To try this request in the playground, run the
first code block in this section. The playground draws a sample receipt and
shows the AI's raw reply.
[`puter.ai.chat()`](/AI/chat/).
## Check the Image
@@ -742,6 +838,45 @@ For the sample expense, `csv` holds this text:
"2026-09-30","Corner Cafe","EUR","12.40"
```
Save the string or offer it as a download with whatever your platform provides.
To start a new report after the export, set `REPORT_ID` to a new id the next
To save the CSV in the user's Puter account, pass it to
[`puter.ui.showSaveFilePicker()`](/UI/showSaveFilePicker/). It opens the Puter
save dialog, where the user picks a folder and a file name, and writes the text
to the file the user chose. When the user closes the dialog in the Puter
desktop, its promise stays pending, and its `undefinedOnCancel` promise resolves
to `undefined` instead. In a website, closing the save popup resolves the
dialog's own promise to an object whose `msg` is `'fileSaveCanceled'`. The
`saveCsvFile()` function treats both forms as a cancel and resolves to `null`.
It resolves to the [`FSItem`](/Objects/fsitem/) of the saved file only when the
result has a file name. It rejects when the save fails or the result doesn't
name a saved file:
```js
async function saveCsvFile (csv, suggestedName) {
const request = puter.ui.showSaveFilePicker(csv, suggestedName);
// A desktop cancel settles undefinedOnCancel, and a website popup cancel resolves with a cancel message.
const saved = await Promise.race([request, request.undefinedOnCancel]);
if ( saved === undefined || saved?.msg === 'fileSaveCanceled' ) {
return null;
}
// Only a result that names the saved file confirms the save.
if ( typeof saved?.name !== 'string' || saved.name === '' ) {
throw new Error('The save dialog did not return the saved file, so the save could not be confirmed.');
}
return saved;
}
```
In your Save button's click handler, call `exportCsv()` and then
`saveCsvFile()` before any other `await`, so the browser allows the dialog
popup. Tell the user the file is saved only when the call resolves to an item.
When it resolves to `null`, the user canceled and no file was saved. A rejection
can come after the file was written, so tell the user the save could not be
confirmed and to check the destination before retrying. The report in
[`puter.kv`](/KV/) doesn't change in either case, so the user can save again:
```js
const savedFile = await saveCsvFile(csv, 'expense-report.csv');
```
To start a new report after saving the CSV, set `REPORT_ID` to a new id the next
time this code runs, as in [Save the Report](#save-the-report).
@@ -4,8 +4,9 @@ import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
// Executes the recipe's own JS blocks, in order, so the tests cover exactly the
// code readers copy. The context has no DOM, and `puter.ai.chat` and `puter.kv`
// are in-memory mocks. Every receipt is synthetic text, not a real image.
// code readers copy. The context has no DOM, and `puter.ai.chat`, `puter.kv`,
// the `puter.ui` pickers, and `FileReader` are in-memory mocks. Every receipt is
// synthetic bytes behind an image signature, not a real image.
const markdown = readFileSync(new URL('./build-an-expense-report-from-receipts.md', import.meta.url), 'utf8');
const source = [...markdown.matchAll(/^```js(?:;[\w-]+)?\n([\s\S]*?)^```$/gm)].map(([, code]) => code).join('\n');
const companion = readFileSync(new URL('../playground/examples/build-an-expense-report-from-receipts.html', import.meta.url), 'utf8');
@@ -22,7 +23,17 @@ const reply = fields => ({ message: { content: JSON.stringify(fields) } });
const imageOf = text => `data:image/png;base64,${ Buffer.from(text).toString('base64') }`;
const idOf = image => createHash('sha256').update(image).digest('hex');
const SAMPLE_IMAGE = imageOf('synthetic receipt: corner cafe');
const SIGNATURES = {
'image/png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
'image/jpeg': Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]),
'image/webp': Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Buffer.from('WEBP')]),
};
const imageBytes = (type, text) => Buffer.concat([SIGNATURES[type], Buffer.from(text)]);
const dataUrlOf = (type, bytes) => `data:${ type };base64,${ bytes.toString('base64') }`;
const MAX_IMAGE_BYTES = 3_700_000;
const SAMPLE_BYTES = imageBytes('image/png', 'synthetic receipt: corner cafe');
const SAMPLE_IMAGE = dataUrlOf('image/png', SAMPLE_BYTES);
const OTHER_IMAGE = imageOf('synthetic receipt: city transit');
const THIRD_IMAGE = imageOf('synthetic receipt: hardware store');
const FOURTH_IMAGE = imageOf('synthetic receipt: bakery');
@@ -94,6 +105,68 @@ const persistThenReject = kv => async (key, value) => {
throw new Error('connection reset');
};
/** Reads a Blob the way the browser's FileReader does, with the Blob's type in the data URL. */
class FakeFileReader {
readAsDataURL (blob) {
blob.arrayBuffer().then(buffer => {
this.result = dataUrlOf(blob.type || 'application/octet-stream', Buffer.from(buffer));
this.onload?.();
}, error => {
this.error = error;
this.onerror?.();
});
}
}
/** Picker promises as `puter.ui` returns them, with `undefinedOnCancel` attached. */
function pickerResolving (value) {
const request = Promise.resolve(value);
request.undefinedOnCancel = Promise.resolve(value);
return request;
}
function pickerCancelled () {
const request = new Promise(() => {});
request.undefinedOnCancel = Promise.resolve(undefined);
return request;
}
function pickerRejecting (error) {
const request = Promise.reject(error);
request.undefinedOnCancel = new Promise(() => {});
return request;
}
/** A website popup cancel, which resolves the picker's own promise with the popup's message. */
function pickerPopupCancelled (msg) {
const request = Promise.resolve({ msg, original_msg_id: 1 });
request.undefinedOnCancel = new Promise(() => {});
return request;
}
/** A picked file shaped like an `FSItem`, whose `read()` resolves to a Blob. */
function fakeFile (name, bytes, { type = 'image/png', size = bytes.length, ...rest } = {}) {
return {
name,
path: `/test-user/Desktop/${ name }`,
size,
isDir: false,
read: vi.fn(async () => new Blob([bytes], { type })),
...rest,
};
}
const savedItem = name => ({ name, path: `/test-user/Desktop/${ name }`, isDir: false });
function fakeUi ({ file = fakeFile('receipt.png', SAMPLE_BYTES) } = {}) {
return {
showOpenFilePicker: vi.fn(() => pickerResolving(file)),
showSaveFilePicker: vi.fn((content, name) => pickerResolving(savedItem(name))),
};
}
const browserGlobals = () => ({ crypto: globalThis.crypto, TextEncoder, Blob, FileReader: FakeFileReader });
const REPORT_ID_LINE = `const REPORT_ID = '${ KEY }';`;
/**
@@ -101,34 +174,35 @@ const REPORT_ID_LINE = `const REPORT_ID = '${ KEY }';`;
* steps, and returns its top-level declarations. A `reportId` replaces the
* recipe's `REPORT_ID` value, as a reader configuring another report would.
*/
async function loadRecipe ({ reportId = KEY } = {}) {
async function loadRecipe ({
reportId = KEY,
ui = fakeUi(),
sampleChat = vi.fn(async () => reply(SAMPLE_REPLY)),
sampleKv = memoryKv(),
} = {}) {
if ( ! source.includes(REPORT_ID_LINE) ) {
throw new Error(`The recipe no longer declares ${ REPORT_ID_LINE }`);
}
const sampleChat = vi.fn(async () => reply(SAMPLE_REPLY));
const sampleKv = memoryKv();
const context = vm.createContext({
puter: { ai: { chat: sampleChat }, kv: sampleKv },
crypto: globalThis.crypto,
TextEncoder,
receiptImage: SAMPLE_IMAGE,
puter: { ai: { chat: sampleChat }, kv: sampleKv, ui },
...browserGlobals(),
});
const configured = source.replace(REPORT_ID_LINE, `const REPORT_ID = ${ JSON.stringify(reportId) };`);
const recipe = await vm.runInContext(
`(async () => {\n${ configured }\nreturn { ${ topLevelNames.join(', ') }, currentDraft: () => draft };\n})()`,
context,
);
return { recipe, context, sampleChat, sampleKv };
return { recipe, context, sampleChat, sampleKv, ui };
}
/** Loads the recipe, then points it at fresh mocks and reopens the report from them. */
async function setup ({ chat = vi.fn(async () => reply(FULL_REPLY)), stored, kv = memoryKv(stored), open = true, reportId } = {}) {
async function setup ({ chat = vi.fn(async () => reply(FULL_REPLY)), stored, kv = memoryKv(stored), open = true, reportId, ui = fakeUi() } = {}) {
const { recipe, context } = await loadRecipe({ reportId });
context.puter = { ai: { chat }, kv };
context.puter = { ai: { chat }, kv, ui };
if ( open ) {
await recipe.openDraft();
}
return { recipe, chat, kv };
return { recipe, chat, kv, ui, context };
}
function deferredChat () {
@@ -212,53 +286,527 @@ describe('sample flow', () => {
expect(history.snapshots.map(({ key }) => orderOf(key))).toEqual([3, 2, 1]);
expect(history.snapshots.map(({ report }) => report.revision)).toEqual([3, 2, 1]);
});
});
describe('playground companion', () => {
/** Runs the companion's module script against a minimal DOM whose reply element rejects HTML. */
async function runCompanion (chat) {
const output = { textContent: '' };
Object.defineProperty(output, 'innerHTML', {
set () {
throw new Error('the reply must be rendered as text');
},
});
const canvas = { getContext: () => ({ fillRect () {}, fillText () {} }), toDataURL: vi.fn(() => SAMPLE_IMAGE) };
const document = { createElement: () => canvas, getElementById: () => output, body: { prepend () {} } };
const context = vm.createContext({ puter: { ai: { chat } }, document });
await vm.runInContext(`(async () => {\n${ companionScript }\n})()`, context);
return { output, canvas };
}
it('picks the receipt from Puter before sending it, and saves the CSV through the save dialog', async () => {
const { recipe, ui, sampleChat } = await loadRecipe();
it('sends the drawn receipt with the same prompt and options as the recipe', async () => {
const { sampleChat } = await loadRecipe();
const chat = vi.fn(async () => reply(SAMPLE_REPLY));
const { canvas } = await runCompanion(chat);
expect(chat).toHaveBeenCalledTimes(1);
const [prompt, image, options] = plain(chat.mock.calls[0]);
const [recipePrompt, , recipeOptions] = plain(sampleChat.mock.calls[0]);
expect(image).toBe(canvas.toDataURL.mock.results[0].value);
expect(prompt).toBe(recipePrompt);
expect(options).toEqual(recipeOptions);
expect(plain(ui.showOpenFilePicker.mock.calls)).toEqual([[{ multiple: false, accept: 'image/*' }]]);
expect(ui.showOpenFilePicker.mock.invocationCallOrder[0]).toBeLessThan(sampleChat.mock.invocationCallOrder[0]);
expect(recipe.receiptImage).toBe(SAMPLE_IMAGE);
expect(ui.showSaveFilePicker.mock.calls).toEqual([[SAMPLE_CSV, 'expense-report.csv']]);
expect(plain(recipe.savedFile)).toEqual(savedItem('expense-report.csv'));
});
it('shows the raw reply as text', async () => {
const content = '<img src=x onerror="alert(1)">';
const { output } = await runCompanion(vi.fn(async () => ({ message: { content } })));
expect(output.textContent).toBe(content);
it('stops on cancel without sending, saving, approving, or exporting', async () => {
const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerCancelled()) };
const sampleChat = vi.fn(async () => reply(SAMPLE_REPLY));
const sampleKv = memoryKv();
await expect(loadRecipe({ ui, sampleChat, sampleKv })).rejects.toThrow('No receipt image was picked.');
expect(sampleChat).not.toHaveBeenCalled();
expect(sampleKv.list).not.toHaveBeenCalled();
expect(sampleKv.incr).not.toHaveBeenCalled();
expect(sampleKv.set).not.toHaveBeenCalled();
expect(ui.showSaveFilePicker).not.toHaveBeenCalled();
});
});
describe('picking a receipt image', () => {
it.each([
['receipt.png', 'image/png', 'image/png'],
['receipt.PNG', 'image/png', 'application/octet-stream'],
['receipt.jpg', 'image/jpeg', 'image/jpeg'],
['receipt.JPEG', 'image/jpeg', ''],
['receipt.webp', 'image/webp', 'image/webp'],
['receipt.webp', 'image/webp', 'application/octet-stream'],
])('reads %s as a %s data URL when storage reports %j', async (name, type, reported) => {
const bytes = imageBytes(type, `synthetic ${ name }`);
const file = fakeFile(name, bytes, { type: reported });
const { recipe, chat, kv } = await setup({ ui: fakeUi({ file }), open: false });
const image = await recipe.pickReceiptImage();
expect(image).toBe(dataUrlOf(type, bytes));
expect(() => recipe.checkImage(image)).not.toThrow();
expect(file.read).toHaveBeenCalledTimes(1);
expect(chat).not.toHaveBeenCalled();
expect(kv.list).not.toHaveBeenCalled();
});
it.each([
['an empty response', vi.fn(async () => ({}))],
['a rejected request', vi.fn(async () => {
['undefinedOnCancel', () => pickerCancelled()],
['a website popup cancel message', () => pickerPopupCancelled('fileOpenCanceled')],
])('resolves to null on a cancel through %s without reading, sending, or saving anything', async (_, cancel) => {
const file = fakeFile('receipt.png', SAMPLE_BYTES);
const ui = { ...fakeUi({ file }), showOpenFilePicker: vi.fn(cancel) };
const { recipe, chat, kv } = await setup({ ui, open: false });
expect(await recipe.pickReceiptImage()).toBeNull();
expect(file.read).not.toHaveBeenCalled();
expect(chat).not.toHaveBeenCalled();
expect(kv.list).not.toHaveBeenCalled();
expect(kv.set).not.toHaveBeenCalled();
});
it.each([
[new Error('The popup was blocked')],
['This API is not compatible in Web Workers.'],
])('rejects when the picker rejects with %j', async (failure) => {
const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerRejecting(failure)) };
const { recipe, chat } = await setup({ ui, open: false });
await expect(recipe.pickReceiptImage()).rejects.toBe(failure);
expect(chat).not.toHaveBeenCalled();
});
it.each([
['two files', () => [fakeFile('a.png', SAMPLE_BYTES), fakeFile('b.png', SAMPLE_BYTES)]],
['a list with one file', () => [fakeFile('a.png', SAMPLE_BYTES)]],
['a directory', () => fakeFile('receipts.png', SAMPLE_BYTES, { isDir: true })],
['an item without read()', () => ({ name: 'a.png', isDir: false })],
['a GIF', () => fakeFile('receipt.gif', SAMPLE_BYTES, { type: 'image/gif' })],
['an SVG', () => fakeFile('receipt.svg', SAMPLE_BYTES, { type: 'image/svg+xml' })],
['a PDF', () => fakeFile('receipt.pdf', SAMPLE_BYTES, { type: 'application/pdf' })],
['a name without an extension', () => fakeFile('png', SAMPLE_BYTES)],
['an extension that is an object key', () => fakeFile('receipt.constructor', SAMPLE_BYTES)],
['an item without a name', () => fakeFile(undefined, SAMPLE_BYTES)],
['null', () => null],
['a save cancel message', () => ({ msg: 'fileSaveCanceled', original_msg_id: 1 })],
['another popup message', () => ({ msg: 'fileOpened', original_msg_id: 1 })],
])('rejects %s without reading it', async (_, makePick) => {
const picked = makePick();
const { recipe, chat } = await setup({ ui: fakeUi({ file: picked }), open: false });
await expect(recipe.pickReceiptImage()).rejects.toThrow('Pick one PNG, JPEG, or WebP image.');
for ( const item of [picked].flat() ) {
if ( item?.read ) {
expect(item.read).not.toHaveBeenCalled();
}
}
expect(chat).not.toHaveBeenCalled();
});
it('rejects an image over the cap by its reported size without reading it', async () => {
const file = fakeFile('receipt.png', SAMPLE_BYTES, { size: MAX_IMAGE_BYTES + 1 });
const { recipe } = await setup({ ui: fakeUi({ file }), open: false });
await expect(recipe.pickReceiptImage()).rejects.toThrow(/at most 3,700,000 bytes/);
expect(file.read).not.toHaveBeenCalled();
});
it.each([
['a small reported size', 10],
['no reported size', null],
])('rejects read bytes over the cap with %s', async (_, size) => {
const bytes = Buffer.concat([SIGNATURES['image/png'], Buffer.alloc(MAX_IMAGE_BYTES)]);
const file = fakeFile('receipt.png', bytes, { size });
const { recipe } = await setup({ ui: fakeUi({ file }), open: false });
await expect(recipe.pickReceiptImage()).rejects.toThrow(/at most 3,700,000 bytes/);
expect(file.read).toHaveBeenCalledTimes(1);
});
it('accepts an image at the byte cap, and its data URL passes checkImage()', async () => {
const bytes = Buffer.concat([SIGNATURES['image/png'], Buffer.alloc(MAX_IMAGE_BYTES - 8, 1)]);
const { recipe } = await setup({ ui: fakeUi({ file: fakeFile('receipt.png', bytes) }), open: false });
const image = await recipe.pickReceiptImage();
expect(image.length).toBeLessThanOrEqual(5_000_000);
expect(() => recipe.checkImage(image)).not.toThrow();
});
it.each([
['a different image type', 'receipt.png', SAMPLE_BYTES, 'image/gif'],
['a JPEG type for a PNG name', 'receipt.png', SAMPLE_BYTES, 'image/jpeg'],
['an HTML type', 'receipt.png', SAMPLE_BYTES, 'text/html'],
['JPEG bytes behind a PNG name', 'receipt.png', imageBytes('image/jpeg', 'synthetic'), 'application/octet-stream'],
['text behind a PNG name', 'receipt.png', Buffer.from('<svg onload="alert(1)"></svg>'), ''],
['a RIFF file that is not WebP', 'receipt.webp', Buffer.from('RIFF\x24\x00\x00\x00WAVEfmt '), 'image/webp'],
['an empty file', 'receipt.jpg', Buffer.alloc(0), 'image/jpeg'],
])('rejects %s', async (_, name, bytes, type) => {
const { recipe, chat } = await setup({ ui: fakeUi({ file: fakeFile(name, bytes, { type }) }), open: false });
await expect(recipe.pickReceiptImage()).rejects.toThrow('Pick one PNG, JPEG, or WebP image.');
expect(chat).not.toHaveBeenCalled();
});
it.each([
['text instead of a Blob', async () => SAMPLE_IMAGE, /could not be read/],
['nothing', async () => undefined, /could not be read/],
['a rejection', async () => {
throw new Error('offline');
})],
])('shows a text message for %s', async (_, chat) => {
const { output } = await runCompanion(chat);
expect(typeof output.textContent).toBe('string');
expect(output.textContent).not.toBe('');
}, /offline/],
])('rejects when read() gives %s', async (_, read, message) => {
const file = { ...fakeFile('receipt.png', SAMPLE_BYTES), read: vi.fn(read) };
const { recipe } = await setup({ ui: fakeUi({ file }), open: false });
await expect(recipe.pickReceiptImage()).rejects.toThrow(message);
});
it('rejects when the FileReader fails', async () => {
const { recipe, context } = await setup({ open: false });
context.FileReader = class {
readAsDataURL () {
this.error = new Error('The file could not be decoded');
queueMicrotask(() => this.onerror());
}
};
await expect(recipe.pickReceiptImage()).rejects.toThrow('The file could not be decoded');
});
it('feeds the picked image into the review and approval flow', async () => {
const { recipe, chat, kv } = await setup({ chat: vi.fn(async () => reply(SAMPLE_REPLY)) });
const image = await recipe.pickReceiptImage();
const review = await recipe.extractReceipt(image);
expect(chat).toHaveBeenCalledTimes(1);
expect(chat.mock.calls[0][1]).toBe(SAMPLE_IMAGE);
expect(review.receiptId).toBe(idOf(SAMPLE_IMAGE));
expect(latest(kv).expenses).toEqual([]);
await recipe.approveExpense(review.receiptId, VALID);
expect(recipe.exportCsv()).toBe(SAMPLE_CSV);
});
});
describe('saving the CSV file', () => {
it.each([
['undefinedOnCancel', () => pickerCancelled()],
['a website popup cancel message', () => pickerPopupCancelled('fileSaveCanceled')],
])('resolves to null when the user cancels through %s, and leaves the report unchanged', async (_, cancel) => {
const ui = { ...fakeUi(), showSaveFilePicker: vi.fn(cancel) };
const { recipe, kv } = await setup({ ui, stored: storedReport({ expenses: [expenseRow(SAMPLE_IMAGE)] }) });
const before = new Map(kv.store);
expect(await recipe.saveCsvFile(recipe.exportCsv(), 'expense-report.csv')).toBeNull();
expect(ui.showSaveFilePicker).toHaveBeenCalledWith(SAMPLE_CSV, 'expense-report.csv');
expect(kv.store).toEqual(before);
expect(recipe.currentDraft()).not.toBeNull();
});
it.each([
['an empty object', {}],
['an item without a name', { path: '/test-user/Desktop/expense-report.csv', isDir: false }],
['an item with an empty name', savedItem('')],
['a non-string name', { name: 42 }],
['an open cancel message', { msg: 'fileOpenCanceled', original_msg_id: 1 }],
['another popup message', { msg: 'fileSaved', original_msg_id: 1 }],
['null', null],
['a string', 'expense-report.csv'],
])('rejects %s as an unconfirmed save, and leaves the report unchanged', async (_, value) => {
const ui = { ...fakeUi(), showSaveFilePicker: vi.fn(() => pickerResolving(value)) };
const { recipe, kv } = await setup({ ui, stored: storedReport({ expenses: [expenseRow(SAMPLE_IMAGE)] }) });
const before = new Map(kv.store);
await expect(recipe.saveCsvFile(recipe.exportCsv(), 'expense-report.csv')).rejects.toThrow(/could not be confirmed/);
expect(kv.store).toEqual(before);
expect(recipe.exportCsv()).toBe(SAMPLE_CSV);
});
it.each([
[new Error('Storage is full')],
['This API is not compatible in Web Workers.'],
])('rejects when the save rejects with %j, and the report can still be exported', async (failure) => {
const ui = { ...fakeUi(), showSaveFilePicker: vi.fn(() => pickerRejecting(failure)) };
const { recipe, kv } = await setup({ ui, stored: storedReport({ expenses: [expenseRow(SAMPLE_IMAGE)] }) });
const before = new Map(kv.store);
await expect(recipe.saveCsvFile(recipe.exportCsv(), 'expense-report.csv')).rejects.toBe(failure);
expect(kv.store).toEqual(before);
expect(recipe.exportCsv()).toBe(SAMPLE_CSV);
});
});
// -- Playground companion --
/** A DOM element stub that renders text only and records listeners. */
function fakeElement (tag) {
let text = '';
return {
tag,
children: [],
listeners: {},
value: '',
disabled: false,
hidden: false,
get textContent () {
return text + this.children.map(child => child.textContent).join('');
},
set textContent (value) {
text = String(value);
this.children = [];
},
set innerHTML (_) {
throw new Error('The companion must render text, not HTML.');
},
append (...nodes) {
this.children.push(...nodes);
},
replaceChildren (...nodes) {
this.children = [...nodes];
},
addEventListener (type, listener) {
(this.listeners[type] ??= []).push(listener);
},
};
}
const fire = (element, type) => Promise.all((element.listeners[type] ?? []).map(listener => listener({ type })));
describe('playground companion', () => {
/** Runs the companion's module script against the stub DOM, then drives it like a user. */
async function runCompanion ({ chat = vi.fn(async () => reply(SAMPLE_REPLY)), kv = memoryKv(), ui = fakeUi() } = {}) {
const elements = new Map();
const byId = id => {
if ( ! elements.has(id) ) {
elements.set(id, fakeElement(`#${ id }`));
}
return elements.get(id);
};
const document = { getElementById: byId, createElement: tag => fakeElement(tag) };
const context = vm.createContext({ puter: { ai: { chat }, kv, ui }, document, ...browserGlobals() });
await vm.runInContext(`(async () => {\n${ companionScript }\n})()`, context);
return {
chat,
kv,
ui,
byId,
click: id => fire(byId(id), 'click'),
status: () => byId('status').textContent,
problems: () => byId('problems').children.map(item => item.textContent),
fields: () => Object.fromEntries(['merchant', 'date', 'currency', 'total'].map(name => [name, byId(name).value])),
type: values => Object.entries(values).forEach(([name, value]) => {
byId(name).value = value;
}),
};
}
const kvCalls = kv => kv.list.mock.calls.length + kv.get.mock.calls.length + kv.set.mock.calls.length + kv.incr.mock.calls.length;
it('makes no Puter call until the user picks a receipt', async () => {
const page = await runCompanion();
expect(page.ui.showOpenFilePicker).not.toHaveBeenCalled();
expect(page.ui.showSaveFilePicker).not.toHaveBeenCalled();
expect(page.chat).not.toHaveBeenCalled();
expect(kvCalls(page.kv)).toBe(0);
expect(page.byId('pick').disabled).toBe(false);
expect(page.byId('approve').disabled).toBe(true);
expect(page.byId('save').disabled).toBe(true);
});
it('sends and saves nothing when the user cancels the picker', async () => {
const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerCancelled()) };
const page = await runCompanion({ ui });
await page.click('pick');
expect(page.status()).toMatch(/No image was picked, so nothing was sent/);
expect(page.chat).not.toHaveBeenCalled();
expect(kvCalls(page.kv)).toBe(0);
});
it.each([
['a GIF', fakeFile('receipt.gif', SAMPLE_BYTES, { type: 'image/gif' }), /PNG, JPEG, or WebP/],
['an oversize image', fakeFile('receipt.png', SAMPLE_BYTES, { size: MAX_IMAGE_BYTES + 1 }), /3,700,000 bytes/],
])('rejects %s without sending it or opening the report', async (_, file, message) => {
const page = await runCompanion({ ui: fakeUi({ file }) });
await page.click('pick');
expect(page.status()).toMatch(message);
expect(page.status()).toMatch(/Nothing was sent/);
expect(page.chat).not.toHaveBeenCalled();
expect(kvCalls(page.kv)).toBe(0);
});
it('reads the picked receipt with the recipe request, and presets only checked fields for review', async () => {
const { sampleChat } = await loadRecipe();
const page = await runCompanion();
await page.click('pick');
expect(plain(page.ui.showOpenFilePicker.mock.calls)).toEqual([[{ multiple: false, accept: 'image/*' }]]);
expect(page.chat).toHaveBeenCalledTimes(1);
expect(plain(page.chat.mock.calls[0])).toEqual(plain(sampleChat.mock.calls[0]));
expect(page.fields()).toEqual({ merchant: 'Corner Cafe', date: '', currency: '', total: '12.40' });
expect(page.problems()).toEqual([
'Ignored the suggested date. It must be a real date written as YYYY-MM-DD.',
'Fill in from the receipt: date, currency.',
]);
expect(page.byId('preview').src).toBe(SAMPLE_IMAGE);
expect(page.byId('preview').hidden).toBe(false);
expect(latest(page.kv).expenses).toEqual([]);
expect(latest(page.kv).reviews.map(review => review.receiptId)).toEqual([idOf(SAMPLE_IMAGE)]);
expect(page.byId('approve').disabled).toBe(false);
expect(page.byId('save').disabled).toBe(true);
});
it('approves only the checked values the user confirms, then saves the approved CSV', async () => {
const page = await runCompanion();
await page.click('pick');
await page.click('approve');
expect(page.status()).toMatch(/The date must be/);
expect(page.status()).toMatch(/The currency must be/);
expect(page.kv.set).toHaveBeenCalledTimes(1);
expect(latest(page.kv).expenses).toEqual([]);
page.type({ date: '2026-09-30', currency: 'EUR' });
await page.click('approve');
expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
expect(latest(page.kv).reviews).toEqual([]);
expect(page.fields()).toEqual({ merchant: '', date: '', currency: '', total: '' });
expect(page.byId('approve').disabled).toBe(true);
expect(page.byId('save').disabled).toBe(false);
expect(page.ui.showSaveFilePicker).not.toHaveBeenCalled();
await page.click('save');
expect(page.ui.showSaveFilePicker.mock.calls).toEqual([[SAMPLE_CSV, 'expense-report.csv']]);
expect(page.status()).toMatch(/Saved the report as expense-report\.csv/);
expect(page.chat).toHaveBeenCalledTimes(1);
});
it('reports a cancelled or failed CSV save without claiming success, and saves on retry', async () => {
const ui = fakeUi();
const page = await runCompanion({ ui });
await page.click('pick');
page.type({ date: '2026-09-30', currency: 'EUR' });
await page.click('approve');
ui.showSaveFilePicker.mockImplementationOnce(() => pickerCancelled());
await page.click('save');
expect(page.status()).toMatch(/Save cancelled\. No CSV file was saved\./);
expect(page.byId('save').disabled).toBe(false);
ui.showSaveFilePicker.mockImplementationOnce(() => pickerRejecting(new Error('Storage is full')));
await page.click('save');
expect(page.status()).toBe('Save could not be confirmed (Storage is full). Check the destination before retrying.');
expect(page.status()).not.toMatch(/Saved the report/);
expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
expect(page.byId('save').disabled).toBe(false);
await page.click('save');
expect(page.status()).toMatch(/Saved the report as expense-report\.csv/);
expect(ui.showSaveFilePicker.mock.calls.map(([csv]) => csv)).toEqual([SAMPLE_CSV, SAMPLE_CSV, SAMPLE_CSV]);
});
it('sends and saves nothing when the user cancels the website popup picker', async () => {
const ui = { ...fakeUi(), showOpenFilePicker: vi.fn(() => pickerPopupCancelled('fileOpenCanceled')) };
const page = await runCompanion({ ui });
await page.click('pick');
expect(page.status()).toBe('No image was picked, so nothing was sent.');
expect(page.chat).not.toHaveBeenCalled();
expect(kvCalls(page.kv)).toBe(0);
expect(page.byId('approve').disabled).toBe(true);
});
it('reports a website popup save cancel as cancelled and an unexpected save result as unconfirmed', async () => {
const ui = fakeUi();
const page = await runCompanion({ ui });
await page.click('pick');
page.type({ date: '2026-09-30', currency: 'EUR' });
await page.click('approve');
ui.showSaveFilePicker.mockImplementationOnce(() => pickerPopupCancelled('fileSaveCanceled'));
await page.click('save');
expect(page.status()).toBe('Save cancelled. No CSV file was saved.');
expect(page.byId('save').disabled).toBe(false);
ui.showSaveFilePicker.mockImplementationOnce(() => pickerResolving({}));
await page.click('save');
expect(page.status()).toMatch(/^Save could not be confirmed \(.*could not be confirmed\.\)\. Check the destination before retrying\.$/);
expect(page.status()).not.toMatch(/Saved the report|No CSV file was saved/);
expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
expect(page.byId('save').disabled).toBe(false);
await page.click('save');
expect(page.status()).toMatch(/Saved the report as expense-report\.csv/);
});
it('requires reopening the report after an unconfirmed approval, then approves once', async () => {
const page = await runCompanion();
await page.click('pick');
page.type({ date: '2026-09-30', currency: 'EUR' });
page.kv.set.mockResolvedValueOnce(false);
await page.click('approve');
expect(page.status()).toMatch(/was not confirmed/);
expect(page.status()).toMatch(/Use Open report/);
expect(page.byId('reopen').disabled).toBe(false);
expect(page.byId('save').disabled).toBe(true);
const sets = page.kv.set.mock.calls.length;
await page.click('approve');
expect(page.status()).toMatch(/Open the report again/);
expect(page.kv.set).toHaveBeenCalledTimes(sets);
await page.click('reopen');
expect(page.status()).toBe('The report is open.');
expect(page.byId('reopen').disabled).toBe(true);
expect(page.fields()).toEqual(VALID);
await page.click('approve');
expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
expect(page.chat).toHaveBeenCalledTimes(1);
});
it('keeps a review whose save failed, and approves it after reopening', async () => {
const page = await runCompanion();
page.kv.set.mockRejectedValueOnce(new Error('offline'));
await page.click('pick');
expect(page.status()).toMatch(/The review may not be saved\. The save was not confirmed \(offline\)/);
expect(page.fields()).toEqual({ merchant: 'Corner Cafe', date: '', currency: '', total: '12.40' });
await page.click('reopen');
page.type({ date: '2026-09-30', currency: 'EUR' });
await page.click('approve');
expect(latest(page.kv).expenses).toEqual([expenseRow(SAMPLE_IMAGE)]);
expect(page.chat).toHaveBeenCalledTimes(1);
});
it('starts with every field empty when the AI request fails', async () => {
const page = await runCompanion({ chat: vi.fn(async () => {
throw new Error('offline');
}) });
await page.click('pick');
expect(page.fields()).toEqual({ merchant: '', date: '', currency: '', total: '' });
expect(page.problems()[0]).toBe('No AI suggestions (offline). Fill in each field from the receipt.');
expect(latest(page.kv).expenses).toEqual([]);
});
it('shows AI text that looks like markup only as input values and text', async () => {
const markup = '<img src=x onerror="globalThis.pwned = true">';
const page = await runCompanion({ chat: vi.fn(async () => reply({ ...SAMPLE_REPLY, merchant: markup, currency: markup })) });
await page.click('pick');
expect(page.fields().merchant).toBe(markup);
expect(page.fields().currency).toBe('');
expect(page.problems()).toContain('Ignored the suggested currency. It must be a three-letter code in capitals, such as EUR.');
expect(globalThis.pwned).toBeUndefined();
});
it('shows an error when the report cannot be opened, without sending the image', async () => {
const kv = memoryKv(storedReport({ version: 2 }));
const page = await runCompanion({ kv });
await page.click('pick');
expect(page.status()).toMatch(/version 1/);
expect(page.chat).not.toHaveBeenCalled();
expect(page.kv.set).not.toHaveBeenCalled();
expect(page.byId('reopen').disabled).toBe(false);
});
});