lists-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev ebccaf0b13fe
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone ee59501e91bae6d2c23a8370
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
functions/lib/lists.ts
// Lists' shared logic, used by the web app and the public API: store sections and the built-in
// dictionary that puts an item in its aisle, item names and their keys, quantities typed or spoken
// ("2 lb chicken", "milk and eggs"), and grouping a list for display.

export const SECTIONS = [
  { id: "produce", label: "Produce" },
  { id: "bakery", label: "Bakery" },
  { id: "deli", label: "Deli" },
  { id: "meat", label: "Meat and seafood" },
  { id: "dairy", label: "Dairy and eggs" },
  { id: "frozen", label: "Frozen" },
  { id: "pantry", label: "Pantry" },
  { id: "baking", label: "Baking and spices" },
  { id: "breakfast", label: "Breakfast and cereal" },
  { id: "snacks", label: "Snacks and sweets" },
  { id: "drinks", label: "Drinks" },
  { id: "household", label: "Household" },
  { id: "personal", label: "Personal care" },
  { id: "baby", label: "Baby" },
  { id: "pets", label: "Pets" },
  { id: "other", label: "Other" },
] as const;

export type SectionId = (typeof SECTIONS)[number]["id"];
export type Kind = "grocery" | "todo";

const SECTION_IDS = new Set<string>(SECTIONS.map((s) => s.id));
export const isSection = (s: unknown): s is SectionId => typeof s === "string" && SECTION_IDS.has(s);
export const sectionLabel = (id: string) => SECTIONS.find((s) => s.id === id)?.label ?? "Other";

/** Finds a section by id or by name ("dairy", "Meat and seafood", "meat"). */
export function findSection(ref: unknown): SectionId | null {
  const s = String(ref ?? "").trim().toLowerCase();
  if (!s) return null;
  if (isSection(s)) return s;
  const hit = SECTIONS.find((x) => x.label.toLowerCase() === s || x.label.toLowerCase().startsWith(s));
  return hit ? hit.id : null;
}

