A draft: Shopping Sorter
· updated
I like to keep my shopping list ordered by how I walk through the shop (I walk in the same order each time more or less). Usually I would do this manually on the walk to the shop, but after a year that got tedious. So I asked Claude to build a script for Drafts that will do this for me automatically!
The way it works is:
- In a new draft I’ll make a list of things I want to add:
milk, bread, tomatoes - I trigger the script
- For unknown items I select the aisle they belong in or create a new aisle
- If an aisle is created I select which aisle it goes before/after
That produces two notes:
- An archived json file with the list of aisles and learned item -> aisle mappings
{"aisleOrder": ["Veg", "Bakery", "Dairy"],"items": {"milk": "Dairy","bread": "Bakery","ginger": "Veg"}}
- A shopping list
# Veg- [ ] ginger# Bakery- [ ] bread# Dairy- [ ] milk
Some additional features:
- It supports bullet point or comma separated input drafts
- Capitals, amounts (
<item> x2) etc get mapped to the correct aisle - Completed (
- [x] <item>) items are deleted - Input drafts are archived
- You can re-sort the shopping list by running it on it directly
Show code
// Shopping List Aisle Sorter for Drafts//// Two modes:// 1. Run on the Shopping List draft → sort it in place// 2. Run on any other draft → parse items, append to Shopping List, sort it//// Supports comma-separated, bullet-pointed, and newline-separated input.//// Stores mappings in an archived draft tagged "aisle-mappings"// Shopping list lives in a draft tagged "shopping-list"
const STORE_TAG = "aisle-mappings";const SHOPPING_TAG = "shopping-list";
// --- Draft lookup ---
function getStoreDraft() { let results = Draft.query("", "archive", [STORE_TAG]); if (results.length > 0) return results[0]; let d = new Draft(); d.content = JSON.stringify({ aisleOrder: [], items: {} }, null, 2); d.addTag(STORE_TAG); d.isArchived = true; d.update(); return d;}
function getShoppingDraft() { let results = Draft.query("", "inbox", [SHOPPING_TAG]); if (results.length > 0) return results[0]; let d = new Draft(); d.content = ""; d.addTag(SHOPPING_TAG); d.update(); return d;}
// --- Load / Save store ---
function loadStore() { let d = getStoreDraft(); try { return JSON.parse(d.content); } catch (e) { app.displayErrorMessage("Aisle mappings draft has corrupt JSON — please fix or delete it"); context.cancel(); throw new Error("cancelled"); }}
function saveStore(store) { let d = getStoreDraft(); d.content = JSON.stringify(store, null, 2); d.update();}
// --- Normalise item text for matching ---
function normalise(item) { return item .toLowerCase() .replace(/^[-*•]\s*/, "") // strip bullet markers .replace(/^\[[ x]\]\s*/, "") // strip checkbox markers .replace(/^\d+[\.\)\s]*/, "") // strip leading numbers "1. " "2) " .replace(/\s*\(.*?\)\s*/g, " ") // strip parentheticals .replace(/\bx\s*\d+/gi, "") // strip "x2", "x 3" .trim();}
// --- Clean display text (strip list formatting only) ---
function cleanDisplay(text) { return text .replace(/^[-*•]\s*/, "") .replace(/^\[[ x]\]\s*/, "") .replace(/^\d+[\.\)\s]*/, "");}
// --- Parse items from freeform text ---// Supports: comma-separated, bullet lists, newline-separated, or a mix
function parseItems(text) { let lines = text.split("\n").filter(l => l.trim().length > 0); // Strip aisle headers from previous sorts lines = lines.filter(l => !/^## /.test(l.trim())); // Remove checked-off items lines = lines.filter(l => !/^-\s*\[x\]/i.test(l.trim()));
let rawItems = []; for (let line of lines) { // If a line contains commas, split it into separate items if (line.includes(",")) { for (let part of line.split(",")) { if (part.trim().length > 0) rawItems.push(part.trim()); } } else { rawItems.push(line.trim()); } }
return rawItems .map(raw => ({ display: cleanDisplay(raw), key: normalise(raw) })) .filter(i => i.key.length > 0);}
// --- Cancel the script ---
function cancelScript() { context.cancel(); throw new Error("cancelled");}
// --- Prompt to position a new aisle in the walk order ---
function askAislePosition(aisleName, aisleOrder) { if (aisleOrder.length === 0) return 0;
let p = new Prompt(); p.title = "Place aisle in walk order"; p.message = `Where does "${aisleName}" come in your store walk?`;
let options = []; for (let i = 0; i < aisleOrder.length; i++) { options.push(`Before "${aisleOrder[i]}"`); } options.push(`After "${aisleOrder[aisleOrder.length - 1]}" (end)`);
p.addPicker("pos", "", [options], [aisleOrder.length]); p.addButton("OK");
if (!p.show()) cancelScript(); return p.fieldValues["pos"][0];}
// --- Prompt user for aisle assignment ---
function askAisle(item, store) { let p = new Prompt(); p.title = "Unknown item"; p.message = `Which aisle for "${item}"?`;
if (store.aisleOrder.length > 0) { p.addSelect("existing", "Pick existing aisle", store.aisleOrder, [], false); }
p.addTextField("custom", "Or type new aisle", ""); p.addButton("Save"); p.addButton("Skip");
if (!p.show()) cancelScript(); if (p.buttonPressed === "Skip") return null;
let custom = p.fieldValues["custom"].trim(); if (custom) { if (!store.aisleOrder.includes(custom)) { let pos = askAislePosition(custom, store.aisleOrder); store.aisleOrder.splice(pos, 0, custom); } return custom; }
let selected = p.fieldValues["existing"]; if (selected && selected.length > 0) return selected[0];
return null;}
// --- Sort items and return formatted output ---
function sortItems(items, store) { const UNASSIGNED = "❓ Unsorted";
// Resolve unknown items let unknowns = items.filter(i => !store.items[i.key]); if (unknowns.length > 0) { let countMsg = `${unknowns.length} new item${unknowns.length > 1 ? "s" : ""} to classify`; app.displayInfoMessage(countMsg);
for (let item of unknowns) { let aisle = askAisle(item.display, store); if (aisle) { store.items[item.key] = aisle; } }
saveStore(store); }
// Group by aisle let grouped = {}; for (let item of items) { let aisle = store.items[item.key] || UNASSIGNED; if (!grouped[aisle]) grouped[aisle] = []; grouped[aisle].push([item.key, item.display]); }
// Custom aisle order; unknowns after, unsorted last let knownOrder = store.aisleOrder.filter(a => grouped[a]); let unknownAisles = Object.keys(grouped).filter( a => a !== UNASSIGNED && !store.aisleOrder.includes(a) ); let aisleSequence = [...knownOrder, ...unknownAisles.sort()]; if (grouped[UNASSIGNED]) aisleSequence.push(UNASSIGNED);
// Build sorted output let output = []; for (let aisle of aisleSequence) { output.push(`## ${aisle}`); grouped[aisle] .sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: "base" })) .forEach(([, display]) => output.push(`- [ ] ${display}`)); output.push(""); }
return { text: output.join("\n"), itemCount: items.length, aisleCount: aisleSequence.length };}
// --- Main ---
let store = loadStore();let shoppingDraft = getShoppingDraft();let isShoppingDraft = draft.uuid === shoppingDraft.uuid;
if (isShoppingDraft) { // Mode 1: Run on the shopping list — sort in place let items = parseItems(draft.content); let result = sortItems(items, store); draft.content = result.text; draft.update(); app.displaySuccessMessage(`Sorted ${result.itemCount} items into ${result.aisleCount} aisles`);} else { // Mode 2: Run on another draft — parse, append to shopping list, sort let newItems = parseItems(draft.content); if (newItems.length === 0) { app.displayWarningMessage("No items found to add"); } else { // Append new display text to shopping list let existing = shoppingDraft.content.trim(); let appendText = newItems.map(i => i.display).join("\n"); shoppingDraft.content = existing.length > 0 ? existing + "\n" + appendText : appendText;
// Re-parse and sort the full shopping list let allItems = parseItems(shoppingDraft.content); let result = sortItems(allItems, store); shoppingDraft.content = result.text; shoppingDraft.update(); draft.isArchived = true; draft.update(); app.displaySuccessMessage(`Added ${newItems.length} items — ${result.itemCount} total in ${result.aisleCount} aisles`); }}It even comes with tests!
Show tests
const { describe, it } = require("node:test");const assert = require("node:assert/strict");const vm = require("node:vm");const fs = require("node:fs");const path = require("node:path");
const SCRIPT_SRC = fs.readFileSync( path.join(__dirname, "shopping-sorter.js"), "utf-8");
/** * Internal: Build a mock Drafts context and run the script. * Use runOnShoppingDraft() or runFromOtherDraft() instead. */function _run({ draftContent, shoppingContent, isOnShoppingDraft, storedJson = null, promptResponses = [] }) { let savedJson = null; let promptQueue = [...promptResponses]; let messages = { info: [], success: [], warning: [], error: [] }; let promptsShown = [];
const SHOPPING_UUID = "shopping-uuid"; const OTHER_UUID = "other-uuid";
const mockDraft = { content: draftContent, uuid: isOnShoppingDraft ? SHOPPING_UUID : OTHER_UUID, isArchived: false, update() {}, };
const mockShoppingDraft = isOnShoppingDraft ? mockDraft : { content: shoppingContent, uuid: SHOPPING_UUID, update() {}, };
// Each `new Prompt()` pops the next response from the queue function MockPrompt() { this.title = ""; this.message = ""; this.buttonPressed = null; this.fieldValues = {}; this._fields = {}; this._buttons = [];
this.addSelect = (key, label, options, selected, multi) => { this._fields[key] = { type: "select", options, selected }; this.fieldValues[key] = selected; }; this.addTextField = (key, label, defaultVal) => { this._fields[key] = { type: "text", defaultVal }; this.fieldValues[key] = defaultVal; }; this.addPicker = (key, label, columns, selectedRows) => { this._fields[key] = { type: "picker", columns, selectedRows }; this.fieldValues[key] = selectedRows; }; this.addButton = (label) => { this._buttons.push(label); };
this.show = () => { let response = promptQueue.shift(); if (!response) { throw new Error( `Unexpected prompt "${this.title}": no response queued. ` + `Buttons: [${this._buttons.join(", ")}]` ); } if (response.cancel) { promptsShown.push({ title: this.title, message: this.message, cancelled: true }); return false; } if (!response.button) { throw new Error( `Prompt "${this.title}" got a test response with no button. ` + `Available buttons: [${this._buttons.join(", ")}]` ); } if (!this._buttons.includes(response.button)) { throw new Error( `Prompt "${this.title}" has no button "${response.button}". ` + `Available buttons: [${this._buttons.join(", ")}]` ); } promptsShown.push({ title: this.title, message: this.message, ...response }); this.buttonPressed = response.button; if (response.fields) { for (let [k, v] of Object.entries(response.fields)) { this.fieldValues[k] = v; } } return true; }; }
// Store draft mock let storeDraftContent = storedJson !== null ? JSON.stringify(storedJson) : JSON.stringify({ aisleOrder: [], items: {} }); let storeDraftExists = storedJson !== null;
let storeDraft = { content: storeDraftContent, addTag() {}, isArchived: false, update() { savedJson = JSON.parse(this.content); }, };
let createdDrafts = [];
function MockDraft() { this.content = ""; this.uuid = "new-draft-" + createdDrafts.length; this.isArchived = false; this.addTag = () => {}; this.update = () => {}; createdDrafts.push(this); } MockDraft.query = (text, filter, tags) => { if (tags && tags[0] === "aisle-mappings") { return storeDraftExists ? [storeDraft] : []; } if (tags && tags[0] === "shopping-list") { return [mockShoppingDraft]; } return []; };
let cancelled = false;
const vmContext = vm.createContext({ Draft: MockDraft, Prompt: MockPrompt, draft: mockDraft, app: { displayInfoMessage: (msg) => messages.info.push(msg), displaySuccessMessage: (msg) => messages.success.push(msg), displayWarningMessage: (msg) => messages.warning.push(msg), displayErrorMessage: (msg) => messages.error.push(msg), }, context: { cancel: () => { cancelled = true; } }, });
try { vm.runInContext(SCRIPT_SRC, vmContext); } catch (e) { if (!cancelled) throw e; }
return { draft: mockDraft, shoppingDraft: mockShoppingDraft, savedJson, messages, prompts: promptsShown, cancelled, };}
/** * Run the script as if the current draft IS the shopping list. * @param {object} opts * @param {string} opts.draftContent - shopping list content * @param {object|null} opts.storedJson - aisle-mappings content * @param {Array} opts.promptResponses - prompt response queue */function runOnShoppingDraft({ draftContent, storedJson, promptResponses }) { return _run({ draftContent, shoppingContent: draftContent, isOnShoppingDraft: true, storedJson, promptResponses });}
/** * Run the script on a non-shopping draft, appending items to the shopping list. * @param {object} opts * @param {string} opts.draftContent - content of the current (non-shopping) draft * @param {string} opts.shoppingContent - existing shopping list content * @param {object|null} opts.storedJson - aisle-mappings content * @param {Array} opts.promptResponses - prompt response queue */function runFromOtherDraft({ draftContent, shoppingContent, storedJson, promptResponses }) { return _run({ draftContent, shoppingContent, isOnShoppingDraft: false, storedJson, promptResponses });}
// --- Helpers ---
/** Parse the sorted output back into { aisle: [items] } */function parseOutput(content) { let result = {}; let currentAisle = null; for (let line of content.split("\n")) { if (line.startsWith("## ")) { currentAisle = line.slice(3); result[currentAisle] = []; } else if (currentAisle && line.trim()) { // Strip the "- [ ] " checkbox prefix to get clean item text let item = line.replace(/^- \[ \] /, ""); result[currentAisle].push(item); } } return result;}
/** Get just the aisle names in order from the output */function aisleNames(content) { return content .split("\n") .filter((l) => l.startsWith("## ")) .map((l) => l.slice(3));}
/** * Extract the normalise function from the script for direct unit testing. */function getNormalise() { function StubDraft() { this.content = ""; this.uuid = "stub"; this.isArchived = false; this.addTag = () => {}; this.update = () => {}; } StubDraft.query = (text, filter, tags) => { if (tags && tags[0] === "aisle-mappings") { return [{ content: JSON.stringify({ aisleOrder: [], items: {} }), update() {} }]; } if (tags && tags[0] === "shopping-list") { return [{ content: "", uuid: "stub", update() {} }]; } return []; };
const context = vm.createContext({ Draft: StubDraft, Prompt: function () { this.fieldValues = {}; this._buttons = []; this.addSelect = () => {}; this.addTextField = (k, _, d) => (this.fieldValues[k] = d); this.addPicker = () => {}; this.addButton = () => {}; this.show = () => false; }, draft: { content: "", uuid: "stub", update() {} }, app: { displayInfoMessage: () => {}, displaySuccessMessage: () => {} }, console: { log: () => {} }, }); vm.runInContext(SCRIPT_SRC + "\nthis._normalise = normalise;", context); return context._normalise;}
// =============================================================================// Tests// =============================================================================
describe("normalise (unit)", () => { const normalise = getNormalise();
it("lowercases input", () => { assert.equal(normalise("MILK"), "milk"); assert.equal(normalise("Oat Milk"), "oat milk"); });
it("strips bullet markers: - * •", () => { assert.equal(normalise("- milk"), "milk"); assert.equal(normalise("* milk"), "milk"); assert.equal(normalise("• milk"), "milk"); });
it("strips numbered prefixes: 1. 2) 3", () => { assert.equal(normalise("1. milk"), "milk"); assert.equal(normalise("2) milk"), "milk"); assert.equal(normalise("3 milk"), "milk"); });
it("strips quantity markers: x2, X3", () => { assert.equal(normalise("milk x2"), "milk"); assert.equal(normalise("milk X3"), "milk"); });
it("strips parentheticals", () => { assert.equal(normalise("Oat Milk (Oatly)"), "oat milk"); });
it("handles combined prefixes and suffixes", () => { assert.equal(normalise("- 1. Oat Milk (Oatly) x2"), "oat milk"); });
it("strips checkbox markers", () => { assert.equal(normalise("- [ ] milk"), "milk"); assert.equal(normalise("- [x] milk"), "milk"); });
it("trims whitespace", () => { assert.equal(normalise(" milk "), "milk"); });
it("returns empty string for empty input", () => { assert.equal(normalise(""), ""); assert.equal(normalise(" "), ""); });});
describe("loadStore", () => { it("returns empty store when no file exists", () => { const { draft } = runOnShoppingDraft({ draftContent: "milk", promptResponses: [{ button: "Skip" }], }); assert.ok(draft.content.includes("milk")); });
it("shows error and cancels on corrupt JSON", () => { let errorMessages = []; let cancelled = false;
function CorruptDraft() { this.content = ""; this.uuid = "new"; this.isArchived = false; this.addTag = () => {}; this.update = () => {}; } CorruptDraft.query = (text, filter, tags) => { if (tags && tags[0] === "aisle-mappings") return [{ content: "{corrupt!", update() {} }]; if (tags && tags[0] === "shopping-list") return [{ content: "milk", uuid: "shop", update() {} }]; return []; };
const ctx = vm.createContext({ Draft: CorruptDraft, Prompt: function () {}, draft: { content: "milk", uuid: "shop", update() {} }, app: { displayInfoMessage: () => {}, displaySuccessMessage: () => {}, displayErrorMessage: (msg) => { errorMessages.push(msg); }, }, context: { cancel: () => { cancelled = true; } }, }); assert.throws(() => vm.runInContext(SCRIPT_SRC, ctx), /cancelled/); assert.ok(errorMessages[0].includes("corrupt")); assert.ok(cancelled, "should cancel the action"); });});
describe("normalise (integration)", () => { it("bullet-prefixed items match stored keys without prompting", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { prompts, draft } = runOnShoppingDraft({ draftContent: "- milk\n* milk\n• milk", storedJson: store }); assert.equal(prompts.length, 0, "should not prompt — all items should match"); assert.equal(parseOutput(draft.content)["Dairy"].length, 3); });
it("numbered-prefix items match stored keys without prompting", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { prompts, draft } = runOnShoppingDraft({ draftContent: "1. milk\n2) milk\n3 milk", storedJson: store }); assert.equal(prompts.length, 0); assert.equal(parseOutput(draft.content)["Dairy"].length, 3); });
it("quantity-suffixed items match stored keys without prompting", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { prompts, draft } = runOnShoppingDraft({ draftContent: "milk x2\nmilk X3", storedJson: store }); assert.equal(prompts.length, 0); assert.equal(parseOutput(draft.content)["Dairy"].length, 2); });
it("parenthetical items match stored keys without prompting", () => { const store = { aisleOrder: ["Dairy"], items: { "oat milk": "Dairy" } }; const { prompts, draft } = runOnShoppingDraft({ draftContent: "Oat Milk (Oatly)", storedJson: store }); assert.equal(prompts.length, 0); assert.equal(parseOutput(draft.content)["Dairy"].length, 1); });});
describe("display text cleanup", () => { it("strips bullet markers from output", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "- milk\n* milk\n• milk", storedJson: store }); assert.ok(parseOutput(draft.content)["Dairy"].every(item => item === "milk")); });
it("strips numbered prefixes from output", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "1. milk\n2) milk\n3 milk", storedJson: store }); assert.ok(parseOutput(draft.content)["Dairy"].every(item => item === "milk")); });
it("preserves case in output", () => { const store = { aisleOrder: ["Dairy"], items: { "oat milk": "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "Oat Milk", storedJson: store }); assert.equal(parseOutput(draft.content)["Dairy"][0], "Oat Milk"); });
it("preserves quantities in output", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "Milk x2", storedJson: store }); assert.ok(draft.content.includes("Milk x2")); });
it("preserves parentheticals in output", () => { const store = { aisleOrder: ["Dairy"], items: { "oat milk": "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "Oat Milk (Oatly)", storedJson: store }); assert.ok(draft.content.includes("Oat Milk (Oatly)")); });
it("strips bullets but preserves everything else combined", () => { const store = { aisleOrder: ["Dairy"], items: { "oat milk": "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "- Oat Milk (Oatly) x2", storedJson: store }); assert.equal(parseOutput(draft.content)["Dairy"][0], "Oat Milk (Oatly) x2"); });});
describe("sorting", () => { it("sorts items alphabetically within an aisle by normalised key", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy", butter: "Dairy", cheese: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "milk\ncheese\nbutter", storedJson: store }); assert.deepEqual(parseOutput(draft.content)["Dairy"], ["butter", "cheese", "milk"]); });
it("follows custom aisle order, not alphabetical", () => { const store = { aisleOrder: ["Frozen", "Bakery", "Dairy"], items: { milk: "Dairy", bread: "Bakery", "ice cream": "Frozen" }, }; const { draft } = runOnShoppingDraft({ draftContent: "milk\nbread\nice cream", storedJson: store }); assert.deepEqual(aisleNames(draft.content), ["Frozen", "Bakery", "Dairy"]); });
it("puts unsorted items at the end", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "milk\nmystery item", storedJson: store, promptResponses: [{ button: "Skip" }], }); const order = aisleNames(draft.content); assert.equal(order[0], "Dairy"); assert.equal(order[order.length - 1], "❓ Unsorted"); });
it("omits aisles with no items in this list", () => { const store = { aisleOrder: ["Fruit & Veg", "Bakery", "Dairy", "Frozen"], items: { milk: "Dairy", bread: "Bakery" }, }; const { draft } = runOnShoppingDraft({ draftContent: "milk\nbread", storedJson: store }); assert.deepEqual(aisleNames(draft.content), ["Bakery", "Dairy"]); });});
describe("aisle headers from previous sort", () => { it("strips existing ## headers before re-sorting", () => { const store = { aisleOrder: ["Dairy", "Bakery"], items: { milk: "Dairy", bread: "Bakery" } }; const { draft } = runOnShoppingDraft({ draftContent: "## Dairy\nmilk\n## Bakery\nbread", storedJson: store }); const parsed = parseOutput(draft.content); assert.equal(parsed["Dairy"].length, 1); assert.equal(parsed["Bakery"].length, 1); });});
describe("prompt interactions", () => { it("assigns item to existing aisle via select", () => { const store = { aisleOrder: ["Dairy", "Bakery"], items: { milk: "Dairy" } }; const { draft, savedJson } = runOnShoppingDraft({ draftContent: "milk\nbread", storedJson: store, promptResponses: [{ button: "Save", fields: { existing: ["Bakery"], custom: "" } }], }); assert.equal(savedJson.items["bread"], "Bakery"); assert.deepEqual(aisleNames(draft.content), ["Dairy", "Bakery"]); });
it("creates a new aisle and asks for position", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { savedJson } = runOnShoppingDraft({ draftContent: "milk\nbleach", storedJson: store, promptResponses: [ { button: "Save", fields: { existing: [], custom: "Cleaning" } }, { button: "OK", fields: { pos: [1] } }, ], }); assert.equal(savedJson.items["bleach"], "Cleaning"); assert.deepEqual(savedJson.aisleOrder, ["Dairy", "Cleaning"]); });
it("inserts new aisle before an existing one", () => { const store = { aisleOrder: ["Dairy", "Frozen"], items: { milk: "Dairy", "ice cream": "Frozen" } }; const { savedJson } = runOnShoppingDraft({ draftContent: "milk\nice cream\nbread", storedJson: store, promptResponses: [ { button: "Save", fields: { existing: [], custom: "Bakery" } }, { button: "OK", fields: { pos: [1] } }, ], }); assert.deepEqual(savedJson.aisleOrder, ["Dairy", "Bakery", "Frozen"]); });
it("skips items when user presses Skip", () => { const store = { aisleOrder: [], items: {} }; const { draft, savedJson } = runOnShoppingDraft({ draftContent: "mystery", storedJson: store, promptResponses: [{ button: "Skip" }], }); assert.equal(savedJson.items["mystery"], undefined); assert.ok(draft.content.includes("❓ Unsorted")); });
it("throws on unexpected prompt (no response queued)", () => { const store = { aisleOrder: [], items: {} }; assert.throws( () => runOnShoppingDraft({ draftContent: "mystery", storedJson: store }), /Unexpected prompt/ ); });
it("does not save when all items are already known", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { savedJson, prompts } = runOnShoppingDraft({ draftContent: "milk", storedJson: store }); assert.equal(prompts.length, 0); assert.equal(savedJson, null); });
it("cancels the whole script when aisle prompt is cancelled", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { cancelled, draft, savedJson } = runOnShoppingDraft({ draftContent: "milk\nmystery", storedJson: store, promptResponses: [{ cancel: true }], }); assert.ok(cancelled, "script should be cancelled"); // Draft should not have been modified assert.equal(draft.content, "milk\nmystery"); assert.equal(savedJson, null); });
it("cancels on second item without saving the first", () => { const store = { aisleOrder: ["Dairy"], items: {} }; const { cancelled, savedJson } = runOnShoppingDraft({ draftContent: "apples\nbananas", storedJson: store, promptResponses: [ { button: "Save", fields: { existing: [], custom: "Fruit" } }, { button: "OK", fields: { pos: [1] } }, { cancel: true }, ], }); assert.ok(cancelled, "script should be cancelled"); assert.equal(savedJson, null, "nothing should be saved on cancel"); });
it("cancels when position prompt is cancelled", () => { const store = { aisleOrder: ["Dairy"], items: {} }; const { cancelled, savedJson } = runOnShoppingDraft({ draftContent: "bleach", storedJson: store, promptResponses: [ { button: "Save", fields: { existing: [], custom: "Cleaning" } }, { cancel: true }, ], }); assert.ok(cancelled, "script should be cancelled"); assert.equal(savedJson, null); });
it("does not archive source draft when cancelled from other draft", () => { const store = { aisleOrder: [], items: {} }; const { cancelled, draft } = runFromOtherDraft({ draftContent: "mystery", shoppingContent: "milk", storedJson: store, promptResponses: [{ cancel: true }], }); assert.ok(cancelled); assert.equal(draft.isArchived, false, "source draft should not be archived on cancel"); });});
describe("multiple unknown items", () => { it("prompts for each unknown item in order", () => { const store = { aisleOrder: ["Dairy"], items: {} }; const { savedJson } = runOnShoppingDraft({ draftContent: "apples\nbananas\ncarrots", storedJson: store, promptResponses: [ { button: "Save", fields: { existing: [], custom: "Fruit & Veg" } }, { button: "OK", fields: { pos: [1] } }, { button: "Save", fields: { existing: ["Fruit & Veg"], custom: "" } }, { button: "Save", fields: { existing: ["Fruit & Veg"], custom: "" } }, ], }); assert.equal(savedJson.items["apples"], "Fruit & Veg"); assert.equal(savedJson.items["bananas"], "Fruit & Veg"); assert.equal(savedJson.items["carrots"], "Fruit & Veg"); });});
describe("empty / edge cases", () => { it("handles empty shopping draft", () => { const store = { aisleOrder: [], items: {} }; const { draft, messages } = runOnShoppingDraft({ draftContent: "", storedJson: store }); assert.equal(draft.content, ""); assert.equal(messages.success.length, 1); });
it("handles blank lines and whitespace-only lines", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: " \n\nmilk\n \n", storedJson: store }); assert.equal(parseOutput(draft.content)["Dairy"].length, 1); });
it("is case-insensitive for matching", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft, prompts } = runOnShoppingDraft({ draftContent: "MILK\nMilk\nmilk", storedJson: store }); assert.equal(prompts.length, 0); assert.equal(parseOutput(draft.content)["Dairy"].length, 3); });
it("does not archive the shopping draft when sorting in place", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "milk", storedJson: store }); assert.equal(draft.isArchived, false); });});
describe("info messages", () => { it("reports correct count of new items", () => { const store = { aisleOrder: [], items: { milk: "Dairy" } }; const { messages } = runOnShoppingDraft({ draftContent: "milk\napples\nbread", storedJson: store, promptResponses: [{ button: "Skip" }, { button: "Skip" }], }); assert.equal(messages.info[0], "2 new items to classify"); });
it("uses singular for 1 new item", () => { const store = { aisleOrder: [], items: {} }; const { messages } = runOnShoppingDraft({ draftContent: "apples", storedJson: store, promptResponses: [{ button: "Skip" }], }); assert.equal(messages.info[0], "1 new item to classify"); });});
// =============================================================================// Mode 2: Run on another draft → append to shopping list// =============================================================================
describe("append from other draft", () => { it("appends items to empty shopping list and sorts", () => { const store = { aisleOrder: ["Dairy", "Bakery"], items: { milk: "Dairy", bread: "Bakery" } }; const { shoppingDraft, draft } = runFromOtherDraft({ draftContent: "milk\nbread", shoppingContent: "", storedJson: store, }); const parsed = parseOutput(shoppingDraft.content); assert.equal(parsed["Dairy"].length, 1); assert.equal(parsed["Bakery"].length, 1); // Original draft is unchanged but archived assert.equal(draft.content, "milk\nbread"); assert.ok(draft.isArchived, "source draft should be archived"); });
it("appends items to existing shopping list", () => { const store = { aisleOrder: ["Dairy", "Bakery"], items: { milk: "Dairy", bread: "Bakery", eggs: "Dairy" } }; const { shoppingDraft, draft } = runFromOtherDraft({ draftContent: "bread", shoppingContent: "## Dairy\nmilk\neggs\n", storedJson: store, }); const parsed = parseOutput(shoppingDraft.content); assert.equal(parsed["Dairy"].length, 2); assert.equal(parsed["Bakery"].length, 1); assert.ok(draft.isArchived, "source draft should be archived"); });
it("does not archive source draft when it has no items", () => { const store = { aisleOrder: [], items: {} }; const { draft } = runFromOtherDraft({ draftContent: "", shoppingContent: "milk", storedJson: store, }); assert.equal(draft.isArchived, false, "empty source draft should not be archived"); });
it("shows warning for empty other draft", () => { const store = { aisleOrder: [], items: {} }; const { messages } = runFromOtherDraft({ draftContent: "", shoppingContent: "milk", storedJson: store, }); assert.equal(messages.warning[0], "No items found to add"); });
it("shows correct success message with counts", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy", bread: "Dairy" } }; const { messages } = runFromOtherDraft({ draftContent: "bread", shoppingContent: "milk", storedJson: store, }); assert.ok(messages.success[0].includes("Added 1 items")); assert.ok(messages.success[0].includes("2 total")); });});
// =============================================================================// Parsing formats// =============================================================================
describe("comma-separated parsing", () => { it("splits comma-separated items on a single line", () => { const store = { aisleOrder: ["Dairy", "Bakery"], items: { milk: "Dairy", bread: "Bakery", eggs: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "milk, bread, eggs", storedJson: store }); const parsed = parseOutput(draft.content); assert.equal(parsed["Dairy"].length, 2); // milk + eggs assert.equal(parsed["Bakery"].length, 1); // bread });
it("handles mixed comma and newline input", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy", butter: "Dairy", cheese: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "milk, butter\ncheese", storedJson: store }); assert.equal(parseOutput(draft.content)["Dairy"].length, 3); });
it("ignores empty segments from trailing commas", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft, prompts } = runOnShoppingDraft({ draftContent: "milk,,,", storedJson: store }); assert.equal(prompts.length, 0); assert.equal(parseOutput(draft.content)["Dairy"].length, 1); });
it("parses comma-separated input from other draft and appends", () => { const store = { aisleOrder: ["Dairy", "Bakery"], items: { milk: "Dairy", bread: "Bakery" } }; const { shoppingDraft } = runFromOtherDraft({ draftContent: "milk, bread", shoppingContent: "", storedJson: store, }); const parsed = parseOutput(shoppingDraft.content); assert.equal(parsed["Dairy"].length, 1); assert.equal(parsed["Bakery"].length, 1); });});
// =============================================================================// Checkbox format// =============================================================================
describe("checkbox output format", () => { it("outputs items with - [ ] prefix", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "milk", storedJson: store }); assert.ok(draft.content.includes("- [ ] milk")); });
it("re-sorts existing checkbox items correctly", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy", butter: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "## Dairy\n- [ ] butter\n- [ ] milk", storedJson: store, }); const parsed = parseOutput(draft.content); assert.deepEqual(parsed["Dairy"], ["butter", "milk"]); });});
describe("checked item removal", () => { it("removes checked-off items from the list", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy", butter: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "## Dairy\n- [x] milk\n- [ ] butter", storedJson: store, }); const parsed = parseOutput(draft.content); assert.equal(parsed["Dairy"].length, 1); assert.equal(parsed["Dairy"][0], "butter"); });
it("removes all items if all are checked", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "- [x] milk", storedJson: store, }); assert.equal(draft.content, ""); });
it("handles uppercase X in checkbox", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy", butter: "Dairy" } }; const { draft } = runOnShoppingDraft({ draftContent: "- [X] milk\n- [ ] butter", storedJson: store, }); const parsed = parseOutput(draft.content); assert.equal(parsed["Dairy"].length, 1); assert.equal(parsed["Dairy"][0], "butter"); });
it("does not prompt for checked items that are unknown", () => { const store = { aisleOrder: ["Dairy"], items: { milk: "Dairy" } }; const { prompts } = runOnShoppingDraft({ draftContent: "- [x] mystery\nmilk", storedJson: store, }); assert.equal(prompts.length, 0); });});Tags: #drafts , #automation