diff --git a/internal/exporters/docx.go b/internal/exporters/docx.go index 0c32b26..cd543e6 100644 --- a/internal/exporters/docx.go +++ b/internal/exporters/docx.go @@ -13,7 +13,6 @@ import ( "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/models" - "github.com/kjanat/articulate-parser/internal/services" ) // Font sizes for DOCX document headings (in half-points, so "32" = 16pt). @@ -28,7 +27,7 @@ const ( // using the go-docx package. type DocxExporter struct { // htmlCleaner is used to convert HTML content to plain text - htmlCleaner *services.HTMLCleaner + htmlCleaner interfaces.HTMLCleaner // logger is used for logging warnings and errors logger interfaces.Logger } @@ -42,7 +41,7 @@ type DocxExporter struct { // // Returns: // - An implementation of the Exporter interface for DOCX format -func NewDocxExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { +func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { return &DocxExporter{ htmlCleaner: htmlCleaner, logger: logger, diff --git a/internal/exporters/factory.go b/internal/exporters/factory.go index e3623b1..5e4361c 100644 --- a/internal/exporters/factory.go +++ b/internal/exporters/factory.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/kjanat/articulate-parser/internal/interfaces" - "github.com/kjanat/articulate-parser/internal/services" ) // Format constants for supported export formats. @@ -24,7 +23,7 @@ const ( // It creates appropriate exporter instances based on the requested format. type Factory struct { // htmlCleaner is used by exporters to convert HTML content to plain text - htmlCleaner *services.HTMLCleaner + htmlCleaner interfaces.HTMLCleaner // logger is used by exporters for logging logger interfaces.Logger } @@ -39,7 +38,7 @@ type Factory struct { // // Returns: // - An implementation of the ExporterFactory interface -func NewFactory(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory { +func NewFactory(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory { return &Factory{ htmlCleaner: htmlCleaner, logger: logger, diff --git a/internal/exporters/html.go b/internal/exporters/html.go index 9d7dfa5..92a1060 100644 --- a/internal/exporters/html.go +++ b/internal/exporters/html.go @@ -9,7 +9,6 @@ import ( "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/models" - "github.com/kjanat/articulate-parser/internal/services" ) //go:embed html_styles.css @@ -22,7 +21,7 @@ var htmlTemplate string // It converts Articulate Rise course data into a structured HTML document using templates. type HTMLExporter struct { // htmlCleaner is used to convert HTML content to plain text when needed - htmlCleaner *services.HTMLCleaner + htmlCleaner interfaces.HTMLCleaner // tmpl holds the parsed HTML template tmpl *template.Template // logger is used for logging warnings and errors @@ -39,7 +38,7 @@ type HTMLExporter struct { // // Returns: // - An implementation of the Exporter interface for HTML format -func NewHTMLExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { +func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { // Parse the template with custom functions funcMap := template.FuncMap{ "safeHTML": func(s string) template.HTML { diff --git a/internal/exporters/html_template_data.go b/internal/exporters/html_template_data.go index d57b5f8..c270a62 100644 --- a/internal/exporters/html_template_data.go +++ b/internal/exporters/html_template_data.go @@ -6,24 +6,10 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/models" - "github.com/kjanat/articulate-parser/internal/services" ) -// Item type constants. -const ( - itemTypeText = "text" - itemTypeList = "list" - itemTypeKnowledgeCheck = "knowledgecheck" - itemTypeMultimedia = "multimedia" - itemTypeImage = "image" - itemTypeInteractive = "interactive" - itemTypeDivider = "divider" -) - -// lessonTypeSection identifies a lesson that acts as a section header. -const lessonTypeSection = "section" - // templateData represents the data structure passed to the HTML template. type templateData struct { Course models.CourseInfo @@ -61,7 +47,7 @@ type templateSubItem struct { } // prepareTemplateData converts a Course model into template-friendly data. -func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleaner) *templateData { +func prepareTemplateData(course *models.Course, htmlCleaner interfaces.HTMLCleaner) *templateData { data := &templateData{ Course: course.Course, ShareID: course.ShareID, @@ -77,7 +63,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane Description: lesson.Description, } - if lesson.Type != lessonTypeSection { + if lesson.Type != models.LessonTypeSection { lessonCounter++ section.Number = lessonCounter section.Items = prepareItems(lesson.Items, htmlCleaner) @@ -90,7 +76,7 @@ func prepareTemplateData(course *models.Course, htmlCleaner *services.HTMLCleane } // prepareItems converts model Items to template Items. -func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []templateItem { +func prepareItems(items []models.Item, htmlCleaner interfaces.HTMLCleaner) []templateItem { result := make([]templateItem, 0, len(items)) for _, item := range items { @@ -100,9 +86,9 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp } // Set type title for unknown items - if tItem.Type != itemTypeText && tItem.Type != itemTypeList && tItem.Type != itemTypeKnowledgeCheck && - tItem.Type != itemTypeMultimedia && tItem.Type != itemTypeImage && tItem.Type != itemTypeInteractive && - tItem.Type != itemTypeDivider { + if tItem.Type != models.ItemTypeText && tItem.Type != models.ItemTypeList && tItem.Type != models.ItemTypeKnowledgeCheck && + tItem.Type != models.ItemTypeMultimedia && tItem.Type != models.ItemTypeImage && tItem.Type != models.ItemTypeInteractive && + tItem.Type != models.ItemTypeDivider { caser := cases.Title(language.English) tItem.TypeTitle = caser.String(item.Type) } @@ -120,7 +106,7 @@ func prepareItems(items []models.Item, htmlCleaner *services.HTMLCleaner) []temp } // Clean HTML for list items - if tItem.Type == itemTypeList && subItem.Paragraph != "" { + if tItem.Type == models.ItemTypeList && subItem.Paragraph != "" { tSubItem.CleanText = htmlCleaner.CleanHTML(subItem.Paragraph) } diff --git a/internal/exporters/markdown.go b/internal/exporters/markdown.go index b407402..29a2509 100644 --- a/internal/exporters/markdown.go +++ b/internal/exporters/markdown.go @@ -11,14 +11,13 @@ import ( "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/models" - "github.com/kjanat/articulate-parser/internal/services" ) // MarkdownExporter implements the Exporter interface for Markdown format. // It converts Articulate Rise course data into a structured Markdown document. type MarkdownExporter struct { // htmlCleaner is used to convert HTML content to plain text - htmlCleaner *services.HTMLCleaner + htmlCleaner interfaces.HTMLCleaner // logger is used for logging warnings and errors logger interfaces.Logger } @@ -32,7 +31,7 @@ type MarkdownExporter struct { // // Returns: // - An implementation of the Exporter interface for Markdown format -func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { +func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { return &MarkdownExporter{ htmlCleaner: htmlCleaner, logger: logger, @@ -47,8 +46,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title)) if course.Course.Description != "" { - desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(course.Course.Description)) - if desc != "" { + if desc := e.htmlCleaner.CleanAndTrim(course.Course.Description); desc != "" { fmt.Fprintf(&buf, "%s\n\n", desc) } } @@ -66,7 +64,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro // Process lessons lessonCounter := 0 for _, lesson := range course.Course.Lessons { - if lesson.Type == lessonTypeSection { + if lesson.Type == models.LessonTypeSection { fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title)) continue } @@ -75,8 +73,7 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title)) if lesson.Description != "" { - desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(lesson.Description)) - if desc != "" { + if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); desc != "" { fmt.Fprintf(&buf, "%s\n\n", desc) } } @@ -143,19 +140,19 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models. itemType := strings.ToLower(item.Type) switch itemType { - case itemTypeText: + case models.ItemTypeText: e.processTextItem(buf, item, headingPrefix) - case itemTypeList: + case models.ItemTypeList: e.processListItem(buf, item) - case itemTypeMultimedia: + case models.ItemTypeMultimedia: e.processMultimediaItem(buf, item, headingPrefix) - case itemTypeImage: + case models.ItemTypeImage: e.processImageItem(buf, item, headingPrefix) - case itemTypeKnowledgeCheck: + case models.ItemTypeKnowledgeCheck: e.processKnowledgeCheckItem(buf, item, headingPrefix) - case itemTypeInteractive: + case models.ItemTypeInteractive: e.processInteractiveItem(buf, item, headingPrefix) - case itemTypeDivider: + case models.ItemTypeDivider: e.processDividerItem(buf) default: e.processUnknownItem(buf, item, headingPrefix) @@ -166,14 +163,12 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models. func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) { for _, subItem := range item.Items { if subItem.Heading != "" { - heading := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Heading)) - if heading != "" { + if heading := e.htmlCleaner.CleanAndTrim(subItem.Heading); heading != "" { fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading) } } if subItem.Paragraph != "" { - paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) - if paragraph != "" { + if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" { fmt.Fprintf(buf, "%s\n\n", paragraph) } } @@ -184,8 +179,7 @@ func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) { for _, subItem := range item.Items { if subItem.Paragraph != "" { - paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) - if paragraph != "" { + if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" { fmt.Fprintf(buf, "- %s\n", paragraph) } } @@ -209,8 +203,7 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models e.processImageMedia(buf, subItem.Media) } if subItem.Caption != "" { - caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption)) - if caption != "" { + if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" { fmt.Fprintf(buf, "*%s*\n", caption) } } @@ -241,8 +234,7 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item, fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL) } if subItem.Caption != "" { - caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption)) - if caption != "" { + if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" { fmt.Fprintf(buf, "*%s*\n", caption) } } @@ -262,8 +254,7 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod // processQuestionSubItem processes individual question items. func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) { if subItem.Title != "" { - title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) - if title != "" { + if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" { fmt.Fprintf(buf, "**Question**: %s\n\n", title) } } @@ -271,8 +262,7 @@ func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem mod e.processAnswers(buf, subItem.Answers) if subItem.Feedback != "" { - feedback := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Feedback)) - if feedback != "" { + if feedback := e.htmlCleaner.CleanAndTrim(subItem.Feedback); feedback != "" { fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback) } } @@ -299,8 +289,7 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix) for _, subItem := range item.Items { if subItem.Title != "" { - title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) - if title != "" { + if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" { fmt.Fprintf(buf, "**%s**\n\n", title) } } @@ -326,14 +315,12 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite // processGenericSubItem processes sub-items for unknown types. func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) { if subItem.Title != "" { - title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) - if title != "" { + if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" { fmt.Fprintf(buf, "**%s**\n\n", title) } } if subItem.Paragraph != "" { - paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) - if paragraph != "" { + if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" { fmt.Fprintf(buf, "%s\n\n", paragraph) } } diff --git a/internal/interfaces/html_cleaner.go b/internal/interfaces/html_cleaner.go new file mode 100644 index 0000000..777eb93 --- /dev/null +++ b/internal/interfaces/html_cleaner.go @@ -0,0 +1,13 @@ +package interfaces + +// HTMLCleaner defines the interface for cleaning HTML content. +// Implementations convert HTML to plain text, stripping tags and decoding entities. +type HTMLCleaner interface { + // CleanHTML removes HTML tags and decodes HTML entities from a string. + // Returns plain text suitable for non-HTML output formats. + CleanHTML(htmlStr string) string + + // CleanAndTrim removes HTML tags, decodes entities, and trims whitespace. + // Convenience method combining CleanHTML with strings.TrimSpace. + CleanAndTrim(htmlStr string) string +} diff --git a/internal/models/item_types.go b/internal/models/item_types.go new file mode 100644 index 0000000..caf83d4 --- /dev/null +++ b/internal/models/item_types.go @@ -0,0 +1,41 @@ +package models + +// Item type constants used throughout the application. +// These match the type field values in Articulate Rise course data. +const ( + // ItemTypeText represents text content blocks with headings and paragraphs. + ItemTypeText = "text" + + // ItemTypeList represents bullet or numbered list content. + ItemTypeList = "list" + + // ItemTypeKnowledgeCheck represents quiz or assessment items. + ItemTypeKnowledgeCheck = "knowledgecheck" + + // ItemTypeMultimedia represents video or audio content. + ItemTypeMultimedia = "multimedia" + + // ItemTypeImage represents standalone image content. + ItemTypeImage = "image" + + // ItemTypeInteractive represents interactive elements like accordions or tabs. + ItemTypeInteractive = "interactive" + + // ItemTypeDivider represents visual separator elements. + ItemTypeDivider = "divider" + + // ItemTypeQuote represents quotation blocks. + ItemTypeQuote = "quote" + + // ItemTypeFlipcard represents flashcard-style content. + ItemTypeFlipcard = "flipcard" +) + +// Lesson type constants. +const ( + // LessonTypeSection represents a section header (not a real lesson). + LessonTypeSection = "section" + + // LessonTypeLesson represents a regular lesson. + LessonTypeLesson = "lesson" +) diff --git a/internal/services/html_cleaner.go b/internal/services/html_cleaner.go index 5c28c16..6a06606 100644 --- a/internal/services/html_cleaner.go +++ b/internal/services/html_cleaner.go @@ -7,13 +7,19 @@ import ( "strings" "golang.org/x/net/html" + + "github.com/kjanat/articulate-parser/internal/interfaces" ) // HTMLCleaner provides utilities for converting HTML content to plain text. // It removes HTML tags while preserving their content and converts HTML entities // to their plain text equivalents using proper HTML parsing instead of regex. +// Implements interfaces.HTMLCleaner. type HTMLCleaner struct{} +// Compile-time check that HTMLCleaner implements interfaces.HTMLCleaner. +var _ interfaces.HTMLCleaner = (*HTMLCleaner)(nil) + // NewHTMLCleaner creates a new HTML cleaner instance. // This service is typically injected into exporters that need to handle // HTML content from Articulate Rise courses. @@ -46,6 +52,13 @@ func (h *HTMLCleaner) CleanHTML(htmlStr string) string { return strings.TrimSpace(cleaned) } +// CleanAndTrim removes HTML tags, decodes entities, and trims whitespace. +// Convenience method that combines CleanHTML with strings.TrimSpace. +// Returns empty string if input is empty or contains only whitespace/tags. +func (h *HTMLCleaner) CleanAndTrim(htmlStr string) string { + return strings.TrimSpace(h.CleanHTML(htmlStr)) +} + // extractText recursively traverses the HTML node tree and extracts text content. // It skips script and style tags to avoid including their content in the output. func extractText(w io.Writer, n *html.Node) {