// Common grocery items by section. Plural or singular does not matter: both sides are stemmed.
const DICTIONARY: Record<Exclude<SectionId, "other">, string> = {
  produce: `apple, banana, orange, lemon, lime, grapefruit, grape, strawberry, blueberry, raspberry, blackberry, cranberry, cherry,
    peach, nectarine, plum, pear, apricot, mango, pineapple, papaya, kiwi, melon, watermelon, cantaloupe, honeydew, pomegranate,
    fig, avocado, tomato, cherry tomato, grape tomato, potato, sweet potato, yam, onion, red onion, yellow onion, green onion,
    scallion, shallot, garlic, ginger, carrot, baby carrot, celery, cucumber, zucchini, squash, butternut squash, pumpkin,
    bell pepper, pepper, red pepper, green pepper, jalapeno, chili, broccoli, cauliflower, cabbage, brussels sprout, kale,
    spinach, lettuce, romaine, iceberg, arugula, salad, salad mix, spring mix, mushroom, asparagus, green bean, snap pea,
    snow pea, corn, corn on the cob, eggplant, beet, radish, turnip, parsnip, leek, fennel, artichoke, okra, bok choy, basil,
    cilantro, parsley, mint, dill, rosemary, thyme, sage, chive, sprout, bean sprout, coleslaw, fruit, vegetable, veggie,
    berry, clementine, mandarin, tangerine, plantain, rhubarb, tomatillo, microgreen, tofu, lemongrass, persimmon, guava,
    dragon fruit, passion fruit, lychee, coconut, potatoes, herb`,
  bakery: `bread, loaf, white bread, wheat bread, whole wheat bread, sourdough, rye bread, baguette, bagel, bun, hamburger bun,
    hot dog bun, roll, dinner roll, croissant, muffin, english muffin, tortilla, flour tortilla, corn tortilla, pita, naan,
    flatbread, wrap, cake, birthday cake, cupcake, pie, donut, doughnut, danish, scone, brioche, ciabatta, focaccia,
    cinnamon roll, pastry, crumpet, challah, biscuit, garlic bread, pound cake`,
  deli: `ham, salami, pepperoni, prosciutto, bologna, deli meat, lunch meat, cold cut, roast beef, sliced turkey, hummus,
    rotisserie chicken, potato salad, pasta salad, guacamole, dip, olive bar, deli, pastrami, mortadella, sandwich`,
  meat: `chicken, chicken breast, chicken thigh, chicken wing, chicken leg, drumstick, whole chicken, ground chicken, beef,
    ground beef, steak, ribeye, sirloin, brisket, pork, pork chop, pork loin, pork shoulder, bacon, sausage, hot dog,
    bratwurst, ground turkey, turkey, lamb, veal, rib, short rib, meatball, fish, salmon, cod, tilapia, halibut, trout,
    shrimp, prawn, crab, lobster, scallop, mussel, clam, oyster, ground pork, chorizo, duck, venison, burger, patty,
    tenderloin, roast, pot roast, chuck, mince, tuna steak, mahi mahi, sea bass, catfish, meat, seafood`,
  dairy: `milk, whole milk, skim milk, oat milk, almond milk, soy milk, rice milk, lactose free milk, chocolate milk,
    half and half, cream, heavy cream, whipping cream, sour cream, whipped cream, butter, margarine, egg, cheese, cheddar,
    mozzarella, parmesan, feta, goat cheese, cream cheese, cottage cheese, ricotta, brie, swiss, swiss cheese, provolone,
    string cheese, shredded cheese, sliced cheese, american cheese, yogurt, greek yogurt, kefir, creamer, coffee creamer,
    ghee, buttermilk, custard, pudding, dairy`,
  frozen: `ice cream, frozen pizza, pizza, frozen vegetable, frozen fruit, waffle, frozen waffle, popsicle, ice pop, sorbet,
    gelato, frozen yogurt, ice, ice cube, tater tot, french fry, fries, fish stick, chicken nugget, nugget, burrito,
    dumpling, pizza roll, pot pie, frozen dinner, tv dinner, edamame, hash brown, pierogi, egg roll, ice cream sandwich, frozen`,
  pantry: `rice, brown rice, white rice, jasmine rice, basmati rice, pasta, spaghetti, penne, macaroni, mac and cheese,
    macaroni and cheese, noodle, egg noodle, ramen, instant noodle, lasagna, quinoa, couscous, lentil, bean, black bean,
    kidney bean, pinto bean, baked bean, refried bean, chickpea, garbanzo, canned tomato, diced tomato, crushed tomato,
    tomato sauce, tomato paste, pasta sauce, spaghetti sauce, marinara, pesto, salsa, soup, broth, stock, chicken broth,
    beef broth, vegetable broth, bouillon, tuna, canned tuna, sardine, anchovy, olive, pickle, ketchup, mustard, mayo,
    mayonnaise, relish, bbq sauce, barbecue sauce, hot sauce, sriracha, soy sauce, teriyaki sauce, worcestershire, vinegar,
    olive oil, vegetable oil, canola oil, coconut oil, sesame oil, oil, cooking spray, peanut butter, almond butter, jelly, jam,
    honey, maple syrup, syrup, nutella, salad dressing, dressing, ranch, crouton, breadcrumb, panko, coconut milk,
    curry paste, taco shell, canned corn, canned fruit, applesauce, gravy, stuffing, sauce, caper, sun dried tomato,
    canned, can, fish sauce, hoisin, tahini, ramen noodle, mac n cheese`,
  baking: `flour, all purpose flour, almond flour, sugar, brown sugar, powdered sugar, baking soda, baking powder, yeast,
    vanilla, vanilla extract, chocolate chip, cocoa, cocoa powder, cornstarch, cake mix, brownie mix, muffin mix, frosting,
    sprinkle, salt, sea salt, black pepper, peppercorn, spice, cinnamon, nutmeg, paprika, cumin, oregano, chili powder,
    garlic powder, onion powder, curry powder, turmeric, bay leaf, red pepper flake, seasoning, italian seasoning,
    taco seasoning, ground ginger, food coloring, shortening, condensed milk, evaporated milk, gelatin, pie crust, molasses,
    corn syrup, cream of tartar, sweetener, stevia, extract`,
  breakfast: `cereal, oatmeal, oat, rolled oat, granola, pancake mix, waffle mix, pop tart, grits, cream of wheat, muesli,
    breakfast bar, pancake, cheerio, frosted flake, corn flake`,
  snacks: `chip, potato chip, tortilla chip, pretzel, popcorn, cracker, cookie, candy, chocolate, dark chocolate, gum, nut,
    almond, cashew, peanut, pistachio, walnut, pecan, trail mix, granola bar, protein bar, fruit snack, raisin, dried fruit,
    jerky, beef jerky, rice cake, goldfish, marshmallow, licorice, gummy, gummy bear, snack, brownie, cheese cracker,
    graham cracker, oreo, peanut butter cup`,
  drinks: `water, bottled water, sparkling water, seltzer, soda, pop, cola, coke, diet coke, pepsi, sprite, ginger ale, juice,
    orange juice, apple juice, cranberry juice, lemonade, iced tea, tea, green tea, coffee, coffee bean, ground coffee,
    k cup, espresso, energy drink, sports drink, gatorade, kombucha, beer, wine, red wine, white wine, rose, vodka, whiskey,
    bourbon, rum, gin, tequila, champagne, prosecco, cider, hard seltzer, tonic, tonic water, club soda, coconut water, drink`,
  household: `paper towel, toilet paper, tissue, napkin, trash bag, garbage bag, bin bag, aluminum foil, foil, plastic wrap,
    cling film, parchment paper, wax paper, ziploc, zip bag, sandwich bag, freezer bag, dish soap, dishwasher detergent,
    dishwasher pod, dishwasher tablet, laundry detergent, detergent, laundry pod, fabric softener, dryer sheet, bleach,
    cleaner, all purpose cleaner, glass cleaner, disinfectant, disinfecting wipe, wipe, sponge, scrubber, light bulb,
    battery, candle, match, lighter, air freshener, paper plate, plastic cup, paper cup, straw, coffee filter, broom, mop,
    vacuum bag, charcoal, lighter fluid, tape, glue, stain remover, toilet cleaner, rubber glove, storage bag`,
  personal: `shampoo, conditioner, soap, body wash, hand soap, bar soap, toothpaste, toothbrush, floss, mouthwash, deodorant,
    razor, razor blade, shaving cream, lotion, body lotion, sunscreen, lip balm, chapstick, cotton ball, cotton swab, q tip,
    band aid, bandage, tampon, pad, makeup, hair gel, hairspray, vitamin, multivitamin, medicine, ibuprofen, advil, tylenol,
    acetaminophen, aspirin, allergy medicine, cough drop, cold medicine, contact solution, nail polish, face wash,
    moisturizer, dry shampoo, hair tie, feminine pad, painkiller, antacid, melatonin`,
  baby: `diaper, baby wipe, formula, baby formula, baby food, pacifier, baby shampoo, diaper cream, pull up, teether, baby`,
  pets: `dog food, cat food, kibble, cat litter, litter, dog treat, cat treat, pet food, bird seed, fish food, poop bag,
    dog chew, flea treatment, pet`,
};

