// Sketchnote Scaffolding Generator
// Detects existing canvas content and positions structured info around it.
// Works with sermon notes, book visuals, or any Excalidraw hybrid note.
// Parses "# Back of Card" to determine note type and adapt layout.
//
// Open any hybrid note in Excalidraw view, run from Cmd+P.
if (!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("2.0.0")) {
new Notice("This script requires Excalidraw plugin v2.0+");
return;
}
var view = ea.targetView;
if (!view || !view.file) {
new Notice("Open a note in Excalidraw view first.");
return;
}
var fileContent = await app.vault.read(view.file);
var backOfCardMatch = fileContent.match(
/# Back of Card\s*\n([\s\S]*?)(?=\n# Excalidraw Data|\n# Text Elements|$)/
);
if (!backOfCardMatch) {
new Notice("No '# Back of Card' section found.");
return;
}
var markdown = backOfCardMatch[1];
// --- PARSING HELPERS ---
function extractSection(md, heading, level) {
level = level || 2;
var prefix = "";
for (var i = 0; i < level; i++) prefix += "#";
var re = new RegExp(
prefix + "\\s+" + heading + "\\s*\\n([\\s\\S]*?)(?=\\n#{1," + level + "}\\s|$)"
);
var m = md.match(re);
return m ? m[1].trim() : "";
}
function extractBullets(text) {
var results = {};
var re = /\*\*(.+?):\*\*\s*(.+)/g;
var m;
while ((m = re.exec(text)) !== null) {
results[m[1].trim()] = m[2].trim();
}
return results;
}
function extractH2Headings(md) {
var headings = [];
var re = /^## (.+)/gm;
var m;
while ((m = re.exec(md)) !== null) {
headings.push(m[1].trim());
}
return headings;
}
function firstLine(text) {
if (!text) return "";
var lines = text.split("\n");
for (var i = 0; i < lines.length; i++) {
var l = lines[i].trim();
if (l && !l.startsWith("#")) return l.substring(0, 80);
}
return "";
}
// --- DETECT NOTE TYPE ---
var h2s = extractH2Headings(markdown);
var h2Lower = h2s.join("|").toLowerCase();
var noteType = "generic";
if (h2Lower.indexOf("liturgical context") >= 0 || h2Lower.indexOf("order of worship") >= 0) {
noteType = "sermon";
} else if (h2Lower.indexOf("central thesis") >= 0 || h2Lower.indexOf("book details") >= 0) {
noteType = "book";
}
// --- COLOR SYSTEM ---
var BLUE = "#1971c2";
var GREEN = "#2f9e44";
var ORANGE = "#e8590c";
var PURPLE = "#7048e8";
var GRAY = "#868e96";
function lightHex(hex) {
var map = {
"#7048e8": "#e5dbff", "#1971c2": "#d0ebff", "#2f9e44": "#d3f9d8",
"#f59f00": "#fff3bf", "#e03131": "#ffe3e3", "#d6336c": "#ffdeeb",
"#868e96": "#f1f3f5", "#e8590c": "#ffe8cc",
};
return map[hex] || "#f1f3f5";
}
function getLiturgicalHex(colorName) {
var lower = (colorName || "").toLowerCase();
if (lower.indexOf("purple") >= 0 || lower.indexOf("violet") >= 0) return "#7048e8";
if (lower.indexOf("blue") >= 0) return "#1971c2";
if (lower.indexOf("green") >= 0) return "#2f9e44";
if (lower.indexOf("white") >= 0 || lower.indexOf("gold") >= 0) return "#f59f00";
if (lower.indexOf("red") >= 0) return "#e03131";
if (lower.indexOf("rose") >= 0 || lower.indexOf("pink") >= 0) return "#d6336c";
return "#868e96";
}
// --- DETECT EXISTING CONTENT ---
var existingEls = ea.getViewElements();
var centerX = 0;
var centerY = 0;
var centerW = 1200;
var centerH = 800;
if (existingEls.length > 0) {
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (var e = 0; e < existingEls.length; e++) {
var el = existingEls[e];
var ex = el.x || 0;
var ey = el.y || 0;
var ew = el.width || 0;
var eh = el.height || 0;
if (ex < minX) minX = ex;
if (ey < minY) minY = ey;
if (ex + ew > maxX) maxX = ex + ew;
if (ey + eh > maxY) maxY = ey + eh;
}
centerX = minX;
centerY = minY;
centerW = maxX - minX;
centerH = maxY - minY;
}
var GAP = 40;
var TOP = centerY - GAP;
var BOTTOM = centerY + centerH + GAP;
var LEFT = centerX - GAP;
var RIGHT = centerX + centerW + GAP;
// --- COMMON STYLE SETUP ---
ea.style.roughness = 1;
ea.style.strokeWidth = 2;
ea.style.fontFamily = 1;
ea.style.strokeColor = "#1e1e1e";
// ============================================================
// SERMON NOTE SCAFFOLDING
// ============================================================
if (noteType === "sermon") {
var liturgicalSection = extractSection(markdown, "Liturgical Context");
var christianCal = extractSection(liturgicalSection, "Christian Calendar", 3);
var calValues = extractBullets(christianCal);
var season = calValues["Season"] || "Unknown";
var week = calValues["Week"] || "";
var litYear = calValues["Liturgical Year"] || "";
var litColor = calValues["Liturgical Color"] || "";
var serviceSection = extractSection(markdown, "Service Information");
var serviceValues = extractBullets(serviceSection);
var church = serviceValues["Church"] || "";
var serviceDate = serviceValues["Date"] || "";
var pastor = serviceValues["Pastor"] || "";
var rclSection = extractSection(liturgicalSection, "Revised Common Lectionary Readings", 3);
var rclValues = extractBullets(rclSection);
var nlSection = extractSection(liturgicalSection, "Narrative Lectionary Reading", 3);
var nlValues = extractBullets(nlSection);
var orderSection = extractSection(markdown, "Order of Worship");
var sermonMatch = orderSection.match(
/####\s+Sermon[:\s]*["\u201C]?([^"\u201D\n]+)["\u201D]?\s*(?:\u2014|--|[-])\s*(.+)/
);
var sermonTitle = sermonMatch ? sermonMatch[1].trim() : "";
if (!sermonTitle) {
var altMatch = orderSection.match(/####\s+Sermon[:\s]*(.+)/);
sermonTitle = altMatch ? altMatch[1].trim().replace(/^["'\u201C]+|["'\u201D]+$/g, "") : "Sermon";
}
var preacher = sermonMatch ? sermonMatch[2].trim() : pastor;
var sermonTextMatch = orderSection.match(/\*\*Scripture Text:\*\*\s*(.+)/);
var sermonScripture = sermonTextMatch ? sermonTextMatch[1].trim() : "";
var hymnRegex = /####\s+(?:Opening Hymn|Hymn of Response|Closing Hymn|Hymn)[^:]*:\s*(.+)/g;
var hymns = [];
var hm;
while ((hm = hymnRegex.exec(orderSection)) !== null) {
hymns.push(hm[1].trim());
}
var seasonHex = getLiturgicalHex(litColor);
var seasonBg = lightHex(seasonHex);
// TOP-LEFT: Liturgical Badge
ea.style.backgroundColor = seasonBg;
ea.style.fontSize = 16;
var seasonLabel = season;
if (week) seasonLabel += "\n" + week;
if (litYear) seasonLabel += "\nYear " + litYear;
ea.addText(LEFT, TOP - 120, seasonLabel, {
width: 220, textAlign: "center", box: "blob", boxPadding: 12, boxStrokeColor: seasonHex,
});
if (litColor) {
ea.style.backgroundColor = seasonHex;
ea.style.fontSize = 12;
ea.addText(LEFT + 30, TOP - 30, litColor, {
width: 160, textAlign: "center", box: "ellipse", boxPadding: 6, boxStrokeColor: seasonHex,
});
}
// TOP-CENTER: Sermon Title + Date
ea.style.backgroundColor = lightHex(GREEN);
ea.style.fontSize = 22;
var titleLabel = sermonTitle || "Sermon";
if (serviceDate) titleLabel = serviceDate + "\n" + titleLabel;
ea.addText(LEFT + 260, TOP - 110, titleLabel, {
width: 500, textAlign: "center", box: "box", boxPadding: 14, boxStrokeColor: GREEN,
});
if (preacher) {
ea.style.backgroundColor = "transparent";
ea.style.fontSize = 13;
ea.addText(LEFT + 410, TOP - 30, "\u2014 " + preacher, { width: 200, textAlign: "center" });
}
// TOP-RIGHT: Scripture Text
if (sermonScripture) {
ea.style.backgroundColor = lightHex(BLUE);
ea.style.fontSize = 14;
ea.addText(RIGHT - 280, TOP - 100, sermonScripture, {
width: 260, textAlign: "center", box: "diamond", boxPadding: 18, boxStrokeColor: BLUE,
});
}
// LEFT SIDE: RCL Readings
var rclKeys = ["Old Testament", "Psalm", "Epistle", "Gospel"];
var readY = centerY + 20;
for (var i = 0; i < rclKeys.length; i++) {
var key = rclKeys[i];
var val = rclValues[key];
if (val) {
ea.style.backgroundColor = lightHex(BLUE);
ea.style.fontSize = 11;
ea.addText(LEFT - 200, readY, key + ":\n" + val.replace(/\s*\u2014.*$/, "").trim(), {
width: 180, textAlign: "center", box: "box", boxPadding: 8, boxStrokeColor: BLUE,
});
readY += 70;
}
}
if (nlValues["Reading"]) {
ea.style.backgroundColor = lightHex(PURPLE);
ea.style.fontSize = 11;
ea.addText(LEFT - 200, readY, "NL:\n" + nlValues["Reading"].replace(/\s*\u2014.*$/, ""), {
width: 180, textAlign: "center", box: "box", boxPadding: 8, boxStrokeColor: PURPLE,
});
}
// RIGHT SIDE: Reflection Zones
ea.style.fontSize = 13;
ea.style.backgroundColor = lightHex(ORANGE);
ea.addText(RIGHT + 20, centerY, "What Spoke to Me\n\n\n", {
width: 240, height: 120, textAlign: "left", textVerticalAlign: "top",
box: "box", boxPadding: 10, boxStrokeColor: ORANGE,
});
ea.style.backgroundColor = lightHex(PURPLE);
ea.addText(RIGHT + 20, centerY + 160, "Connections\n\n\n", {
width: 240, height: 120, textAlign: "left", textVerticalAlign: "top",
box: "box", boxPadding: 10, boxStrokeColor: PURPLE,
});
ea.style.backgroundColor = lightHex(ORANGE);
ea.addText(RIGHT + 20, centerY + 320, "Action Items\n\n\n", {
width: 240, height: 120, textAlign: "left", textVerticalAlign: "top",
box: "box", boxPadding: 10, boxStrokeColor: ORANGE,
});
// BOTTOM: Hymns
if (hymns.length > 0) {
ea.style.fontSize = 11;
var hymnX = LEFT;
for (var h = 0; h < hymns.length; h++) {
ea.style.backgroundColor = lightHex(GRAY);
var hw = Math.min(350, Math.max(160, hymns[h].length * 7));
ea.addText(hymnX, BOTTOM + 20, hymns[h], {
width: hw, textAlign: "center", box: "box", boxPadding: 8, boxStrokeColor: GRAY,
});
hymnX += hw + 15;
}
}
// BOTTOM-RIGHT: Church info
var infoText = church || "";
if (pastor) infoText += "\n" + pastor;
if (infoText) {
ea.style.backgroundColor = lightHex(GRAY);
ea.style.fontSize = 10;
ea.addText(RIGHT - 200, BOTTOM + 20, infoText, {
width: 200, textAlign: "right", box: "box", boxPadding: 6, boxStrokeColor: GRAY,
});
}
// ============================================================
// BOOK NOTE SCAFFOLDING
// ============================================================
} else if (noteType === "book") {
var bookDetails = extractSection(markdown, "Book Details");
var bookValues = extractBullets(bookDetails);
var bookTitle = bookValues["Title"] || view.file.basename.replace(/ Visual$/, "");
var bookAuthor = bookValues["Author"] || "";
var thesis = extractSection(markdown, "Central Thesis \\(expanded\\)");
if (!thesis) thesis = extractSection(markdown, "Central Thesis");
var thesisSummary = firstLine(thesis);
var conceptsSection = extractSection(markdown, "Key Concepts \\(detailed\\)");
if (!conceptsSection) conceptsSection = extractSection(markdown, "Key Concepts");
var conceptHeadings = [];
var conceptRe = /^### (.+)/gm;
var cm;
while ((cm = conceptRe.exec(conceptsSection)) !== null) {
conceptHeadings.push(cm[1].trim());
}
var reflectionSection = extractSection(markdown, "Personal Reflection");
var surprisedText = firstLine(extractSection(reflectionSection, "What surprised me\\?", 3));
var challengedText = firstLine(extractSection(reflectionSection, "What challenged", 3));
var actionSection = extractSection(markdown, "Action Items");
var actions = [];
var actionRe = /- \[.\]\s*(.+)/g;
var am;
while ((am = actionRe.exec(actionSection)) !== null) {
actions.push(am[1].trim());
}
var ideaCompass = extractSection(markdown, "Idea Compass");
var compassValues = extractBullets(ideaCompass);
// TOP-CENTER: Book Title + Author
ea.style.backgroundColor = lightHex(GRAY);
ea.style.fontSize = 24;
var bookLabel = bookTitle;
if (bookAuthor) bookLabel += "\n" + bookAuthor;
ea.addText(LEFT + 200, TOP - 110, bookLabel, {
width: 600, textAlign: "center", box: "box", boxPadding: 14, boxStrokeColor: GRAY,
});
// TOP-LEFT: Thesis highlight
if (thesisSummary) {
ea.style.backgroundColor = lightHex(GREEN);
ea.style.fontSize = 13;
ea.addText(LEFT - 280, TOP - 90, "THESIS\n" + thesisSummary, {
width: 260, textAlign: "left", box: "box", boxPadding: 12, boxStrokeColor: GREEN,
});
}
// LEFT SIDE: Key Concepts (stacked)
if (conceptHeadings.length > 0) {
var conceptY = centerY + 10;
for (var c = 0; c < conceptHeadings.length && c < 6; c++) {
ea.style.backgroundColor = lightHex(BLUE);
ea.style.fontSize = 12;
ea.addText(LEFT - 260, conceptY, conceptHeadings[c], {
width: 240, textAlign: "center", box: "box", boxPadding: 8, boxStrokeColor: BLUE,
});
conceptY += 55;
}
}
// RIGHT SIDE: Reflections
ea.style.fontSize = 13;
if (surprisedText) {
ea.style.backgroundColor = lightHex(ORANGE);
ea.addText(RIGHT + 20, centerY, "Surprised me:\n" + surprisedText, {
width: 260, textAlign: "left", box: "box", boxPadding: 10, boxStrokeColor: ORANGE,
});
}
if (challengedText) {
ea.style.backgroundColor = lightHex(ORANGE);
ea.addText(RIGHT + 20, centerY + 130, "Challenged me:\n" + challengedText, {
width: 260, textAlign: "left", box: "box", boxPadding: 10, boxStrokeColor: ORANGE,
});
}
// RIGHT SIDE LOWER: Empty takeaway zone
ea.style.backgroundColor = lightHex(ORANGE);
ea.addText(RIGHT + 20, centerY + 280, "My Takeaways\n\n\n", {
width: 260, height: 120, textAlign: "left", textVerticalAlign: "top",
box: "box", boxPadding: 10, boxStrokeColor: ORANGE,
});
// BOTTOM-LEFT: Action Items (first 4)
if (actions.length > 0) {
ea.style.backgroundColor = lightHex(GREEN);
ea.style.fontSize = 11;
var actionText = actions.slice(0, 4).join("\n");
ea.addText(LEFT, BOTTOM + 20, "ACTION ITEMS\n" + actionText, {
width: 400, textAlign: "left", box: "box", boxPadding: 10, boxStrokeColor: GREEN,
});
}
// BOTTOM-RIGHT: Idea Compass
if (compassValues["Parents (origins)"] || compassValues["Friends (supporting ideas)"]) {
ea.style.backgroundColor = lightHex(PURPLE);
ea.style.fontSize = 10;
var compassText = "";
if (compassValues["Parents (origins)"]) compassText += "Parents: " + compassValues["Parents (origins)"].substring(0, 100) + "\n";
if (compassValues["Friends (supporting ideas)"]) compassText += "Friends: " + compassValues["Friends (supporting ideas)"].substring(0, 100) + "\n";
if (compassValues["Children (applications)"]) compassText += "Children: " + compassValues["Children (applications)"].substring(0, 100);
ea.addText(RIGHT - 420, BOTTOM + 20, compassText, {
width: 420, textAlign: "left", box: "box", boxPadding: 10, boxStrokeColor: PURPLE,
});
}
// ============================================================
// GENERIC NOTE SCAFFOLDING
// ============================================================
} else {
var fileName = view.file.basename.replace(/ Visual$/, "").replace(/\.excalidraw$/, "");
// TOP-CENTER: File title
ea.style.backgroundColor = lightHex(GRAY);
ea.style.fontSize = 22;
ea.addText(LEFT + 200, TOP - 80, fileName, {
width: 600, textAlign: "center", box: "box", boxPadding: 14, boxStrokeColor: GRAY,
});
// Distribute sections around the perimeter
// Color cycle: green, blue, orange, purple, then repeat
var colors = [GREEN, BLUE, ORANGE, PURPLE];
var sectionCount = h2s.length;
if (sectionCount > 0) {
// Split sections into left, right, and bottom groups
var leftSections = [];
var rightSections = [];
var bottomSections = [];
for (var s = 0; s < sectionCount; s++) {
if (s < Math.ceil(sectionCount / 3)) {
leftSections.push(h2s[s]);
} else if (s < Math.ceil(2 * sectionCount / 3)) {
rightSections.push(h2s[s]);
} else {
bottomSections.push(h2s[s]);
}
}
// Left side
var ly = centerY + 10;
for (var l = 0; l < leftSections.length; l++) {
var lColor = colors[l % colors.length];
ea.style.backgroundColor = lightHex(lColor);
ea.style.fontSize = 12;
var sectionPreview = firstLine(extractSection(markdown, leftSections[l].replace(/[.*+?^${}()|[\]\\]/g, "\\
amp;")));
var label = leftSections[l];
if (sectionPreview) label += "\n" + sectionPreview;
ea.addText(LEFT - 260, ly, label, {
width: 240, textAlign: "left", box: "box", boxPadding: 10, boxStrokeColor: lColor,
});
ly += 90;
}
// Right side
var ry = centerY + 10;
for (var r = 0; r < rightSections.length; r++) {
var rColor = colors[(r + leftSections.length) % colors.length];
ea.style.backgroundColor = lightHex(rColor);
ea.style.fontSize = 12;
var rPreview = firstLine(extractSection(markdown, rightSections[r].replace(/[.*+?^${}()|[\]\\]/g, "\\amp;")));
var rLabel = rightSections[r];
if (rPreview) rLabel += "\n" + rPreview;
ea.addText(RIGHT + 20, ry, rLabel, {
width: 240, textAlign: "left", box: "box", boxPadding: 10, boxStrokeColor: rColor,
});
ry += 90;
}
// Bottom
var bx = LEFT;
for (var b = 0; b < bottomSections.length; b++) {
var bColor = colors[(b + leftSections.length + rightSections.length) % colors.length];
ea.style.backgroundColor = lightHex(bColor);
ea.style.fontSize = 12;
ea.addText(bx, BOTTOM + 20, bottomSections[b], {
width: 260, textAlign: "center", box: "box", boxPadding: 10, boxStrokeColor: bColor,
});
bx += 280;
}
}
}
await ea.addElementsToView(false, true, true);
new Notice("Scaffolding added! (" + noteType + " mode)");