// Package exporters provides implementations of the Exporter interface // for converting Articulate Rise courses into various file formats. package exporters import ( "bytes" "fmt" "html" "os" "strings" "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/services" "golang.org/x/text/cases" "golang.org/x/text/language" ) // HTMLExporter implements the Exporter interface for HTML format. // It converts Articulate Rise course data into a structured HTML document. type HTMLExporter struct { // htmlCleaner is used to convert HTML content to plain text when needed htmlCleaner *services.HTMLCleaner } // NewHTMLExporter creates a new HTMLExporter instance. // It takes an HTMLCleaner to handle HTML content conversion when plain text is needed. // // Parameters: // - htmlCleaner: Service for cleaning HTML content in course data // // Returns: // - An implementation of the Exporter interface for HTML format func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { return &HTMLExporter{ htmlCleaner: htmlCleaner, } } // Export exports a course to HTML format. // It generates a structured HTML document from the course data // and writes it to the specified output path. // // Parameters: // - course: The course data model to export // - outputPath: The file path where the HTML content will be written // // Returns: // - An error if writing to the output file fails func (e *HTMLExporter) Export(course *models.Course, outputPath string) error { var buf bytes.Buffer // Write HTML document structure buf.WriteString("\n") buf.WriteString("\n") buf.WriteString("\n") buf.WriteString(" \n") buf.WriteString(" \n") buf.WriteString(fmt.Sprintf(" %s\n", html.EscapeString(course.Course.Title))) buf.WriteString(" \n") buf.WriteString("\n") buf.WriteString("\n") // Write course header buf.WriteString(fmt.Sprintf("
\n

%s

\n", html.EscapeString(course.Course.Title))) if course.Course.Description != "" { buf.WriteString(fmt.Sprintf("
%s
\n", course.Course.Description)) } buf.WriteString("
\n\n") // Add metadata section buf.WriteString("
\n") buf.WriteString("

Course Information

\n") buf.WriteString(" \n") buf.WriteString("
\n\n") // Process lessons lessonCounter := 0 for _, lesson := range course.Course.Lessons { if lesson.Type == "section" { buf.WriteString(fmt.Sprintf("
\n

%s

\n
\n\n", html.EscapeString(lesson.Title))) continue } lessonCounter++ buf.WriteString(fmt.Sprintf("
\n

Lesson %d: %s

\n", lessonCounter, html.EscapeString(lesson.Title))) if lesson.Description != "" { buf.WriteString(fmt.Sprintf("
%s
\n", lesson.Description)) } // Process lesson items for _, item := range lesson.Items { e.processItemToHTML(&buf, item) } buf.WriteString("
\n\n") } buf.WriteString("\n") buf.WriteString("\n") return os.WriteFile(outputPath, buf.Bytes(), 0644) } // GetSupportedFormat returns the format name this exporter supports // It indicates the file format that the HTMLExporter can generate. // // Returns: // - A string representing the supported format ("html") func (e *HTMLExporter) GetSupportedFormat() string { return "html" } // getDefaultCSS returns basic CSS styling for the HTML document func (e *HTMLExporter) getDefaultCSS() string { return ` body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; background-color: #f9f9f9; } header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; border-radius: 10px; margin-bottom: 2rem; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } header h1 { margin: 0; font-size: 2.5rem; font-weight: 300; } .course-description { margin-top: 1rem; font-size: 1.1rem; opacity: 0.9; } .course-info { background: white; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .course-info h2 { margin-top: 0; color: #4a5568; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; } .course-info ul { list-style: none; padding: 0; } .course-info li { margin: 0.5rem 0; padding: 0.5rem; background: #f7fafc; border-radius: 4px; } .course-section { background: #4299e1; color: white; padding: 1.5rem; border-radius: 8px; margin: 2rem 0; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .course-section h2 { margin: 0; font-weight: 400; } .lesson { background: white; padding: 2rem; border-radius: 8px; margin: 2rem 0; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); border-left: 4px solid #4299e1; } .lesson h3 { margin-top: 0; color: #2d3748; font-size: 1.5rem; } .lesson-description { margin: 1rem 0; padding: 1rem; background: #f7fafc; border-radius: 4px; border-left: 3px solid #4299e1; } .item { margin: 1.5rem 0; padding: 1rem; border-radius: 6px; background: #fafafa; border: 1px solid #e2e8f0; } .item h4 { margin-top: 0; color: #4a5568; font-size: 1.2rem; text-transform: capitalize; } .text-item { background: #f0fff4; border-left: 3px solid #48bb78; } .list-item { background: #fffaf0; border-left: 3px solid #ed8936; } .knowledge-check { background: #e6fffa; border-left: 3px solid #38b2ac; } .multimedia-item { background: #faf5ff; border-left: 3px solid #9f7aea; } .interactive-item { background: #fff5f5; border-left: 3px solid #f56565; } .unknown-item { background: #f7fafc; border-left: 3px solid #a0aec0; } .answers { margin: 1rem 0; } .answers h5 { margin: 0.5rem 0; color: #4a5568; } .answers ol { margin: 0.5rem 0; padding-left: 1.5rem; } .answers li { margin: 0.3rem 0; padding: 0.3rem; } .correct-answer { background: #c6f6d5; border-radius: 3px; font-weight: bold; } .correct-answer::after { content: " ✓"; color: #38a169; } .feedback { margin: 1rem 0; padding: 1rem; background: #edf2f7; border-radius: 4px; border-left: 3px solid #4299e1; font-style: italic; } .media-info { background: #edf2f7; padding: 1rem; border-radius: 4px; margin: 0.5rem 0; } .media-info strong { color: #4a5568; } hr { border: none; height: 2px; background: linear-gradient(to right, #667eea, #764ba2); margin: 2rem 0; border-radius: 1px; } ul { padding-left: 1.5rem; } li { margin: 0.5rem 0; } ` } // processItemToHTML converts a course item into HTML format // and appends it to the provided buffer. It handles different item types // with appropriate HTML formatting. // // Parameters: // - buf: The buffer to write the HTML content to // - item: The course item to process func (e *HTMLExporter) processItemToHTML(buf *bytes.Buffer, item models.Item) { switch strings.ToLower(item.Type) { case "text": e.processTextItem(buf, item) case "list": e.processListItem(buf, item) case "knowledgecheck": e.processKnowledgeCheckItem(buf, item) case "multimedia": e.processMultimediaItem(buf, item) case "image": e.processImageItem(buf, item) case "interactive": e.processInteractiveItem(buf, item) case "divider": e.processDividerItem(buf) default: e.processUnknownItem(buf, item) } } // processTextItem handles text content with headings and paragraphs func (e *HTMLExporter) processTextItem(buf *bytes.Buffer, item models.Item) { buf.WriteString("
\n") buf.WriteString("