// ---------- Names, stems and keys ----------

const IRREGULAR: Record<string, string> = { loaves: "loaf", leaves: "leaf", halves: "half", knives: "knife" };

/** One word's stem, so "eggs" and "egg", "cookies" and "cookie", "berries" and "berry" match. */
export function stem(word: string): string {
  const w = IRREGULAR[word] ?? word;
  if (w.length <= 3 || /(ss|us|is)$/.test(w)) return w;
  if (/ies$/.test(w)) return w.slice(0, -3) + "i";
  if (/ie$/.test(w)) return w.slice(0, -2) + "i";
  if (/[^aeiou]y$/.test(w)) return w.slice(0, -1) + "i";
  if (/(ches|shes|sses|xes|zes|oes)$/.test(w)) return w.slice(0, -2);
  if (/s$/.test(w)) return w.slice(0, -1);
  return w;
}

/** Lowercase words without accents or punctuation. */
export function words(text: string): string[] {
  return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/&/g, " and ").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}

/** The normal form of an item name: stemmed words. "Eggs " and "egg" are the same item. */
export const normal = (name: string) => words(name).map(stem).join(" ");

function hash32(s: string, seed: number): number {
  let h = seed >>> 0;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619) >>> 0;
  }
  return h >>> 0;
}

/**
 * An item's id on a list: the same name always gives the same id, so two phones adding "milk" while
 * offline end up with one milk, not two. Names with no letters or digits fall back to the raw text.
 */
export function itemKey(name: string): string {
  const n = normal(name) || name.trim().toLowerCase();
  return "i" + hash32(n, 2166136261).toString(36).padStart(7, "0") + hash32(n, 0x9e3779b9).toString(36).padStart(7, "0");
}

