|
| 1 | +import { OutcomeFinder } from "@utils/outcome-finder"; |
| 2 | + |
1 | 3 | export enum WorksheetBlockType { |
2 | 4 | Question = "question", |
3 | 5 | Paragraph = "paragraph", |
@@ -100,4 +102,179 @@ export class Worksheet implements IWorksheet { |
100 | 102 | .filter(b => b.currentType === WorksheetBlockType.Question) |
101 | 103 | .reduce((sum, b) => sum + (b.points ?? 0), 0); |
102 | 104 | } |
| 105 | + |
| 106 | + /** |
| 107 | + * Returns a full worksheet export as Markdown. |
| 108 | + * (LaTeX is preserved as-is inside markdown strings.) |
| 109 | + */ |
| 110 | + async copyAsMarkdown(): Promise<string> { |
| 111 | + const lines: string[] = []; |
| 112 | + |
| 113 | + const pushIf = (label: string, value: string | undefined) => { |
| 114 | + const v = (value ?? "").trim(); |
| 115 | + if (v) lines.push(`- **${label}:** ${v}`); |
| 116 | + }; |
| 117 | + |
| 118 | + const ensureBlankLine = () => { |
| 119 | + if (lines.length === 0) return; |
| 120 | + if (lines[lines.length - 1]?.trim() !== "") lines.push(""); |
| 121 | + }; |
| 122 | + |
| 123 | + const md = (s?: string) => (s ?? "").trim(); |
| 124 | + const escapeForHeading = (s?: string) => (s ?? "").trim(); |
| 125 | + |
| 126 | + // ---------- Header / Metadata ---------- |
| 127 | + const title = escapeForHeading(this.name) || "Worksheet"; |
| 128 | + lines.push(`# ${title}`); |
| 129 | + ensureBlankLine(); |
| 130 | + |
| 131 | + // Metadata list |
| 132 | + pushIf("Topic", this.topic); |
| 133 | + pushIf("Grade", this.gradeLevel); |
| 134 | + pushIf("Author", this.author); |
| 135 | + pushIf("Date", this.date); |
| 136 | + |
| 137 | + const totalPoints = this.getTotalPoints(); |
| 138 | + if (totalPoints > 0) lines.push(`- **Total Points:** ${totalPoints}`); |
| 139 | + |
| 140 | + ensureBlankLine(); |
| 141 | + |
| 142 | + // Outcomes |
| 143 | + if ((this.curricularOutcomes?.length ?? 0) > 0) { |
| 144 | + lines.push(`## Curricular Outcomes`); |
| 145 | + ensureBlankLine(); |
| 146 | + for (const outcomeId of this.curricularOutcomes) { |
| 147 | + const outcome = await OutcomeFinder.getById(outcomeId); |
| 148 | + if (outcome) lines.push(outcome.toString()); |
| 149 | + } |
| 150 | + ensureBlankLine(); |
| 151 | + } |
| 152 | + |
| 153 | + // Teacher notes |
| 154 | + if (md(this.teacherNotes)) { |
| 155 | + lines.push(`## Teacher Notes`); |
| 156 | + ensureBlankLine(); |
| 157 | + lines.push(md(this.teacherNotes)); |
| 158 | + ensureBlankLine(); |
| 159 | + } |
| 160 | + |
| 161 | + // ---------- Blocks ---------- |
| 162 | + if ((this.blocks?.length ?? 0) > 0) { |
| 163 | + lines.push(`---`); |
| 164 | + ensureBlankLine(); |
| 165 | + |
| 166 | + for (const block of this.blocks) { |
| 167 | + const b = this.blockToMarkdown(block); |
| 168 | + |
| 169 | + if (b) { |
| 170 | + lines.push(b.trimEnd()); |
| 171 | + ensureBlankLine(); |
| 172 | + } |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + // Trim trailing blank lines |
| 177 | + // while (lines.length > 1 && lines[lines.length - 1].trim() === "") { |
| 178 | + // lines.pop(); |
| 179 | + // } |
| 180 | + |
| 181 | + return lines.join("\n"); |
| 182 | + } |
| 183 | + |
| 184 | + private blockToMarkdown( |
| 185 | + block: WorksheetBlock |
| 186 | + ): string { |
| 187 | + |
| 188 | + const md = (s?: string) => (s ?? "").trim(); |
| 189 | + const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); |
| 190 | + |
| 191 | + switch (block.currentType) { |
| 192 | + case WorksheetBlockType.SectionHeader: { |
| 193 | + const headerText = md(block.title) || ""; |
| 194 | + if (!headerText) return ""; |
| 195 | + |
| 196 | + // headerType could be "h1" | "h2" | "h3" ... or something custom; map safely |
| 197 | + const ht = (block.headerType ?? "h2").toLowerCase(); |
| 198 | + const level = |
| 199 | + ht === "h1" ? 1 : |
| 200 | + ht === "h2" ? 2 : |
| 201 | + ht === "h3" ? 3 : |
| 202 | + ht === "h4" ? 4 : |
| 203 | + ht === "h5" ? 5 : |
| 204 | + ht === "h6" ? 6 : 2; |
| 205 | + |
| 206 | + return `${"#".repeat(clamp(level, 1, 6))} ${headerText}`; |
| 207 | + } |
| 208 | + |
| 209 | + case WorksheetBlockType.Paragraph: { |
| 210 | + // Your interface includes paragraphMarkdown (even though it’s under QUESTION FIELDS) |
| 211 | + const text = md(block.paragraphMarkdown); |
| 212 | + return text ? text : ""; |
| 213 | + } |
| 214 | + |
| 215 | + case WorksheetBlockType.Question: { |
| 216 | + const q = md(block.questionMarkdown); |
| 217 | + const a = md(block.answerMarkdown); |
| 218 | + |
| 219 | + const points = block.points ?? 0; |
| 220 | + const ptsSuffix = points > 0 ? ` (${points} pt${points === 1 ? "" : "s"})` : ""; |
| 221 | + |
| 222 | + const parts: string[] = []; |
| 223 | + if (q) { |
| 224 | + parts.push(`### Question${ptsSuffix}`); |
| 225 | + parts.push(q); |
| 226 | + } else { |
| 227 | + // still emit a heading if empty question but points exist |
| 228 | + if (points > 0) parts.push(`### Question${ptsSuffix}`); |
| 229 | + } |
| 230 | + |
| 231 | + const showSolutionFlag = block.showSolution ?? false; |
| 232 | + |
| 233 | + if (showSolutionFlag && a) { |
| 234 | + parts.push(""); |
| 235 | + parts.push(`<details>`); |
| 236 | + parts.push(`<summary>Solution</summary>`); |
| 237 | + parts.push(""); |
| 238 | + parts.push(a); |
| 239 | + parts.push(""); |
| 240 | + parts.push(`</details>`); |
| 241 | + } |
| 242 | + |
| 243 | + // Optional: add a space indicator based on questionSpaceSize (if you want) |
| 244 | + // (Not required; but you can uncomment if you want a visible cue) |
| 245 | + // |
| 246 | + // const space = block.questionSpaceSize ?? 0; |
| 247 | + // if (space > 0) { |
| 248 | + // parts.push(""); |
| 249 | + // parts.push(`_Space: ${space}_`); |
| 250 | + // } |
| 251 | + |
| 252 | + return parts.join("\n").trim(); |
| 253 | + } |
| 254 | + |
| 255 | + case WorksheetBlockType.Divider: { |
| 256 | + return `---`; |
| 257 | + } |
| 258 | + |
| 259 | + case WorksheetBlockType.PageBreak: { |
| 260 | + // Markdown has no official page break; HTML works in many renderers/print pipelines |
| 261 | + return `\n<div style="page-break-after: always;"></div>\n`; |
| 262 | + } |
| 263 | + |
| 264 | + case WorksheetBlockType.BlankSpace: { |
| 265 | + const size = block.size ?? 0; |
| 266 | + |
| 267 | + // Represent as a “blank lines” area in Markdown. |
| 268 | + // You can change the visual to underscores or an HTML spacer if preferred. |
| 269 | + const n = clamp(Math.round(size), 1, 20); // cap so it doesn't explode |
| 270 | + const lines: string[] = []; |
| 271 | + lines.push(`_Blank space:_`); |
| 272 | + for (let i = 0; i < n; i++) lines.push(`\n`); |
| 273 | + return lines.join("").trimEnd(); |
| 274 | + } |
| 275 | + |
| 276 | + default: |
| 277 | + return ""; |
| 278 | + } |
| 279 | + } |
103 | 280 | } |
0 commit comments