mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-10-13 01:35:27 +02:00
324 lines
10 KiB
Go
324 lines
10 KiB
Go
package exporters
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/kjanat/articulate-parser/internal/interfaces"
|
|
"github.com/kjanat/articulate-parser/internal/models"
|
|
)
|
|
|
|
// MarkdownExporter implements the Exporter interface for Markdown format.
|
|
// It converts Articulate Rise course data into a structured Markdown document.
|
|
type MarkdownExporter struct {
|
|
baseExporter
|
|
}
|
|
|
|
// NewMarkdownExporter creates a new MarkdownExporter instance.
|
|
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
|
|
//
|
|
// Parameters:
|
|
// - htmlCleaner: Service for cleaning HTML content in course data
|
|
// - logger: Logger instance for warnings and errors
|
|
//
|
|
// Returns:
|
|
// - An implementation of the Exporter interface for Markdown format
|
|
func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
|
|
return &MarkdownExporter{
|
|
baseExporter: newBaseExporter(htmlCleaner, logger),
|
|
}
|
|
}
|
|
|
|
// Export converts the course to Markdown format and writes it to the output path.
|
|
func (e *MarkdownExporter) Export(course *models.Course, outputPath string) error {
|
|
var buf bytes.Buffer
|
|
|
|
// Write course header
|
|
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title))
|
|
|
|
if course.Course.Description != "" {
|
|
if desc := e.htmlCleaner.CleanAndTrim(course.Course.Description); desc != "" {
|
|
fmt.Fprintf(&buf, "%s\n\n", desc)
|
|
}
|
|
}
|
|
|
|
// Add metadata
|
|
buf.WriteString("## Course Information\n\n")
|
|
fmt.Fprintf(&buf, "- **Course ID**: %s\n", strings.TrimSpace(course.Course.ID))
|
|
fmt.Fprintf(&buf, "- **Share ID**: %s\n", strings.TrimSpace(course.ShareID))
|
|
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", strings.TrimSpace(course.Course.NavigationMode))
|
|
if course.Course.ExportSettings != nil {
|
|
fmt.Fprintf(&buf, "- **Export Format**: %s\n", strings.TrimSpace(course.Course.ExportSettings.Format))
|
|
}
|
|
buf.WriteString("\n---\n\n")
|
|
|
|
// Process lessons
|
|
lessonCounter := 0
|
|
for _, lesson := range course.Course.Lessons {
|
|
if lesson.Type == models.LessonTypeSection {
|
|
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title))
|
|
continue
|
|
}
|
|
|
|
lessonCounter++
|
|
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title))
|
|
|
|
if lesson.Description != "" {
|
|
if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); desc != "" {
|
|
fmt.Fprintf(&buf, "%s\n\n", desc)
|
|
}
|
|
}
|
|
|
|
// Process lesson items
|
|
for _, item := range lesson.Items {
|
|
e.processItemToMarkdown(&buf, item, 3)
|
|
}
|
|
|
|
buf.WriteString("---\n\n")
|
|
}
|
|
|
|
// Clean up multiple consecutive blank lines before writing
|
|
output := cleanMarkdownWhitespace(buf.Bytes())
|
|
|
|
// #nosec G306 - 0644 is appropriate for export files that should be readable by others
|
|
if err := os.WriteFile(outputPath, output, 0o644); err != nil {
|
|
return fmt.Errorf("failed to write markdown file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cleanMarkdownWhitespace normalizes whitespace in markdown output.
|
|
// It removes trailing whitespace from lines and collapses multiple blank lines into one.
|
|
func cleanMarkdownWhitespace(data []byte) []byte {
|
|
lines := strings.Split(string(data), "\n")
|
|
result := make([]string, 0, len(lines))
|
|
prevBlank := false
|
|
|
|
for _, line := range lines {
|
|
// Trim trailing whitespace from each line
|
|
trimmed := strings.TrimRight(line, " \t")
|
|
|
|
// Collapse multiple consecutive blank lines
|
|
isBlank := trimmed == ""
|
|
if isBlank && prevBlank {
|
|
continue
|
|
}
|
|
|
|
result = append(result, trimmed)
|
|
prevBlank = isBlank
|
|
}
|
|
|
|
// Remove trailing blank lines
|
|
for len(result) > 0 && result[len(result)-1] == "" {
|
|
result = result[:len(result)-1]
|
|
}
|
|
|
|
// Ensure file ends with newline
|
|
return []byte(strings.Join(result, "\n") + "\n")
|
|
}
|
|
|
|
// SupportedFormat returns "markdown".
|
|
func (e *MarkdownExporter) SupportedFormat() string {
|
|
return FormatMarkdown
|
|
}
|
|
|
|
// processItemToMarkdown converts a course item into Markdown format.
|
|
// The level parameter determines the heading level (number of # characters).
|
|
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
|
|
headingPrefix := strings.Repeat("#", level)
|
|
|
|
// Normalize item type to lowercase for consistent matching
|
|
itemType := strings.ToLower(item.Type)
|
|
|
|
switch itemType {
|
|
case models.ItemTypeText:
|
|
e.processTextItem(buf, item, headingPrefix)
|
|
case models.ItemTypeList:
|
|
e.processListItem(buf, item)
|
|
case models.ItemTypeMultimedia:
|
|
e.processMultimediaItem(buf, item, headingPrefix)
|
|
case models.ItemTypeImage:
|
|
e.processImageItem(buf, item, headingPrefix)
|
|
case models.ItemTypeKnowledgeCheck:
|
|
e.processKnowledgeCheckItem(buf, item, headingPrefix)
|
|
case models.ItemTypeInteractive:
|
|
e.processInteractiveItem(buf, item, headingPrefix)
|
|
case models.ItemTypeDivider:
|
|
e.processDividerItem(buf)
|
|
default:
|
|
e.processUnknownItem(buf, item, headingPrefix)
|
|
}
|
|
}
|
|
|
|
// processTextItem handles text content with headings and paragraphs.
|
|
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
|
for _, subItem := range item.Items {
|
|
if subItem.Heading != "" {
|
|
if heading := e.htmlCleaner.CleanAndTrim(subItem.Heading); heading != "" {
|
|
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
|
|
}
|
|
}
|
|
if subItem.Paragraph != "" {
|
|
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
|
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// processListItem handles list items with bullet points.
|
|
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
|
|
for _, subItem := range item.Items {
|
|
if subItem.Paragraph != "" {
|
|
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
|
fmt.Fprintf(buf, "- %s\n", paragraph)
|
|
}
|
|
}
|
|
}
|
|
buf.WriteString("\n")
|
|
}
|
|
|
|
// processMultimediaItem handles multimedia content including videos and images.
|
|
func (e *MarkdownExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
|
fmt.Fprintf(buf, "%s Media Content\n\n", headingPrefix)
|
|
for _, subItem := range item.Items {
|
|
e.processMediaSubItem(buf, subItem)
|
|
}
|
|
buf.WriteString("\n")
|
|
}
|
|
|
|
// processMediaSubItem processes individual media items (video/image).
|
|
func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
|
if subItem.Media != nil {
|
|
e.processVideoMedia(buf, subItem.Media)
|
|
e.processImageMedia(buf, subItem.Media)
|
|
}
|
|
if subItem.Caption != "" {
|
|
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
|
|
fmt.Fprintf(buf, "*%s*\n", caption)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processVideoMedia processes video media content.
|
|
func (e *MarkdownExporter) processVideoMedia(buf *bytes.Buffer, media *models.Media) {
|
|
if media.Video != nil {
|
|
fmt.Fprintf(buf, "**Video**: %s\n", media.Video.OriginalURL)
|
|
if media.Video.Duration > 0 {
|
|
fmt.Fprintf(buf, "**Duration**: %d seconds\n", media.Video.Duration)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processImageMedia processes image media content.
|
|
func (e *MarkdownExporter) processImageMedia(buf *bytes.Buffer, media *models.Media) {
|
|
if media.Image != nil {
|
|
fmt.Fprintf(buf, "**Image**: %s\n", media.Image.OriginalURL)
|
|
}
|
|
}
|
|
|
|
// processImageItem handles standalone image items.
|
|
func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
|
fmt.Fprintf(buf, "%s Image\n\n", headingPrefix)
|
|
for _, subItem := range item.Items {
|
|
if subItem.Media != nil && subItem.Media.Image != nil {
|
|
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
|
|
}
|
|
if subItem.Caption != "" {
|
|
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
|
|
fmt.Fprintf(buf, "*%s*\n", caption)
|
|
}
|
|
}
|
|
}
|
|
buf.WriteString("\n")
|
|
}
|
|
|
|
// processKnowledgeCheckItem handles quiz questions and knowledge checks.
|
|
func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
|
fmt.Fprintf(buf, "%s Knowledge Check\n\n", headingPrefix)
|
|
for _, subItem := range item.Items {
|
|
e.processQuestionSubItem(buf, subItem)
|
|
}
|
|
buf.WriteString("\n")
|
|
}
|
|
|
|
// processQuestionSubItem processes individual question items.
|
|
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
|
if subItem.Title != "" {
|
|
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
|
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
|
|
}
|
|
}
|
|
|
|
e.processAnswers(buf, subItem.Answers)
|
|
|
|
if subItem.Feedback != "" {
|
|
if feedback := e.htmlCleaner.CleanAndTrim(subItem.Feedback); feedback != "" {
|
|
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processAnswers processes answer choices for quiz questions.
|
|
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
|
|
if len(answers) == 0 {
|
|
return
|
|
}
|
|
buf.WriteString("**Answers**:\n")
|
|
for i, answer := range answers {
|
|
correctMark := ""
|
|
if answer.Correct {
|
|
correctMark = " [correct]"
|
|
}
|
|
title := strings.TrimSpace(answer.Title)
|
|
fmt.Fprintf(buf, "%d. %s%s\n", i+1, title, correctMark)
|
|
}
|
|
}
|
|
|
|
// processInteractiveItem handles interactive content.
|
|
func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
|
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
|
|
for _, subItem := range item.Items {
|
|
if subItem.Title != "" {
|
|
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
|
fmt.Fprintf(buf, "**%s**\n\n", title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// processDividerItem handles divider elements.
|
|
func (e *MarkdownExporter) processDividerItem(buf *bytes.Buffer) {
|
|
buf.WriteString("---\n\n")
|
|
}
|
|
|
|
// processUnknownItem handles unknown or unsupported item types.
|
|
func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
|
if len(item.Items) > 0 {
|
|
caser := cases.Title(language.English)
|
|
fmt.Fprintf(buf, "%s %s Content\n\n", headingPrefix, caser.String(item.Type))
|
|
for _, subItem := range item.Items {
|
|
e.processGenericSubItem(buf, subItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processGenericSubItem processes sub-items for unknown types.
|
|
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
|
if subItem.Title != "" {
|
|
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
|
fmt.Fprintf(buf, "**%s**\n\n", title)
|
|
}
|
|
}
|
|
if subItem.Paragraph != "" {
|
|
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
|
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
|
}
|
|
}
|
|
}
|