const DICT = new Map<string, { section: SectionId; name: string }>();
const AND_PHRASES: string[] = [];
for (const [section, list] of Object.entries(DICTIONARY) as [SectionId, string][]) {
  for (const raw of list.split(",")) {
    const n = normal(raw);
    if (!n || DICT.has(n)) continue;
    DICT.set(n, { section, name: raw.trim() });
    if (n.includes(" and ")) AND_PHRASES.push(n);
  }
}

/** The dictionary's guess for an item's section. The head noun wins: "chocolate chip cookies" is a snack. */
export function guessSection(name: string): SectionId {
  const w = words(name).map(stem);
  if (w.includes("frozen")) return "frozen";
  if (w.includes("canned")) return "pantry";
  for (let end = w.length; end > 0; end--) {
    for (let n = Math.min(4, end); n >= 1; n--) {
      const hit = DICT.get(w.slice(end - n, end).join(" "));
      if (hit) return hit.section;
    }
  }
  return "other";
}

/** Dictionary items whose name starts with what was typed (or has a word that does), shortest first. */
export function dictionaryMatches(typed: string, limit: number): string[] {
  const t = normal(typed);
  if (t.length < 2) return [];
  const out: string[] = [];
  for (const [n, { name }] of DICT) {
    if (n.startsWith(t) || n.includes(` ${t}`)) out.push(name);
  }
  return out.sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, limit);
}

/** An item name as stored: single spaces, and a capital first letter when it was typed all lowercase ("milk" -> "Milk"). */
export function tidyName(name: string): string {
  const t = name.trim().replace(/\s+/g, " ");
  return t === t.toLowerCase() ? t.charAt(0).toUpperCase() + t.slice(1) : t;
}

// ---------- Typed and spoken entries ----------

const NUMBER_WORDS: Record<string, string> = {
  a: "1", an: "1", one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
  eleven: "11", twelve: "12", half: "1/2", "½": "1/2", "¼": "1/4", "¾": "3/4",
};
const UNIT = "lbs?|pounds?|kgs?|kilos?|g|grams?|oz|ounces?|l|liters?|litres?|ml|gal|gallons?|qt|quarts?|pints?|dozen|doz|packs?|pk|bags?|box(?:es)?|cans?|bottles?|jars?|bunch(?:es)?|heads?|loaf|loaves|cups?|tbsp|tsp|ct|count|cartons?|rolls?|bars?|pieces?|pcs?|slices?|sticks?|cloves?|tubs?|cases?|packets?|containers?|blocks?|bundles?";
const NUM = "\\d+(?:[.,]\\d+)?(?:\\s*\\/\\s*\\d+)?|½|¼|¾|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|half";
const LEADING = new RegExp(`^(${NUM})(?:\\s*(${UNIT}))?(?:\\s+of)?\\s+(.+)$`, "i");
const ARTICLE_UNIT = new RegExp(`^(a|an)\\s+(${UNIT})(?:\\s+of)?\\s+(.+)$`, "i");
const TRAILING = /^(.+?)\s*(?:[x×]\s*(\d+)|\((\d+)\))$/i;

export interface Entry { name: string; qty: string }

/** "2 lb chicken" -> {name: "chicken", qty: "2 lb"}; "eggs x2" -> {name: "eggs", qty: "2"}. */
export function parseEntry(text: string): Entry {
  const t = text.trim().replace(/\s+/g, " ");
  if (DICT.has(normal(t))) return { name: t, qty: "" };
  const num = (s: string) => NUMBER_WORDS[s.toLowerCase()] ?? s.replace(/\s+/g, "");
  let m = t.match(ARTICLE_UNIT);
  if (m) return { name: m[3].trim(), qty: `1 ${m[2].toLowerCase()}` };
  m = t.match(LEADING);
  if (m && /\p{L}/u.test(m[3])) return { name: m[3].trim(), qty: [num(m[1]), m[2]?.toLowerCase()].filter(Boolean).join(" ") };
  m = t.match(TRAILING);
  if (m) return { name: m[1].trim(), qty: m[2] || m[3] };
  return { name: t, qty: "" };
}

