Skip to content

Commit 9de7222

Browse files
committed
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.
1 parent 48cad71 commit 9de7222

File tree

15 files changed

+1091
-595
lines changed

15 files changed

+1091
-595
lines changed

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ jobs:
105105
- name: Run tests
106106
run: go test -v ./...
107107

108+
- name: Install UPX
109+
run: |
110+
sudo apt-get update
111+
sudo apt-get install -y upx
112+
108113
- name: Build binaries
109114
run: |
110115
# Set the build time environment variable
@@ -121,6 +126,32 @@ jobs:
121126
--verbose \
122127
-ldflags "-s -w -X github.com/kjanat/articulate-parser/internal/version.Version=${{ github.ref_name }} -X github.com/kjanat/articulate-parser/internal/version.BuildTime=$BUILD_TIME -X github.com/kjanat/articulate-parser/internal/version.GitCommit=${{ github.sha }}"
123128
129+
- name: Compress binaries with UPX
130+
run: |
131+
echo "Compressing binaries with UPX..."
132+
cd build/
133+
134+
# Get original sizes
135+
echo "Original sizes:"
136+
ls -lah
137+
echo ""
138+
139+
# Compress all binaries except Darwin (macOS) binaries as UPX doesn't work well with recent macOS versions
140+
for binary in articulate-parser-*; do
141+
if [[ "$binary" == *"darwin"* ]]; then
142+
echo "Skipping UPX compression for $binary (macOS compatibility)"
143+
else
144+
echo "Compressing $binary..."
145+
upx --best --lzma "$binary" || {
146+
echo "Warning: UPX compression failed for $binary, keeping original"
147+
}
148+
fi
149+
done
150+
151+
echo ""
152+
echo "Final sizes:"
153+
ls -lah
154+
124155
- name: Upload a Build Artifact
125156
uses: actions/[email protected]
126157
with:

internal/exporters/docx.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Package exporters provides implementations of the Exporter interface
2+
// for converting Articulate Rise courses into various file formats.
3+
package exporters
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
9+
"github.com/kjanat/articulate-parser/internal/interfaces"
10+
"github.com/kjanat/articulate-parser/internal/models"
11+
"github.com/kjanat/articulate-parser/internal/services"
12+
"github.com/unidoc/unioffice/document"
13+
)
14+
15+
// DocxExporter implements the Exporter interface for DOCX format.
16+
// It converts Articulate Rise course data into a Microsoft Word document
17+
// using the unioffice/document package.
18+
type DocxExporter struct {
19+
// htmlCleaner is used to convert HTML content to plain text
20+
htmlCleaner *services.HTMLCleaner
21+
}
22+
23+
// NewDocxExporter creates a new DocxExporter instance.
24+
// It takes an HTMLCleaner to handle HTML content conversion.
25+
//
26+
// Parameters:
27+
// - htmlCleaner: Service for cleaning HTML content in course data
28+
//
29+
// Returns:
30+
// - An implementation of the Exporter interface for DOCX format
31+
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
32+
return &DocxExporter{
33+
htmlCleaner: htmlCleaner,
34+
}
35+
}
36+
37+
// Export exports the course to a DOCX file.
38+
// It creates a Word document with formatted content based on the course data
39+
// and saves it to the specified output path.
40+
//
41+
// Parameters:
42+
// - course: The course data model to export
43+
// - outputPath: The file path where the DOCX content will be written
44+
//
45+
// Returns:
46+
// - An error if creating or saving the document fails
47+
func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
48+
doc := document.New()
49+
50+
// Add title
51+
titlePara := doc.AddParagraph()
52+
titleRun := titlePara.AddRun()
53+
titleRun.AddText(course.Course.Title)
54+
titleRun.Properties().SetBold(true)
55+
titleRun.Properties().SetSize(16)
56+
57+
// Add description if available
58+
if course.Course.Description != "" {
59+
descPara := doc.AddParagraph()
60+
descRun := descPara.AddRun()
61+
cleanDesc := e.htmlCleaner.CleanHTML(course.Course.Description)
62+
descRun.AddText(cleanDesc)
63+
}
64+
65+
// Add each lesson
66+
for _, lesson := range course.Course.Lessons {
67+
e.exportLesson(doc, &lesson)
68+
}
69+
70+
// Ensure output directory exists and add .docx extension
71+
if !strings.HasSuffix(strings.ToLower(outputPath), ".docx") {
72+
outputPath = outputPath + ".docx"
73+
}
74+
75+
return doc.SaveToFile(outputPath)
76+
}
77+
78+
// exportLesson adds a lesson to the document with appropriate formatting.
79+
// It creates a lesson heading, adds the description, and processes all items in the lesson.
80+
//
81+
// Parameters:
82+
// - doc: The Word document being created
83+
// - lesson: The lesson data model to export
84+
func (e *DocxExporter) exportLesson(doc *document.Document, lesson *models.Lesson) {
85+
// Add lesson title
86+
lessonPara := doc.AddParagraph()
87+
lessonRun := lessonPara.AddRun()
88+
lessonRun.AddText(fmt.Sprintf("Lesson: %s", lesson.Title))
89+
lessonRun.Properties().SetBold(true)
90+
lessonRun.Properties().SetSize(14)
91+
92+
// Add lesson description if available
93+
if lesson.Description != "" {
94+
descPara := doc.AddParagraph()
95+
descRun := descPara.AddRun()
96+
cleanDesc := e.htmlCleaner.CleanHTML(lesson.Description)
97+
descRun.AddText(cleanDesc)
98+
}
99+
100+
// Add each item in the lesson
101+
for _, item := range lesson.Items {
102+
e.exportItem(doc, &item)
103+
}
104+
}
105+
106+
// exportItem adds an item to the document.
107+
// It creates an item heading and processes all sub-items within the item.
108+
//
109+
// Parameters:
110+
// - doc: The Word document being created
111+
// - item: The item data model to export
112+
func (e *DocxExporter) exportItem(doc *document.Document, item *models.Item) {
113+
// Add item type as heading
114+
if item.Type != "" {
115+
itemPara := doc.AddParagraph()
116+
itemRun := itemPara.AddRun()
117+
itemRun.AddText(strings.Title(item.Type))
118+
itemRun.Properties().SetBold(true)
119+
itemRun.Properties().SetSize(12)
120+
}
121+
122+
// Add sub-items
123+
for _, subItem := range item.Items {
124+
e.exportSubItem(doc, &subItem)
125+
}
126+
}
127+
128+
// exportSubItem adds a sub-item to the document.
129+
// It handles different components of a sub-item like title, heading,
130+
// paragraph content, answers, and feedback.
131+
//
132+
// Parameters:
133+
// - doc: The Word document being created
134+
// - subItem: The sub-item data model to export
135+
func (e *DocxExporter) exportSubItem(doc *document.Document, subItem *models.SubItem) {
136+
// Add title if available
137+
if subItem.Title != "" {
138+
subItemPara := doc.AddParagraph()
139+
subItemRun := subItemPara.AddRun()
140+
subItemRun.AddText(" " + subItem.Title) // Indented
141+
subItemRun.Properties().SetBold(true)
142+
}
143+
144+
// Add heading if available
145+
if subItem.Heading != "" {
146+
headingPara := doc.AddParagraph()
147+
headingRun := headingPara.AddRun()
148+
cleanHeading := e.htmlCleaner.CleanHTML(subItem.Heading)
149+
headingRun.AddText(" " + cleanHeading) // Indented
150+
headingRun.Properties().SetBold(true)
151+
}
152+
153+
// Add paragraph content if available
154+
if subItem.Paragraph != "" {
155+
contentPara := doc.AddParagraph()
156+
contentRun := contentPara.AddRun()
157+
cleanContent := e.htmlCleaner.CleanHTML(subItem.Paragraph)
158+
contentRun.AddText(" " + cleanContent) // Indented
159+
}
160+
161+
// Add answers if this is a question
162+
if len(subItem.Answers) > 0 {
163+
answersPara := doc.AddParagraph()
164+
answersRun := answersPara.AddRun()
165+
answersRun.AddText(" Answers:")
166+
answersRun.Properties().SetBold(true)
167+
168+
for i, answer := range subItem.Answers {
169+
answerPara := doc.AddParagraph()
170+
answerRun := answerPara.AddRun()
171+
prefix := fmt.Sprintf(" %d. ", i+1)
172+
if answer.Correct {
173+
prefix += "✓ "
174+
}
175+
cleanAnswer := e.htmlCleaner.CleanHTML(answer.Title)
176+
answerRun.AddText(prefix + cleanAnswer)
177+
}
178+
}
179+
180+
// Add feedback if available
181+
if subItem.Feedback != "" {
182+
feedbackPara := doc.AddParagraph()
183+
feedbackRun := feedbackPara.AddRun()
184+
cleanFeedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
185+
feedbackRun.AddText(" Feedback: " + cleanFeedback)
186+
feedbackRun.Properties().SetItalic(true)
187+
}
188+
}
189+
190+
// GetSupportedFormat returns the format name this exporter supports.
191+
//
192+
// Returns:
193+
// - A string representing the supported format ("docx")
194+
func (e *DocxExporter) GetSupportedFormat() string {
195+
return "docx"
196+
}

