Web Worker for PDF gen#274
Conversation
|
""" WalkthroughPDF generation for quizzes was refactored in both the web and extension projects to use a Web Worker for off-main-thread processing. The main thread now delegates PDF creation to a new Changes
Sequence Diagram(s)sequenceDiagram
participant UI as Main Thread (UI)
participant Worker as PDF Worker
UI->>Worker: postMessage({ qaPairs, mode, logoBytes })
Worker-->>UI: message (PDF bytes)
UI->>UI: Create Blob and trigger PDF download
Poem
""" 📜 Recent review detailsConfiguration used: CodeRabbit UI 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
⏰ Context from checks skipped due to timeout of 90000ms (2)
✨ Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 4
🔭 Outside diff range comments (1)
extension/src/workers/pdfWorker.js (1)
1-163: Eliminate code duplication between web and extension workersThis file is identical to
eduaid_web/src/workers/pdfWorker.js. Consider extracting the PDF generation logic to a shared module that both workers can import.Create a shared PDF generation module:
- Extract the common logic to a shared location (e.g.,
shared/pdfGenerator.js)- Import and use it in both worker files
- This will ensure consistency and make maintenance easier
🧹 Nitpick comments (2)
eduaid_web/src/workers/pdfWorker.js (1)
133-133: Fix inconsistent vertical spacing calculationThe spacing calculation mixes two different approaches, which could lead to layout issues.
-y -= Math.max(25, optionLines.length * 20); +y -= optionLines.length * 20 + 5; // Consistent line height plus margineduaid_web/src/pages/Output.jsx (1)
2-2: Remove unused importsThe
PDFDocumentandrgbimports frompdf-libare no longer used after delegating PDF generation to the worker.-import { PDFDocument, rgb } from "pdf-lib";
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
eduaid_web/src/pages/Output.jsx(1 hunks)eduaid_web/src/workers/pdfWorker.js(1 hunks)extension/src/pages/question/Question.jsx(1 hunks)extension/src/workers/pdfWorker.js(1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (2)
- GitHub Check: Analyze (javascript-typescript)
- GitHub Check: Analyze (python)
| const wrapText = (text, maxWidth) => { | ||
| const words = text.split(' '); | ||
| const lines = []; | ||
| let currentLine = ''; | ||
|
|
||
| words.forEach(word => { | ||
| const testLine = currentLine ? `${currentLine} ${word}` : word; | ||
| const testWidth = testLine.length * 6; | ||
| if (testWidth > maxWidth) { | ||
| lines.push(currentLine); | ||
| currentLine = word; | ||
| } else { | ||
| currentLine = testLine; | ||
| } | ||
| }); | ||
|
|
||
| if (currentLine) lines.push(currentLine); | ||
| return lines; | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Improve text width calculation accuracy
The current implementation uses a fixed multiplier (6) to estimate text width, which won't work accurately for variable-width fonts or special characters. Consider using pdf-lib's font metrics for accurate measurements.
-const wrapText = (text, maxWidth) => {
+const wrapText = (text, maxWidth, font, fontSize) => {
const words = text.split(' ');
const lines = [];
let currentLine = '';
words.forEach(word => {
const testLine = currentLine ? `${currentLine} ${word}` : word;
- const testWidth = testLine.length * 6;
+ const testWidth = font.widthOfTextAtSize(testLine, fontSize);
if (testWidth > maxWidth) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
});
if (currentLine) lines.push(currentLine);
return lines;
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const wrapText = (text, maxWidth) => { | |
| const words = text.split(' '); | |
| const lines = []; | |
| let currentLine = ''; | |
| words.forEach(word => { | |
| const testLine = currentLine ? `${currentLine} ${word}` : word; | |
| const testWidth = testLine.length * 6; | |
| if (testWidth > maxWidth) { | |
| lines.push(currentLine); | |
| currentLine = word; | |
| } else { | |
| currentLine = testLine; | |
| } | |
| }); | |
| if (currentLine) lines.push(currentLine); | |
| return lines; | |
| }; | |
| const wrapText = (text, maxWidth, font, fontSize) => { | |
| const words = text.split(' '); | |
| const lines = []; | |
| let currentLine = ''; | |
| words.forEach(word => { | |
| const testLine = currentLine ? `${currentLine} ${word}` : word; | |
| const testWidth = font.widthOfTextAtSize(testLine, fontSize); | |
| if (testWidth > maxWidth) { | |
| lines.push(currentLine); | |
| currentLine = word; | |
| } else { | |
| currentLine = testLine; | |
| } | |
| }); | |
| if (currentLine) lines.push(currentLine); | |
| return lines; | |
| }; |
🤖 Prompt for AI Agents
In eduaid_web/src/workers/pdfWorker.js between lines 11 and 29, the wrapText
function estimates text width by multiplying character count by a fixed value
(6), which is inaccurate for variable-width fonts. To fix this, use pdf-lib's
font width measurement methods to calculate the actual width of the testLine
string with the current font and size, replacing the fixed multiplier
calculation. This will ensure text wrapping respects the true rendered width.
| self.onmessage = async (e) => { | ||
| const { qaPairs, mode, logoBytes } = e.data; | ||
|
|
||
| const pageWidth = 595.28; | ||
| const pageHeight = 841.89; | ||
| const margin = 50; | ||
| const maxContentWidth = pageWidth - 2 * margin; | ||
|
|
||
| const pdfDoc = await PDFDocument.create(); | ||
| let page = pdfDoc.addPage([pageWidth, pageHeight]); | ||
| const d = new Date(Date.now()); | ||
|
|
||
| const form = pdfDoc.getForm(); | ||
| let y = pageHeight - margin - 70; | ||
| let questionIndex = 1; | ||
|
|
||
| const createNewPageIfNeeded = (requiredHeight) => { | ||
| if (y - requiredHeight < margin) { | ||
| page = pdfDoc.addPage([pageWidth, pageHeight]); | ||
| y = pageHeight - margin; | ||
| } | ||
| }; | ||
|
|
||
| try { | ||
| if (logoBytes) { | ||
| const logoImage = await pdfDoc.embedPng(logoBytes); | ||
| const logoDims = logoImage.scale(0.2); | ||
| page.drawImage(logoImage, { | ||
| x: margin, | ||
| y: pageHeight - margin - 30, | ||
| width: logoDims.width, | ||
| height: logoDims.height, | ||
| }); | ||
| page.drawText("EduAid generated Quiz", { | ||
| x: margin + logoDims.width + 10, | ||
| y: pageHeight - margin, | ||
| size: 20 | ||
| }); | ||
| page.drawText("Created On: " + d.toString(), { | ||
| x: margin + logoDims.width + 10, | ||
| y: pageHeight - margin - 30, | ||
| size: 10 | ||
| }); | ||
| } | ||
| } catch (err) { | ||
| page.drawText("EduAid generated Quiz", { x: margin, y: pageHeight - margin, size: 20 }); | ||
| } | ||
|
|
||
| for (const qaPair of qaPairs) { | ||
| let requiredHeight = 60; | ||
| const questionLines = wrapText(qaPair.question, maxContentWidth); | ||
| requiredHeight += questionLines.length * 20; | ||
|
|
||
| if (mode !== 'answers') { | ||
| if (qaPair.question_type === "Boolean") { | ||
| requiredHeight += 60; | ||
| } else if (qaPair.question_type === "MCQ" || qaPair.question_type === "MCQ_Hard") { | ||
| const optionsCount = qaPair.options ? qaPair.options.length + 1 : 1; | ||
| requiredHeight += optionsCount * 25; | ||
| } else { | ||
| requiredHeight += 40; | ||
| } | ||
| } | ||
|
|
||
| if (mode === 'answers' || mode === 'questions_answers') { | ||
| requiredHeight += 40; | ||
| } | ||
|
|
||
| createNewPageIfNeeded(requiredHeight); | ||
|
|
||
| if (mode !== 'answers') { | ||
| questionLines.forEach((line, i) => { | ||
| const prefix = i === 0 ? `Q${questionIndex}) ` : ' '; | ||
| page.drawText(`${prefix}${line}`, { x: margin, y: y - i * 20, size: 12 }); | ||
| }); | ||
| y -= questionLines.length * 20 + 20; | ||
|
|
||
| if (mode === 'questions') { | ||
| if (qaPair.question_type === "Boolean") { | ||
| const radioGroup = form.createRadioGroup(`question${questionIndex}_answer`); | ||
| ['True', 'False'].forEach(option => { | ||
| radioGroup.addOptionToPage(option, page, { | ||
| x: margin + 20, y, width: 15, height: 15 | ||
| }); | ||
| page.drawText(option, { x: margin + 40, y: y + 2, size: 12 }); | ||
| y -= 20; | ||
| }); | ||
| } else if (qaPair.question_type === "MCQ" || qaPair.question_type === "MCQ_Hard") { | ||
| const allOptions = [...(qaPair.options || [])]; | ||
| if (qaPair.answer && !allOptions.includes(qaPair.answer)) allOptions.push(qaPair.answer); | ||
| const shuffled = shuffleArray([...allOptions]); | ||
|
|
||
| const radioGroup = form.createRadioGroup(`question${questionIndex}_answer`); | ||
| shuffled.forEach((option, idx) => { | ||
| radioGroup.addOptionToPage(`option${idx}`, page, { | ||
| x: margin + 20, y, width: 15, height: 15 | ||
| }); | ||
|
|
||
| const optionLines = wrapText(option, maxContentWidth - 60); | ||
| optionLines.forEach((line, i) => { | ||
| page.drawText(line, { x: margin + 40, y: y + 2 - i * 15, size: 12 }); | ||
| }); | ||
| y -= Math.max(25, optionLines.length * 20); | ||
| }); | ||
| } else if (qaPair.question_type === "Short") { | ||
| const field = form.createTextField(`question${questionIndex}_answer`); | ||
| field.setText(''); | ||
| field.addToPage(page, { | ||
| x: margin, y: y - 20, width: maxContentWidth, height: 20 | ||
| }); | ||
| y -= 40; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (mode === 'answers' || mode === 'questions_answers') { | ||
| const answerLines = wrapText(`Answer ${questionIndex}: ${qaPair.answer}`, maxContentWidth); | ||
| answerLines.forEach((line, i) => { | ||
| page.drawText(line, { | ||
| x: margin, y: y - i * 15, size: 12, color: rgb(0, 0.5, 0) | ||
| }); | ||
| }); | ||
| y -= answerLines.length * 20; | ||
| } | ||
|
|
||
| y -= 20; | ||
| questionIndex++; | ||
| } | ||
|
|
||
| const pdfBytes = await pdfDoc.save(); | ||
| self.postMessage(pdfBytes); | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Add input validation and error handling
The worker should validate inputs and handle errors gracefully. Also, ensure proper cleanup by terminating the worker after completion.
Add input validation at the beginning:
self.onmessage = async (e) => {
+ try {
const { qaPairs, mode, logoBytes } = e.data;
+
+ // Validate inputs
+ if (!qaPairs || !Array.isArray(qaPairs)) {
+ throw new Error('Invalid qaPairs: expected an array');
+ }
+ if (!mode || !['questions', 'answers', 'questions_answers'].includes(mode)) {
+ throw new Error('Invalid mode: expected questions, answers, or questions_answers');
+ }Add error handling and worker termination:
const pdfBytes = await pdfDoc.save();
self.postMessage(pdfBytes);
+ self.close(); // Terminate the worker
+ } catch (error) {
+ self.postMessage({ error: error.message });
+ self.close();
+ }
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| self.onmessage = async (e) => { | |
| const { qaPairs, mode, logoBytes } = e.data; | |
| const pageWidth = 595.28; | |
| const pageHeight = 841.89; | |
| const margin = 50; | |
| const maxContentWidth = pageWidth - 2 * margin; | |
| const pdfDoc = await PDFDocument.create(); | |
| let page = pdfDoc.addPage([pageWidth, pageHeight]); | |
| const d = new Date(Date.now()); | |
| const form = pdfDoc.getForm(); | |
| let y = pageHeight - margin - 70; | |
| let questionIndex = 1; | |
| const createNewPageIfNeeded = (requiredHeight) => { | |
| if (y - requiredHeight < margin) { | |
| page = pdfDoc.addPage([pageWidth, pageHeight]); | |
| y = pageHeight - margin; | |
| } | |
| }; | |
| try { | |
| if (logoBytes) { | |
| const logoImage = await pdfDoc.embedPng(logoBytes); | |
| const logoDims = logoImage.scale(0.2); | |
| page.drawImage(logoImage, { | |
| x: margin, | |
| y: pageHeight - margin - 30, | |
| width: logoDims.width, | |
| height: logoDims.height, | |
| }); | |
| page.drawText("EduAid generated Quiz", { | |
| x: margin + logoDims.width + 10, | |
| y: pageHeight - margin, | |
| size: 20 | |
| }); | |
| page.drawText("Created On: " + d.toString(), { | |
| x: margin + logoDims.width + 10, | |
| y: pageHeight - margin - 30, | |
| size: 10 | |
| }); | |
| } | |
| } catch (err) { | |
| page.drawText("EduAid generated Quiz", { x: margin, y: pageHeight - margin, size: 20 }); | |
| } | |
| for (const qaPair of qaPairs) { | |
| let requiredHeight = 60; | |
| const questionLines = wrapText(qaPair.question, maxContentWidth); | |
| requiredHeight += questionLines.length * 20; | |
| if (mode !== 'answers') { | |
| if (qaPair.question_type === "Boolean") { | |
| requiredHeight += 60; | |
| } else if (qaPair.question_type === "MCQ" || qaPair.question_type === "MCQ_Hard") { | |
| const optionsCount = qaPair.options ? qaPair.options.length + 1 : 1; | |
| requiredHeight += optionsCount * 25; | |
| } else { | |
| requiredHeight += 40; | |
| } | |
| } | |
| if (mode === 'answers' || mode === 'questions_answers') { | |
| requiredHeight += 40; | |
| } | |
| createNewPageIfNeeded(requiredHeight); | |
| if (mode !== 'answers') { | |
| questionLines.forEach((line, i) => { | |
| const prefix = i === 0 ? `Q${questionIndex}) ` : ' '; | |
| page.drawText(`${prefix}${line}`, { x: margin, y: y - i * 20, size: 12 }); | |
| }); | |
| y -= questionLines.length * 20 + 20; | |
| if (mode === 'questions') { | |
| if (qaPair.question_type === "Boolean") { | |
| const radioGroup = form.createRadioGroup(`question${questionIndex}_answer`); | |
| ['True', 'False'].forEach(option => { | |
| radioGroup.addOptionToPage(option, page, { | |
| x: margin + 20, y, width: 15, height: 15 | |
| }); | |
| page.drawText(option, { x: margin + 40, y: y + 2, size: 12 }); | |
| y -= 20; | |
| }); | |
| } else if (qaPair.question_type === "MCQ" || qaPair.question_type === "MCQ_Hard") { | |
| const allOptions = [...(qaPair.options || [])]; | |
| if (qaPair.answer && !allOptions.includes(qaPair.answer)) allOptions.push(qaPair.answer); | |
| const shuffled = shuffleArray([...allOptions]); | |
| const radioGroup = form.createRadioGroup(`question${questionIndex}_answer`); | |
| shuffled.forEach((option, idx) => { | |
| radioGroup.addOptionToPage(`option${idx}`, page, { | |
| x: margin + 20, y, width: 15, height: 15 | |
| }); | |
| const optionLines = wrapText(option, maxContentWidth - 60); | |
| optionLines.forEach((line, i) => { | |
| page.drawText(line, { x: margin + 40, y: y + 2 - i * 15, size: 12 }); | |
| }); | |
| y -= Math.max(25, optionLines.length * 20); | |
| }); | |
| } else if (qaPair.question_type === "Short") { | |
| const field = form.createTextField(`question${questionIndex}_answer`); | |
| field.setText(''); | |
| field.addToPage(page, { | |
| x: margin, y: y - 20, width: maxContentWidth, height: 20 | |
| }); | |
| y -= 40; | |
| } | |
| } | |
| } | |
| if (mode === 'answers' || mode === 'questions_answers') { | |
| const answerLines = wrapText(`Answer ${questionIndex}: ${qaPair.answer}`, maxContentWidth); | |
| answerLines.forEach((line, i) => { | |
| page.drawText(line, { | |
| x: margin, y: y - i * 15, size: 12, color: rgb(0, 0.5, 0) | |
| }); | |
| }); | |
| y -= answerLines.length * 20; | |
| } | |
| y -= 20; | |
| questionIndex++; | |
| } | |
| const pdfBytes = await pdfDoc.save(); | |
| self.postMessage(pdfBytes); | |
| }; | |
| self.onmessage = async (e) => { | |
| try { | |
| const { qaPairs, mode, logoBytes } = e.data; | |
| // Validate inputs | |
| if (!qaPairs || !Array.isArray(qaPairs)) { | |
| throw new Error('Invalid qaPairs: expected an array'); | |
| } | |
| if (!mode || !['questions', 'answers', 'questions_answers'].includes(mode)) { | |
| throw new Error('Invalid mode: expected questions, answers, or questions_answers'); | |
| } | |
| const pageWidth = 595.28; | |
| const pageHeight = 841.89; | |
| const margin = 50; | |
| const maxContentWidth = pageWidth - 2 * margin; | |
| const pdfDoc = await PDFDocument.create(); | |
| let page = pdfDoc.addPage([pageWidth, pageHeight]); | |
| const d = new Date(Date.now()); | |
| const form = pdfDoc.getForm(); | |
| let y = pageHeight - margin - 70; | |
| let questionIndex = 1; | |
| const createNewPageIfNeeded = (requiredHeight) => { | |
| if (y - requiredHeight < margin) { | |
| page = pdfDoc.addPage([pageWidth, pageHeight]); | |
| y = pageHeight - margin; | |
| } | |
| }; | |
| try { | |
| if (logoBytes) { | |
| const logoImage = await pdfDoc.embedPng(logoBytes); | |
| const logoDims = logoImage.scale(0.2); | |
| page.drawImage(logoImage, { | |
| x: margin, | |
| y: pageHeight - margin - 30, | |
| width: logoDims.width, | |
| height: logoDims.height, | |
| }); | |
| page.drawText("EduAid generated Quiz", { | |
| x: margin + logoDims.width + 10, | |
| y: pageHeight - margin, | |
| size: 20, | |
| }); | |
| page.drawText("Created On: " + d.toString(), { | |
| x: margin + logoDims.width + 10, | |
| y: pageHeight - margin - 30, | |
| size: 10, | |
| }); | |
| } | |
| } catch (err) { | |
| page.drawText("EduAid generated Quiz", { | |
| x: margin, | |
| y: pageHeight - margin, | |
| size: 20, | |
| }); | |
| } | |
| for (const qaPair of qaPairs) { | |
| let requiredHeight = 60; | |
| const questionLines = wrapText(qaPair.question, maxContentWidth); | |
| requiredHeight += questionLines.length * 20; | |
| if (mode !== 'answers') { | |
| if (qaPair.question_type === "Boolean") { | |
| requiredHeight += 60; | |
| } else if (qaPair.question_type === "MCQ" || qaPair.question_type === "MCQ_Hard") { | |
| const optionsCount = qaPair.options ? qaPair.options.length + 1 : 1; | |
| requiredHeight += optionsCount * 25; | |
| } else { | |
| requiredHeight += 40; | |
| } | |
| } | |
| if (mode === 'answers' || mode === 'questions_answers') { | |
| requiredHeight += 40; | |
| } | |
| createNewPageIfNeeded(requiredHeight); | |
| if (mode !== 'answers') { | |
| questionLines.forEach((line, i) => { | |
| const prefix = i === 0 ? `Q${questionIndex}) ` : ' '; | |
| page.drawText(`${prefix}${line}`, { x: margin, y: y - i * 20, size: 12 }); | |
| }); | |
| y -= questionLines.length * 20 + 20; | |
| if (mode === 'questions') { | |
| if (qaPair.question_type === "Boolean") { | |
| const radioGroup = form.createRadioGroup(`question${questionIndex}_answer`); | |
| ['True', 'False'].forEach(option => { | |
| radioGroup.addOptionToPage(option, page, { | |
| x: margin + 20, y, width: 15, height: 15 | |
| }); | |
| page.drawText(option, { x: margin + 40, y: y + 2, size: 12 }); | |
| y -= 20; | |
| }); | |
| } else if (qaPair.question_type === "MCQ" || qaPair.question_type === "MCQ_Hard") { | |
| const allOptions = [...(qaPair.options || [])]; | |
| if (qaPair.answer && !allOptions.includes(qaPair.answer)) allOptions.push(qaPair.answer); | |
| const shuffled = shuffleArray([...allOptions]); | |
| const radioGroup = form.createRadioGroup(`question${questionIndex}_answer`); | |
| shuffled.forEach((option, idx) => { | |
| radioGroup.addOptionToPage(`option${idx}`, page, { | |
| x: margin + 20, y, width: 15, height: 15 | |
| }); | |
| const optionLines = wrapText(option, maxContentWidth - 60); | |
| optionLines.forEach((line, i) => { | |
| page.drawText(line, { x: margin + 40, y: y + 2 - i * 15, size: 12 }); | |
| }); | |
| y -= Math.max(25, optionLines.length * 20); | |
| }); | |
| } else if (qaPair.question_type === "Short") { | |
| const field = form.createTextField(`question${questionIndex}_answer`); | |
| field.setText(''); | |
| field.addToPage(page, { | |
| x: margin, y: y - 20, width: maxContentWidth, height: 20 | |
| }); | |
| y -= 40; | |
| } | |
| } | |
| } | |
| if (mode === 'answers' || mode === 'questions_answers') { | |
| const answerLines = wrapText(`Answer ${questionIndex}: ${qaPair.answer}`, maxContentWidth); | |
| answerLines.forEach((line, i) => { | |
| page.drawText(line, { | |
| x: margin, y: y - i * 15, size: 12, color: rgb(0, 0.5, 0) | |
| }); | |
| }); | |
| y -= answerLines.length * 20; | |
| } | |
| y -= 20; | |
| questionIndex++; | |
| } | |
| const pdfBytes = await pdfDoc.save(); | |
| self.postMessage(pdfBytes); | |
| self.close(); | |
| } catch (error) { | |
| self.postMessage({ error: error.message }); | |
| self.close(); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In eduaid_web/src/workers/pdfWorker.js between lines 31 and 162, add input
validation at the start of the onmessage handler to check that qaPairs is an
array, mode is a valid string, and logoBytes is either undefined or a
Uint8Array; if validation fails, post an error message and terminate the worker.
Wrap the entire processing logic in a try-catch block to catch any runtime
errors, post an error message on failure, and ensure the worker terminates after
completing processing or on error by calling self.close().
Description
This PR introduces the use of a Web Worker to offload the PDF generation process, enhancing performance and preventing UI blocking during heavy computation.
✅ Changes Made
worker.onmessageto:Blobfrom the received PDF bytes.generated_questions.pdf).#pdfDropdown) post-download.worker.onerrorto gracefully handle and log any errors from the worker.💡 Motivation
Generating PDFs can be computationally expensive. Moving this logic to a Web Worker:
Summary by CodeRabbit
New Features
Bug Fixes
Chores