Meeting notes to actions
@opsdesk/meeting-notes-to-actions
Turn messy meeting notes into a clean list of decisions and action items, each with one owner and a real due date, plus a CSV you can paste into any tracker or spreadsheet.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Pull action items out of meeting notes into a CSV with owner and due date.
- 3// Usage: node scripts/actions-to-csv.mjs <notes.md> <out.csv> [--meeting-date 2026-11-04] [--meeting "Weekly ops"]
- 4// Recognized lines:
- 5// - [ ] Owner: do the thing (due 2026-11-12)
- 6// - [ ] @Owner do the thing by Friday
- 7// Action: Owner to do the thing by end of week
- 8// AI / TODO / Next step: ...
- 9// Only Node built-ins. Prints a summary with unassigned and undated items.
- 11import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 12import { dirname } from "node:path";
- 14function fail(message) {
- 15 console.error(`actions-to-csv: ${message}`);
- 16 console.error(
- 17 'Usage: node scripts/actions-to-csv.mjs <notes.md> <out.csv> [--meeting-date YYYY-MM-DD] [--meeting "Weekly ops"]',
- 18 );
- 19 process.exit(1);
- 20}
- 22const args = process.argv.slice(2);
- 23const positional = [];
- 24let meetingDate = "";
- 25let meeting = "";
- 26for (let i = 0; i < args.length; i++) {
- 27 const a = args[i];
- 28 if (a === "--meeting-date") meetingDate = args[++i] ?? "";
- 29 else if (a === "--meeting") meeting = args[++i] ?? "";
- 30 else if (a.startsWith("--")) fail(`unknown option ${a}`);
- 31 else positional.push(a);
- 32}
- 33const [notesPath, outPath] = positional;
- 34if (!notesPath || !outPath) fail("need a notes file and an output CSV path.");
- 36let text;
- 37try {
- 38 text = readFileSync(notesPath, "utf8");
- 39} catch (e) {
- 40 fail(`cannot read ${notesPath}: ${e.message}`);
- 41}
- 43// Meeting date: flag, else the first YYYY-MM-DD in the notes, else none (relative dates stay blank).
- 44const ISO = /\b(\d{4}-\d{2}-\d{2})\b/;
- 45if (!meetingDate) meetingDate = ISO.exec(text)?.[1] ?? "";
- 46if (meetingDate && !/^\d{4}-\d{2}-\d{2}$/.test(meetingDate))
- 47 fail(`--meeting-date must look like 2026-11-04, got "${meetingDate}".`);
- 48if (!meeting) meeting = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? "";
- 50const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
- 51const iso = (d) => d.toISOString().slice(0, 10);
- 52const base = meetingDate ? new Date(`${meetingDate}T00:00:00Z`) : null;
- 53const addDays = (n) => {
- 54 const d = new Date(base);
- 55 d.setUTCDate(d.getUTCDate() + n);
- 56 return iso(d);
- 57};
- 58const nextWeekday = (dow) => {
- 59 // Strictly after the meeting day: "by Friday" said on a Friday means next Friday.
- 60 let n = (dow - base.getUTCDay() + 7) % 7;
- 61 if (n === 0) n = 7;
- 62 return addDays(n);
- 63};
- 65function resolveDue(s) {
- 66 const explicit = /\b(?:due|by|before|on)?\s*:?\s*(\d{4}-\d{2}-\d{2})\b/i.exec(s);
- 67 if (explicit) return { due: explicit[1], phrase: explicit[0].trim() };
- 68 const rel =
- 69 /\b(?:due|by|before|on)\s+(today|tomorrow|eod|eow|end of (?:the )?week|end of (?:the )?month|eom|next week|(?:next\s+)?(?:mon|tues|wednes|thurs|fri|satur|sun)day)\b/i.exec(
- 70 s,
- 71 );
- 72 if (!rel) return { due: "", phrase: "" };
- 73 const word = rel[1].toLowerCase();
- 74 if (!base) return { due: "", phrase: rel[0], unresolved: true };
- 75 let due = "";
- 76 if (word === "today" || word === "eod") due = addDays(0);
- 77 else if (word === "tomorrow") due = addDays(1);
- 78 else if (word === "eow" || /end of (the )?week/.test(word)) due = nextWeekday(5);
- 79 else if (word === "next week") due = addDays(7);
- 80 else if (word === "eom" || /end of (the )?month/.test(word)) {
- 81 const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + 1, 0));
- 82 due = iso(d);
- 83 } else {
- 84 const name = word.replace(/^next\s+/, "");
- 85 due = nextWeekday(DAYS.indexOf(name));
- 86 if (word.startsWith("next ") && DAYS.indexOf(name) > base.getUTCDay()) {
- 87 // "next Thursday" said on a Monday usually means the week after.
- 88 const d = new Date(`${due}T00:00:00Z`);
- 89 d.setUTCDate(d.getUTCDate() + 7);
- 90 due = iso(d);
- 91 }
- 92 }
- 93 return { due, phrase: rel[0] };
- 94}
- 96// An owner is a name or role: capitalized words, optionally joined by "of", "the", "for", "&", "and".
- 97const NAME = String.raw`[A-Z][\w.'-]*(?:\s+(?:(?:of|the|for|and|&)\s+)?[A-Z][\w.'-]*){0,3}`;
- 98// Capitalized verbs at the start of a line are tasks, not owners ("Send to legal").
- 99const VERBS = new Set(
- 100 "send review share draft write update check confirm schedule book follow ask email call set move add fix ship plan prep prepare post get make find circle loop".split(" "),
- 101);
- 102const isName = (s) => !VERBS.has(s.split(/\s+/)[0].toLowerCase());
- 104const items = [];
- 105const lines = text.split(/\r?\n/);
- 106lines.forEach((raw, index) => {
- 107 const line = raw.trim();
- 108 let body = null;
- 109 let status = "open";
- 110 const box = /^[-*+]\s+\[( |x|X)\]\s+(.+)$/.exec(line);
- 111 if (box) {
- 112 body = box[2];
- 113 status = box[1] === " " ? "open" : "done";
- 114 } else {
- 115 const tagged = /^(?:[-*+]\s+)?(?:\*\*)?(?:action(?: item)?|ai|todo|next step|follow[- ]up)(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.+)$/i.exec(
- 116 line,
- 117 );
- 118 if (tagged) body = tagged[1];
- 119 }
- 120 if (!body) return;
- 122 let owner = "";
- 123 let action = body;
- 124 let m;
- 125 if ((m = /^@([\w.-]+)\s*[:,-]?\s+(.+)$/.exec(action))) {
- 126 owner = m[1];
- 127 action = m[2];
- 128 } else if ((m = new RegExp(`^(${NAME})\\s*:\\s+(.+)$`).exec(action)) && isName(m[1])) {
- 129 owner = m[1];
- 130 action = m[2];
- 131 } else if ((m = new RegExp(`^(${NAME})\\s+(?:to|will)\\s+(.+)$`).exec(action)) && isName(m[1])) {
- 132 owner = m[1];
- 133 action = m[2];
- 134 } else if ((m = new RegExp(`\\(\\s*(?:owner\\s*:\\s*)?@?(${NAME})\\s*\\)`).exec(action)) && isName(m[1])) {
- 135 owner = m[1];
- 136 action = action.replace(m[0], "");
- 137 }
- 138 const { due, phrase, unresolved } = resolveDue(action);
- 139 if (phrase) {
- 140 action = action
- 141 .replace(new RegExp(`\\(?\\s*${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\)?`, "i"), "")
- 142 .trim();
- 143 }
- 144 action = action.replace(/\s{2,}/g, " ").replace(/[\s,;.-]+$/, "").trim();
- 145 action = action.charAt(0).toUpperCase() + action.slice(1);
- 146 items.push({
- 147 owner: owner || "UNASSIGNED",
- 148 action,
- 149 due: due || "",
- 150 due_note: unresolved ? `"${phrase}" (no meeting date)` : "",
- 151 status,
- 152 line: index + 1,
- 153 });
- 154});
- 156const header = ["id", "owner", "action", "due", "status", "meeting", "meeting_date", "source_line", "due_note"];
- 157const esc = (v) => {
- 158 const s = String(v ?? "");
- 159 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
- 160};
- 161const rows = items.map((it, i) =>
- 162 [i + 1, it.owner, it.action, it.due, it.status, meeting, meetingDate, it.line, it.due_note].map(esc).join(","),
- 163);
- 164try {
- 165 mkdirSync(dirname(outPath), { recursive: true });
- 166 writeFileSync(outPath, [header.join(","), ...rows].join("\n") + "\n");
- 167} catch (e) {
- 168 fail(`cannot write ${outPath}: ${e.message}`);
- 169}
- 171const unassigned = items.filter((i) => i.owner === "UNASSIGNED").length;
- 172const undated = items.filter((i) => !i.due && i.status === "open").length;
- 173console.log(
- 174 `Wrote ${outPath}: ${items.length} action items, ${unassigned} without an owner, ${undated} open without a due date.`,
- 175);
- 176if (items.length === 0)
- 177 console.log('No action items found. Mark them as "- [ ] Owner: task" or "Action: Owner to task by Friday".');
