mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-10-13 01:35:27 +02:00
Compare commits
5
Commits
8ec9dd4f20
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76f8017ea7
|
||
|
|
7d3487312f
|
||
|
|
defce7d2d3
|
||
|
|
74906ee6a3
|
||
|
|
adb60ca7e5
|
+15
-38
@@ -1,42 +1,19 @@
|
||||
{
|
||||
"typescript": {
|
||||
},
|
||||
"json": {
|
||||
},
|
||||
"markdown": {
|
||||
},
|
||||
"toml": {
|
||||
},
|
||||
"dockerfile": {
|
||||
},
|
||||
"oxc": {
|
||||
},
|
||||
"ruff": {
|
||||
},
|
||||
"jupyter": {
|
||||
},
|
||||
"malva": {
|
||||
},
|
||||
"markup": {
|
||||
},
|
||||
"yaml": {
|
||||
},
|
||||
"excludes": [
|
||||
"**/node_modules",
|
||||
"**/*-lock.json",
|
||||
],
|
||||
"$schema": "https://dprint.dev/schemas/v0.json",
|
||||
"extends": "https://github.com/kjanat/kjanat/raw/8cb7b67/configs/dprint.remote.json",
|
||||
"excludes": ["**/node_modules", "**/.venv", "**/bun.lock*"],
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/typescript-0.95.13.wasm",
|
||||
"https://plugins.dprint.dev/json-0.21.1.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.20.0.wasm",
|
||||
"https://plugins.dprint.dev/toml-0.7.0.wasm",
|
||||
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
|
||||
"https://plugins.dprint.dev/oxc-0.1.0.wasm",
|
||||
"https://plugins.dprint.dev/ruff-0.6.11.wasm",
|
||||
"https://plugins.dprint.dev/jupyter-0.2.1.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm",
|
||||
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
|
||||
"npm:@dprint/prettier@0.70.0/plugin.json@97c0dccf54ed5b7343e7a3f6418e77bd80afcd2b74beed3a632689d47e392ddc",
|
||||
"npm:@dprint/exec@0.7.3/plugin.json@704701df449dd7e942a71144773778ac529d68c2e4657bfc236d393b898b9a67",
|
||||
],
|
||||
"markdown": { "associations": ["!README.md"] },
|
||||
"prettier": { "associations": ["README.md"], "useTabs": false },
|
||||
"exec": {
|
||||
"cwd": "${configDir}",
|
||||
"commands": [{
|
||||
"command": "bunx tombi format - --stdin-filename {{file_path}}",
|
||||
"exts": ["toml"],
|
||||
"cacheKeyFiles": ["tombi.toml"],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/coverage.out
|
||||
flags: Go ${{ env.GO_VERSION }}
|
||||
@@ -401,7 +401,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -34,6 +34,8 @@ output/
|
||||
outputs/
|
||||
articulate-sample.json
|
||||
test-output.*
|
||||
# Example test exports (defensive; tests now write to a temp dir)
|
||||
internal/exporters/output.*
|
||||
go-os-arch-matrix.csv
|
||||
test_godocx.go
|
||||
test_input.json
|
||||
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
||||
# golangci-lint configuration for articulate-parser
|
||||
# https://golangci-lint.run/usage/configuration/
|
||||
|
||||
---
|
||||
version: "2"
|
||||
|
||||
# Options for analysis running
|
||||
@@ -51,6 +52,7 @@ formatters:
|
||||
- gofmt
|
||||
- goimports
|
||||
- gofumpt
|
||||
exclusions: { paths: [".zed"] }
|
||||
settings:
|
||||
# gofmt settings
|
||||
gofmt:
|
||||
@@ -344,7 +346,8 @@ linters:
|
||||
|
||||
# stylecheck settings
|
||||
staticcheck:
|
||||
checks: [
|
||||
checks:
|
||||
[
|
||||
"all",
|
||||
"-ST1000",
|
||||
"-ST1003",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": {
|
||||
"golangci-lint": {
|
||||
"command": ["golangci-lint-langserver"],
|
||||
"extensions": [".go"],
|
||||
"initialization": {
|
||||
"command": [
|
||||
"golangci-lint",
|
||||
"run",
|
||||
"--output.json.path",
|
||||
"stdout",
|
||||
"--show-stats=false",
|
||||
"--issues-exit-code=1",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"formatter": {
|
||||
"dprint": {
|
||||
"command": ["dprint", "fmt", "$FILE"],
|
||||
"extensions": [".go", ".md", ".Dockerfile", ".Dockerfile.dev", ".md"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"tab_size": 2,
|
||||
"hard_tabs": true,
|
||||
"prettier": { "allowed": false },
|
||||
"formatter": [
|
||||
{ "language_server": { "name": "dprint" } }
|
||||
],
|
||||
"languages": {
|
||||
"Go": {
|
||||
"language_servers": ["gopls", "golangci-lint-langserver", "..."]
|
||||
}
|
||||
},
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"enable_lsp_tasks": true,
|
||||
"initialization_options": {
|
||||
"workspaceFiles": ["go.mod", "go.work", "*.go"]
|
||||
}
|
||||
},
|
||||
"golangci-lint": {
|
||||
"enable_lsp_tasks": false
|
||||
},
|
||||
"golangci-lint-langserver": {
|
||||
"binary": {
|
||||
"path": "golangci-lint-langserver",
|
||||
"arguments": ["-debug"]
|
||||
},
|
||||
"initialization_options": {
|
||||
"command": [
|
||||
"golangci-lint",
|
||||
"run",
|
||||
"--output.json.path",
|
||||
"stdout",
|
||||
"--show-stats=false",
|
||||
"--issues-exit-code=1"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
"github.com/kjanat/articulate-parser/internal/testdata"
|
||||
)
|
||||
|
||||
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
|
||||
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
@@ -22,7 +23,7 @@ func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
|
||||
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
|
||||
func BenchmarkFactory_CreateExporter_All(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
formats := []string{"markdown", "docx", "html"}
|
||||
|
||||
b.ResetTimer()
|
||||
@@ -36,15 +37,15 @@ func BenchmarkFactory_CreateExporter_All(b *testing.B) {
|
||||
// BenchmarkAllExporters_Export benchmarks all exporters with the same course.
|
||||
func BenchmarkAllExporters_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
course := createBenchmarkCourse()
|
||||
course := testdata.BasicCourse()
|
||||
|
||||
exporters := map[string]struct {
|
||||
exporter any
|
||||
ext string
|
||||
}{
|
||||
"Markdown": {NewMarkdownExporter(htmlCleaner), ".md"},
|
||||
"Docx": {NewDocxExporter(htmlCleaner), ".docx"},
|
||||
"HTML": {NewHTMLExporter(htmlCleaner), ".html"},
|
||||
"Markdown": {NewMarkdownExporter(htmlCleaner, nil), ".md"},
|
||||
"Docx": {NewDocxExporter(htmlCleaner, nil), ".docx"},
|
||||
"HTML": {NewHTMLExporter(htmlCleaner, nil), ".html"},
|
||||
}
|
||||
|
||||
for name, exp := range exporters {
|
||||
@@ -66,10 +67,10 @@ func BenchmarkAllExporters_Export(b *testing.B) {
|
||||
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
|
||||
func BenchmarkExporters_LargeCourse(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
course := createLargeBenchmarkCourse()
|
||||
course := testdata.LargeCourse(50)
|
||||
|
||||
b.Run("Markdown_Large", func(b *testing.B) {
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
@@ -80,7 +81,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
|
||||
})
|
||||
|
||||
b.Run("Docx_Large", func(b *testing.B) {
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
@@ -91,7 +92,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
|
||||
})
|
||||
|
||||
b.Run("HTML_Large", func(b *testing.B) {
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
@@ -101,100 +102,3 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createBenchmarkCourse creates a standard-sized course for benchmarking.
|
||||
func createBenchmarkCourse() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "benchmark-id",
|
||||
Author: "Benchmark Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "bench-course",
|
||||
Title: "Benchmark Course",
|
||||
Description: "Performance testing course",
|
||||
NavigationMode: "menu",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson1",
|
||||
Title: "Introduction",
|
||||
Type: "lesson",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "Welcome",
|
||||
Paragraph: "<p>This is a test paragraph with <strong>HTML</strong> content.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "Item 1"},
|
||||
{Paragraph: "Item 2"},
|
||||
{Paragraph: "Item 3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createLargeBenchmarkCourse creates a large course for stress testing.
|
||||
func createLargeBenchmarkCourse() *models.Course {
|
||||
lessons := make([]models.Lesson, 50)
|
||||
for i := range 50 {
|
||||
lessons[i] = models.Lesson{
|
||||
ID: string(rune(i)),
|
||||
Title: "Lesson " + string(rune(i)),
|
||||
Type: "lesson",
|
||||
Description: "<p>This is lesson description with <em>formatting</em>.</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "Section Heading",
|
||||
Paragraph: "<p>Content with <strong>bold</strong> and <em>italic</em> text.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "Point 1"},
|
||||
{Paragraph: "Point 2"},
|
||||
{Paragraph: "Point 3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "Quiz Question",
|
||||
Answers: []models.Answer{
|
||||
{Title: "Answer A", Correct: false},
|
||||
{Title: "Answer B", Correct: true},
|
||||
{Title: "Answer C", Correct: false},
|
||||
},
|
||||
Feedback: "Good job!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Course{
|
||||
ShareID: "large-benchmark-id",
|
||||
Author: "Benchmark Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "large-bench-course",
|
||||
Title: "Large Benchmark Course",
|
||||
Description: "Large performance testing course",
|
||||
Lessons: lessons,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -27,21 +26,21 @@ const (
|
||||
// It converts Articulate Rise course data into a Microsoft Word document
|
||||
// using the go-docx package.
|
||||
type DocxExporter struct {
|
||||
// htmlCleaner is used to convert HTML content to plain text
|
||||
htmlCleaner *services.HTMLCleaner
|
||||
baseExporter
|
||||
}
|
||||
|
||||
// NewDocxExporter creates a new DocxExporter instance.
|
||||
// It takes an HTMLCleaner to handle HTML content conversion.
|
||||
// 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 DOCX format
|
||||
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
||||
func NewDocxExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
|
||||
return &DocxExporter{
|
||||
htmlCleaner: htmlCleaner,
|
||||
baseExporter: newBaseExporter(htmlCleaner, logger),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +90,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
||||
// affect the validity of the exported file.
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", err)
|
||||
e.logger.Warn("failed to close output file", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// TestNewDocxExporter tests the NewDocxExporter constructor.
|
||||
func TestNewDocxExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatal("NewDocxExporter() returned nil")
|
||||
@@ -32,7 +32,7 @@ func TestNewDocxExporter(t *testing.T) {
|
||||
// TestDocxExporter_SupportedFormat tests the SupportedFormat method.
|
||||
func TestDocxExporter_SupportedFormat(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
expected := "docx"
|
||||
result := exporter.SupportedFormat()
|
||||
@@ -45,7 +45,7 @@ func TestDocxExporter_SupportedFormat(t *testing.T) {
|
||||
// TestDocxExporter_Export tests the Export method.
|
||||
func TestDocxExporter_Export(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create test course
|
||||
testCourse := createTestCourseForDocx()
|
||||
@@ -79,7 +79,7 @@ func TestDocxExporter_Export(t *testing.T) {
|
||||
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
|
||||
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
|
||||
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
|
||||
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestDocxExporter_Export_InvalidPath(t *testing.T) {
|
||||
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
|
||||
func TestDocxExporter_ExportLesson(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create course with specific lesson content
|
||||
course := &models.Course{
|
||||
@@ -165,7 +165,7 @@ func TestDocxExporter_ExportLesson(t *testing.T) {
|
||||
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
|
||||
func TestDocxExporter_ExportItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create course with different item types
|
||||
course := &models.Course{
|
||||
@@ -231,7 +231,7 @@ func TestDocxExporter_ExportItem(t *testing.T) {
|
||||
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
|
||||
func TestDocxExporter_ExportSubItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create course with sub-item containing all possible fields
|
||||
course := &models.Course{
|
||||
@@ -284,7 +284,7 @@ func TestDocxExporter_ExportSubItem(t *testing.T) {
|
||||
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
|
||||
func TestDocxExporter_ComplexCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create complex test course
|
||||
course := &models.Course{
|
||||
@@ -421,7 +421,7 @@ func TestDocxExporter_ComplexCourse(t *testing.T) {
|
||||
// TestDocxExporter_EmptyCourse tests export of an empty course.
|
||||
func TestDocxExporter_EmptyCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create minimal course
|
||||
course := &models.Course{
|
||||
@@ -450,7 +450,7 @@ func TestDocxExporter_EmptyCourse(t *testing.T) {
|
||||
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
|
||||
func TestDocxExporter_HTMLCleaning(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create course with HTML content that needs cleaning
|
||||
course := &models.Course{
|
||||
@@ -498,7 +498,7 @@ func TestDocxExporter_HTMLCleaning(t *testing.T) {
|
||||
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
|
||||
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
@@ -526,7 +526,7 @@ func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
|
||||
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
|
||||
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
@@ -599,7 +599,7 @@ func createTestCourseForDocx() *models.Course {
|
||||
// BenchmarkDocxExporter_Export benchmarks the Export method.
|
||||
func BenchmarkDocxExporter_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
course := createTestCourseForDocx()
|
||||
|
||||
// Create temporary directory
|
||||
@@ -618,7 +618,7 @@ func BenchmarkDocxExporter_Export(b *testing.B) {
|
||||
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
|
||||
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
exporter := NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
// Create complex course for benchmarking
|
||||
course := &models.Course{
|
||||
|
||||
@@ -4,16 +4,33 @@ package exporters_test
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/exporters"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// exportToTemp runs export against a path in a fresh temp dir and removes the
|
||||
// dir afterwards. Example functions can't take *testing.T, so t.TempDir is
|
||||
// unavailable here.
|
||||
func exportToTemp(name string, export func(path string) error) {
|
||||
dir, err := os.MkdirTemp("", "exporters-example-*")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err = export(filepath.Join(dir, name))
|
||||
_ = os.RemoveAll(dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleNewFactory demonstrates creating an exporter factory.
|
||||
func ExampleNewFactory() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := exporters.NewFactory(htmlCleaner)
|
||||
factory := exporters.NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Get supported formats
|
||||
formats := factory.SupportedFormats()
|
||||
@@ -24,7 +41,7 @@ func ExampleNewFactory() {
|
||||
// ExampleFactory_CreateExporter demonstrates creating exporters.
|
||||
func ExampleFactory_CreateExporter() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := exporters.NewFactory(htmlCleaner)
|
||||
factory := exporters.NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Create a markdown exporter
|
||||
exporter, err := factory.CreateExporter("markdown")
|
||||
@@ -39,7 +56,7 @@ func ExampleFactory_CreateExporter() {
|
||||
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
|
||||
func ExampleFactory_CreateExporter_caseInsensitive() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := exporters.NewFactory(htmlCleaner)
|
||||
factory := exporters.NewFactory(htmlCleaner, nil)
|
||||
|
||||
// All these work (case-insensitive)
|
||||
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
|
||||
@@ -58,7 +75,7 @@ func ExampleFactory_CreateExporter_caseInsensitive() {
|
||||
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
|
||||
func ExampleMarkdownExporter_Export() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := exporters.NewMarkdownExporter(htmlCleaner)
|
||||
exporter := exporters.NewMarkdownExporter(htmlCleaner, nil)
|
||||
|
||||
course := &models.Course{
|
||||
ShareID: "example-id",
|
||||
@@ -69,10 +86,9 @@ func ExampleMarkdownExporter_Export() {
|
||||
}
|
||||
|
||||
// Export to markdown file
|
||||
err := exporter.Export(course, "output.md")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
exportToTemp("output.md", func(path string) error {
|
||||
return exporter.Export(course, path)
|
||||
})
|
||||
|
||||
fmt.Println("Export complete")
|
||||
// Output: Export complete
|
||||
@@ -81,7 +97,7 @@ func ExampleMarkdownExporter_Export() {
|
||||
// ExampleDocxExporter_Export demonstrates exporting to DOCX.
|
||||
func ExampleDocxExporter_Export() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := exporters.NewDocxExporter(htmlCleaner)
|
||||
exporter := exporters.NewDocxExporter(htmlCleaner, nil)
|
||||
|
||||
course := &models.Course{
|
||||
ShareID: "example-id",
|
||||
@@ -91,11 +107,39 @@ func ExampleDocxExporter_Export() {
|
||||
}
|
||||
|
||||
// Export to Word document
|
||||
err := exporter.Export(course, "output.docx")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
exportToTemp("output.docx", func(path string) error {
|
||||
return exporter.Export(course, path)
|
||||
})
|
||||
|
||||
fmt.Println("DOCX export complete")
|
||||
// Output: DOCX export complete
|
||||
}
|
||||
|
||||
// ExampleHTMLExporter_Export demonstrates exporting to HTML.
|
||||
func ExampleHTMLExporter_Export() {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := exporters.NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
course := &models.Course{
|
||||
ShareID: "example-id",
|
||||
Course: models.CourseInfo{
|
||||
Title: "Example Course",
|
||||
Description: "<p>A sample course</p>",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Introduction",
|
||||
Type: "lesson",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Export to HTML file
|
||||
exportToTemp("output.html", func(path string) error {
|
||||
return exporter.Export(course, path)
|
||||
})
|
||||
|
||||
fmt.Println("HTML export complete")
|
||||
// Output: HTML export complete
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/apperrors"
|
||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// Format constants for supported export formats.
|
||||
@@ -24,21 +24,25 @@ 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
|
||||
}
|
||||
|
||||
// NewFactory creates a new exporter factory.
|
||||
// It takes an HTMLCleaner instance that will be passed to the exporters
|
||||
// It takes an HTMLCleaner instance and a logger that will be passed to the exporters
|
||||
// created by this factory.
|
||||
//
|
||||
// Parameters:
|
||||
// - htmlCleaner: Service for cleaning HTML content in course data
|
||||
// - logger: Logger instance for exporter logging
|
||||
//
|
||||
// Returns:
|
||||
// - An implementation of the ExporterFactory interface
|
||||
func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
|
||||
func NewFactory(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory {
|
||||
return &Factory{
|
||||
htmlCleaner: htmlCleaner,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +51,13 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
|
||||
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
||||
switch strings.ToLower(format) {
|
||||
case FormatMarkdown, formatAliasMarkdown:
|
||||
return NewMarkdownExporter(f.htmlCleaner), nil
|
||||
return NewMarkdownExporter(f.htmlCleaner, f.logger), nil
|
||||
case FormatDocx, formatAliasDocx:
|
||||
return NewDocxExporter(f.htmlCleaner), nil
|
||||
return NewDocxExporter(f.htmlCleaner, f.logger), nil
|
||||
case FormatHTML, formatAliasHTML:
|
||||
return NewHTMLExporter(f.htmlCleaner), nil
|
||||
return NewHTMLExporter(f.htmlCleaner, f.logger), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported export format: %s", format)
|
||||
return nil, fmt.Errorf("%w: %s", apperrors.ErrUnsupportedFormat, format)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// TestNewFactory tests the NewFactory constructor.
|
||||
func TestNewFactory(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
if factory == nil {
|
||||
t.Fatal("NewFactory() returned nil")
|
||||
@@ -32,7 +32,7 @@ func TestNewFactory(t *testing.T) {
|
||||
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
|
||||
func TestFactory_CreateExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -135,7 +135,7 @@ func TestFactory_CreateExporter(t *testing.T) {
|
||||
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
|
||||
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
testCases := []struct {
|
||||
format string
|
||||
@@ -182,7 +182,7 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
||||
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
|
||||
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
testCases := []string{
|
||||
"pdf",
|
||||
@@ -222,7 +222,7 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
||||
// TestFactory_SupportedFormats tests the SupportedFormats method.
|
||||
func TestFactory_SupportedFormats(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
formats := factory.SupportedFormats()
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestFactory_SupportedFormats(t *testing.T) {
|
||||
// TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify.
|
||||
func TestFactory_SupportedFormats_Immutable(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Get formats twice
|
||||
formats1 := factory.SupportedFormats()
|
||||
@@ -281,7 +281,7 @@ func TestFactory_SupportedFormats_Immutable(t *testing.T) {
|
||||
// TestFactory_ExporterTypes tests that created exporters are of correct types.
|
||||
func TestFactory_ExporterTypes(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Test markdown exporter
|
||||
markdownExporter, err := factory.CreateExporter("markdown")
|
||||
@@ -307,7 +307,7 @@ func TestFactory_ExporterTypes(t *testing.T) {
|
||||
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
|
||||
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Test with markdown exporter
|
||||
markdownExporter, err := factory.CreateExporter("markdown")
|
||||
@@ -358,7 +358,7 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
||||
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
|
||||
func TestFactory_MultipleExporterCreation(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Create multiple markdown exporters
|
||||
exporter1, err := factory.CreateExporter("markdown")
|
||||
@@ -395,7 +395,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
factory := NewFactory(nil)
|
||||
factory := NewFactory(nil, nil)
|
||||
|
||||
if factory == nil {
|
||||
t.Fatal("NewFactory(nil) returned nil")
|
||||
@@ -412,7 +412,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
|
||||
// TestFactory_FormatNormalization tests that format strings are properly normalized.
|
||||
func TestFactory_FormatNormalization(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
// Test formats with extra whitespace
|
||||
testCases := []struct {
|
||||
@@ -445,7 +445,7 @@ func TestFactory_FormatNormalization(t *testing.T) {
|
||||
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
|
||||
func BenchmarkFactory_CreateExporter(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
for b.Loop() {
|
||||
_, _ = factory.CreateExporter("markdown")
|
||||
@@ -455,7 +455,7 @@ func BenchmarkFactory_CreateExporter(b *testing.B) {
|
||||
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
|
||||
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
for b.Loop() {
|
||||
_, _ = factory.CreateExporter("docx")
|
||||
@@ -465,7 +465,7 @@ func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
|
||||
// BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method.
|
||||
func BenchmarkFactory_SupportedFormats(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
factory := NewFactory(htmlCleaner, nil)
|
||||
|
||||
for b.Loop() {
|
||||
_ = factory.SupportedFormats()
|
||||
|
||||
@@ -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
|
||||
@@ -21,21 +20,22 @@ var htmlTemplate string
|
||||
// HTMLExporter implements the Exporter interface for HTML format.
|
||||
// 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
|
||||
// tmpl holds the parsed HTML template
|
||||
baseExporter
|
||||
// tmpl holds the parsed HTML template.
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewHTMLExporter creates a new HTMLExporter instance.
|
||||
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
|
||||
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed
|
||||
// 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 HTML format
|
||||
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) 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 {
|
||||
@@ -49,7 +49,7 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
||||
tmpl := template.Must(template.New("html").Funcs(funcMap).Parse(htmlTemplate))
|
||||
|
||||
return &HTMLExporter{
|
||||
htmlCleaner: htmlCleaner,
|
||||
baseExporter: newBaseExporter(htmlCleaner, logger),
|
||||
tmpl: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
|
||||
func TestNewHTMLExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatal("NewHTMLExporter() returned nil")
|
||||
@@ -37,7 +37,7 @@ func TestNewHTMLExporter(t *testing.T) {
|
||||
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
|
||||
func TestHTMLExporter_SupportedFormat(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
expected := "html"
|
||||
result := exporter.SupportedFormat()
|
||||
@@ -50,7 +50,7 @@ func TestHTMLExporter_SupportedFormat(t *testing.T) {
|
||||
// TestHTMLExporter_Export tests the Export method.
|
||||
func TestHTMLExporter_Export(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
// Create test course
|
||||
testCourse := createTestCourseForHTML()
|
||||
@@ -129,7 +129,7 @@ func TestHTMLExporter_Export(t *testing.T) {
|
||||
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
|
||||
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
testCourse := createTestCourseForHTML()
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
|
||||
// TestHTMLExporter_ComplexCourse tests export of a course with complex content.
|
||||
func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
// Create complex test course
|
||||
course := &models.Course{
|
||||
@@ -276,7 +276,7 @@ func TestHTMLExporter_ComplexCourse(t *testing.T) {
|
||||
// TestHTMLExporter_EmptyCourse tests export of an empty course.
|
||||
func TestHTMLExporter_EmptyCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
// Create minimal course
|
||||
course := &models.Course{
|
||||
@@ -324,7 +324,7 @@ func TestHTMLExporter_EmptyCourse(t *testing.T) {
|
||||
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
|
||||
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
// Create course with HTML content that needs cleaning in some places
|
||||
course := &models.Course{
|
||||
@@ -392,6 +392,227 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLExporter_Export_ErrorPaths tests various error conditions for the HTML exporter.
|
||||
func TestHTMLExporter_Export_ErrorPaths(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
course *models.Course
|
||||
outputPath string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "non-existent directory",
|
||||
course: createTestCourseForHTML(),
|
||||
outputPath: "/nonexistent/deeply/nested/path/output.html",
|
||||
wantErr: true,
|
||||
errContains: "failed to create file",
|
||||
},
|
||||
{
|
||||
name: "empty output path",
|
||||
course: createTestCourseForHTML(),
|
||||
outputPath: "",
|
||||
wantErr: true,
|
||||
errContains: "failed to create file",
|
||||
},
|
||||
{
|
||||
name: "output path is directory",
|
||||
course: createTestCourseForHTML(),
|
||||
outputPath: t.TempDir(), // directory, not a file
|
||||
wantErr: true,
|
||||
errContains: "failed to create file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := exporter.Export(tt.course, tt.outputPath)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Export() expected error containing %q, got nil", tt.errContains)
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Export() error = %v, want error containing %q", err, tt.errContains)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("Export() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLExporter_WriteHTML_ErrorPaths tests WriteHTML error conditions.
|
||||
func TestHTMLExporter_WriteHTML_ErrorPaths(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
htmlExporter := exporter.(*HTMLExporter)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
course *models.Course
|
||||
writer *failingWriter
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "writer fails immediately",
|
||||
course: createTestCourseForHTML(),
|
||||
writer: &failingWriter{failAfter: 0},
|
||||
wantErr: true,
|
||||
errContains: "failed to execute template",
|
||||
},
|
||||
{
|
||||
name: "writer fails mid-write",
|
||||
course: createTestCourseForHTML(),
|
||||
writer: &failingWriter{failAfter: 100},
|
||||
wantErr: true,
|
||||
errContains: "failed to execute template",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := htmlExporter.WriteHTML(tt.writer, tt.course)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("WriteHTML() expected error containing %q, got nil", tt.errContains)
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("WriteHTML() error = %v, want error containing %q", err, tt.errContains)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("WriteHTML() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLExporter_Export_NilCourse verifies that exporting a nil course panics.
|
||||
// This documents the current behavior - nil course is not a supported input.
|
||||
func TestHTMLExporter_Export_NilCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "nil-course.html")
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("Export(nil) expected panic, got none")
|
||||
}
|
||||
}()
|
||||
|
||||
// Export nil course - should panic
|
||||
_ = exporter.Export(nil, outputPath)
|
||||
}
|
||||
|
||||
// TestHTMLExporter_WriteHTML_NilCourse verifies that WriteHTML with nil course panics.
|
||||
func TestHTMLExporter_WriteHTML_NilCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
htmlExporter := exporter.(*HTMLExporter)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("WriteHTML(nil) expected panic, got none")
|
||||
}
|
||||
}()
|
||||
|
||||
var buf strings.Builder
|
||||
_ = htmlExporter.WriteHTML(&buf, nil)
|
||||
}
|
||||
|
||||
// TestHTMLExporter_WriteHTML_SuccessWithBuffer tests WriteHTML with a buffer.
|
||||
func TestHTMLExporter_WriteHTML_SuccessWithBuffer(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
htmlExporter := exporter.(*HTMLExporter)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
course *models.Course
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "basic course",
|
||||
course: createTestCourseForHTML(),
|
||||
contains: []string{
|
||||
"<!DOCTYPE html>",
|
||||
"<html lang=\"en\">",
|
||||
"Test Course",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "course with special characters",
|
||||
course: &models.Course{
|
||||
ShareID: "special-chars-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "special-course",
|
||||
Title: "Course with <special> & \"characters\"",
|
||||
Description: "<p>Description with & entities</p>",
|
||||
Lessons: []models.Lesson{},
|
||||
},
|
||||
},
|
||||
contains: []string{
|
||||
"<!DOCTYPE html>",
|
||||
"Course with",
|
||||
"special",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := htmlExporter.WriteHTML(&buf, tt.course)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteHTML() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("WriteHTML() output missing %q", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter is a test helper that fails after writing a certain number of bytes.
|
||||
type failingWriter struct {
|
||||
written int
|
||||
failAfter int
|
||||
}
|
||||
|
||||
func (w *failingWriter) Write(p []byte) (n int, err error) {
|
||||
if w.written >= w.failAfter {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
w.written += len(p)
|
||||
if w.written > w.failAfter {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
var errWriteFailed = &writeError{msg: "simulated write failure"}
|
||||
|
||||
type writeError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *writeError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
||||
// createTestCourseForHTML creates a test course for HTML export tests.
|
||||
func createTestCourseForHTML() *models.Course {
|
||||
return &models.Course{
|
||||
@@ -439,7 +660,7 @@ func createTestCourseForHTML() *models.Course {
|
||||
// BenchmarkHTMLExporter_Export benchmarks the Export method.
|
||||
func BenchmarkHTMLExporter_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
course := createTestCourseForHTML()
|
||||
|
||||
tempDir := b.TempDir()
|
||||
@@ -455,7 +676,7 @@ func BenchmarkHTMLExporter_Export(b *testing.B) {
|
||||
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
|
||||
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewHTMLExporter(htmlCleaner)
|
||||
exporter := NewHTMLExporter(htmlCleaner, nil)
|
||||
|
||||
// Create complex course for benchmarking
|
||||
course := &models.Course{
|
||||
|
||||
@@ -11,27 +11,26 @@ 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
|
||||
baseExporter
|
||||
}
|
||||
|
||||
// NewMarkdownExporter creates a new MarkdownExporter instance.
|
||||
// It takes an HTMLCleaner to handle HTML content conversion.
|
||||
// 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 *services.HTMLCleaner) interfaces.Exporter {
|
||||
func NewMarkdownExporter(htmlCleaner interfaces.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
|
||||
return &MarkdownExporter{
|
||||
htmlCleaner: htmlCleaner,
|
||||
baseExporter: newBaseExporter(htmlCleaner, logger),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,35 +39,39 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Write course header
|
||||
fmt.Fprintf(&buf, "# %s\n\n", course.Course.Title)
|
||||
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title))
|
||||
|
||||
if course.Course.Description != "" {
|
||||
fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(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", course.Course.ID)
|
||||
fmt.Fprintf(&buf, "- **Share ID**: %s\n", course.ShareID)
|
||||
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", course.Course.NavigationMode)
|
||||
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", course.Course.ExportSettings.Format)
|
||||
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 == lessonTypeSection {
|
||||
fmt.Fprintf(&buf, "# %s\n\n", lesson.Title)
|
||||
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, lesson.Title)
|
||||
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title))
|
||||
|
||||
if lesson.Description != "" {
|
||||
fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description))
|
||||
if desc := e.htmlCleaner.CleanAndTrim(lesson.Description); desc != "" {
|
||||
fmt.Fprintf(&buf, "%s\n\n", desc)
|
||||
}
|
||||
}
|
||||
|
||||
// Process lesson items
|
||||
@@ -76,16 +79,49 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
|
||||
e.processItemToMarkdown(&buf, item, 3)
|
||||
}
|
||||
|
||||
buf.WriteString("\n---\n\n")
|
||||
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, buf.Bytes(), 0o644); err != nil {
|
||||
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
|
||||
@@ -100,19 +136,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)
|
||||
@@ -123,14 +159,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 := 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 := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph != "" {
|
||||
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
||||
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +175,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 := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph != "" {
|
||||
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
||||
fmt.Fprintf(buf, "- %s\n", paragraph)
|
||||
}
|
||||
}
|
||||
@@ -166,10 +199,11 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models
|
||||
e.processImageMedia(buf, subItem.Media)
|
||||
}
|
||||
if subItem.Caption != "" {
|
||||
caption := e.htmlCleaner.CleanHTML(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) {
|
||||
@@ -196,10 +230,11 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item,
|
||||
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
|
||||
}
|
||||
if subItem.Caption != "" {
|
||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
||||
if caption := e.htmlCleaner.CleanAndTrim(subItem.Caption); caption != "" {
|
||||
fmt.Fprintf(buf, "*%s*\n", caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -215,27 +250,33 @@ 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 := e.htmlCleaner.CleanHTML(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 != "" {
|
||||
feedback := e.htmlCleaner.CleanHTML(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 = " ✓"
|
||||
correctMark = " [correct]"
|
||||
}
|
||||
fmt.Fprintf(buf, "%d. %s%s\n", i+1, answer.Title, correctMark)
|
||||
title := strings.TrimSpace(answer.Title)
|
||||
fmt.Fprintf(buf, "%d. %s%s\n", i+1, title, correctMark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,11 +285,12 @@ 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 := e.htmlCleaner.CleanHTML(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) {
|
||||
@@ -269,11 +311,13 @@ 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 := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
if title := e.htmlCleaner.CleanAndTrim(subItem.Title); title != "" {
|
||||
fmt.Fprintf(buf, "**%s**\n\n", title)
|
||||
}
|
||||
}
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph := e.htmlCleaner.CleanAndTrim(subItem.Paragraph); paragraph != "" {
|
||||
fmt.Fprintf(buf, "%s\n\n", paragraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
|
||||
func TestNewMarkdownExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatal("NewMarkdownExporter() returned nil")
|
||||
@@ -34,7 +34,7 @@ func TestNewMarkdownExporter(t *testing.T) {
|
||||
// TestMarkdownExporter_SupportedFormat tests the SupportedFormat method.
|
||||
func TestMarkdownExporter_SupportedFormat(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
|
||||
expected := "markdown"
|
||||
result := exporter.SupportedFormat()
|
||||
@@ -47,7 +47,7 @@ func TestMarkdownExporter_SupportedFormat(t *testing.T) {
|
||||
// TestMarkdownExporter_Export tests the Export method.
|
||||
func TestMarkdownExporter_Export(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
|
||||
// Create test course
|
||||
testCourse := createTestCourseForMarkdown()
|
||||
@@ -108,7 +108,7 @@ func TestMarkdownExporter_Export(t *testing.T) {
|
||||
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
|
||||
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
|
||||
testCourse := createTestCourseForMarkdown()
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
|
||||
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -153,7 +153,7 @@ func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
|
||||
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -178,7 +178,7 @@ func TestMarkdownExporter_ProcessListItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
|
||||
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -217,7 +217,7 @@ func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
|
||||
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -252,7 +252,7 @@ func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
|
||||
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -283,7 +283,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
||||
if !strings.Contains(result, "**Answers**:") {
|
||||
t.Error("Should contain answers heading")
|
||||
}
|
||||
if !strings.Contains(result, "2. Paris ✓") {
|
||||
if !strings.Contains(result, "2. Paris [correct]") {
|
||||
t.Error("Should mark correct answer")
|
||||
}
|
||||
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
|
||||
@@ -294,7 +294,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
|
||||
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -319,7 +319,7 @@ func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
|
||||
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
exporter.processDividerItem(&buf)
|
||||
@@ -335,7 +335,7 @@ func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
|
||||
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
@@ -366,7 +366,7 @@ func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
|
||||
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
media := &models.Media{
|
||||
@@ -391,7 +391,7 @@ func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
|
||||
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
media := &models.Media{
|
||||
@@ -413,7 +413,7 @@ func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
|
||||
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
var buf bytes.Buffer
|
||||
answers := []models.Answer{
|
||||
@@ -432,7 +432,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
||||
if !strings.Contains(result, "1. Answer 1") {
|
||||
t.Error("Should contain first answer")
|
||||
}
|
||||
if !strings.Contains(result, "2. Answer 2 ✓") {
|
||||
if !strings.Contains(result, "2. Answer 2 [correct]") {
|
||||
t.Error("Should mark correct answer")
|
||||
}
|
||||
if !strings.Contains(result, "3. Answer 3") {
|
||||
@@ -443,7 +443,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
||||
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
|
||||
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -510,7 +510,7 @@ func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
||||
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
|
||||
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
|
||||
// Create complex test course
|
||||
course := &models.Course{
|
||||
@@ -603,7 +603,7 @@ func TestMarkdownExporter_ComplexCourse(t *testing.T) {
|
||||
"- Second objective",
|
||||
"### Knowledge Check",
|
||||
"**Question**: What will you learn?",
|
||||
"2. Everything ✓",
|
||||
"2. Everything [correct]",
|
||||
"**Feedback**: Great choice!",
|
||||
}
|
||||
|
||||
@@ -654,7 +654,7 @@ func createTestCourseForMarkdown() *models.Course {
|
||||
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
|
||||
func BenchmarkMarkdownExporter_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
exporter := NewMarkdownExporter(htmlCleaner, nil)
|
||||
course := createTestCourseForMarkdown()
|
||||
|
||||
// Create temporary directory
|
||||
@@ -673,7 +673,7 @@ func BenchmarkMarkdownExporter_Export(b *testing.B) {
|
||||
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
|
||||
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
exporter := &MarkdownExporter{baseExporter: baseExporter{htmlCleaner: htmlCleaner}}
|
||||
|
||||
item := models.Item{
|
||||
Type: "text",
|
||||
|
||||
Binary file not shown.
@@ -1,12 +0,0 @@
|
||||
# Example Course
|
||||
|
||||
Course description
|
||||
|
||||
## Course Information
|
||||
|
||||
- **Course ID**:
|
||||
- **Share ID**: example-id
|
||||
- **Navigation Mode**:
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// These structures closely match the JSON format used by Articulate Rise.
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Course represents the top-level structure of an Articulate Rise course.
|
||||
// It contains metadata and the actual course content.
|
||||
type Course struct {
|
||||
@@ -45,6 +47,8 @@ type ExportSettings struct {
|
||||
|
||||
// LabelSet contains customized labels used throughout the course.
|
||||
// This allows course creators to modify standard terminology.
|
||||
//
|
||||
// NOTE: Currently parsed but not used in export logic.
|
||||
type LabelSet struct {
|
||||
// ID is the unique identifier for this label set
|
||||
ID string `json:"id"`
|
||||
@@ -53,3 +57,46 @@ type LabelSet struct {
|
||||
// Labels is a mapping of label keys to their customized values
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
// Validate checks whether the Course structure contains valid data.
|
||||
// It returns an error describing the first validation failure found,
|
||||
// or nil if the course is valid.
|
||||
//
|
||||
// Validation rules:
|
||||
// - ShareID must not be empty
|
||||
// - Course.ID must not be empty
|
||||
// - Course.Title must not be empty
|
||||
// - Each Lesson must have a non-empty ID and Title
|
||||
func (c *Course) Validate() error {
|
||||
if c.ShareID == "" {
|
||||
return &ValidationError{Field: "ShareID", Message: "share ID is required"}
|
||||
}
|
||||
if c.Course.ID == "" {
|
||||
return &ValidationError{Field: "Course.ID", Message: "course ID is required"}
|
||||
}
|
||||
if c.Course.Title == "" {
|
||||
return &ValidationError{Field: "Course.Title", Message: "course title is required"}
|
||||
}
|
||||
for i, lesson := range c.Course.Lessons {
|
||||
if lesson.ID == "" {
|
||||
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty ID", i)}
|
||||
}
|
||||
if lesson.Title == "" {
|
||||
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty title", i)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationError represents a validation failure for a specific field.
|
||||
type ValidationError struct {
|
||||
// Field is the name of the field that failed validation
|
||||
Field string
|
||||
// Message describes the validation failure
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error implements the error interface for ValidationError.
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -15,7 +15,8 @@ type Lesson struct {
|
||||
Icon string `json:"icon"`
|
||||
// Items is an ordered array of content items within the lesson
|
||||
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"`
|
||||
// Ready indicates whether the lesson is marked as complete
|
||||
Ready bool `json:"ready"`
|
||||
@@ -38,9 +39,11 @@ type Item struct {
|
||||
Variant string `json:"variant"`
|
||||
// Items contains the actual content elements (sub-items) of this item
|
||||
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"`
|
||||
// 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"`
|
||||
// Media contains any associated media for the item
|
||||
Media *Media `json:"media,omitempty"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -664,6 +665,161 @@ func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCourse_Validate tests the Course.Validate method.
|
||||
func TestCourse_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
course Course
|
||||
wantErr bool
|
||||
errField string
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid course",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "lesson-1", Title: "First Lesson"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid course with no lessons",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing share ID",
|
||||
course: Course{
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "ShareID",
|
||||
errSubstr: "share ID is required",
|
||||
},
|
||||
{
|
||||
name: "missing course ID",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
Title: "Test Course",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.ID",
|
||||
errSubstr: "course ID is required",
|
||||
},
|
||||
{
|
||||
name: "missing course title",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Title",
|
||||
errSubstr: "course title is required",
|
||||
},
|
||||
{
|
||||
name: "lesson with empty ID",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "", Title: "First Lesson"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Lessons",
|
||||
errSubstr: "lesson at index 0 has empty ID",
|
||||
},
|
||||
{
|
||||
name: "lesson with empty title",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "lesson-1", Title: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Lessons",
|
||||
errSubstr: "lesson at index 0 has empty title",
|
||||
},
|
||||
{
|
||||
name: "second lesson invalid",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "lesson-1", Title: "First Lesson"},
|
||||
{ID: "lesson-2", Title: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Lessons",
|
||||
errSubstr: "lesson at index 1 has empty title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.course.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Course.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
var valErr *ValidationError
|
||||
if ok := errors.As(err, &valErr); !ok {
|
||||
t.Errorf("Expected ValidationError, got %T", err)
|
||||
return
|
||||
}
|
||||
if valErr.Field != tt.errField {
|
||||
t.Errorf("ValidationError.Field = %q, want %q", valErr.Field, tt.errField)
|
||||
}
|
||||
if valErr.Message != tt.errSubstr {
|
||||
t.Errorf("ValidationError.Message = %q, want %q", valErr.Message, tt.errSubstr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationError_Error tests the ValidationError.Error method.
|
||||
func TestValidationError_Error(t *testing.T) {
|
||||
err := &ValidationError{Field: "TestField", Message: "test message"}
|
||||
expected := "validation error on TestField: test message"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
// compareMaps compares two any values that should be maps.
|
||||
func compareMaps(original, unmarshaled any) bool {
|
||||
origMap, origOk := original.(map[string]any)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/apperrors"
|
||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
@@ -31,33 +32,46 @@ func NewApp(parser interfaces.CourseParser, exporterFactory interfaces.ExporterF
|
||||
}
|
||||
|
||||
// ProcessCourseFromFile loads a course from a local file and exports it to the specified format.
|
||||
// It takes the path to the course file, the desired export format, and the output file path.
|
||||
// Returns an error if loading or exporting fails.
|
||||
func (a *App) ProcessCourseFromFile(filePath, format, outputPath string) error {
|
||||
// It takes a context for cancellation, the path to the course file, the desired export format,
|
||||
// and the output file path. Returns an error if loading or exporting fails, or if the context
|
||||
// is canceled.
|
||||
func (a *App) ProcessCourseFromFile(ctx context.Context, filePath, format, outputPath string) error {
|
||||
// Check for cancellation before loading
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
|
||||
}
|
||||
|
||||
course, err := a.parser.LoadCourseFromFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load course from file: %w", err)
|
||||
}
|
||||
|
||||
return a.exportCourse(course, format, outputPath)
|
||||
return a.exportCourse(ctx, course, format, outputPath)
|
||||
}
|
||||
|
||||
// ProcessCourseFromURI fetches a course from the provided URI and exports it to the specified format.
|
||||
// It takes the URI to fetch the course from, the desired export format, and the output file path.
|
||||
// Returns an error if fetching or exporting fails.
|
||||
// It takes a context for cancellation, the URI to fetch the course from, the desired export format,
|
||||
// and the output file path. Returns an error if fetching or exporting fails, or if the context
|
||||
// is canceled.
|
||||
func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath string) error {
|
||||
course, err := a.parser.FetchCourse(ctx, uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch course: %w", err)
|
||||
}
|
||||
|
||||
return a.exportCourse(course, format, outputPath)
|
||||
return a.exportCourse(ctx, course, format, outputPath)
|
||||
}
|
||||
|
||||
// exportCourse exports a course to the specified format and output path.
|
||||
// It's a helper method that creates the appropriate exporter and performs the export.
|
||||
// Returns an error if creating the exporter or exporting the course fails.
|
||||
func (a *App) exportCourse(course *models.Course, format, outputPath string) error {
|
||||
// Returns an error if creating the exporter or exporting the course fails, or if the
|
||||
// context is canceled.
|
||||
func (a *App) exportCourse(ctx context.Context, course *models.Course, format, outputPath string) error {
|
||||
// Check for cancellation before export
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("%w: %w", apperrors.ErrOperationCanceled, err)
|
||||
}
|
||||
|
||||
exporter, err := a.exporterFactory.CreateExporter(format)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create exporter: %w", err)
|
||||
|
||||
@@ -207,7 +207,7 @@ func TestApp_ProcessCourseFromFile(t *testing.T) {
|
||||
tt.setupMocks(parser, factory, exporter)
|
||||
|
||||
app := NewApp(parser, factory)
|
||||
err := app.ProcessCourseFromFile(tt.filePath, tt.format, tt.outputPath)
|
||||
err := app.ProcessCourseFromFile(context.Background(), tt.filePath, tt.format, tt.outputPath)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if err == nil {
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/config"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
@@ -14,8 +17,8 @@ func ExampleNewArticulateParser() {
|
||||
// Create a no-op logger for this example
|
||||
logger := services.NewNoOpLogger()
|
||||
|
||||
// Create parser with defaults
|
||||
parser := services.NewArticulateParser(logger, "", 0)
|
||||
// Create parser with defaults (nil config uses defaults)
|
||||
parser := services.NewArticulateParser(logger, nil)
|
||||
|
||||
fmt.Printf("Parser created: %T\n", parser)
|
||||
// Output: Parser created: *services.ArticulateParser
|
||||
@@ -26,11 +29,11 @@ func ExampleNewArticulateParser_custom() {
|
||||
logger := services.NewNoOpLogger()
|
||||
|
||||
// Create parser with custom base URL and timeout
|
||||
parser := services.NewArticulateParser(
|
||||
logger,
|
||||
"https://custom.articulate.com",
|
||||
60_000_000_000, // 60 seconds in nanoseconds
|
||||
)
|
||||
cfg := &config.Config{
|
||||
BaseURL: "https://custom.articulate.com",
|
||||
RequestTimeout: 60 * time.Second,
|
||||
}
|
||||
parser := services.NewArticulateParser(logger, cfg)
|
||||
|
||||
fmt.Printf("Parser configured: %T\n", parser)
|
||||
// Output: Parser configured: *services.ArticulateParser
|
||||
@@ -39,7 +42,7 @@ func ExampleNewArticulateParser_custom() {
|
||||
// ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file.
|
||||
func ExampleArticulateParser_LoadCourseFromFile() {
|
||||
logger := services.NewNoOpLogger()
|
||||
parser := services.NewArticulateParser(logger, "", 0)
|
||||
parser := services.NewArticulateParser(logger, nil)
|
||||
|
||||
// In a real scenario, you'd have an actual file
|
||||
// This example shows the API usage
|
||||
@@ -52,7 +55,7 @@ func ExampleArticulateParser_LoadCourseFromFile() {
|
||||
// ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI.
|
||||
func ExampleArticulateParser_FetchCourse() {
|
||||
logger := services.NewNoOpLogger()
|
||||
parser := services.NewArticulateParser(logger, "", 0)
|
||||
parser := services.NewArticulateParser(logger, nil)
|
||||
|
||||
// Create a context with timeout
|
||||
ctx := context.Background()
|
||||
@@ -64,6 +67,54 @@ func ExampleArticulateParser_FetchCourse() {
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleNewSlogLogger demonstrates creating a JSON logger.
|
||||
func ExampleNewSlogLogger() {
|
||||
// Create a JSON structured logger at Info level
|
||||
// Output goes to stdout in JSON format
|
||||
logger := services.NewSlogLogger(slog.LevelInfo)
|
||||
|
||||
// Logger is ready to use - calls like logger.Info("msg") output JSON
|
||||
fmt.Printf("Logger type: %T\n", logger)
|
||||
// Output: Logger type: *services.SlogLogger
|
||||
}
|
||||
|
||||
// ExampleNewTextLogger demonstrates creating a human-readable text logger.
|
||||
func ExampleNewTextLogger() {
|
||||
// Create a text logger, more readable for development
|
||||
logger := services.NewTextLogger(slog.LevelDebug)
|
||||
|
||||
// Logger is ready - calls like logger.Debug("msg") output text
|
||||
fmt.Printf("Logger type: %T\n", logger)
|
||||
// Output: Logger type: *services.SlogLogger
|
||||
}
|
||||
|
||||
// ExampleNewNoOpLogger demonstrates creating a no-op logger for testing.
|
||||
func ExampleNewNoOpLogger() {
|
||||
logger := services.NewNoOpLogger()
|
||||
|
||||
// All log calls are silently discarded
|
||||
logger.Debug("this won't appear")
|
||||
logger.Info("neither will this")
|
||||
logger.Error("or this")
|
||||
|
||||
fmt.Println("Logging silently completed")
|
||||
// Output: Logging silently completed
|
||||
}
|
||||
|
||||
// ExampleSlogLogger_With demonstrates creating a child logger with context.
|
||||
func ExampleSlogLogger_With() {
|
||||
logger := services.NewNoOpLogger()
|
||||
|
||||
// Create a child logger with additional context
|
||||
requestLogger := logger.With("request_id", "abc123", "user", "john")
|
||||
|
||||
// All logs from requestLogger will include request_id and user
|
||||
requestLogger.Info("processing request")
|
||||
|
||||
fmt.Printf("Child logger type: %T\n", requestLogger)
|
||||
// Output: Child logger type: *services.NoOpLogger
|
||||
}
|
||||
|
||||
// ExampleHTMLCleaner demonstrates cleaning HTML content.
|
||||
func ExampleHTMLCleaner() {
|
||||
cleaner := services.NewHTMLCleaner()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/kjanat/articulate-parser/internal/config"
|
||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||
)
|
||||
|
||||
// NewLoggerFromConfig creates a logger based on the configuration.
|
||||
// Returns a JSON logger if cfg.LogFormat is "json", otherwise returns a text logger.
|
||||
func NewLoggerFromConfig(cfg *config.Config) interfaces.Logger {
|
||||
if cfg.LogFormat == "json" {
|
||||
return NewSlogLogger(cfg.LogLevel)
|
||||
}
|
||||
return NewTextLogger(cfg.LogLevel)
|
||||
}
|
||||
+14
-20
@@ -9,17 +9,15 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/apperrors"
|
||||
"github.com/kjanat/articulate-parser/internal/config"
|
||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
|
||||
// Default endpoint configuration for the Articulate Rise API.
|
||||
const (
|
||||
riseHost = "rise.articulate.com"
|
||||
defaultBaseURL = "https://" + riseHost
|
||||
)
|
||||
// riseHost is the only host accepted in Articulate Rise share URIs.
|
||||
const riseHost = "rise.articulate.com"
|
||||
|
||||
// shareIDRegex is compiled once at package init for extracting share IDs from URIs.
|
||||
var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
|
||||
@@ -35,23 +33,19 @@ type ArticulateParser struct {
|
||||
Logger interfaces.Logger
|
||||
}
|
||||
|
||||
// NewArticulateParser creates a new ArticulateParser instance.
|
||||
// If baseURL is empty, uses the default Articulate Rise API URL.
|
||||
// If timeout is zero, uses a 30-second timeout.
|
||||
func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser {
|
||||
// NewArticulateParser creates a new ArticulateParser instance using the provided configuration.
|
||||
// If cfg is nil, a default configuration will be used.
|
||||
func NewArticulateParser(logger interfaces.Logger, cfg *config.Config) interfaces.CourseParser {
|
||||
if logger == nil {
|
||||
logger = NewNoOpLogger()
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
if cfg == nil {
|
||||
cfg = config.Load()
|
||||
}
|
||||
return &ArticulateParser{
|
||||
BaseURL: baseURL,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Timeout: cfg.RequestTimeout,
|
||||
},
|
||||
Logger: logger,
|
||||
}
|
||||
@@ -134,17 +128,17 @@ func (p *ArticulateParser) extractShareID(uri string) (string, error) {
|
||||
// Parse the URL to validate the domain
|
||||
parsedURL, err := url.Parse(uri)
|
||||
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
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/config"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
|
||||
@@ -133,7 +135,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile(b *testing.B) {
|
||||
b.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
|
||||
parser := NewArticulateParser(nil, "", 0)
|
||||
cfg := &config.Config{
|
||||
BaseURL: "https://rise.articulate.com",
|
||||
RequestTimeout: 30 * time.Second,
|
||||
}
|
||||
parser := NewArticulateParser(nil, cfg)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
@@ -180,7 +186,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile_Large(b *testing.B) {
|
||||
b.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
|
||||
parser := NewArticulateParser(nil, "", 0)
|
||||
cfg := &config.Config{
|
||||
BaseURL: "https://rise.articulate.com",
|
||||
RequestTimeout: 30 * time.Second,
|
||||
}
|
||||
parser := NewArticulateParser(nil, cfg)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
|
||||
// FuzzArticulateParser_ParseJSON fuzzes JSON parsing to ensure no panics on malformed input.
|
||||
// It tests that the parser gracefully handles any byte sequence without crashing.
|
||||
func FuzzArticulateParser_ParseJSON(f *testing.F) {
|
||||
// Add seed corpus with valid JSON structures
|
||||
f.Add([]byte(`{"shareId":"test","course":{"id":"1","title":"Test"}}`))
|
||||
f.Add([]byte(`{"shareId":"abc123","author":"John Doe","course":{"id":"c1","title":"Course Title","description":"A test course"}}`))
|
||||
f.Add([]byte(`{}`))
|
||||
f.Add([]byte(`{"course":{}}`))
|
||||
f.Add([]byte(`{"shareId":"","course":{"id":"","title":"","lessons":[]}}`))
|
||||
|
||||
// Add seed corpus with invalid/edge case inputs
|
||||
f.Add([]byte(`invalid`))
|
||||
f.Add([]byte(``))
|
||||
f.Add([]byte(`null`))
|
||||
f.Add([]byte(`[]`))
|
||||
f.Add([]byte(`"string"`))
|
||||
f.Add([]byte(`123`))
|
||||
f.Add([]byte(`true`))
|
||||
f.Add([]byte(`{`))
|
||||
f.Add([]byte(`}`))
|
||||
f.Add([]byte(`{"unclosed": `))
|
||||
f.Add([]byte(`{"key": "value with \x00 null byte"}`))
|
||||
|
||||
// Add seed corpus with nested structures
|
||||
f.Add([]byte(`{"course":{"lessons":[{"id":"l1","title":"Lesson 1","items":[{"id":"i1","type":"text","data":"content"}]}]}}`))
|
||||
f.Add([]byte(`{"course":{"lessons":[{},{},{}]}}`))
|
||||
|
||||
// Add seed corpus with special characters
|
||||
f.Add([]byte(`{"shareId":"test<script>alert('xss')</script>"}`))
|
||||
f.Add([]byte(`{"course":{"title":"Test\n\r\t\u0000Title"}}`))
|
||||
f.Add([]byte(`{"course":{"description":"<p>HTML content</p>"}}`))
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
// Test that unmarshaling doesn't panic
|
||||
var course models.Course
|
||||
_ = json.Unmarshal(data, &course)
|
||||
|
||||
// Also test that re-marshaling doesn't panic
|
||||
_, _ = json.Marshal(&course)
|
||||
|
||||
// Test accessing fields doesn't panic
|
||||
_ = course.ShareID
|
||||
_ = course.Author
|
||||
_ = course.Course.ID
|
||||
_ = course.Course.Title
|
||||
_ = course.Course.Description
|
||||
|
||||
// Test iterating over lessons doesn't panic
|
||||
for _, lesson := range course.Course.Lessons {
|
||||
_ = lesson.ID
|
||||
_ = lesson.Title
|
||||
for _, item := range lesson.Items {
|
||||
_ = item.ID
|
||||
_ = item.Type
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzArticulateParser_LoadCourseFromFile_JSON tests JSON parsing behavior used by LoadCourseFromFile.
|
||||
// This fuzz test ensures the JSON unmarshaling logic handles arbitrary input gracefully.
|
||||
func FuzzArticulateParser_LoadCourseFromFile_JSON(f *testing.F) {
|
||||
// Seed corpus mimicking file contents
|
||||
f.Add([]byte(`{"shareId":"file-test","course":{"id":"1","title":"From File"}}`))
|
||||
f.Add([]byte(`{
|
||||
"shareId": "multiline",
|
||||
"course": {
|
||||
"id": "2",
|
||||
"title": "Multiline JSON"
|
||||
}
|
||||
}`))
|
||||
f.Add([]byte("\xef\xbb\xbf{\"shareId\":\"bom\"}")) // UTF-8 BOM prefix
|
||||
f.Add([]byte(`{"shareId": "spaces", "course": {}}`))
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
var course models.Course
|
||||
err := json.Unmarshal(data, &course)
|
||||
|
||||
// If unmarshaling succeeded, verify the result is usable
|
||||
if err == nil {
|
||||
// Access all fields to ensure no panics on zero values
|
||||
_ = course.ShareID
|
||||
_ = course.Author
|
||||
_ = course.Course.ID
|
||||
_ = course.Course.Title
|
||||
|
||||
// Iterate through nested structures
|
||||
for _, lesson := range course.Course.Lessons {
|
||||
_ = lesson.ID
|
||||
_ = lesson.Title
|
||||
_ = lesson.Type
|
||||
for _, item := range lesson.Items {
|
||||
_ = item.ID
|
||||
_ = item.Type
|
||||
_ = item.Data
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzHTMLCleaner_CleanHTML fuzzes the HTML cleaner to ensure no panics on malformed HTML.
|
||||
// It tests that arbitrary HTML-like input is handled gracefully.
|
||||
func FuzzHTMLCleaner_CleanHTML(f *testing.F) {
|
||||
cleaner := NewHTMLCleaner()
|
||||
|
||||
// Add seed corpus with valid HTML patterns
|
||||
f.Add("<p>Simple paragraph</p>")
|
||||
f.Add("<div><p>Nested</p></div>")
|
||||
f.Add("<h1>Title</h1><p>Content</p>")
|
||||
f.Add("<strong>Bold</strong> and <em>italic</em>")
|
||||
f.Add("<a href=\"https://example.com\">Link</a>")
|
||||
f.Add("<img src=\"image.png\" alt=\"Image\"/>")
|
||||
f.Add("<br/><hr/>")
|
||||
f.Add("<ul><li>Item 1</li><li>Item 2</li></ul>")
|
||||
f.Add("<table><tr><td>Cell</td></tr></table>")
|
||||
|
||||
// Add seed corpus with HTML entities
|
||||
f.Add("AT&T")
|
||||
f.Add("<script>alert('xss')</script>")
|
||||
f.Add(" ")
|
||||
f.Add("'apostrophe'")
|
||||
f.Add(""quoted"")
|
||||
f.Add("éïö")
|
||||
f.Add("'hex entity'")
|
||||
f.Add("’numeric entity’")
|
||||
|
||||
// Add seed corpus with malformed/edge case HTML
|
||||
f.Add("")
|
||||
f.Add("plain text no HTML")
|
||||
f.Add("<p>Unclosed paragraph")
|
||||
f.Add("</p>Close tag first<p>")
|
||||
f.Add("<div><div><div>Deep nesting")
|
||||
f.Add("<script>alert('xss')</script>")
|
||||
f.Add("<style>body{color:red}</style>")
|
||||
f.Add("<!--comment-->")
|
||||
f.Add("<!DOCTYPE html>")
|
||||
f.Add("<![CDATA[data]]>")
|
||||
f.Add("<p class=\"test\" id='test' data-attr=unquoted>Attributes</p>")
|
||||
f.Add("<p\n\t>Whitespace in tag</p\n>")
|
||||
f.Add("<>empty tag</>")
|
||||
f.Add("<<<nested angles>>>")
|
||||
f.Add("<p onclick=\"evil()\">Event handler</p>")
|
||||
|
||||
// Add seed corpus with special characters
|
||||
f.Add("<p>\x00null byte</p>")
|
||||
f.Add("<p>\xffhigh byte</p>")
|
||||
f.Add("<p>emoji: \xf0\x9f\x98\x80</p>")
|
||||
f.Add("<p>unicode: \u2603</p>")
|
||||
f.Add("<p>newlines\n\r\nand\rtabs\there</p>")
|
||||
|
||||
// Add seed corpus with potentially problematic patterns
|
||||
f.Add(string(make([]byte, 0))) // Empty
|
||||
f.Add(string(make([]byte, 1))) // Single null byte
|
||||
f.Add("<" + string(make([]byte, 100)) + ">") // Tag with null bytes
|
||||
f.Add("<p>" + string(make([]byte, 1000)) + "</p>") // Large content
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
// Test that CleanHTML doesn't panic on any input
|
||||
result := cleaner.CleanHTML(input)
|
||||
|
||||
// Verify the result is a valid string (doesn't panic on access)
|
||||
_ = len(result)
|
||||
|
||||
// Test that the result can be used in common string operations without panic
|
||||
_ = result == ""
|
||||
_ = result == input
|
||||
|
||||
// Test that calling CleanHTML on its own output doesn't panic (idempotency check)
|
||||
_ = cleaner.CleanHTML(result)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzHTMLCleaner_CleanHTML_Bytes tests HTML cleaner with raw byte sequences.
|
||||
// This catches issues with invalid UTF-8 and binary data in HTML content.
|
||||
func FuzzHTMLCleaner_CleanHTML_Bytes(f *testing.F) {
|
||||
cleaner := NewHTMLCleaner()
|
||||
|
||||
// Add seed corpus with byte sequences
|
||||
f.Add([]byte("<p>normal</p>"))
|
||||
f.Add([]byte{0x3c, 0x70, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x70, 0x3e}) // <p>..invalid..</p>
|
||||
f.Add([]byte{0x00}) // Null byte
|
||||
f.Add([]byte{0xff, 0xfe}) // Invalid UTF-8
|
||||
f.Add([]byte{0xef, 0xbb, 0xbf, 0x3c, 0x70, 0x3e, 0x3c, 0x2f, 0x70, 0x3e}) // UTF-8 BOM + <p></p>
|
||||
f.Add([]byte("<p>\xc0\xaf</p>")) // Overlong UTF-8
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
// Convert bytes to string and test
|
||||
input := string(data)
|
||||
|
||||
// Test that CleanHTML doesn't panic
|
||||
result := cleaner.CleanHTML(input)
|
||||
|
||||
// Verify result is usable
|
||||
_ = len(result)
|
||||
_ = []byte(result)
|
||||
})
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/config"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
|
||||
// TestNewArticulateParser tests the NewArticulateParser constructor.
|
||||
func TestNewArticulateParser(t *testing.T) {
|
||||
parser := NewArticulateParser(nil, "", 0)
|
||||
// Test with nil config (should use defaults)
|
||||
parser := NewArticulateParser(nil, nil)
|
||||
|
||||
if parser == nil {
|
||||
t.Fatal("NewArticulateParser() returned nil")
|
||||
@@ -212,7 +214,11 @@ func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
parser := NewArticulateParser(nil, "", 0)
|
||||
cfg := &config.Config{
|
||||
BaseURL: "https://rise.articulate.com",
|
||||
RequestTimeout: 30 * time.Second,
|
||||
}
|
||||
parser := NewArticulateParser(nil, cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -271,7 +277,11 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
parser := NewArticulateParser(nil, "", 0)
|
||||
cfg := &config.Config{
|
||||
BaseURL: "https://rise.articulate.com",
|
||||
RequestTimeout: 30 * time.Second,
|
||||
}
|
||||
parser := NewArticulateParser(nil, cfg)
|
||||
_, err := parser.LoadCourseFromFile(tempFile)
|
||||
|
||||
if err == nil {
|
||||
|
||||
Vendored
+467
@@ -0,0 +1,467 @@
|
||||
// Package testdata provides shared test fixtures for Articulate Rise course testing.
|
||||
// It contains builders for creating Course, Lesson, and Item structures with various
|
||||
// configurations to support unit tests, integration tests, and benchmarks.
|
||||
package testdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
|
||||
// SampleAnswers provides a reusable set of quiz answers for testing.
|
||||
var SampleAnswers = []models.Answer{
|
||||
{ID: "ans-1", Title: "Option A", Correct: false},
|
||||
{ID: "ans-2", Title: "Option B", Correct: true},
|
||||
{ID: "ans-3", Title: "Option C", Correct: false},
|
||||
{ID: "ans-4", Title: "Option D", Correct: false},
|
||||
}
|
||||
|
||||
// SampleSubItems provides a reusable set of sub-items for testing.
|
||||
var SampleSubItems = []models.SubItem{
|
||||
{
|
||||
ID: "sub-1",
|
||||
Heading: "Sample Heading",
|
||||
Paragraph: "<p>Sample paragraph with <strong>formatting</strong>.</p>",
|
||||
},
|
||||
{
|
||||
ID: "sub-2",
|
||||
Paragraph: "<p>Another paragraph with <em>emphasis</em>.</p>",
|
||||
},
|
||||
}
|
||||
|
||||
// MinimalCourse returns a course with just ShareID and Title set.
|
||||
// Useful for testing edge cases with minimal data.
|
||||
func MinimalCourse() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "minimal-share-id",
|
||||
Course: models.CourseInfo{
|
||||
Title: "Minimal Course",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BasicCourse returns a course with 1 section, 1 lesson, and 1 text item.
|
||||
// Suitable for basic functionality tests.
|
||||
func BasicCourse() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "basic-share-id",
|
||||
Author: "Test Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "basic-course-id",
|
||||
Title: "Basic Test Course",
|
||||
Description: "<p>A basic course for testing.</p>",
|
||||
NavigationMode: "menu",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "section-1",
|
||||
Title: "Introduction Section",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "First Lesson",
|
||||
Type: "lesson",
|
||||
Description: "<p>Lesson introduction text.</p>",
|
||||
Items: []models.Item{
|
||||
TextItem("Welcome", "<p>Welcome to the course!</p>"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteCourse returns a course with all item types represented.
|
||||
// Useful for comprehensive export and parsing tests.
|
||||
func CompleteCourse() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "complete-share-id",
|
||||
Author: "Complete Test Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "complete-course-id",
|
||||
Title: "Complete Test Course",
|
||||
Description: "<p>A comprehensive course with <strong>all</strong> item types.</p>",
|
||||
Color: "#3366CC",
|
||||
NavigationMode: "menu",
|
||||
ExportSettings: &models.ExportSettings{
|
||||
Title: "Exported Complete Course",
|
||||
Format: "scorm",
|
||||
},
|
||||
CoverImage: &models.Media{
|
||||
Image: &models.ImageMedia{
|
||||
Key: "cover-img-key",
|
||||
Type: "jpg",
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
OriginalURL: "https://example.com/cover.jpg",
|
||||
},
|
||||
},
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "section-1",
|
||||
Title: "Course Overview",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Introduction",
|
||||
Type: "lesson",
|
||||
Description: "<p>Introduction to the course content.</p>",
|
||||
Icon: "book",
|
||||
Items: []models.Item{
|
||||
TextItem("Welcome", "<p>Welcome to this comprehensive course!</p>"),
|
||||
TextItem("Objectives", "<p>In this course you will learn:</p>"),
|
||||
ListItem("First objective", "Second objective", "Third objective"),
|
||||
DividerItem(),
|
||||
MediaItem("video", "https://example.com/intro.mp4"),
|
||||
MediaItem("image", "https://example.com/diagram.png"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "lesson-2",
|
||||
Title: "Core Concepts",
|
||||
Type: "lesson",
|
||||
Description: "<p>Core concepts and fundamentals.</p>",
|
||||
Items: []models.Item{
|
||||
TextItem("Key Concepts", "<p>Let's explore the key concepts.</p>"),
|
||||
KnowledgeCheckItem("What is the primary purpose of this course?", []models.Answer{
|
||||
{ID: "kc-1-a", Title: "Learning basics", Correct: false},
|
||||
{ID: "kc-1-b", Title: "Comprehensive understanding", Correct: true},
|
||||
{ID: "kc-1-c", Title: "Quick overview", Correct: false},
|
||||
}),
|
||||
InteractiveItem("Interactive Exercise"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "lesson-3",
|
||||
Title: "Advanced Topics",
|
||||
Type: "lesson",
|
||||
Description: "<p>Deep dive into advanced topics.</p>",
|
||||
Items: []models.Item{
|
||||
TextItem("Advanced Section", "<p>This section covers advanced material.</p>"),
|
||||
FlipCardItem("Front content", "Back content"),
|
||||
QuoteItem("Learning is not attained by chance.", "Abigail Adams"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "section-2",
|
||||
Title: "Summary",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-4",
|
||||
Title: "Conclusion",
|
||||
Type: "lesson",
|
||||
Description: "<p>Course wrap-up and next steps.</p>",
|
||||
Items: []models.Item{
|
||||
TextItem("Summary", "<p>Thank you for completing this course!</p>"),
|
||||
ListItem("Review materials regularly", "Practice learned skills", "Share knowledge"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
LabelSet: models.LabelSet{
|
||||
ID: "default-labels",
|
||||
Name: "Default",
|
||||
Labels: map[string]string{
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"complete": "Mark Complete",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LargeCourse returns a parameterized course for benchmarking with the specified number of lessons.
|
||||
// Each lesson contains multiple item types for realistic performance testing.
|
||||
func LargeCourse(lessons int) *models.Course {
|
||||
courseLessons := make([]models.Lesson, 0, lessons+lessons/5) // +sections
|
||||
|
||||
for i := range lessons {
|
||||
// Add section header every 5 lessons
|
||||
if i%5 == 0 {
|
||||
courseLessons = append(courseLessons, models.Lesson{
|
||||
ID: fmt.Sprintf("section-%d", i/5+1),
|
||||
Title: fmt.Sprintf("Section %d", i/5+1),
|
||||
Type: "section",
|
||||
})
|
||||
}
|
||||
|
||||
lesson := models.Lesson{
|
||||
ID: fmt.Sprintf("lesson-%d", i+1),
|
||||
Title: fmt.Sprintf("Lesson %d", i+1),
|
||||
Type: "lesson",
|
||||
Description: fmt.Sprintf("<p>Description for lesson %d with <em>formatting</em>.</p>", i+1),
|
||||
Items: []models.Item{
|
||||
TextItem(
|
||||
fmt.Sprintf("Heading %d", i+1),
|
||||
fmt.Sprintf("<p>Content for lesson %d with <strong>bold</strong> and <em>italic</em> text.</p>", i+1),
|
||||
),
|
||||
ListItem("Point 1", "Point 2", "Point 3"),
|
||||
KnowledgeCheckItem(
|
||||
fmt.Sprintf("Quiz Question %d", i+1),
|
||||
[]models.Answer{
|
||||
{ID: fmt.Sprintf("q%d-a", i), Title: "Answer A", Correct: false},
|
||||
{ID: fmt.Sprintf("q%d-b", i), Title: "Answer B", Correct: true},
|
||||
{ID: fmt.Sprintf("q%d-c", i), Title: "Answer C", Correct: false},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
courseLessons = append(courseLessons, lesson)
|
||||
}
|
||||
|
||||
return &models.Course{
|
||||
ShareID: "large-benchmark-id",
|
||||
Author: "Benchmark Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "large-benchmark-course",
|
||||
Title: fmt.Sprintf("Large Benchmark Course (%d lessons)", lessons),
|
||||
Description: "<p>Large course for performance testing.</p>",
|
||||
NavigationMode: "menu",
|
||||
Lessons: courseLessons,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TextItem creates a text item with the specified heading and paragraph content.
|
||||
func TextItem(heading, paragraph string) models.Item {
|
||||
return models.Item{
|
||||
Type: "text",
|
||||
Family: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "text-sub-1",
|
||||
Heading: heading,
|
||||
Paragraph: paragraph,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListItem creates a list item with the specified items as list entries.
|
||||
func ListItem(items ...string) models.Item {
|
||||
subItems := make([]models.SubItem, len(items))
|
||||
for i, item := range items {
|
||||
subItems[i] = models.SubItem{
|
||||
ID: fmt.Sprintf("list-sub-%d", i+1),
|
||||
Paragraph: fmt.Sprintf("<p>%s</p>", item),
|
||||
}
|
||||
}
|
||||
|
||||
return models.Item{
|
||||
Type: "list",
|
||||
Family: "list",
|
||||
Items: subItems,
|
||||
}
|
||||
}
|
||||
|
||||
// KnowledgeCheckItem creates a knowledge check item with the specified question and answers.
|
||||
func KnowledgeCheckItem(question string, answers []models.Answer) models.Item {
|
||||
return models.Item{
|
||||
Type: "knowledgeCheck",
|
||||
Family: "knowledge",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "kc-sub-1",
|
||||
Title: fmt.Sprintf("<p>%s</p>", question),
|
||||
Answers: answers,
|
||||
Feedback: "<p>Thank you for your response.</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MediaItem creates a media item (video or image) with the specified type and URL.
|
||||
// The mediaType should be "video" or "image".
|
||||
func MediaItem(mediaType, url string) models.Item {
|
||||
var media *models.Media
|
||||
|
||||
switch mediaType {
|
||||
case "video":
|
||||
media = &models.Media{
|
||||
Video: &models.VideoMedia{
|
||||
Key: "video-key",
|
||||
URL: url,
|
||||
Type: "mp4",
|
||||
Duration: 120,
|
||||
OriginalURL: url,
|
||||
},
|
||||
}
|
||||
case "image":
|
||||
media = &models.Media{
|
||||
Image: &models.ImageMedia{
|
||||
Key: "image-key",
|
||||
Type: "png",
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
OriginalURL: url,
|
||||
},
|
||||
}
|
||||
default:
|
||||
media = &models.Media{}
|
||||
}
|
||||
|
||||
return models.Item{
|
||||
Type: "multimedia",
|
||||
Family: "media",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "media-sub-1",
|
||||
Title: fmt.Sprintf("<p>%s content</p>", mediaType),
|
||||
Caption: fmt.Sprintf("<p>Caption for %s</p>", mediaType),
|
||||
Media: media,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InteractiveItem creates an interactive content item with the specified title.
|
||||
func InteractiveItem(title string) models.Item {
|
||||
return models.Item{
|
||||
Type: "interactive",
|
||||
Family: "interactive",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "interactive-sub-1",
|
||||
Title: fmt.Sprintf("<p>%s</p>", title),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DividerItem creates a divider/separator item.
|
||||
func DividerItem() models.Item {
|
||||
return models.Item{
|
||||
Type: "divider",
|
||||
Family: "divider",
|
||||
}
|
||||
}
|
||||
|
||||
// FlipCardItem creates a flashcard-style item with front and back content.
|
||||
func FlipCardItem(front, back string) models.Item {
|
||||
return models.Item{
|
||||
Type: "flashcard",
|
||||
Family: "interactive",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "flipcard-sub-1",
|
||||
Front: &models.CardSide{
|
||||
Description: fmt.Sprintf("<p>%s</p>", front),
|
||||
},
|
||||
Back: &models.CardSide{
|
||||
Description: fmt.Sprintf("<p>%s</p>", back),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// QuoteItem creates a quote/blockquote item with text and attribution.
|
||||
func QuoteItem(quote, author string) models.Item {
|
||||
return models.Item{
|
||||
Type: "quote",
|
||||
Family: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "quote-sub-1",
|
||||
Paragraph: fmt.Sprintf("<blockquote>%s</blockquote>", quote),
|
||||
Title: author,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ImageItem creates a standalone image item with caption.
|
||||
func ImageItem(url, caption string) models.Item {
|
||||
return models.Item{
|
||||
Type: "image",
|
||||
Family: "media",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "image-sub-1",
|
||||
Caption: fmt.Sprintf("<p>%s</p>", caption),
|
||||
Media: &models.Media{
|
||||
Image: &models.ImageMedia{
|
||||
Key: "standalone-img-key",
|
||||
Type: "jpg",
|
||||
Width: 1200,
|
||||
Height: 800,
|
||||
OriginalURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VideoItem creates a standalone video item with caption.
|
||||
func VideoItem(url string, duration int, caption string) models.Item {
|
||||
return models.Item{
|
||||
Type: "video",
|
||||
Family: "media",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "video-sub-1",
|
||||
Caption: fmt.Sprintf("<p>%s</p>", caption),
|
||||
Media: &models.Media{
|
||||
Video: &models.VideoMedia{
|
||||
Key: "standalone-video-key",
|
||||
URL: url,
|
||||
Type: "mp4",
|
||||
Duration: duration,
|
||||
OriginalURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MatchingItem creates a matching/pairing knowledge check item.
|
||||
func MatchingItem(pairs map[string]string) models.Item {
|
||||
answers := make([]models.Answer, 0, len(pairs))
|
||||
i := 0
|
||||
for term, match := range pairs {
|
||||
answers = append(answers, models.Answer{
|
||||
ID: fmt.Sprintf("match-%d", i),
|
||||
Title: term,
|
||||
MatchTitle: match,
|
||||
Correct: true,
|
||||
})
|
||||
i++
|
||||
}
|
||||
|
||||
return models.Item{
|
||||
Type: "matching",
|
||||
Family: "knowledge",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
ID: "matching-sub-1",
|
||||
Title: "<p>Match the following items:</p>",
|
||||
Answers: answers,
|
||||
Feedback: "<p>Check your matches!</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EmptyLesson returns a lesson with no items for edge case testing.
|
||||
func EmptyLesson(id, title string) models.Lesson {
|
||||
return models.Lesson{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Type: "lesson",
|
||||
Items: []models.Item{},
|
||||
}
|
||||
}
|
||||
|
||||
// SectionLesson returns a section header (not a content lesson).
|
||||
func SectionLesson(id, title string) models.Lesson {
|
||||
return models.Lesson{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Type: "section",
|
||||
}
|
||||
}
|
||||
Vendored
+273
@@ -0,0 +1,273 @@
|
||||
package testdata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
)
|
||||
|
||||
func TestMinimalCourse(t *testing.T) {
|
||||
course := MinimalCourse()
|
||||
if course == nil {
|
||||
t.Fatal("MinimalCourse() returned nil")
|
||||
}
|
||||
if course.ShareID == "" {
|
||||
t.Error("MinimalCourse() should have ShareID set")
|
||||
}
|
||||
if course.Course.Title == "" {
|
||||
t.Error("MinimalCourse() should have Title set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicCourse(t *testing.T) {
|
||||
course := BasicCourse()
|
||||
if course == nil {
|
||||
t.Fatal("BasicCourse() returned nil")
|
||||
}
|
||||
if len(course.Course.Lessons) != 2 {
|
||||
t.Errorf("BasicCourse() should have 2 lessons, got %d", len(course.Course.Lessons))
|
||||
}
|
||||
if course.Course.Lessons[0].Type != "section" {
|
||||
t.Error("First lesson should be a section")
|
||||
}
|
||||
if course.Course.Lessons[1].Type != "lesson" {
|
||||
t.Error("Second lesson should be a lesson")
|
||||
}
|
||||
if len(course.Course.Lessons[1].Items) != 1 {
|
||||
t.Errorf("Lesson should have 1 item, got %d", len(course.Course.Lessons[1].Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCourse(t *testing.T) {
|
||||
course := CompleteCourse()
|
||||
if course == nil {
|
||||
t.Fatal("CompleteCourse() returned nil")
|
||||
}
|
||||
if len(course.Course.Lessons) == 0 {
|
||||
t.Error("CompleteCourse() should have lessons")
|
||||
}
|
||||
|
||||
// Verify all expected item types exist
|
||||
itemTypes := make(map[string]bool)
|
||||
for _, lesson := range course.Course.Lessons {
|
||||
for _, item := range lesson.Items {
|
||||
itemTypes[item.Type] = true
|
||||
}
|
||||
}
|
||||
|
||||
expectedTypes := []string{"text", "list", "divider", "multimedia", "knowledgeCheck", "interactive", "flashcard", "quote"}
|
||||
for _, et := range expectedTypes {
|
||||
if !itemTypes[et] {
|
||||
t.Errorf("CompleteCourse() should have item type %q", et)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeCourse(t *testing.T) {
|
||||
tests := []struct {
|
||||
lessons int
|
||||
expected int
|
||||
}{
|
||||
{1, 2}, // 1 lesson + 1 section
|
||||
{5, 6}, // 5 lessons + 1 section
|
||||
{10, 12}, // 10 lessons + 2 sections
|
||||
{50, 60}, // 50 lessons + 10 sections
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
course := LargeCourse(tt.lessons)
|
||||
if course == nil {
|
||||
t.Fatalf("LargeCourse(%d) returned nil", tt.lessons)
|
||||
}
|
||||
if len(course.Course.Lessons) != tt.expected {
|
||||
t.Errorf("LargeCourse(%d) should have %d lessons+sections, got %d",
|
||||
tt.lessons, tt.expected, len(course.Course.Lessons))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextItem(t *testing.T) {
|
||||
item := TextItem("Test Heading", "<p>Test paragraph</p>")
|
||||
if item.Type != "text" {
|
||||
t.Errorf("TextItem Type should be 'text', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 {
|
||||
t.Fatalf("TextItem should have 1 sub-item, got %d", len(item.Items))
|
||||
}
|
||||
if item.Items[0].Heading != "Test Heading" {
|
||||
t.Errorf("TextItem heading mismatch: got %q", item.Items[0].Heading)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListItem(t *testing.T) {
|
||||
item := ListItem("Item 1", "Item 2", "Item 3")
|
||||
if item.Type != "list" {
|
||||
t.Errorf("ListItem Type should be 'list', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 3 {
|
||||
t.Errorf("ListItem should have 3 sub-items, got %d", len(item.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeCheckItem(t *testing.T) {
|
||||
answers := []models.Answer{
|
||||
{Title: "A", Correct: false},
|
||||
{Title: "B", Correct: true},
|
||||
}
|
||||
item := KnowledgeCheckItem("Test question?", answers)
|
||||
if item.Type != "knowledgeCheck" {
|
||||
t.Errorf("KnowledgeCheckItem Type should be 'knowledgeCheck', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 {
|
||||
t.Fatalf("KnowledgeCheckItem should have 1 sub-item, got %d", len(item.Items))
|
||||
}
|
||||
if len(item.Items[0].Answers) != 2 {
|
||||
t.Errorf("KnowledgeCheckItem should have 2 answers, got %d", len(item.Items[0].Answers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaItem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaType string
|
||||
hasVideo bool
|
||||
hasImage bool
|
||||
}{
|
||||
{"video", "video", true, false},
|
||||
{"image", "image", false, true},
|
||||
{"unknown", "unknown", false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
item := MediaItem(tt.mediaType, "https://example.com/media")
|
||||
if item.Type != "multimedia" {
|
||||
t.Errorf("MediaItem Type should be 'multimedia', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 {
|
||||
t.Fatalf("MediaItem should have 1 sub-item, got %d", len(item.Items))
|
||||
}
|
||||
if tt.hasVideo && item.Items[0].Media.Video == nil {
|
||||
t.Error("Expected video media to be set")
|
||||
}
|
||||
if tt.hasImage && item.Items[0].Media.Image == nil {
|
||||
t.Error("Expected image media to be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveItem(t *testing.T) {
|
||||
item := InteractiveItem("Interactive Title")
|
||||
if item.Type != "interactive" {
|
||||
t.Errorf("InteractiveItem Type should be 'interactive', got %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDividerItem(t *testing.T) {
|
||||
item := DividerItem()
|
||||
if item.Type != "divider" {
|
||||
t.Errorf("DividerItem Type should be 'divider', got %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlipCardItem(t *testing.T) {
|
||||
item := FlipCardItem("Front text", "Back text")
|
||||
if item.Type != "flashcard" {
|
||||
t.Errorf("FlipCardItem Type should be 'flashcard', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 {
|
||||
t.Fatalf("FlipCardItem should have 1 sub-item, got %d", len(item.Items))
|
||||
}
|
||||
if item.Items[0].Front == nil || item.Items[0].Back == nil {
|
||||
t.Error("FlipCardItem should have Front and Back set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteItem(t *testing.T) {
|
||||
item := QuoteItem("Test quote", "Test author")
|
||||
if item.Type != "quote" {
|
||||
t.Errorf("QuoteItem Type should be 'quote', got %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageItem(t *testing.T) {
|
||||
item := ImageItem("https://example.com/img.jpg", "Image caption")
|
||||
if item.Type != "image" {
|
||||
t.Errorf("ImageItem Type should be 'image', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Image == nil {
|
||||
t.Error("ImageItem should have image media set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoItem(t *testing.T) {
|
||||
item := VideoItem("https://example.com/video.mp4", 120, "Video caption")
|
||||
if item.Type != "video" {
|
||||
t.Errorf("VideoItem Type should be 'video', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Video == nil {
|
||||
t.Error("VideoItem should have video media set")
|
||||
}
|
||||
if item.Items[0].Media.Video.Duration != 120 {
|
||||
t.Errorf("VideoItem duration should be 120, got %d", item.Items[0].Media.Video.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchingItem(t *testing.T) {
|
||||
pairs := map[string]string{
|
||||
"Term 1": "Match 1",
|
||||
"Term 2": "Match 2",
|
||||
}
|
||||
item := MatchingItem(pairs)
|
||||
if item.Type != "matching" {
|
||||
t.Errorf("MatchingItem Type should be 'matching', got %q", item.Type)
|
||||
}
|
||||
if len(item.Items) != 1 {
|
||||
t.Fatalf("MatchingItem should have 1 sub-item, got %d", len(item.Items))
|
||||
}
|
||||
if len(item.Items[0].Answers) != 2 {
|
||||
t.Errorf("MatchingItem should have 2 answers, got %d", len(item.Items[0].Answers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyLesson(t *testing.T) {
|
||||
lesson := EmptyLesson("test-id", "Test Title")
|
||||
if lesson.ID != "test-id" {
|
||||
t.Errorf("EmptyLesson ID mismatch: got %q", lesson.ID)
|
||||
}
|
||||
if lesson.Type != "lesson" {
|
||||
t.Errorf("EmptyLesson Type should be 'lesson', got %q", lesson.Type)
|
||||
}
|
||||
if len(lesson.Items) != 0 {
|
||||
t.Errorf("EmptyLesson should have no items, got %d", len(lesson.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionLesson(t *testing.T) {
|
||||
lesson := SectionLesson("section-id", "Section Title")
|
||||
if lesson.Type != "section" {
|
||||
t.Errorf("SectionLesson Type should be 'section', got %q", lesson.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleVariables(t *testing.T) {
|
||||
if len(SampleAnswers) == 0 {
|
||||
t.Error("SampleAnswers should not be empty")
|
||||
}
|
||||
if len(SampleSubItems) == 0 {
|
||||
t.Error("SampleSubItems should not be empty")
|
||||
}
|
||||
|
||||
// Verify one correct answer exists in SampleAnswers
|
||||
hasCorrect := false
|
||||
for _, a := range SampleAnswers {
|
||||
if a.Correct {
|
||||
hasCorrect = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCorrect {
|
||||
t.Error("SampleAnswers should have at least one correct answer")
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/config"
|
||||
"github.com/kjanat/articulate-parser/internal/exporters"
|
||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
"github.com/kjanat/articulate-parser/internal/version"
|
||||
)
|
||||
@@ -26,20 +27,19 @@ func main() {
|
||||
// run contains the main application logic and returns an exit code.
|
||||
// This function is testable as it doesn't call os.Exit directly.
|
||||
func run(args []string) int {
|
||||
// Create context that cancels on interrupt signals (SIGINT, SIGTERM)
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Dependency injection setup with configuration
|
||||
var logger interfaces.Logger
|
||||
if cfg.LogFormat == "json" {
|
||||
logger = services.NewSlogLogger(cfg.LogLevel)
|
||||
} else {
|
||||
logger = services.NewTextLogger(cfg.LogLevel)
|
||||
}
|
||||
logger := services.NewLoggerFromConfig(cfg)
|
||||
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout)
|
||||
exporterFactory := exporters.NewFactory(htmlCleaner)
|
||||
parser := services.NewArticulateParser(logger, cfg)
|
||||
exporterFactory := exporters.NewFactory(htmlCleaner, logger)
|
||||
app := services.NewApp(parser, exporterFactory)
|
||||
|
||||
// Check for version flag
|
||||
@@ -70,9 +70,9 @@ func run(args []string) int {
|
||||
|
||||
// Determine if source is a URI or file path
|
||||
if isURI(source) {
|
||||
err = app.ProcessCourseFromURI(context.Background(), source, format, output)
|
||||
err = app.ProcessCourseFromURI(ctx, source, format, output)
|
||||
} else {
|
||||
err = app.ProcessCourseFromFile(source, format, output)
|
||||
err = app.ProcessCourseFromFile(ctx, source, format, output)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# :schema tombi://www.schemastore.org/tombi.json
|
||||
# :tombi toml-version = "v1.1.0"
|
||||
|
||||
toml-version = "v1.1.0"
|
||||
|
||||
[files]
|
||||
include = ["**/*.toml", "**/.*.toml"]
|
||||
exclude = ["**/fixtures"]
|
||||
|
||||
[format]
|
||||
[format.rules]
|
||||
array-bracket-space-width = 0
|
||||
array-comma-space-width = 1
|
||||
date-time-delimiter = "T"
|
||||
indent-style = "space"
|
||||
indent-sub-tables = false
|
||||
indent-table-key-value-pairs = false
|
||||
indent-width = 2
|
||||
inline-table-brace-space-width = 1
|
||||
inline-table-comma-space-width = 1
|
||||
key-value-equals-sign-alignment = true
|
||||
key-value-equals-sign-space-width = 1
|
||||
line-ending = "lf"
|
||||
line-width = 140
|
||||
string-quote-style = "double"
|
||||
trailing-comment-alignment = true
|
||||
trailing-comment-space-width = 2
|
||||
|
||||
[lint]
|
||||
[lint.rules]
|
||||
dotted-keys-out-of-order = "warn"
|
||||
key-empty = "warn"
|
||||
tables-out-of-order = "error"
|
||||
|
||||
[lsp]
|
||||
code-action.enabled = true
|
||||
completion.enabled = true
|
||||
diagnostic.enabled = true
|
||||
document-link.enabled = true
|
||||
formatting.enabled = true
|
||||
goto-declaration.enabled = true
|
||||
goto-definition.enabled = true
|
||||
goto-type-definition.enabled = true
|
||||
hover.enabled = true
|
||||
workspace-diagnostic.enabled = true
|
||||
|
||||
[schema]
|
||||
enabled = true
|
||||
strict = true
|
||||
Reference in New Issue
Block a user