/** Splits "milk, eggs and bread" into three entries; keeps "mac and cheese" and "half and half" whole, also next to other items ("mac and cheese and milk"). */
export function splitEntries(text: string): string[] {
  const out: string[] = [];
  const has = (s: string, phrase: string) => ` ${normal(s)} `.includes(` ${phrase} `);
  for (const chunk of text.split(/[\n,;]+/)) {
    let entry = "";
    for (const part of chunk.trim().replace(/^(and|plus)\s+/i, "").split(/\s+(?:and|&)\s+/i).map((s) => s.trim()).filter(Boolean)) {
      const joined = `${entry} and ${part}`;
      if (entry && AND_PHRASES.some((p) => has(joined, p) && !has(entry, p) && !has(part, p))) entry = joined;
      else {
        if (entry) out.push(entry);
        entry = part;
      }
    }
    if (entry) out.push(entry);
  }
  return out;
}

// ---------- Lists and items ----------

export const LIMITS = { listName: 60, itemName: 100, qty: 40, note: 300, memberName: 40 };

export interface Item {
  id: string;
  name: string;
  qty: string;
  note: string;
  section: string;
  checked: boolean;
  checkedAt?: number | null;
  /** When it was added, in milliseconds by the adding device's clock. Only used to keep to-do lists in the order things were added. */
  addedAt: number;
}

export interface Known { id: string; name: string; section: string; uses?: number; at?: number }

/** The section order a list keeps (JSON list of ids), with any missing sections at the end in the default order. */
export function sectionOrder(order: string | null | undefined): SectionId[] {
  let saved: unknown = [];
  try { saved = JSON.parse(order || "[]"); } catch { /* bad JSON: default order */ }
  const out = (Array.isArray(saved) ? saved : []).filter(isSection) as SectionId[];
  const seen = new Set(out);
  for (const s of SECTIONS) if (!seen.has(s.id)) out.push(s.id);
  return [...new Set(out)];
}

export interface Group { section: SectionId | null; label: string; items: Item[] }

/**
 * What to show: unchecked items grouped by section in the list's order (grocery lists) or in the order
 * they were added (to-do lists), then everything checked, most recently checked first.
 */
export function groupItems(items: Item[], kind: Kind, order: string | null | undefined): { groups: Group[]; checked: Item[] } {
  const open = items.filter((i) => !i.checked);
  const checked = items.filter((i) => i.checked).sort((a, b) => (b.checkedAt ?? 0) - (a.checkedAt ?? 0) || a.name.localeCompare(b.name));
  if (kind === "todo") {
    return { groups: open.length ? [{ section: null, label: "", items: open.sort((a, b) => (a.addedAt ?? 0) - (b.addedAt ?? 0) || a.name.localeCompare(b.name)) }] : [], checked };
  }
  const groups: Group[] = [];
  for (const s of sectionOrder(order)) {
    const inSection = open.filter((i) => (isSection(i.section) ? i.section : "other") === s).sort((a, b) => a.name.localeCompare(b.name));
    if (inSection.length) groups.push({ section: s, label: sectionLabel(s), items: inSection });
  }
  return { groups, checked };
}

/** "2 lb chicken (boneless)" for reading out loud and for the API. */
export const describeItem = (i: Pick<Item, "name" | "qty" | "note">) => [i.qty, i.name].filter(Boolean).join(" ") + (i.note ? ` (${i.note})` : "");

// ---------- Ids and keys ----------

const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  let out = "";
  for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
  return out;
}

export const LIST_ID_RE = /^[A-Za-z0-9]{20}$/;
/** The list key is the list id (20 characters) followed by its secret (24 characters). The share link carries the secret. */
export const LIST_KEY_RE = /^[A-Za-z0-9]{44}$/;
export const listKey = (listId: string, secret: string) => listId + secret;
export function splitListKey(key: string): { listId: string; secret: string } | null {
  const k = key.trim();
  return LIST_KEY_RE.test(k) ? { listId: k.slice(0, 20), secret: k.slice(20) } : null;
}

Change history

Point OpenApps links to openapps.free
ebccaf0b13 · 17 hours ago
Bind agent address in policy constants
519855687c · 19 hours ago
Use OpenApps service URLs and add contribution strip
2c353edfdc · 19 hours ago
Bounded source sync
58ac7e08ec · 1 day ago
Bounded source sync
150962f3d9 · 1 day ago
Bounded source sync
e52e4676b9 · 1 day ago