// app-data.jsx — sample agenda, motion templates, parser, helpers (function () { // ---- Motion boilerplate templates ------------------------------------- // {{}} marks where the secretary types the substance; after insertion it // is replaced with the PLACEHOLDER token and auto-selected in the textarea. const PLACEHOLDER = "\u2026"; // … const MOTIONS = [ { code: "M/S/U", label: "Carried — unanimous", hint: "Made, seconded, unanimously carried", template: "A motion was duly made, seconded, and unanimously carried to {{}}.", }, { code: "M/S/C", label: "Carried", hint: "Made, seconded, and carried", template: "A motion was duly made, seconded, and carried to {{}}.", }, { code: "M/S/F", label: "Failed", hint: "Made, seconded, but failed", template: "A motion was duly made and seconded, but failed, to {{}}.", }, { code: "Tabled", label: "Tabled / deferred", hint: "Laid over to a future meeting", template: "A motion was duly made and seconded to table {{}} until the next regular meeting.", }, ]; // Appendable vote clauses (inserted before the final period of a motion) const CLAUSES = [ { code: "Abstentions", label: "+ abstentions", clause: ", with {{}} abstaining," }, { code: "Opposed", label: "+ opposed", clause: ", with {{}} opposed," }, ]; // map shorthand typed inline -> template const INLINE = {}; MOTIONS.forEach((m) => { INLINE[m.code.toLowerCase()] = m; }); // ---- Decision statuses ------------------------------------------------- const STATUSES = [ { key: "approved", label: "Approved" }, { key: "tabled", label: "Tabled" }, { key: "deferred", label: "Deferred" }, { key: "noaction", label: "No action" }, { key: "info", label: "Informational" }, ]; // ---- Sample agenda (realistic nonprofit board) ------------------------- const SAMPLE_TEXT = `BOARD OF DIRECTORS — REGULAR MEETING AGENDA 1. Call to Order 2. Roll Call and Establishment of Quorum 3. Approval of the Agenda 4. Approval of Minutes — Regular Meeting of May 11, 2026 5. Consent Agenda 6. Treasurer's Report and Financial Statements 7. Executive Director's Report 8. Committee Reports a. Finance Committee b. Governance & Nominating Committee 9. Old Business a. Adoption of the FY2026 Operating Budget 10. New Business a. Renewal of Headquarters Facilities Lease 11. Public Comment 12. Executive Session 13. Adjournment`; const PRESENTERS = [ "Chair", "Vice Chair", "Secretary", "Treasurer", "Executive Director", "Finance Committee Chair", "Governance Chair", "General Counsel", "Staff", ]; // ---- Roman-numeral helpers -------------------------------------------- const ROMAN_RE = /^(M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3}))$/i; function isRoman(s) { return !!s && ROMAN_RE.test(s); } function romanToInt(s) { const map = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 }; const u = s.toUpperCase(); let n = 0; for (let i = 0; i < u.length; i++) { const cur = map[u[i]], next = map[u[i + 1]] || 0; n += cur < next ? -cur : cur; } return n; } function intToRoman(n) { const t = [["M", 1000], ["CM", 900], ["D", 500], ["CD", 400], ["C", 100], ["XC", 90], ["L", 50], ["XL", 40], ["X", 10], ["IX", 9], ["V", 5], ["IV", 4], ["I", 1]]; let r = ""; t.forEach(([sym, val]) => { while (n >= val) { r += sym; n -= val; } }); return r; } const NOISE = /^(page\s*#?\s*$|agenda\s*$|continued|cont'd)/i; // Pull apart a single raw line into { leadingTabs, page, marker, body }. // page = leading "3-5" / "47" / "76-98" column before a tab/double-space // marker= "I" / "A" / "1" label before a "." or ")" function parseLine(rawLine) { let s = String(rawLine).replace(/\r/g, ""); const leadingTabs = (s.match(/^\t+/) || [""])[0].length; s = s.replace(/^[\t ]+/, "").replace(/[\t ]+$/, ""); if (!s) return { empty: true }; let page = ""; const pm = s.match(/^(\d{1,3}(?:\s*[-\u2013]\s*\d{1,3})?)(?:\t|\u00a0|\s{2,})\s*(.*)$/); if (pm && pm[2]) { page = pm[1].replace(/\s/g, ""); s = pm[2]; } let marker = "", body = s; const mm = s.match(/^([A-Za-z]{1,5}|\d{1,2})[.)][\t ]+(.+)$/); if (mm) { marker = mm[1]; body = mm[2].replace(/^[\t ]+/, ""); } return { empty: false, leadingTabs, page, marker, body: body.trim() }; } // ---- Title-block / meta detection ------------------------------------- const MONTHS = "(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)"; function detectMeta(blockLines) { const meta = {}; let title = ""; const nonEmpty = blockLines.map((l) => l.trim()).filter(Boolean); nonEmpty.forEach((line, i) => { if (/^page\s*#?$/i.test(line)) return; if (i === 0 && !meta.org) { meta.org = toTitleCase(line); return; } if (/\bagenda\b/i.test(line) && !title) { title = toTitleCase(line.replace(/\s*[-\u2013\u2014]?\s*agenda\s*$/i, "").trim()); return; } const tm = line.match(/(\d{1,2}:\d{2})\s*([ap])\.?\s*m\.?/i); if (tm && !meta.time) meta.time = tm[1] + " " + tm[2].toLowerCase() + ".m."; const dm = line.match(new RegExp(MONTHS + "[a-z]*\\.?\\s+(\\d{1,2}),?\\s+(\\d{4})", "i")); if (dm && !meta.date) { const parsed = new Date(dm[0]); if (!isNaN(parsed)) meta.date = parsed.toISOString().slice(0, 10); } if (/(center|hall|room|clubhouse|library|office|videoconference|zoom|teleconference|\d+\s+[NSEW]\.?\s|\bst\b|\bave\b|\bblvd\b|\bdr\b)/i.test(line) && !/\bagenda\b/i.test(line) && !tm) { if (!meta.location) meta.location = toTitleCase(line); } }); return { meta, title }; } // ---- Agenda parser ----------------------------------------------------- // Builds a hierarchical outline. Top level follows whatever scheme the // first item uses (roman "I." or numeric "1."); everything else nests // beneath it as lettered / numbered / unlabelled sub-items. A leading // page-number column is captured per item, and wrapped lines are rejoined. function parseAgenda(raw) { const rawLines = String(raw || "").split(/\r?\n/); // locate first real item line let firstIdx = -1; for (let i = 0; i < rawLines.length; i++) { const p = parseLine(rawLines[i]); if (!p.empty && p.marker) { firstIdx = i; break; } } let title = "", meta = {}; if (firstIdx > 0) { const det = detectMeta(rawLines.slice(0, firstIdx)); meta = det.meta; title = det.title; } const items = []; let scheme = null; // 'roman' | 'num' let topCount = 0; // sequence value of last top-level item let lastItem = null; const startAt = firstIdx >= 0 ? firstIdx : 0; for (let i = startAt; i < rawLines.length; i++) { const p = parseLine(rawLines[i]); if (p.empty) continue; if (NOISE.test(p.body) && !p.marker) continue; const mk = p.marker; const isNum = /^\d+$/.test(mk); let isTop = false, level = 1; if (mk) { if (!scheme) { // first item sets the top-level scheme scheme = isNum ? "num" : (isRoman(mk) ? "roman" : "letter"); isTop = true; topCount = isNum ? parseInt(mk, 10) : (isRoman(mk) ? romanToInt(mk) : 1); } else if (scheme === "num") { if (isNum && parseInt(mk, 10) === topCount + 1) { isTop = true; topCount++; } } else if (scheme === "roman") { if (!isNum && isRoman(mk)) { const val = romanToInt(mk); if (mk.length > 1) { isTop = true; topCount = val; } // II, III, IV… unambiguous else if (val === romanToInt(intToRoman(topCount + 1))) { isTop = true; topCount = val; } // next single } } if (!isTop) level = isNum ? 2 : 1; // numeric markers nest deeper than letters } else { // no marker: continuation of the previous line, or an unlabelled sub if (p.leadingTabs === 0 && lastItem && !p.page) { lastItem.title = (lastItem.title + " " + p.body).replace(/\s+/g, " ").trim(); continue; } level = 1; } const number = isTop ? (scheme === "roman" ? mk.toUpperCase() : mk) : (mk ? (isNum ? mk : mk.toUpperCase()) : ""); const it = makeItem(number, clean(p.body), isTop ? 0 : level, p.page); items.push(it); lastItem = it; } if (items.length >= 2) return { title, items, meta }; // ---- fallback: heading-style (no explicit numbering) ---- items.length = 0; let n = 0; rawLines.forEach((line) => { const text = clean(line.trim()); if (!text || text.length > 90) return; if (NOISE.test(text)) return; const words = text.split(/\s+/).length; if (/[.!?]$/.test(text) && words > 9) return; if (words > 14) return; if (!title && /agenda|board|meeting/i.test(text)) { title = toTitleCase(text); return; } n += 1; items.push(makeItem(String(n), text, 0, "")); }); return { title, items, meta }; } function clean(s) { return String(s) .replace(/\s*\.{2,}\s*\d*$/, "") // dotted leader + trailing page .replace(/\t/g, " ") .replace(/\s+/g, " ") .trim(); } let _seq = 0; function makeItem(number, rawTitle, level, page) { const t = guessType(rawTitle); return { id: "it_" + (Date.now().toString(36)) + "_" + (_seq++), number, title: rawTitle, level: level || 0, isSub: (level || 0) > 0, page: page || "", presenter: t.presenter || "", notes: "", status: t.status || "", vote: { recorded: false, for: "", against: "", abstain: "" }, actions: [], collapsed: false, }; } // light heuristics to pre-fill presenter / likely status function guessType(title) { const t = title.toLowerCase(); if (/treasurer|financial/.test(t)) return { presenter: "Treasurer" }; if (/executive director/.test(t)) return { presenter: "Executive Director" }; if (/finance committee/.test(t)) return { presenter: "Finance Committee Chair" }; if (/governance/.test(t)) return { presenter: "Governance Chair" }; if (/call to order|roll call|adjourn|public comment/.test(t)) return { presenter: "Chair", status: "info" }; if (/executive session/.test(t)) return { presenter: "Chair" }; return {}; } function toTitleCase(s) { return s.toLowerCase().replace(/\b([a-z])/g, (m) => m.toUpperCase()); } window.MeetingData = { PLACEHOLDER, MOTIONS, CLAUSES, INLINE, STATUSES, SAMPLE_TEXT, PRESENTERS, parseAgenda, makeItem, }; })();