refactor: extract base exporter, add sentinel errors, document unused fields

This commit is contained in:
2026-09-21 09:46:39 +02:00
parent 74906ee6a3
commit defce7d2d3
12 changed files with 136 additions and 42 deletions
+28
View File
@@ -0,0 +1,28 @@
// Package apperrors provides sentinel errors for the articulate-parser application.
package apperrors
import "errors"
// Export errors.
var (
// ErrUnsupportedFormat is returned when an unknown export format is requested.
ErrUnsupportedFormat = errors.New("unsupported export format")
)
// URI parsing errors.
var (
// ErrInvalidURI is returned when a URI cannot be parsed.
ErrInvalidURI = errors.New("invalid URI")
// ErrInvalidDomain is returned when the URI domain is not an Articulate Rise domain.
ErrInvalidDomain = errors.New("invalid domain for Articulate Rise URI")
// ErrNoShareID is returned when a share ID cannot be extracted from the URI.
ErrNoShareID = errors.New("could not extract share ID from URI")
)
// Operation errors.
var (
// ErrOperationCanceled is returned when an operation is canceled via context.
ErrOperationCanceled = errors.New("operation canceled")
)
+50
View File
@@ -0,0 +1,50 @@
package apperrors
import (
"errors"
"fmt"
"testing"
)
func TestSentinelErrors_Unwrap(t *testing.T) {
tests := []struct {
name string
sentinel error
wrapped error
}{
{"ErrUnsupportedFormat", ErrUnsupportedFormat, fmt.Errorf("%w: xyz", ErrUnsupportedFormat)},
{"ErrInvalidURI", ErrInvalidURI, fmt.Errorf("%w: bad", ErrInvalidURI)},
{"ErrInvalidDomain", ErrInvalidDomain, fmt.Errorf("%w: evil.com", ErrInvalidDomain)},
{"ErrNoShareID", ErrNoShareID, fmt.Errorf("%w: no match", ErrNoShareID)},
{"ErrOperationCanceled", ErrOperationCanceled, fmt.Errorf("%w: ctx done", ErrOperationCanceled)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !errors.Is(tt.wrapped, tt.sentinel) {
t.Errorf("errors.Is(%v, %v) = false, want true", tt.wrapped, tt.sentinel)
}
})
}
}
func TestSentinelErrors_Message(t *testing.T) {
tests := []struct {
err error
expected string
}{
{ErrUnsupportedFormat, "unsupported export format"},
{ErrInvalidURI, "invalid URI"},
{ErrInvalidDomain, "invalid domain for Articulate Rise URI"},
{ErrNoShareID, "could not extract share ID from URI"},
{ErrOperationCanceled, "operation canceled"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if tt.err.Error() != tt.expected {
t.Errorf("got %q, want %q", tt.err.Error(), tt.expected)
}
})
}
}
+20
View File
@@ -0,0 +1,20 @@
package exporters
import "github.com/kjanat/articulate-parser/internal/interfaces"
// baseExporter contains common fields shared by all exporter implementations.
// Embed this struct in concrete exporters to inherit HTMLCleaner and Logger.
type baseExporter struct {
// htmlCleaner is used to convert HTML content to plain text.
htmlCleaner interfaces.HTMLCleaner
// logger is used for logging warnings and errors.
logger interfaces.Logger
}
// newBaseExporter creates a new baseExporter with the given dependencies.
func newBaseExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) baseExporter {
return baseExporter{
htmlCleaner: htmlCleaner,
logger: logger,
}
}
+2 -6
View File
@@ -26,10 +26,7 @@ const (
// It converts Articulate Rise course data into a Microsoft Word document // It converts Articulate Rise course data into a Microsoft Word document
// using the go-docx package. // using the go-docx package.
type DocxExporter struct { type DocxExporter struct {
// htmlCleaner is used to convert HTML content to plain text baseExporter
htmlCleaner interfaces.HTMLCleaner
// logger is used for logging warnings and errors
logger interfaces.Logger
} }
// NewDocxExporter creates a new DocxExporter instance. // NewDocxExporter creates a new DocxExporter instance.
@@ -43,8 +40,7 @@ type DocxExporter struct {
// - An implementation of the Exporter interface for DOCX format // - An implementation of the Exporter interface for DOCX format
func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
return &DocxExporter{ return &DocxExporter{
htmlCleaner: htmlCleaner, baseExporter: newBaseExporter(htmlCleaner, logger),
logger: logger,
} }
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/kjanat/articulate-parser/internal/apperrors"
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
) )
@@ -56,7 +57,7 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
case FormatHTML, formatAliasHTML: case FormatHTML, formatAliasHTML:
return NewHTMLExporter(f.htmlCleaner, f.logger), nil return NewHTMLExporter(f.htmlCleaner, f.logger), nil
default: default:
return nil, fmt.Errorf("unsupported export format: %s", format) return nil, fmt.Errorf("%w: %s", apperrors.ErrUnsupportedFormat, format)
} }
} }
+4 -8
View File
@@ -20,12 +20,9 @@ var htmlTemplate string
// HTMLExporter implements the Exporter interface for HTML format. // HTMLExporter implements the Exporter interface for HTML format.
// It converts Articulate Rise course data into a structured HTML document using templates. // It converts Articulate Rise course data into a structured HTML document using templates.
type HTMLExporter struct { type HTMLExporter struct {
// htmlCleaner is used to convert HTML content to plain text when needed baseExporter
htmlCleaner interfaces.HTMLCleaner // tmpl holds the parsed HTML template.
// tmpl holds the parsed HTML template
tmpl *template.Template tmpl *template.Template
// logger is used for logging warnings and errors
logger interfaces.Logger
} }
// NewHTMLExporter creates a new HTMLExporter instance. // NewHTMLExporter creates a new HTMLExporter instance.
@@ -52,9 +49,8 @@ func NewHTMLExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logge
tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate)) tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate))
return &HTMLExporter{ return &HTMLExporter{
htmlCleaner: htmlCleaner, baseExporter: newBaseExporter(htmlCleaner, logger),
tmpl: tmpl, tmpl: tmpl,
logger: logger,
} }
} }
+2 -6
View File
@@ -16,10 +16,7 @@ import (
// MarkdownExporter implements the Exporter interface for Markdown format. // MarkdownExporter implements the Exporter interface for Markdown format.
// It converts Articulate Rise course data into a structured Markdown document. // It converts Articulate Rise course data into a structured Markdown document.
type MarkdownExporter struct { type MarkdownExporter struct {
// htmlCleaner is used to convert HTML content to plain text baseExporter
htmlCleaner interfaces.HTMLCleaner
// logger is used for logging warnings and errors
logger interfaces.Logger
} }
// NewMarkdownExporter creates a new MarkdownExporter instance. // NewMarkdownExporter creates a new MarkdownExporter instance.
@@ -33,8 +30,7 @@ type MarkdownExporter struct {
// - An implementation of the Exporter interface for Markdown format // - An implementation of the Exporter interface for Markdown format
func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
return &MarkdownExporter{ return &MarkdownExporter{
htmlCleaner: htmlCleaner, baseExporter: newBaseExporter(htmlCleaner, logger),
logger: logger,
} }
} }
+13 -13
View File
@@ -124,7 +124,7 @@ func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method. // TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
func TestMarkdownExporter_ProcessTextItem(t *testing.T) { func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -153,7 +153,7 @@ func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
// TestMarkdownExporter_ProcessListItem tests the processListItem method. // TestMarkdownExporter_ProcessListItem tests the processListItem method.
func TestMarkdownExporter_ProcessListItem(t *testing.T) { func TestMarkdownExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -178,7 +178,7 @@ func TestMarkdownExporter_ProcessListItem(t *testing.T) {
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method. // TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) { func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -217,7 +217,7 @@ func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method. // TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
func TestMarkdownExporter_ProcessImageItem(t *testing.T) { func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -252,7 +252,7 @@ func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method. // TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) { func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -294,7 +294,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method. // TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) { func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -319,7 +319,7 @@ func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method. // TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) { func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
exporter.processDividerItem(&buf) exporter.processDividerItem(&buf)
@@ -335,7 +335,7 @@ func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method. // TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) { func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
item := models.Item{ item := models.Item{
@@ -366,7 +366,7 @@ func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method. // TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) { func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
media := &models.Media{ media := &models.Media{
@@ -391,7 +391,7 @@ func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method. // TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) { func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
media := &models.Media{ media := &models.Media{
@@ -413,7 +413,7 @@ func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method. // TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
func TestMarkdownExporter_ProcessAnswers(t *testing.T) { func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
var buf bytes.Buffer var buf bytes.Buffer
answers := []models.Answer{ answers := []models.Answer{
@@ -443,7 +443,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types. // TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) { func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
tests := []struct { tests := []struct {
name string name string
@@ -673,7 +673,7 @@ func BenchmarkMarkdownExporter_Export(b *testing.B) {
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method. // BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) { func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner} exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
item := models.Item{ item := models.Item{
Type: "text", Type: "text",
+2
View File
@@ -47,6 +47,8 @@ type ExportSettings struct {
// LabelSet contains customized labels used throughout the course. // LabelSet contains customized labels used throughout the course.
// This allows course creators to modify standard terminology. // This allows course creators to modify standard terminology.
//
// NOTE: Currently parsed but not used in export logic.
type LabelSet struct { type LabelSet struct {
// ID is the unique identifier for this label set // ID is the unique identifier for this label set
ID string `json:"id"` ID string `json:"id"`
+6 -3
View File
@@ -15,7 +15,8 @@ type Lesson struct {
Icon string `json:"icon"` Icon string `json:"icon"`
// Items is an ordered array of content items within the lesson // Items is an ordered array of content items within the lesson
Items []Item `json:"items"` Items []Item `json:"items"`
// Position stores the ordering information for the lesson // Position stores the ordering information for the lesson.
// NOTE: Currently parsed but not used in export logic.
Position any `json:"position"` Position any `json:"position"`
// Ready indicates whether the lesson is marked as complete // Ready indicates whether the lesson is marked as complete
Ready bool `json:"ready"` Ready bool `json:"ready"`
@@ -38,9 +39,11 @@ type Item struct {
Variant string `json:"variant"` Variant string `json:"variant"`
// Items contains the actual content elements (sub-items) of this item // Items contains the actual content elements (sub-items) of this item
Items []SubItem `json:"items"` Items []SubItem `json:"items"`
// Settings contains configuration options specific to this item type // Settings contains configuration options specific to this item type.
// NOTE: Currently parsed but not used in export logic.
Settings any `json:"settings"` Settings any `json:"settings"`
// Data contains additional structured data for the item // Data contains additional structured data for the item.
// NOTE: Currently parsed but not used in export logic.
Data any `json:"data"` Data any `json:"data"`
// Media contains any associated media for the item // Media contains any associated media for the item
Media *Media `json:"media,omitempty"` Media *Media `json:"media,omitempty"`
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/kjanat/articulate-parser/internal/apperrors"
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
) )
@@ -37,7 +38,7 @@ func NewApp(parser interfaces.CourseParser, exporterFactory interfaces.ExporterF
func (a *App) ProcessCourseFromFile(ctx context.Context, filePath, format, outputPath string) error { func (a *App) ProcessCourseFromFile(ctx context.Context, filePath, format, outputPath string) error {
// Check for cancellation before loading // Check for cancellation before loading
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return fmt.Errorf("operation canceled: %w", err) return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
} }
course, err := a.parser.LoadCourseFromFile(filePath) course, err := a.parser.LoadCourseFromFile(filePath)
@@ -68,7 +69,7 @@ func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath
func (a *App) exportCourse(ctx context.Context, course *models.Course, format, outputPath string) error { func (a *App) exportCourse(ctx context.Context, course *models.Course, format, outputPath string) error {
// Check for cancellation before export // Check for cancellation before export
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return fmt.Errorf("operation canceled: %w", err) return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
} }
exporter, err := a.exporterFactory.CreateExporter(format) exporter, err := a.exporterFactory.CreateExporter(format)
+4 -3
View File
@@ -10,6 +10,7 @@ import (
"os" "os"
"regexp" "regexp"
"github.com/kjanat/articulate-parser/internal/apperrors"
"github.com/kjanat/articulate-parser/internal/config" "github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
@@ -127,17 +128,17 @@ func (p *ArticulateParser) extractShareID(uri string) (string, error) {
// Parse the URL to validate the domain // Parse the URL to validate the domain
parsedURL, err := url.Parse(uri) parsedURL, err := url.Parse(uri)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid URI: %s", uri) return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidURI, uri)
} }
// Validate that it's an Articulate Rise domain // Validate that it's an Articulate Rise domain
if parsedURL.Host != riseHost { if parsedURL.Host != riseHost {
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host) return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidDomain, parsedURL.Host)
} }
matches := shareIDRegex.FindStringSubmatch(uri) matches := shareIDRegex.FindStringSubmatch(uri)
if len(matches) < 2 { if len(matches) < 2 {
return "", fmt.Errorf("could not extract share ID from URI: %s", uri) return "", fmt.Errorf("%w: %s", apperrors.ErrNoShareID, uri)
} }
return matches[1], nil return matches[1], nil
} }