Text Content

\n") for _, subItem := range item.Items { if subItem.Heading != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Heading) } if subItem.Paragraph != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Paragraph) } } buf.WriteString("
\n\n") } // processListItem handles list content func (e *HTMLExporter) processListItem(buf *bytes.Buffer, item models.Item) { buf.WriteString("
\n") buf.WriteString("

List

\n") buf.WriteString(" \n") buf.WriteString("
\n\n") } // processKnowledgeCheckItem handles quiz questions and answers func (e *HTMLExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item) { buf.WriteString("
\n") buf.WriteString("

Knowledge Check

\n") for _, subItem := range item.Items { if subItem.Title != "" { fmt.Fprintf(buf, "

Question: %s

\n", subItem.Title) } if len(subItem.Answers) > 0 { e.processAnswers(buf, subItem.Answers) } if subItem.Feedback != "" { fmt.Fprintf(buf, "
Feedback: %s
\n", subItem.Feedback) } } buf.WriteString("
\n\n") } // processMultimediaItem handles multimedia content like videos func (e *HTMLExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item) { buf.WriteString("
\n") buf.WriteString("

Media Content

\n") for _, subItem := range item.Items { if subItem.Title != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Title) } if subItem.Media != nil { if subItem.Media.Video != nil { buf.WriteString("
\n") fmt.Fprintf(buf, "

Video: %s

\n", html.EscapeString(subItem.Media.Video.OriginalUrl)) if subItem.Media.Video.Duration > 0 { fmt.Fprintf(buf, "

Duration: %d seconds

\n", subItem.Media.Video.Duration) } buf.WriteString("
\n") } } if subItem.Caption != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Caption) } } buf.WriteString("
\n\n") } // processImageItem handles image content func (e *HTMLExporter) processImageItem(buf *bytes.Buffer, item models.Item) { buf.WriteString("
\n") buf.WriteString("

Image

\n") for _, subItem := range item.Items { if subItem.Media != nil && subItem.Media.Image != nil { buf.WriteString("
\n") fmt.Fprintf(buf, "

Image: %s

\n", html.EscapeString(subItem.Media.Image.OriginalUrl)) buf.WriteString("
\n") } if subItem.Caption != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Caption) } } buf.WriteString("
\n\n") } // processInteractiveItem handles interactive content func (e *HTMLExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item) { buf.WriteString("
\n") buf.WriteString("

Interactive Content

\n") for _, subItem := range item.Items { if subItem.Title != "" { fmt.Fprintf(buf, "

%s

\n", subItem.Title) } if subItem.Paragraph != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Paragraph) } } buf.WriteString("
\n\n") } // processDividerItem handles divider elements func (e *HTMLExporter) processDividerItem(buf *bytes.Buffer) { buf.WriteString("
\n\n") } // processUnknownItem handles unknown or unsupported item types func (e *HTMLExporter) processUnknownItem(buf *bytes.Buffer, item models.Item) { if len(item.Items) > 0 { buf.WriteString("
\n") caser := cases.Title(language.English) fmt.Fprintf(buf, "

%s Content

\n", caser.String(item.Type)) for _, subItem := range item.Items { e.processGenericSubItem(buf, subItem) } buf.WriteString("
\n\n") } } // processGenericSubItem processes sub-items for unknown types func (e *HTMLExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) { if subItem.Title != "" { fmt.Fprintf(buf, "

%s

\n", subItem.Title) } if subItem.Paragraph != "" { fmt.Fprintf(buf, "
%s
\n", subItem.Paragraph) } } // processAnswers processes answer choices for quiz questions func (e *HTMLExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) { buf.WriteString("
\n") buf.WriteString("
Answers:
\n") buf.WriteString("
    \n") for _, answer := range answers { cssClass := "" if answer.Correct { cssClass = " class=\"correct-answer\"" } fmt.Fprintf(buf, " %s\n", cssClass, html.EscapeString(answer.Title)) } buf.WriteString("
\n") buf.WriteString("
\n") }