Adds DOCX and Markdown export functionality

Introduces a modular exporter pattern supporting DOCX and Markdown formats
by implementing Exporter interfaces and restructuring application logic.

Enhances CI to install UPX for binary compression, excluding recent macOS
binaries due to compatibility issues.

Enables CGO when building binaries for all platforms, addressing potential
cross-platform compatibility concerns.

Bumps version to 0.1.1.
This commit is contained in:
2025-05-25 13:01:58 +02:00
parent 48cad7144f
commit 9de7222ec3
15 changed files with 1096 additions and 600 deletions

196
internal/exporters/docx.go Normal file
View File

@ -0,0 +1,196 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"fmt"
"strings"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
"github.com/unidoc/unioffice/document"
)
// DocxExporter implements the Exporter interface for DOCX format.
// It converts Articulate Rise course data into a Microsoft Word document
// using the unioffice/document package.
type DocxExporter struct {
// htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
}
// NewDocxExporter creates a new DocxExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the Exporter interface for DOCX format
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &DocxExporter{
htmlCleaner: htmlCleaner,
}
}
// Export exports the course to a DOCX file.
// It creates a Word document with formatted content based on the course data
// and saves it to the specified output path.
//
// Parameters:
// - course: The course data model to export
// - outputPath: The file path where the DOCX content will be written
//
// Returns:
// - An error if creating or saving the document fails
func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
doc := document.New()
// Add title
titlePara := doc.AddParagraph()
titleRun := titlePara.AddRun()
titleRun.AddText(course.Course.Title)
titleRun.Properties().SetBold(true)
titleRun.Properties().SetSize(16)
// Add description if available
if course.Course.Description != "" {
descPara := doc.AddParagraph()
descRun := descPara.AddRun()
cleanDesc := e.htmlCleaner.CleanHTML(course.Course.Description)
descRun.AddText(cleanDesc)
}
// Add each lesson
for _, lesson := range course.Course.Lessons {
e.exportLesson(doc, &lesson)
}
// Ensure output directory exists and add .docx extension
if !strings.HasSuffix(strings.ToLower(outputPath), ".docx") {
outputPath = outputPath + ".docx"
}
return doc.SaveToFile(outputPath)
}
// exportLesson adds a lesson to the document with appropriate formatting.
// It creates a lesson heading, adds the description, and processes all items in the lesson.
//
// Parameters:
// - doc: The Word document being created
// - lesson: The lesson data model to export
func (e *DocxExporter) exportLesson(doc *document.Document, lesson *models.Lesson) {
// Add lesson title
lessonPara := doc.AddParagraph()
lessonRun := lessonPara.AddRun()
lessonRun.AddText(fmt.Sprintf("Lesson: %s", lesson.Title))
lessonRun.Properties().SetBold(true)
lessonRun.Properties().SetSize(14)
// Add lesson description if available
if lesson.Description != "" {
descPara := doc.AddParagraph()
descRun := descPara.AddRun()
cleanDesc := e.htmlCleaner.CleanHTML(lesson.Description)
descRun.AddText(cleanDesc)
}
// Add each item in the lesson
for _, item := range lesson.Items {
e.exportItem(doc, &item)
}
}
// exportItem adds an item to the document.
// It creates an item heading and processes all sub-items within the item.
//
// Parameters:
// - doc: The Word document being created
// - item: The item data model to export
func (e *DocxExporter) exportItem(doc *document.Document, item *models.Item) {
// Add item type as heading
if item.Type != "" {
itemPara := doc.AddParagraph()
itemRun := itemPara.AddRun()
itemRun.AddText(strings.Title(item.Type))
itemRun.Properties().SetBold(true)
itemRun.Properties().SetSize(12)
}
// Add sub-items
for _, subItem := range item.Items {
e.exportSubItem(doc, &subItem)
}
}
// exportSubItem adds a sub-item to the document.
// It handles different components of a sub-item like title, heading,
// paragraph content, answers, and feedback.
//
// Parameters:
// - doc: The Word document being created
// - subItem: The sub-item data model to export
func (e *DocxExporter) exportSubItem(doc *document.Document, subItem *models.SubItem) {
// Add title if available
if subItem.Title != "" {
subItemPara := doc.AddParagraph()
subItemRun := subItemPara.AddRun()
subItemRun.AddText(" " + subItem.Title) // Indented
subItemRun.Properties().SetBold(true)
}
// Add heading if available
if subItem.Heading != "" {
headingPara := doc.AddParagraph()
headingRun := headingPara.AddRun()
cleanHeading := e.htmlCleaner.CleanHTML(subItem.Heading)
headingRun.AddText(" " + cleanHeading) // Indented
headingRun.Properties().SetBold(true)
}
// Add paragraph content if available
if subItem.Paragraph != "" {
contentPara := doc.AddParagraph()
contentRun := contentPara.AddRun()
cleanContent := e.htmlCleaner.CleanHTML(subItem.Paragraph)
contentRun.AddText(" " + cleanContent) // Indented
}
// Add answers if this is a question
if len(subItem.Answers) > 0 {
answersPara := doc.AddParagraph()
answersRun := answersPara.AddRun()
answersRun.AddText(" Answers:")
answersRun.Properties().SetBold(true)
for i, answer := range subItem.Answers {
answerPara := doc.AddParagraph()
answerRun := answerPara.AddRun()
prefix := fmt.Sprintf(" %d. ", i+1)
if answer.Correct {
prefix += "✓ "
}
cleanAnswer := e.htmlCleaner.CleanHTML(answer.Title)
answerRun.AddText(prefix + cleanAnswer)
}
}
// Add feedback if available
if subItem.Feedback != "" {
feedbackPara := doc.AddParagraph()
feedbackRun := feedbackPara.AddRun()
cleanFeedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
feedbackRun.AddText(" Feedback: " + cleanFeedback)
feedbackRun.Properties().SetItalic(true)
}
}
// GetSupportedFormat returns the format name this exporter supports.
//
// Returns:
// - A string representing the supported format ("docx")
func (e *DocxExporter) GetSupportedFormat() string {
return "docx"
}