internal/exporters/factory.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Package exporters provides implementations of the Exporter interface
2+
// for converting Articulate Rise courses into various file formats.
3+
package exporters
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
9+
"github.com/kjanat/articulate-parser/internal/interfaces"
10+
"github.com/kjanat/articulate-parser/internal/services"
11+
)
12+
13+
// Factory implements the ExporterFactory interface.
14+
// It creates appropriate exporter instances based on the requested format.
15+
type Factory struct {
16+
// htmlCleaner is used by exporters to convert HTML content to plain text
17+
htmlCleaner *services.HTMLCleaner
18+
}
19+
20+
// NewFactory creates a new exporter factory.
21+
// It takes an HTMLCleaner instance that will be passed to the exporters
22+
// created by this factory.
23+
//
24+
// Parameters:
25+
// - htmlCleaner: Service for cleaning HTML content in course data
26+
//
27+
// Returns:
28+
// - An implementation of the ExporterFactory interface
29+
func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
30+
return &Factory{
31+
htmlCleaner: htmlCleaner,
32+
}
33+
}
34+
35+
// CreateExporter creates an exporter for the specified format.
36+
// It returns an appropriate exporter implementation based on the format string.
37+
// Format strings are case-insensitive.
38+
//
39+
// Parameters:
40+
// - format: The desired export format (e.g., "markdown", "docx")
41+
//
42+
// Returns:
43+
// - An implementation of the Exporter interface if the format is supported
44+
// - An error if the format is not supported
45+
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
46+
switch strings.ToLower(format) {
47+
case "markdown", "md":
48+
return NewMarkdownExporter(f.htmlCleaner), nil
49+
case "docx", "word":
50+
return NewDocxExporter(f.htmlCleaner), nil
51+
default:
52+
return nil, fmt.Errorf("unsupported export format: %s", format)
53+
}
54+
}
55+
56+
// GetSupportedFormats returns a list of all supported export formats.
57+
// This includes both primary format names and their aliases.
58+
//
59+
// Returns:
60+
// - A string slice containing all supported format names
61+
func (f *Factory) GetSupportedFormats() []string {
62+
return []string{"markdown", "md", "docx", "word"}
63+
}

0 commit comments

Comments
 (0)