6 Commits

Author SHA1 Message Date
1b945ca2bc Updates version and improves diagram styling
Bumps application version to 0.3.0 for new features or fixes.
Enhances README diagram styling for better readability in both
light and dark GitHub themes by adjusting colors and adding
text color contrast.
2025-05-28 13:26:33 +02:00
fb343f9a23 Merge pull request #2
Merge pull request #2: Add HTML export functionality and fix build system cross-compilation issues
2025-05-28 13:23:45 +02:00
ce5b5c20bb Enhances README and script improvements
Updates README to include HTML format, architecture overview,
and comprehensive feature descriptions. Improves build scripts
by adding cleanup of environment variables and validating Go
installation, reducing cross-platform build issues.

Fixes README inconsistencies and improves script reliability.
2025-05-28 13:17:08 +02:00
cc11d2fd84 feat: Add HTML export functionality and GitHub workflow
- Implement HTMLExporter with professional styling and embedded CSS
- Add comprehensive test suite for HTML export functionality
- Update factory to support HTML format ('html' and 'htm')
- Add autofix.ci GitHub workflow for code formatting
- Support all content types: text, lists, quizzes, multimedia, etc.
- Include proper HTML escaping for security
- Add benchmark tests for performance validation
2025-05-28 13:00:27 +02:00
b01260e765 Add comprehensive unit tests for services and main package
- Implement tests for the app service, including course processing from file and URI.
- Create mock implementations for CourseParser and Exporter to facilitate testing.
- Add tests for HTML cleaner service to validate HTML content cleaning functionality.
- Develop tests for the parser service, covering course fetching and loading from files.
- Introduce tests for utility functions in the main package, ensuring URI validation and string joining.
- Include benchmarks for performance evaluation of key functions.
2025-05-25 15:46:10 +02:00
9de7222ec3 Adds DOCX and Markdown export functionality
Introduces a modular exporter pattern supporting DOCX and Markdown formats
by implementing Exporter interfaces and restructuring application logic.

Enhances CI to install UPX for binary compression, excluding recent macOS
binaries due to compatibility issues.

Enables CGO when building binaries for all platforms, addressing potential
cross-platform compatibility concerns.

Bumps version to 0.1.1.
2025-05-25 13:03:21 +02:00
31 changed files with 6948 additions and 644 deletions

25
.github/workflows/autofix.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: autofix.ci
on:
pull_request:
push:
branches: [ "master" ]
permissions:
contents: read
jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
# goimports works like gofmt, but also fixes imports.
# see https://pkg.go.dev/golang.org/x/tools/cmd/goimports
- run: go install golang.org/x/tools/cmd/goimports@latest
- run: goimports -w .
# of course we can also do just this instead:
# - run: gofmt -w .
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@ -16,7 +16,11 @@ jobs:
contents: write
strategy:
matrix:
go: [1.21.x, 1.22.x, 1.23.x, 1.24.x]
go:
- 1.21.x
- 1.22.x
- 1.23.x
- 1.24.x
steps:
- uses: actions/checkout@v4
@ -37,20 +41,171 @@ jobs:
- name: Build
run: go build -v ./...
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Run tests with enhanced reporting
id: test
run: |
echo "## 🔧 Test Environment" >> $GITHUB_STEP_SUMMARY
echo "- **Go Version:** ${{ matrix.go }}" >> $GITHUB_STEP_SUMMARY
echo "- **OS:** ubuntu-latest" >> $GITHUB_STEP_SUMMARY
echo "- **Timestamp:** $(date -u)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Running tests with coverage..."
go test -v -race -coverprofile=coverage.out ./... 2>&1 | tee test-output.log
# Extract test results for summary
TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" test-output.log || echo "0")
PASSED_TESTS=$(grep -c "--- PASS:" test-output.log || echo "0")
FAILED_TESTS=$(grep -c "--- FAIL:" test-output.log || echo "0")
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
# Generate test summary
echo "## 🧪 Test Results (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Tests | $TOTAL_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Passed | ✅ $PASSED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Failed | ❌ $FAILED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Skipped | ⏭️ $SKIPPED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Status | $([ $TEST_STATUS -eq 0 ] && echo "✅ PASSED" || echo "❌ FAILED") |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Add package breakdown
echo "### 📦 Package Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Status |" >> $GITHUB_STEP_SUMMARY
echo "|---------|--------|" >> $GITHUB_STEP_SUMMARY
# Extract package results
grep "^ok\|^FAIL" test-output.log | while read line; do
if [[ $line == ok* ]]; then
pkg=$(echo $line | awk '{print $2}')
echo "| $pkg | ✅ PASS |" >> $GITHUB_STEP_SUMMARY
elif [[ $line == FAIL* ]]; then
pkg=$(echo $line | awk '{print $2}')
echo "| $pkg | ❌ FAIL |" >> $GITHUB_STEP_SUMMARY
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
# Add detailed results if tests failed
if [ $TEST_STATUS -ne 0 ]; then
echo "### ❌ Failed Tests Details" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
grep -A 10 "--- FAIL:" test-output.log | head -100 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# Set outputs for other steps
echo "test-status=$TEST_STATUS" >> $GITHUB_OUTPUT
echo "total-tests=$TOTAL_TESTS" >> $GITHUB_OUTPUT
echo "passed-tests=$PASSED_TESTS" >> $GITHUB_OUTPUT
echo "failed-tests=$FAILED_TESTS" >> $GITHUB_OUTPUT
# Exit with the original test status
exit $TEST_STATUS
- name: Generate coverage report
if: always()
run: |
if [ -f coverage.out ]; then
go tool cover -html=coverage.out -o coverage.html
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "## 📊 Code Coverage (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total Coverage: $COVERAGE**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Add coverage by package
echo "### 📋 Coverage by Package" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Coverage |" >> $GITHUB_STEP_SUMMARY
echo "|---------|----------|" >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.out | grep -v total | while read line; do
if [[ $line == *".go:"* ]]; then
pkg=$(echo $line | awk '{print $1}' | cut -d'/' -f1-3)
coverage=$(echo $line | awk '{print $3}')
echo "| $pkg | $coverage |" >> $GITHUB_STEP_SUMMARY
fi
done | sort -u
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "## ⚠️ Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "No coverage file generated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results-go-${{ matrix.go }}
path: |
test-output.log
coverage.out
coverage.html
retention-days: 7
- name: Run go vet
run: go vet ./...
run: |
echo "## 🔍 Static Analysis (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
VET_OUTPUT=$(go vet ./... 2>&1 || echo "")
VET_STATUS=$?
if [ $VET_STATUS -eq 0 ]; then
echo "✅ **go vet:** No issues found" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **go vet:** Issues found" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "$VET_OUTPUT" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
exit $VET_STATUS
- name: Run go fmt
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
gofmt -s -l .
FMT_OUTPUT=$(gofmt -s -l . 2>&1 || echo "")
if [ -z "$FMT_OUTPUT" ]; then
echo "✅ **go fmt:** All files properly formatted" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **go fmt:** Files need formatting" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "$FMT_OUTPUT" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Job Summary
if: always()
run: |
echo "## 📋 Job Summary (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Dependencies | ✅ Success |" >> $GITHUB_STEP_SUMMARY
echo "| Build | ✅ Success |" >> $GITHUB_STEP_SUMMARY
echo "| Tests | ${{ steps.test.outcome == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Coverage | ${{ job.status == 'success' && '✅ Generated' || '⚠️ Partial' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Static Analysis | ${{ job.status == 'success' && '✅ Clean' || '❌ Issues' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Code Formatting | ${{ job.status == 'success' && '✅ Clean' || '❌ Issues' }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
@ -81,9 +236,6 @@ jobs:
fail-on-severity: moderate
comment-summary-in-pr: always
# # Use comma-separated names to pass list arguments:
# deny-licenses: LGPL-2.0, BSD-2-Clause
release:
name: Release
runs-on: ubuntu-latest
@ -103,10 +255,37 @@ jobs:
check-latest: true
- name: Run tests
run: go test -v ./...
run: |
echo "## 🚀 Release Tests" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
go test -v ./... 2>&1 | tee release-test-output.log
TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" release-test-output.log || echo "0")
PASSED_TESTS=$(grep -c "--- PASS:" release-test-output.log || echo "0")
FAILED_TESTS=$(grep -c "--- FAIL:" release-test-output.log || echo "0")
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Tests | $TOTAL_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Passed | ✅ $PASSED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Failed | ❌ $FAILED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Status | $([ $TEST_STATUS -eq 0 ] && echo "✅ PASSED" || echo "❌ FAILED") |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
exit $TEST_STATUS
- name: Install UPX
run: |
sudo apt-get update
sudo apt-get install -y upx
- name: Build binaries
run: |
echo "## 🔨 Build Process" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Set the build time environment variable
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
@ -116,17 +295,54 @@ jobs:
# Display help information for the build script
./scripts/build.sh --help
echo "**Build Configuration:**" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "- Build Time: $BUILD_TIME" >> $GITHUB_STEP_SUMMARY
echo "- Git Commit: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Build for all platforms
./scripts/build.sh \
--verbose \
-ldflags "-s -w -X github.com/kjanat/articulate-parser/internal/version.Version=${{ github.ref_name }} -X github.com/kjanat/articulate-parser/internal/version.BuildTime=$BUILD_TIME -X github.com/kjanat/articulate-parser/internal/version.GitCommit=${{ github.sha }}"
- name: Compress binaries with UPX
run: |
echo "## 📦 Binary Compression" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Compressing binaries with UPX..."
cd build/
# Get original sizes
echo "**Original sizes:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -lah >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Compress all binaries except Darwin (macOS) binaries as UPX doesn't work well with recent macOS versions
for binary in articulate-parser-*; do
if [[ "$binary" == *"darwin"* ]]; then
echo "Skipping UPX compression for $binary (macOS compatibility)"
else
echo "Compressing $binary..."
upx --best --lzma "$binary" || {
echo "Warning: UPX compression failed for $binary, keeping original"
}
fi
done
echo "**Final sizes:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -lah >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- name: Upload a Build Artifact
uses: actions/upload-artifact@v4.6.2
with:
# Artifact name
name: build-artifacts # optional, default is artifact
# A file, directory or wildcard pattern that describes what to upload
name: build-artifacts
path: build/
if-no-files-found: ignore
retention-days: 1

27
.gitignore vendored
View File

@ -26,11 +26,38 @@ go.work
# End of https://www.toptal.com/developers/gitignore/api/go
# Shit
.github/TODO
# Local test files
output/
outputs/
articulate-sample.json
test-output.*
go-os-arch-matrix.csv
test_godocx.go
test_input.json
# Build artifacts
build/
# Old workflows
.github/workflows/ci-old.yml
.github/workflows/ci-enhanced.yml
# Test coverage files
coverage.out
coverage.txt
coverage
*.cover
*.coverprofile
# Other common exclusions
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
/tmp/

185
README.md
View File

@ -1,14 +1,83 @@
# Articulate Rise Parser
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown and Word documents.
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown, HTML, and Word documents.
## System Architecture
```mermaid
flowchart TD
%% User Input
CLI[Command Line Interface<br/>main.go] --> APP{App Service<br/>services/app.go}
%% Core Application Logic
APP --> |"ProcessCourseFromURI"| PARSER[Course Parser<br/>services/parser.go]
APP --> |"ProcessCourseFromFile"| PARSER
APP --> |"exportCourse"| FACTORY[Exporter Factory<br/>exporters/factory.go]
%% Data Sources
PARSER --> |"FetchCourse"| API[Articulate Rise API<br/>rise.articulate.com]
PARSER --> |"LoadCourseFromFile"| FILE[Local JSON File<br/>*.json]
%% Data Models
API --> MODELS[Data Models<br/>models/course.go]
FILE --> MODELS
MODELS --> |Course, Lesson, Item| APP
%% Export Factory Pattern
FACTORY --> |"CreateExporter"| MARKDOWN[Markdown Exporter<br/>exporters/markdown.go]
FACTORY --> |"CreateExporter"| HTML[HTML Exporter<br/>exporters/html.go]
FACTORY --> |"CreateExporter"| DOCX[DOCX Exporter<br/>exporters/docx.go]
%% HTML Cleaning Service
CLEANER[HTML Cleaner<br/>services/html_cleaner.go] --> MARKDOWN
CLEANER --> HTML
CLEANER --> DOCX
%% Output Files
MARKDOWN --> |"Export"| MD_OUT[Markdown Files<br/>*.md]
HTML --> |"Export"| HTML_OUT[HTML Files<br/>*.html]
DOCX --> |"Export"| DOCX_OUT[Word Documents<br/>*.docx]
%% Interfaces (Contracts)
IPARSER[CourseParser Interface<br/>interfaces/parser.go] -.-> PARSER
IEXPORTER[Exporter Interface<br/>interfaces/exporter.go] -.-> MARKDOWN
IEXPORTER -.-> HTML
IEXPORTER -.-> DOCX
IFACTORY[ExporterFactory Interface<br/>interfaces/exporter.go] -.-> FACTORY
%% Styling - Colors that work in both light and dark GitHub themes
classDef userInput fill:#dbeafe,stroke:#1e40af,stroke-width:2px,color:#1e40af
classDef coreLogic fill:#ede9fe,stroke:#6d28d9,stroke-width:2px,color:#6d28d9
classDef dataSource fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#059669
classDef exporter fill:#fed7aa,stroke:#ea580c,stroke-width:2px,color:#ea580c
classDef output fill:#fce7f3,stroke:#be185d,stroke-width:2px,color:#be185d
classDef interface fill:#ecfdf5,stroke:#16a34a,stroke-width:1px,stroke-dasharray: 5 5,color:#16a34a
classDef service fill:#cffafe,stroke:#0891b2,stroke-width:2px,color:#0891b2
class CLI userInput
class APP,FACTORY coreLogic
class API,FILE,MODELS dataSource
class MARKDOWN,HTML,DOCX exporter
class MD_OUT,HTML_OUT,DOCX_OUT output
class IPARSER,IEXPORTER,IFACTORY interface
class PARSER,CLEANER service
```
### Architecture Overview
The system follows **Clean Architecture** principles with clear separation of concerns:
- **🎯 Entry Point**: Command-line interface handles user input and coordinates operations
- **🏗️ Application Layer**: Core business logic with dependency injection
- **📋 Interface Layer**: Contracts defining behavior without implementation details
- **🔧 Service Layer**: Concrete implementations of parsing and utility services
- **📤 Export Layer**: Factory pattern for format-specific exporters
- **📊 Data Layer**: Domain models representing course structure
[![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod]
[![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation]
[![Go Report Card](https://goreportcard.com/badge/github.com/kjanat/articulate-parser)][Go report]
[![Tag](https://img.shields.io/github/v/tag/kjanat/articulate-parser?sort=semver&label=Tag)][Tags]
[![Release Date](https://img.shields.io/github/release-date/kjanat/articulate-parser?label=Release%20date)][Latest release]
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)](LICENSE)
[![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits]
[![Tag](https://img.shields.io/github/v/tag/kjanat/articulate-parser?sort=semver&label=Tag)][Tags] <!-- [![Release Date](https://img.shields.io/github/release-date/kjanat/articulate-parser?label=Release%20date)][Latest release] -->
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)][MIT License] <!-- [![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits] -->
[![Last commit](https://img.shields.io/github/last-commit/kjanat/articulate-parser?label=Last%20commit)][Commits]
[![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/kjanat/articulate-parser?label=Issues)][Issues]
[![CI](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/ci.yml?logo=github&label=CI)][Build]
@ -18,6 +87,7 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
- Parse Articulate Rise JSON data from URLs or local files
- Export to Markdown (.md) format
- Export to HTML (.html) format with professional styling
- Export to Word Document (.docx) format
- Support for various content types:
- Text content with headings and paragraphs
@ -29,20 +99,50 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
## Installation
1. Ensure you have Go 1.21 or later installed
2. Clone or download the parser code
3. Initialize the Go module:
### Prerequisites
- Go, I don't know the version, but I use go1.24.2 right now, and it works, see the [CI][Build] workflow where it is tested.
### Install from source
```bash
go mod init articulate-parser
go mod tidy
git clone https://github.com/kjanat/articulate-parser.git
cd articulate-parser
go mod download
go build -o articulate-parser main.go
```
### Or install directly
```bash
go install github.com/kjanat/articulate-parser@latest
```
## Dependencies
The parser uses the following external library:
- `github.com/unidoc/unioffice` - For creating Word documents
- `github.com/fumiama/go-docx` - For creating Word documents (MIT license)
## Testing
Run the test suite:
```bash
go test ./...
```
Run tests with coverage:
```bash
go test -v -race -coverprofile=coverage.out ./...
```
View coverage report:
```bash
go tool cover -html=coverage.out
```
## Usage
@ -54,9 +154,11 @@ go run main.go <input_uri_or_file> <output_format> [output_path]
#### Parameters
- `input_uri_or_file`: Either an Articulate Rise share URL or path to a local JSON file
- `output_format`: `md` for Markdown or `docx` for Word Document
- `output_path`: Optional. If not provided, files are saved to `./output/` directory
| Parameter | Description | Default |
| ------------------- | ---------------------------------------------------------------- | --------------- |
| `input_uri_or_file` | Either an Articulate Rise share URL or path to a local JSON file | None (required) |
| `output_format` | `md` for Markdown, `html` for HTML, or `docx` for Word Document | None (required) |
| `output_path` | Path where output file will be saved. | `./output/` |
#### Examples
@ -72,10 +174,16 @@ go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviD
go run main.go "articulate-sample.json" docx "my-course.docx"
```
3. **Parse from local file and export to Markdown:**
3. **Parse from local file and export to HTML:**
```bash
go run main.go "C:\Users\kjana\Projects\articulate-parser\articulate-sample.json" md
go run main.go "articulate-sample.json" html "output.html"
```
4. **Parse from local file and export to Markdown:**
```bash
go run main.go "articulate-sample.json" md "output.md"
```
### Building the Executable
@ -92,9 +200,29 @@ Then run:
./articulate-parser input.json md output.md
```
## Development
### Code Quality
The project maintains high code quality standards:
- Cyclomatic complexity ≤ 15 (checked with [gocyclo](https://github.com/fzipp/gocyclo))
- Race condition detection enabled
- Comprehensive test coverage
- Code formatting with `gofmt`
- Static analysis with `go vet`
### Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests: `go test ./...`
5. Submit a pull request
## Output Formats
### Markdown (.md)
### Markdown (`.md`)
- Hierarchical structure with proper heading levels
- Clean text content with HTML tags removed
@ -103,7 +231,16 @@ Then run:
- Media references included
- Course metadata at the top
### Word Document (.docx)
### HTML (`.html`)
- Professional styling with embedded CSS
- Interactive and visually appealing layout
- Proper HTML structure with semantic elements
- Responsive design for different screen sizes
- All content types beautifully formatted
- Maintains course hierarchy and organization
### Word Document (`.docx`)
- Professional document formatting
- Bold headings and proper typography
@ -167,13 +304,20 @@ The parser includes error handling for:
- Styling and visual formatting is not preserved
- Assessment logic and interactivity is lost in static exports
## Performance
- Lightweight with minimal dependencies
- Fast JSON parsing and export
- Memory efficient processing
- No external license requirements
## Future Enhancements
Potential improvements could include:
- PDF export support
- Media file downloading
- HTML export with preserved styling
- ~~HTML export with preserved styling~~**Completed**
- SCORM package support
- Batch processing capabilities
- Custom template support
@ -188,6 +332,7 @@ This is a utility tool for educational content conversion. Please ensure you hav
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
[gomod]: go.mod
[Issues]: https://github.com/kjanat/articulate-parser/issues
[Latest release]: https://github.com/kjanat/articulate-parser/releases/latest
<!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest -->
[MIT License]: LICENSE
[Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser
[Tags]: https://github.com/kjanat/articulate-parser/tags

9
go.mod
View File

@ -1,7 +1,10 @@
module github.com/kjanat/articulate-parser
go 1.21
go 1.23.0
require github.com/unidoc/unioffice v1.39.0
require github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b
require github.com/richardlehane/msoleps v1.0.4 // indirect
require (
github.com/fumiama/imgsz v0.0.4 // indirect
golang.org/x/image v0.27.0 // indirect
)

12
go.sum
View File

@ -1,6 +1,6 @@
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/unidoc/unioffice v1.39.0 h1:Wo5zvrzCqhyK/1Zi5dg8a5F5+NRftIMZPnFPYwruLto=
github.com/unidoc/unioffice v1.39.0/go.mod h1:Axz6ltIZZTUUyHoEnPe4Mb3VmsN4TRHT5iZCGZ1rgnU=
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b h1:/mxSugRc4SgN7XgBtT19dAJ7cAXLTbPmlJLJE4JjRkE=
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b/go.mod h1:ssRF0IaB1hCcKIObp3FkZOsjTcAHpgii70JelNb4H8M=
github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI=
github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=

189
internal/exporters/docx.go Normal file
View File

@ -0,0 +1,189 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"fmt"
"os"
"strings"
"github.com/fumiama/go-docx"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// DocxExporter implements the Exporter interface for DOCX format.
// 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
}
// NewDocxExporter creates a new DocxExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the Exporter interface for DOCX format
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &DocxExporter{
htmlCleaner: htmlCleaner,
}
}
// Export exports the course to a DOCX file.
// It creates a Word document with formatted content based on the course data
// and saves it to the specified output path.
//
// Parameters:
// - course: The course data model to export
// - outputPath: The file path where the DOCX content will be written
//
// Returns:
// - An error if creating or saving the document fails
func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
doc := docx.New()
// Add title
titlePara := doc.AddParagraph()
titlePara.AddText(course.Course.Title).Size("32").Bold()
// Add description if available
if course.Course.Description != "" {
descPara := doc.AddParagraph()
cleanDesc := e.htmlCleaner.CleanHTML(course.Course.Description)
descPara.AddText(cleanDesc)
}
// Add each lesson
for _, lesson := range course.Course.Lessons {
e.exportLesson(doc, &lesson)
}
// Ensure output directory exists and add .docx extension
if !strings.HasSuffix(strings.ToLower(outputPath), ".docx") {
outputPath = outputPath + ".docx"
}
// Create the file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
// Save the document
_, err = doc.WriteTo(file)
if err != nil {
return fmt.Errorf("failed to save document: %w", err)
}
return nil
}
// exportLesson adds a lesson to the document with appropriate formatting.
// It creates a lesson heading, adds the description, and processes all items in the lesson.
//
// Parameters:
// - doc: The Word document being created
// - lesson: The lesson data model to export
func (e *DocxExporter) exportLesson(doc *docx.Docx, lesson *models.Lesson) {
// Add lesson title
lessonPara := doc.AddParagraph()
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size("28").Bold()
// Add lesson description if available
if lesson.Description != "" {
descPara := doc.AddParagraph()
cleanDesc := e.htmlCleaner.CleanHTML(lesson.Description)
descPara.AddText(cleanDesc)
}
// Add each item in the lesson
for _, item := range lesson.Items {
e.exportItem(doc, &item)
}
}
// exportItem adds an item to the document.
// It creates an item heading and processes all sub-items within the item.
//
// Parameters:
// - doc: The Word document being created
// - item: The item data model to export
func (e *DocxExporter) exportItem(doc *docx.Docx, item *models.Item) {
// Add item type as heading
if item.Type != "" {
itemPara := doc.AddParagraph()
itemPara.AddText(strings.Title(item.Type)).Size("24").Bold()
}
// Add sub-items
for _, subItem := range item.Items {
e.exportSubItem(doc, &subItem)
}
}
// exportSubItem adds a sub-item to the document.
// It handles different components of a sub-item like title, heading,
// paragraph content, answers, and feedback.
//
// Parameters:
// - doc: The Word document being created
// - subItem: The sub-item data model to export
func (e *DocxExporter) exportSubItem(doc *docx.Docx, subItem *models.SubItem) {
// Add title if available
if subItem.Title != "" {
subItemPara := doc.AddParagraph()
subItemPara.AddText(" " + subItem.Title).Bold() // Indented
}
// Add heading if available
if subItem.Heading != "" {
headingPara := doc.AddParagraph()
cleanHeading := e.htmlCleaner.CleanHTML(subItem.Heading)
headingPara.AddText(" " + cleanHeading).Bold() // Indented
}
// Add paragraph content if available
if subItem.Paragraph != "" {
contentPara := doc.AddParagraph()
cleanContent := e.htmlCleaner.CleanHTML(subItem.Paragraph)
contentPara.AddText(" " + cleanContent) // Indented
}
// Add answers if this is a question
if len(subItem.Answers) > 0 {
answersPara := doc.AddParagraph()
answersPara.AddText(" Answers:").Bold()
for i, answer := range subItem.Answers {
answerPara := doc.AddParagraph()
prefix := fmt.Sprintf(" %d. ", i+1)
if answer.Correct {
prefix += "✓ "
}
cleanAnswer := e.htmlCleaner.CleanHTML(answer.Title)
answerPara.AddText(prefix + cleanAnswer)
}
}
// Add feedback if available
if subItem.Feedback != "" {
feedbackPara := doc.AddParagraph()
cleanFeedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
feedbackPara.AddText(" Feedback: " + cleanFeedback).Italic()
}
}
// GetSupportedFormat returns the format name this exporter supports.
//
// Returns:
// - A string representing the supported format ("docx")
func (e *DocxExporter) GetSupportedFormat() string {
return "docx"
}

View File

@ -0,0 +1,679 @@
// Package exporters_test provides tests for the docx exporter.
package exporters
import (
"os"
"path/filepath"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewDocxExporter tests the NewDocxExporter constructor.
func TestNewDocxExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewDocxExporter() returned nil")
}
// Type assertion to check internal structure
docxExporter, ok := exporter.(*DocxExporter)
if !ok {
t.Fatal("NewDocxExporter() returned wrong type")
}
if docxExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestDocxExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestDocxExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
expected := "docx"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestDocxExporter_Export tests the Export method.
func TestDocxExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForDocx()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.docx")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Verify file has some content (basic check)
fileInfo, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("Failed to get file info: %v", err)
}
if fileInfo.Size() == 0 {
t.Error("Output file is empty")
}
}
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Create temporary directory and file without .docx extension
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course")
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created with .docx extension
expectedPath := outputPath + ".docx"
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Fatal("Output file with .docx extension was not created")
}
}
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Try to write to invalid path
invalidPath := "/invalid/path/that/does/not/exist/file.docx"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Fatal("Expected error for invalid path, got nil")
}
}
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
func TestDocxExporter_ExportLesson(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with specific lesson content
course := &models.Course{
ShareID: "test-id",
Course: models.CourseInfo{
ID: "test-course",
Title: "Test Course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description with <strong>bold</strong> text.</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Title: "Test Item Title",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "lesson-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created successfully
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
func TestDocxExporter_ExportItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with different item types
course := &models.Course{
ShareID: "test-id",
Course: models.CourseInfo{
ID: "test-course",
Title: "Item Test Course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Item Types Lesson",
Type: "lesson",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Title: "Text Item",
Paragraph: "<p>Text content</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>List item 1</p>"},
{Paragraph: "<p>List item 2</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the answer?</p>",
Answers: []models.Answer{
{Title: "Option A", Correct: false},
{Title: "Option B", Correct: true},
},
Feedback: "<p>Correct answer explanation</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "items-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created successfully
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
func TestDocxExporter_ExportSubItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with sub-item containing all possible fields
course := &models.Course{
ShareID: "test-id",
Course: models.CourseInfo{
ID: "test-course",
Title: "SubItem Test Course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "SubItem Test Lesson",
Type: "lesson",
Items: []models.Item{
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>Question Title</p>",
Heading: "<h3>Question Heading</h3>",
Paragraph: "<p>Question description with <em>emphasis</em>.</p>",
Answers: []models.Answer{
{Title: "Wrong answer", Correct: false},
{Title: "Correct answer", Correct: true},
{Title: "Another wrong answer", Correct: false},
},
Feedback: "<p>Feedback with <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "subitem-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created successfully
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
func TestDocxExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description with <em>formatting</em>.</p>",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course with <code>code</code> and <a href='#'>links</a>.</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our comprehensive course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>Learn advanced concepts</p>"},
{Paragraph: "<p>Practice with real examples</p>"},
{Paragraph: "<p>Apply knowledge in projects</p>"},
},
},
{
Type: "multimedia",
Items: []models.SubItem{
{
Title: "<p>Video Introduction</p>",
Caption: "<p>Watch this introductory video</p>",
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/intro.mp4",
Duration: 300,
},
},
},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn in this course?</p>",
Answers: []models.Answer{
{Title: "Basic concepts only", Correct: false},
{Title: "Advanced concepts and practical application", Correct: true},
{Title: "Theory without practice", Correct: false},
},
Feedback: "<p>Excellent! This course covers both theory and practice.</p>",
},
},
},
{
Type: "image",
Items: []models.SubItem{
{
Caption: "<p>Course overview diagram</p>",
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/overview.png",
},
},
},
},
},
{
Type: "interactive",
Items: []models.SubItem{
{
Title: "<p>Interactive Exercise</p>",
},
},
},
},
},
{
ID: "lesson-2",
Title: "Advanced Topics",
Type: "lesson",
Items: []models.Item{
{
Type: "divider",
},
{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Custom Content</p>",
Paragraph: "<p>This is custom content type</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.docx")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created and has reasonable size
fileInfo, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("Failed to get file info: %v", err)
}
if fileInfo.Size() < 1000 {
t.Error("Output file seems too small for complex course content")
}
}
// TestDocxExporter_EmptyCourse tests export of an empty course.
func TestDocxExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
ShareID: "empty-id",
Course: models.CourseInfo{
ID: "empty-course",
Title: "Empty Course",
Lessons: []models.Lesson{},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "empty-course.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
func TestDocxExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with HTML content that needs cleaning
course := &models.Course{
ShareID: "html-test-id",
Course: models.CourseInfo{
ID: "html-test-course",
Title: "HTML Cleaning Test",
Description: "<p>Description with <script>alert('xss')</script> and <b>bold</b> text.</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<div>Lesson description with <span style='color:red'>styled</span> content.</div>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Heading with <em>emphasis</em> and &amp; entities</h1>",
Paragraph: "<p>Paragraph with &lt;code&gt; entities and <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "html-cleaning-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created (basic check that HTML cleaning didn't break export)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Use path that already has .docx extension
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.docx")
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created at the exact path (no double extension)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created at expected path")
}
// Ensure no double extension was created
doubleExtensionPath := outputPath + ".docx"
if _, err := os.Stat(doubleExtensionPath); err == nil {
t.Error("Double .docx extension file should not exist")
}
}
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Test various case combinations
testCases := []string{
"test-course.DOCX",
"test-course.Docx",
"test-course.DocX",
}
for i, testCase := range testCases {
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, testCase)
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed for case %d (%s): %v", i, testCase, err)
}
// Check that file was created at the exact path (no additional extension)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatalf("Output file was not created at expected path for case %d (%s)", i, testCase)
}
}
}
// createTestCourseForDocx creates a test course for DOCX export testing.
func createTestCourseForDocx() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "<p>Test course description with <strong>formatting</strong>.</p>",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Test Heading</h2>",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First list item</p>"},
{Paragraph: "<p>Second list item</p>"},
},
},
},
},
},
},
}
}
// BenchmarkDocxExporter_Export benchmarks the Export method.
func BenchmarkDocxExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
course := createTestCourseForDocx()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.docx")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
ShareID: "benchmark-id",
Course: models.CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Description: "<p>Complex course for performance testing</p>",
Lessons: make([]models.Lesson, 10), // 10 lessons
},
}
// Fill with test data
for i := 0; i < 10; i++ {
lesson := models.Lesson{
ID: "lesson-" + string(rune(i)),
Title: "Lesson " + string(rune(i)),
Type: "lesson",
Items: make([]models.Item, 5), // 5 items per lesson
}
for j := 0; j < 5; j++ {
item := models.Item{
Type: "text",
Items: make([]models.SubItem, 3), // 3 sub-items per item
}
for k := 0; k < 3; k++ {
item.Items[k] = models.SubItem{
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
}
}
lesson.Items[j] = item
}
course.Course.Lessons[i] = lesson
}
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-complex.docx")
_ = exporter.Export(course, outputPath)
os.Remove(outputPath)
}
}

View File

@ -0,0 +1,65 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"fmt"
"strings"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/services"
)
// Factory implements the ExporterFactory interface.
// 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
}
// NewFactory creates a new exporter factory.
// It takes an HTMLCleaner instance that will be passed to the exporters
// created by this factory.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the ExporterFactory interface
func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
return &Factory{
htmlCleaner: htmlCleaner,
}
}
// CreateExporter creates an exporter for the specified format.
// It returns an appropriate exporter implementation based on the format string.
// Format strings are case-insensitive.
//
// Parameters:
// - format: The desired export format (e.g., "markdown", "docx")
//
// Returns:
// - An implementation of the Exporter interface if the format is supported
// - An error if the format is not supported
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
switch strings.ToLower(format) {
case "markdown", "md":
return NewMarkdownExporter(f.htmlCleaner), nil
case "docx", "word":
return NewDocxExporter(f.htmlCleaner), nil
case "html", "htm":
return NewHTMLExporter(f.htmlCleaner), nil
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
}
// GetSupportedFormats returns a list of all supported export formats.
// This includes both primary format names and their aliases.
//
// Returns:
// - A string slice containing all supported format names
func (f *Factory) GetSupportedFormats() []string {
return []string{"markdown", "md", "docx", "word", "html", "htm"}
}

View File

@ -0,0 +1,478 @@
// Package exporters_test provides tests for the exporter factory.
package exporters
import (
"reflect"
"sort"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewFactory tests the NewFactory constructor.
func TestNewFactory(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
if factory == nil {
t.Fatal("NewFactory() returned nil")
}
// Type assertion to check internal structure
factoryImpl, ok := factory.(*Factory)
if !ok {
t.Fatal("NewFactory() returned wrong type")
}
if factoryImpl.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
func TestFactory_CreateExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
testCases := []struct {
name string
format string
expectedType string
expectedFormat string
shouldError bool
}{
{
name: "markdown format",
format: "markdown",
expectedType: "*exporters.MarkdownExporter",
expectedFormat: "markdown",
shouldError: false,
},
{
name: "md format alias",
format: "md",
expectedType: "*exporters.MarkdownExporter",
expectedFormat: "markdown",
shouldError: false,
},
{
name: "docx format",
format: "docx",
expectedType: "*exporters.DocxExporter",
expectedFormat: "docx",
shouldError: false,
},
{
name: "word format alias",
format: "word",
expectedType: "*exporters.DocxExporter",
expectedFormat: "docx",
shouldError: false,
},
{
name: "html format",
format: "html",
expectedType: "*exporters.HTMLExporter",
expectedFormat: "html",
shouldError: false,
},
{
name: "htm format alias",
format: "htm",
expectedType: "*exporters.HTMLExporter",
expectedFormat: "html",
shouldError: false,
},
{
name: "unsupported format",
format: "pdf",
shouldError: true,
},
{
name: "empty format",
format: "",
shouldError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
exporter, err := factory.CreateExporter(tc.format)
if tc.shouldError {
if err == nil {
t.Errorf("Expected error for format '%s', but got nil", tc.format)
}
if exporter != nil {
t.Errorf("Expected nil exporter for unsupported format '%s'", tc.format)
}
return
}
if err != nil {
t.Fatalf("Unexpected error creating exporter for format '%s': %v", tc.format, err)
}
if exporter == nil {
t.Fatalf("CreateExporter returned nil for supported format '%s'", tc.format)
}
// Check type
exporterType := reflect.TypeOf(exporter).String()
if exporterType != tc.expectedType {
t.Errorf("Expected exporter type '%s' for format '%s', got '%s'", tc.expectedType, tc.format, exporterType)
}
// Check supported format
supportedFormat := exporter.GetSupportedFormat()
if supportedFormat != tc.expectedFormat {
t.Errorf("Expected supported format '%s' for format '%s', got '%s'", tc.expectedFormat, tc.format, supportedFormat)
}
})
}
}
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
testCases := []struct {
format string
expectedFormat string
}{
{"MARKDOWN", "markdown"},
{"Markdown", "markdown"},
{"MarkDown", "markdown"},
{"MD", "markdown"},
{"Md", "markdown"},
{"DOCX", "docx"},
{"Docx", "docx"},
{"DocX", "docx"},
{"WORD", "docx"},
{"Word", "docx"},
{"WoRd", "docx"},
{"HTML", "html"},
{"Html", "html"},
{"HtMl", "html"},
{"HTM", "html"},
{"Htm", "html"},
{"HtM", "html"},
}
for _, tc := range testCases {
t.Run(tc.format, func(t *testing.T) {
exporter, err := factory.CreateExporter(tc.format)
if err != nil {
t.Fatalf("Unexpected error for format '%s': %v", tc.format, err)
}
if exporter == nil {
t.Fatalf("CreateExporter returned nil for format '%s'", tc.format)
}
supportedFormat := exporter.GetSupportedFormat()
if supportedFormat != tc.expectedFormat {
t.Errorf("Expected supported format '%s' for format '%s', got '%s'", tc.expectedFormat, tc.format, supportedFormat)
}
})
}
}
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
testCases := []string{
"pdf",
"txt",
"json",
"xml",
"unknown",
"123",
"markdown-invalid",
}
for _, format := range testCases {
t.Run(format, func(t *testing.T) {
exporter, err := factory.CreateExporter(format)
if err == nil {
t.Errorf("Expected error for unsupported format '%s', got nil", format)
}
if exporter != nil {
t.Errorf("Expected nil exporter for unsupported format '%s', got %v", format, exporter)
}
// Check error message contains the format
if err != nil && !strings.Contains(err.Error(), format) {
t.Errorf("Error message should contain the unsupported format '%s', got: %s", format, err.Error())
}
// Check error message has expected prefix
if err != nil && !strings.Contains(err.Error(), "unsupported export format") {
t.Errorf("Error message should contain 'unsupported export format', got: %s", err.Error())
}
})
}
}
// TestFactory_GetSupportedFormats tests the GetSupportedFormats method.
func TestFactory_GetSupportedFormats(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
formats := factory.GetSupportedFormats()
if formats == nil {
t.Fatal("GetSupportedFormats() returned nil")
}
expected := []string{"markdown", "md", "docx", "word", "html", "htm"}
// Sort both slices for comparison
sort.Strings(formats)
sort.Strings(expected)
if !reflect.DeepEqual(formats, expected) {
t.Errorf("Expected formats %v, got %v", expected, formats)
}
// Verify all returned formats can create exporters
for _, format := range formats {
exporter, err := factory.CreateExporter(format)
if err != nil {
t.Errorf("Format '%s' from GetSupportedFormats() should be creatable, got error: %v", format, err)
}
if exporter == nil {
t.Errorf("Format '%s' from GetSupportedFormats() should create non-nil exporter", format)
}
}
}
// TestFactory_GetSupportedFormats_Immutable tests that the returned slice is safe to modify.
func TestFactory_GetSupportedFormats_Immutable(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Get formats twice
formats1 := factory.GetSupportedFormats()
formats2 := factory.GetSupportedFormats()
// Modify first slice
if len(formats1) > 0 {
formats1[0] = "modified"
}
// Check that second call returns unmodified data
if len(formats2) > 0 && formats2[0] == "modified" {
t.Error("GetSupportedFormats() should return independent slices")
}
// Verify original functionality still works
formats3 := factory.GetSupportedFormats()
if len(formats3) == 0 {
t.Error("GetSupportedFormats() should still return formats after modification")
}
}
// TestFactory_ExporterTypes tests that created exporters are of correct types.
func TestFactory_ExporterTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Test markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
if err != nil {
t.Fatalf("Failed to create markdown exporter: %v", err)
}
if _, ok := markdownExporter.(*MarkdownExporter); !ok {
t.Error("Markdown exporter should be of type *MarkdownExporter")
}
// Test docx exporter
docxExporter, err := factory.CreateExporter("docx")
if err != nil {
t.Fatalf("Failed to create docx exporter: %v", err)
}
if _, ok := docxExporter.(*DocxExporter); !ok {
t.Error("DOCX exporter should be of type *DocxExporter")
}
}
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Test with markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
if err != nil {
t.Fatalf("Failed to create markdown exporter: %v", err)
}
markdownImpl, ok := markdownExporter.(*MarkdownExporter)
if !ok {
t.Fatal("Failed to cast to MarkdownExporter")
}
if markdownImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to MarkdownExporter")
}
// Test with docx exporter
docxExporter, err := factory.CreateExporter("docx")
if err != nil {
t.Fatalf("Failed to create docx exporter: %v", err)
}
docxImpl, ok := docxExporter.(*DocxExporter)
if !ok {
t.Fatal("Failed to cast to DocxExporter")
}
if docxImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to DocxExporter")
}
// Test with html exporter
htmlExporter, err := factory.CreateExporter("html")
if err != nil {
t.Fatalf("Failed to create html exporter: %v", err)
}
htmlImpl, ok := htmlExporter.(*HTMLExporter)
if !ok {
t.Fatal("Failed to cast to HTMLExporter")
}
if htmlImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to HTMLExporter")
}
}
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
func TestFactory_MultipleExporterCreation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Create multiple markdown exporters
exporter1, err := factory.CreateExporter("markdown")
if err != nil {
t.Fatalf("Failed to create first markdown exporter: %v", err)
}
exporter2, err := factory.CreateExporter("md")
if err != nil {
t.Fatalf("Failed to create second markdown exporter: %v", err)
}
// They should be different instances
if exporter1 == exporter2 {
t.Error("Factory should create independent exporter instances")
}
// But both should be MarkdownExporter type
if _, ok := exporter1.(*MarkdownExporter); !ok {
t.Error("First exporter should be MarkdownExporter")
}
if _, ok := exporter2.(*MarkdownExporter); !ok {
t.Error("Second exporter should be MarkdownExporter")
}
}
// TestFactory_WithNilHTMLCleaner tests factory behavior with nil HTMLCleaner.
func TestFactory_WithNilHTMLCleaner(t *testing.T) {
// This tests edge case - should not panic but behavior may vary
defer func() {
if r := recover(); r != nil {
t.Errorf("Factory should handle nil HTMLCleaner gracefully, but panicked: %v", r)
}
}()
factory := NewFactory(nil)
if factory == nil {
t.Fatal("NewFactory(nil) returned nil")
}
// Try to create an exporter - this might fail or succeed depending on implementation
_, err := factory.CreateExporter("markdown")
// We don't assert on the error since nil HTMLCleaner handling is implementation-dependent
// The important thing is that it doesn't panic
_ = err
}
// TestFactory_FormatNormalization tests that format strings are properly normalized.
func TestFactory_FormatNormalization(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Test formats with extra whitespace
testCases := []struct {
input string
expected string
}{
{"markdown", "markdown"},
{"MARKDOWN", "markdown"},
{"Markdown", "markdown"},
{"docx", "docx"},
{"DOCX", "docx"},
{"Docx", "docx"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
exporter, err := factory.CreateExporter(tc.input)
if err != nil {
t.Fatalf("Failed to create exporter for '%s': %v", tc.input, err)
}
format := exporter.GetSupportedFormat()
if format != tc.expected {
t.Errorf("Expected format '%s' for input '%s', got '%s'", tc.expected, tc.input, format)
}
})
}
}
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
func BenchmarkFactory_CreateExporter(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = factory.CreateExporter("markdown")
}
}
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = factory.CreateExporter("docx")
}
}
// BenchmarkFactory_GetSupportedFormats benchmarks the GetSupportedFormats method.
func BenchmarkFactory_GetSupportedFormats(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = factory.GetSupportedFormats()
}
}

476
internal/exporters/html.go Normal file
View File

@ -0,0 +1,476 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"bytes"
"fmt"
"html"
"os"
"strings"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// HTMLExporter implements the Exporter interface for HTML format.
// It converts Articulate Rise course data into a structured HTML document.
type HTMLExporter struct {
// htmlCleaner is used to convert HTML content to plain text when needed
htmlCleaner *services.HTMLCleaner
}
// NewHTMLExporter creates a new HTMLExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &HTMLExporter{
htmlCleaner: htmlCleaner,
}
}
// Export exports a course to HTML format.
// It generates a structured HTML document from the course data
// and writes it to the specified output path.
//
// Parameters:
// - course: The course data model to export
// - outputPath: The file path where the HTML content will be written
//
// Returns:
// - An error if writing to the output file fails
func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
var buf bytes.Buffer
// Write HTML document structure
buf.WriteString("<!DOCTYPE html>\n")
buf.WriteString("<html lang=\"en\">\n")
buf.WriteString("<head>\n")
buf.WriteString(" <meta charset=\"UTF-8\">\n")
buf.WriteString(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
buf.WriteString(fmt.Sprintf(" <title>%s</title>\n", html.EscapeString(course.Course.Title)))
buf.WriteString(" <style>\n")
buf.WriteString(e.getDefaultCSS())
buf.WriteString(" </style>\n")
buf.WriteString("</head>\n")
buf.WriteString("<body>\n")
// Write course header
buf.WriteString(fmt.Sprintf(" <header>\n <h1>%s</h1>\n", html.EscapeString(course.Course.Title)))
if course.Course.Description != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"course-description\">%s</div>\n", course.Course.Description))
}
buf.WriteString(" </header>\n\n")
// Add metadata section
buf.WriteString(" <section class=\"course-info\">\n")
buf.WriteString(" <h2>Course Information</h2>\n")
buf.WriteString(" <ul>\n")
buf.WriteString(fmt.Sprintf(" <li><strong>Course ID:</strong> %s</li>\n", html.EscapeString(course.Course.ID)))
buf.WriteString(fmt.Sprintf(" <li><strong>Share ID:</strong> %s</li>\n", html.EscapeString(course.ShareID)))
buf.WriteString(fmt.Sprintf(" <li><strong>Navigation Mode:</strong> %s</li>\n", html.EscapeString(course.Course.NavigationMode)))
if course.Course.ExportSettings != nil {
buf.WriteString(fmt.Sprintf(" <li><strong>Export Format:</strong> %s</li>\n", html.EscapeString(course.Course.ExportSettings.Format)))
}
buf.WriteString(" </ul>\n")
buf.WriteString(" </section>\n\n")
// Process lessons
lessonCounter := 0
for _, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf(" <section class=\"course-section\">\n <h2>%s</h2>\n </section>\n\n", html.EscapeString(lesson.Title)))
continue
}
lessonCounter++
buf.WriteString(fmt.Sprintf(" <section class=\"lesson\">\n <h3>Lesson %d: %s</h3>\n", lessonCounter, html.EscapeString(lesson.Title)))
if lesson.Description != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"lesson-description\">%s</div>\n", lesson.Description))
}
// Process lesson items
for _, item := range lesson.Items {
e.processItemToHTML(&buf, item)
}
buf.WriteString(" </section>\n\n")
}
buf.WriteString("</body>\n")
buf.WriteString("</html>\n")
return os.WriteFile(outputPath, buf.Bytes(), 0644)
}
// GetSupportedFormat returns the format name this exporter supports
// It indicates the file format that the HTMLExporter can generate.
//
// Returns:
// - A string representing the supported format ("html")
func (e *HTMLExporter) GetSupportedFormat() string {
return "html"
}
// getDefaultCSS returns basic CSS styling for the HTML document
func (e *HTMLExporter) getDefaultCSS() string {
return `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 10px;
margin-bottom: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
header h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 300;
}
.course-description {
margin-top: 1rem;
font-size: 1.1rem;
opacity: 0.9;
}
.course-info {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-info h2 {
margin-top: 0;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.course-info ul {
list-style: none;
padding: 0;
}
.course-info li {
margin: 0.5rem 0;
padding: 0.5rem;
background: #f7fafc;
border-radius: 4px;
}
.course-section {
background: #4299e1;
color: white;
padding: 1.5rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-section h2 {
margin: 0;
font-weight: 400;
}
.lesson {
background: white;
padding: 2rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid #4299e1;
}
.lesson h3 {
margin-top: 0;
color: #2d3748;
font-size: 1.5rem;
}
.lesson-description {
margin: 1rem 0;
padding: 1rem;
background: #f7fafc;
border-radius: 4px;
border-left: 3px solid #4299e1;
}
.item {
margin: 1.5rem 0;
padding: 1rem;
border-radius: 6px;
background: #fafafa;
border: 1px solid #e2e8f0;
}
.item h4 {
margin-top: 0;
color: #4a5568;
font-size: 1.2rem;
text-transform: capitalize;
}
.text-item {
background: #f0fff4;
border-left: 3px solid #48bb78;
}
.list-item {
background: #fffaf0;
border-left: 3px solid #ed8936;
}
.knowledge-check {
background: #e6fffa;
border-left: 3px solid #38b2ac;
}
.multimedia-item {
background: #faf5ff;
border-left: 3px solid #9f7aea;
}
.interactive-item {
background: #fff5f5;
border-left: 3px solid #f56565;
}
.unknown-item {
background: #f7fafc;
border-left: 3px solid #a0aec0;
}
.answers {
margin: 1rem 0;
}
.answers h5 {
margin: 0.5rem 0;
color: #4a5568;
}
.answers ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.answers li {
margin: 0.3rem 0;
padding: 0.3rem;
}
.correct-answer {
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
}
.correct-answer::after {
content: " ✓";
color: #38a169;
}
.feedback {
margin: 1rem 0;
padding: 1rem;
background: #edf2f7;
border-radius: 4px;
border-left: 3px solid #4299e1;
font-style: italic;
}
.media-info {
background: #edf2f7;
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.media-info strong {
color: #4a5568;
}
hr {
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
}
ul {
padding-left: 1.5rem;
}
li {
margin: 0.5rem 0;
}
`
}
// processItemToHTML converts a course item into HTML format
// and appends it to the provided buffer. It handles different item types
// with appropriate HTML formatting.
//
// Parameters:
// - buf: The buffer to write the HTML content to
// - item: The course item to process
func (e *HTMLExporter) processItemToHTML(buf *bytes.Buffer, item models.Item) {
switch strings.ToLower(item.Type) {
case "text":
e.processTextItem(buf, item)
case "list":
e.processListItem(buf, item)
case "knowledgecheck":
e.processKnowledgeCheckItem(buf, item)
case "multimedia":
e.processMultimediaItem(buf, item)
case "image":
e.processImageItem(buf, item)
case "interactive":
e.processInteractiveItem(buf, item)
case "divider":
e.processDividerItem(buf)
default:
e.processUnknownItem(buf, item)
}
}
// processTextItem handles text content with headings and paragraphs
func (e *HTMLExporter) processTextItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item text-item\">\n")
buf.WriteString(" <h4>Text Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Heading != "" {
buf.WriteString(fmt.Sprintf(" <h5>%s</h5>\n", subItem.Heading))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
buf.WriteString(" </div>\n\n")
}
// processListItem handles list content
func (e *HTMLExporter) processListItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item list-item\">\n")
buf.WriteString(" <h4>List</h4>\n")
buf.WriteString(" <ul>\n")
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
cleanText := e.htmlCleaner.CleanHTML(subItem.Paragraph)
buf.WriteString(fmt.Sprintf(" <li>%s</li>\n", html.EscapeString(cleanText)))
}
}
buf.WriteString(" </ul>\n")
buf.WriteString(" </div>\n\n")
}
// processKnowledgeCheckItem handles quiz questions and answers
func (e *HTMLExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item knowledge-check\">\n")
buf.WriteString(" <h4>Knowledge Check</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>Question:</strong> %s</p>\n", subItem.Title))
}
if len(subItem.Answers) > 0 {
e.processAnswers(buf, subItem.Answers)
}
if subItem.Feedback != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"feedback\"><strong>Feedback:</strong> %s</div>\n", subItem.Feedback))
}
}
buf.WriteString(" </div>\n\n")
}
// processMultimediaItem handles multimedia content like videos
func (e *HTMLExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item multimedia-item\">\n")
buf.WriteString(" <h4>Media Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <h5>%s</h5>\n", subItem.Title))
}
if subItem.Media != nil {
if subItem.Media.Video != nil {
buf.WriteString(" <div class=\"media-info\">\n")
buf.WriteString(fmt.Sprintf(" <p><strong>Video:</strong> %s</p>\n", html.EscapeString(subItem.Media.Video.OriginalUrl)))
if subItem.Media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf(" <p><strong>Duration:</strong> %d seconds</p>\n", subItem.Media.Video.Duration))
}
buf.WriteString(" </div>\n")
}
}
if subItem.Caption != "" {
buf.WriteString(fmt.Sprintf(" <div><em>%s</em></div>\n", subItem.Caption))
}
}
buf.WriteString(" </div>\n\n")
}
// processImageItem handles image content
func (e *HTMLExporter) processImageItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item multimedia-item\">\n")
buf.WriteString(" <h4>Image</h4>\n")
for _, subItem := range item.Items {
if subItem.Media != nil && subItem.Media.Image != nil {
buf.WriteString(" <div class=\"media-info\">\n")
buf.WriteString(fmt.Sprintf(" <p><strong>Image:</strong> %s</p>\n", html.EscapeString(subItem.Media.Image.OriginalUrl)))
buf.WriteString(" </div>\n")
}
if subItem.Caption != "" {
buf.WriteString(fmt.Sprintf(" <div><em>%s</em></div>\n", subItem.Caption))
}
}
buf.WriteString(" </div>\n\n")
}
// processInteractiveItem handles interactive content
func (e *HTMLExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item interactive-item\">\n")
buf.WriteString(" <h4>Interactive Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>%s</strong></p>\n", subItem.Title))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
buf.WriteString(" </div>\n\n")
}
// processDividerItem handles divider elements
func (e *HTMLExporter) processDividerItem(buf *bytes.Buffer) {
buf.WriteString(" <hr>\n\n")
}
// processUnknownItem handles unknown or unsupported item types
func (e *HTMLExporter) processUnknownItem(buf *bytes.Buffer, item models.Item) {
if len(item.Items) > 0 {
buf.WriteString(" <div class=\"item unknown-item\">\n")
buf.WriteString(fmt.Sprintf(" <h4>%s Content</h4>\n", strings.Title(item.Type)))
for _, subItem := range item.Items {
e.processGenericSubItem(buf, subItem)
}
buf.WriteString(" </div>\n\n")
}
}
// processGenericSubItem processes sub-items for unknown types
func (e *HTMLExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>%s</strong></p>\n", subItem.Title))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
// processAnswers processes answer choices for quiz questions
func (e *HTMLExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
buf.WriteString(" <div class=\"answers\">\n")
buf.WriteString(" <h5>Answers:</h5>\n")
buf.WriteString(" <ol>\n")
for _, answer := range answers {
cssClass := ""
if answer.Correct {
cssClass = " class=\"correct-answer\""
}
buf.WriteString(fmt.Sprintf(" <li%s>%s</li>\n", cssClass, html.EscapeString(answer.Title)))
}
buf.WriteString(" </ol>\n")
buf.WriteString(" </div>\n")
}

View File

@ -0,0 +1,927 @@
// Package exporters_test provides tests for the html exporter.
package exporters
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
func TestNewHTMLExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewHTMLExporter() returned nil")
}
// Type assertion to check internal structure
htmlExporter, ok := exporter.(*HTMLExporter)
if !ok {
t.Fatal("NewHTMLExporter() returned wrong type")
}
if htmlExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestHTMLExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestHTMLExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
expected := "html"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestHTMLExporter_Export tests the Export method.
func TestHTMLExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForHTML()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.html")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify HTML structure
if !strings.Contains(contentStr, "<!DOCTYPE html>") {
t.Error("Output should contain HTML doctype")
}
if !strings.Contains(contentStr, "<html lang=\"en\">") {
t.Error("Output should contain HTML tag with lang attribute")
}
if !strings.Contains(contentStr, "<title>Test Course</title>") {
t.Error("Output should contain course title in head")
}
// Verify main course title
if !strings.Contains(contentStr, "<h1>Test Course</h1>") {
t.Error("Output should contain course title as main heading")
}
// Verify course information section
if !strings.Contains(contentStr, "Course Information") {
t.Error("Output should contain course information section")
}
// Verify course metadata
if !strings.Contains(contentStr, "Course ID") {
t.Error("Output should contain course ID")
}
if !strings.Contains(contentStr, "Share ID") {
t.Error("Output should contain share ID")
}
// Verify lesson content
if !strings.Contains(contentStr, "Lesson 1: Test Lesson") {
t.Error("Output should contain lesson heading")
}
// Verify CSS is included
if !strings.Contains(contentStr, "<style>") {
t.Error("Output should contain CSS styles")
}
if !strings.Contains(contentStr, "font-family") {
t.Error("Output should contain CSS font-family")
}
}
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
testCourse := createTestCourseForHTML()
// Try to export to invalid path (non-existent directory)
invalidPath := "/non/existent/path/test.html"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Error("Expected error for invalid output path, but got nil")
}
}
// TestHTMLExporter_ProcessTextItem tests the processTextItem method.
func TestHTMLExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Test Heading</h1>",
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
},
{
Paragraph: "<p>Another paragraph.</p>",
},
},
}
exporter.processTextItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "text-item") {
t.Error("Should contain text-item CSS class")
}
if !strings.Contains(result, "Text Content") {
t.Error("Should contain text content heading")
}
if !strings.Contains(result, "<h1>Test Heading</h1>") {
t.Error("Should preserve HTML heading")
}
if !strings.Contains(result, "<strong>bold</strong>") {
t.Error("Should preserve HTML formatting in paragraph")
}
}
// TestHTMLExporter_ProcessListItem tests the processListItem method.
func TestHTMLExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First item</p>"},
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
{Paragraph: "<p>Third item</p>"},
},
}
exporter.processListItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "list-item") {
t.Error("Should contain list-item CSS class")
}
if !strings.Contains(result, "<ul>") {
t.Error("Should contain unordered list")
}
if !strings.Contains(result, "<li>First item</li>") {
t.Error("Should contain first list item")
}
if !strings.Contains(result, "<li>Second item with emphasis</li>") {
t.Error("Should contain second list item with cleaned HTML")
}
if !strings.Contains(result, "<li>Third item</li>") {
t.Error("Should contain third list item")
}
}
// TestHTMLExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestHTMLExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the correct answer?</p>",
Answers: []models.Answer{
{Title: "Wrong answer", Correct: false},
{Title: "Correct answer", Correct: true},
{Title: "Another wrong answer", Correct: false},
},
Feedback: "<p>Great job! This is the feedback.</p>",
},
},
}
exporter.processKnowledgeCheckItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "knowledge-check") {
t.Error("Should contain knowledge-check CSS class")
}
if !strings.Contains(result, "Knowledge Check") {
t.Error("Should contain knowledge check heading")
}
if !strings.Contains(result, "What is the correct answer?") {
t.Error("Should contain question text")
}
if !strings.Contains(result, "Wrong answer") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "correct-answer") {
t.Error("Should mark correct answer with CSS class")
}
if !strings.Contains(result, "Feedback") {
t.Error("Should contain feedback section")
}
}
// TestHTMLExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestHTMLExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "multimedia",
Items: []models.SubItem{
{
Title: "<p>Video Title</p>",
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 120,
},
},
Caption: "<p>Video caption</p>",
},
},
}
exporter.processMultimediaItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "multimedia-item") {
t.Error("Should contain multimedia-item CSS class")
}
if !strings.Contains(result, "Media Content") {
t.Error("Should contain media content heading")
}
if !strings.Contains(result, "Video Title") {
t.Error("Should contain video title")
}
if !strings.Contains(result, "https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "120 seconds") {
t.Error("Should contain video duration")
}
if !strings.Contains(result, "Video caption") {
t.Error("Should contain video caption")
}
}
// TestHTMLExporter_ProcessImageItem tests the processImageItem method.
func TestHTMLExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "image",
Items: []models.SubItem{
{
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.png",
},
},
Caption: "<p>Image caption</p>",
},
},
}
exporter.processImageItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "multimedia-item") {
t.Error("Should contain multimedia-item CSS class")
}
if !strings.Contains(result, "Image") {
t.Error("Should contain image heading")
}
if !strings.Contains(result, "https://example.com/image.png") {
t.Error("Should contain image URL")
}
if !strings.Contains(result, "Image caption") {
t.Error("Should contain image caption")
}
}
// TestHTMLExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestHTMLExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "interactive",
Items: []models.SubItem{
{
Title: "<p>Interactive element title</p>",
Paragraph: "<p>Interactive content description</p>",
},
},
}
exporter.processInteractiveItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "interactive-item") {
t.Error("Should contain interactive-item CSS class")
}
if !strings.Contains(result, "Interactive Content") {
t.Error("Should contain interactive content heading")
}
if !strings.Contains(result, "Interactive element title") {
t.Error("Should contain interactive element title")
}
if !strings.Contains(result, "Interactive content description") {
t.Error("Should contain interactive content description")
}
}
// TestHTMLExporter_ProcessDividerItem tests the processDividerItem method.
func TestHTMLExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
exporter.processDividerItem(&buf)
result := buf.String()
expected := " <hr>\n\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestHTMLExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestHTMLExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Unknown item title</p>",
Paragraph: "<p>Unknown item content</p>",
},
},
}
exporter.processUnknownItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "unknown-item") {
t.Error("Should contain unknown-item CSS class")
}
if !strings.Contains(result, "Unknown Content") {
t.Error("Should contain unknown content heading")
}
if !strings.Contains(result, "Unknown item title") {
t.Error("Should contain unknown item title")
}
if !strings.Contains(result, "Unknown item content") {
t.Error("Should contain unknown item content")
}
}
// TestHTMLExporter_ProcessAnswers tests the processAnswers method.
func TestHTMLExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
answers := []models.Answer{
{Title: "Answer 1", Correct: false},
{Title: "Answer 2", Correct: true},
{Title: "Answer 3", Correct: false},
}
exporter.processAnswers(&buf, answers)
result := buf.String()
if !strings.Contains(result, "answers") {
t.Error("Should contain answers CSS class")
}
if !strings.Contains(result, "<h5>Answers:</h5>") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "<ol>") {
t.Error("Should contain ordered list")
}
if !strings.Contains(result, "<li>Answer 1</li>") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "correct-answer") {
t.Error("Should mark correct answer with CSS class")
}
if !strings.Contains(result, "<li class=\"correct-answer\">Answer 2</li>") {
t.Error("Should mark correct answer properly")
}
if !strings.Contains(result, "<li>Answer 3</li>") {
t.Error("Should contain third answer")
}
}
// TestHTMLExporter_ProcessItemToHTML_AllTypes tests all item types.
func TestHTMLExporter_ProcessItemToHTML_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
tests := []struct {
name string
itemType string
expectedText string
}{
{
name: "text item",
itemType: "text",
expectedText: "Text Content",
},
{
name: "list item",
itemType: "list",
expectedText: "List",
},
{
name: "knowledge check item",
itemType: "knowledgeCheck",
expectedText: "Knowledge Check",
},
{
name: "multimedia item",
itemType: "multimedia",
expectedText: "Media Content",
},
{
name: "image item",
itemType: "image",
expectedText: "Image",
},
{
name: "interactive item",
itemType: "interactive",
expectedText: "Interactive Content",
},
{
name: "divider item",
itemType: "divider",
expectedText: "<hr>",
},
{
name: "unknown item",
itemType: "unknown",
expectedText: "Unknown Content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
item := models.Item{
Type: tt.itemType,
Items: []models.SubItem{
{Title: "Test title", Paragraph: "Test content"},
},
}
// Handle empty unknown items
if tt.itemType == "unknown" && tt.expectedText == "" {
item.Items = []models.SubItem{}
}
exporter.processItemToHTML(&buf, item)
result := buf.String()
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
t.Errorf("Expected content to contain: %q\nGot: %q", tt.expectedText, result)
}
})
}
}
// TestHTMLExporter_ComplexCourse tests export of a complex course structure.
func TestHTMLExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description.</p>",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Format: "scorm",
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First objective</p>"},
{Paragraph: "<p>Second objective</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn?</p>",
Answers: []models.Answer{
{Title: "Nothing", Correct: false},
{Title: "Everything", Correct: true},
},
Feedback: "<p>Great choice!</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.html")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify various elements are present
checks := []string{
"<title>Complex Test Course</title>",
"<h1>Complex Test Course</h1>",
"This is a <strong>complex</strong> course description.",
"Course Information",
"complex-course",
"complex-test-id",
"menu",
"scorm",
"Course Section",
"Lesson 1: Introduction Lesson",
"Introduction to the course",
"<h2>Welcome</h2>",
"Welcome to our course!",
"First objective",
"Second objective",
"Knowledge Check",
"What will you learn?",
"Nothing",
"Everything",
"correct-answer",
"Great choice!",
}
for _, check := range checks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain: %q", check)
}
}
// Verify HTML structure
structureChecks := []string{
"<!DOCTYPE html>",
"<html lang=\"en\">",
"<head>",
"<body>",
"</html>",
"<style>",
"font-family",
}
for _, check := range structureChecks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain HTML structure element: %q", check)
}
}
}
// TestHTMLExporter_EmptyCourse tests export of an empty course.
func TestHTMLExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
ShareID: "empty-id",
Course: models.CourseInfo{
ID: "empty-course",
Title: "Empty Course",
Lessons: []models.Lesson{},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "empty-course.html")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify basic structure
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify basic HTML structure even for empty course
if !strings.Contains(contentStr, "<!DOCTYPE html>") {
t.Error("Output should contain HTML doctype")
}
if !strings.Contains(contentStr, "<title>Empty Course</title>") {
t.Error("Output should contain course title")
}
if !strings.Contains(contentStr, "<h1>Empty Course</h1>") {
t.Error("Output should contain course heading")
}
}
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create course with HTML content that needs cleaning in some places
course := &models.Course{
ShareID: "html-test-id",
Course: models.CourseInfo{
ID: "html-test-course",
Title: "HTML Test Course",
Description: "<p>Description with <script>alert('xss')</script> and <b>bold</b> text.</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<div>Lesson description with <span style='color:red'>styled</span> content.</div>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Heading with <em>emphasis</em> and &amp; entities</h1>",
Paragraph: "<p>Paragraph with &lt;code&gt; entities and <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "html-test.html")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created (basic check that HTML handling didn't break export)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read content and verify some HTML is preserved (descriptions, headings, paragraphs)
// while list items are cleaned for safety
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// HTML should be preserved in some places
if !strings.Contains(contentStr, "<b>bold</b>") {
t.Error("Should preserve HTML formatting in descriptions")
}
if !strings.Contains(contentStr, "<h1>Heading with <em>emphasis</em>") {
t.Error("Should preserve HTML in headings")
}
if !strings.Contains(contentStr, "<strong>formatting</strong>") {
t.Error("Should preserve HTML in paragraphs")
}
}
// createTestCourseForHTML creates a test course for HTML export testing.
func createTestCourseForHTML() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "<p>Test course description with <strong>formatting</strong>.</p>",
NavigationMode: "free",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Test Heading</h2>",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First list item</p>"},
{Paragraph: "<p>Second list item</p>"},
},
},
},
},
},
},
}
}
// BenchmarkHTMLExporter_Export benchmarks the Export method.
func BenchmarkHTMLExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
course := createTestCourseForHTML()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.html")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkHTMLExporter_ProcessTextItem benchmarks text item processing.
func BenchmarkHTMLExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Benchmark Heading</h1>",
Paragraph: "<p>Benchmark paragraph with <strong>formatting</strong>.</p>",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
exporter.processTextItem(&buf, item)
}
}
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
ShareID: "benchmark-id",
Course: models.CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Description: "<p>Complex course for performance testing</p>",
Lessons: make([]models.Lesson, 10), // 10 lessons
},
}
// Fill with test data
for i := 0; i < 10; i++ {
lesson := models.Lesson{
ID: "lesson-" + string(rune(i)),
Title: "Lesson " + string(rune(i)),
Type: "lesson",
Items: make([]models.Item, 5), // 5 items per lesson
}
for j := 0; j < 5; j++ {
item := models.Item{
Type: "text",
Items: make([]models.SubItem, 3), // 3 sub-items per item
}
for k := 0; k < 3; k++ {
item.Items[k] = models.SubItem{
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
}
}
lesson.Items[j] = item
}
course.Course.Lessons[i] = lesson
}
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-complex.html")
_ = exporter.Export(course, outputPath)
os.Remove(outputPath)
}
}

View File

@ -0,0 +1,289 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"bytes"
"fmt"
"os"
"strings"
"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
}
// NewMarkdownExporter creates a new MarkdownExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the Exporter interface for Markdown format
func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &MarkdownExporter{
htmlCleaner: htmlCleaner,
}
}
// Export exports a course to Markdown format.
// It generates a structured Markdown document from the course data
// and writes it to the specified output path.
//
// Parameters:
// - course: The course data model to export
// - outputPath: The file path where the Markdown content will be written
//
// Returns:
// - An error if writing to the output file fails
func (e *MarkdownExporter) Export(course *models.Course, outputPath string) error {
var buf bytes.Buffer
// Write course header
buf.WriteString(fmt.Sprintf("# %s\n\n", course.Course.Title))
if course.Course.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description)))
}
// Add metadata
buf.WriteString("## Course Information\n\n")
buf.WriteString(fmt.Sprintf("- **Course ID**: %s\n", course.Course.ID))
buf.WriteString(fmt.Sprintf("- **Share ID**: %s\n", course.ShareID))
buf.WriteString(fmt.Sprintf("- **Navigation Mode**: %s\n", course.Course.NavigationMode))
if course.Course.ExportSettings != nil {
buf.WriteString(fmt.Sprintf("- **Export Format**: %s\n", course.Course.ExportSettings.Format))
}
buf.WriteString("\n---\n\n")
// Process lessons
lessonCounter := 0
for _, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title))
continue
}
lessonCounter++
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", lessonCounter, lesson.Title))
if lesson.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description)))
}
// Process lesson items
for _, item := range lesson.Items {
e.processItemToMarkdown(&buf, item, 3)
}
buf.WriteString("\n---\n\n")
}
return os.WriteFile(outputPath, buf.Bytes(), 0644)
}
// GetSupportedFormat returns the format name this exporter supports
// It indicates the file format that the MarkdownExporter can generate.
//
// Returns:
// - A string representing the supported format ("markdown")
func (e *MarkdownExporter) GetSupportedFormat() string {
return "markdown"
}
// processItemToMarkdown converts a course item into Markdown format
// and appends it to the provided buffer. It handles different item types
// with appropriate Markdown formatting.
//
// Parameters:
// - buf: The buffer to write the Markdown content to
// - item: The course item to process
// - level: The heading level for the item (determines the number of # characters)
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
headingPrefix := strings.Repeat("#", level)
switch item.Type {
case "text":
e.processTextItem(buf, item, headingPrefix)
case "list":
e.processListItem(buf, item)
case "multimedia":
e.processMultimediaItem(buf, item, headingPrefix)
case "image":
e.processImageItem(buf, item, headingPrefix)
case "knowledgeCheck":
e.processKnowledgeCheckItem(buf, item, headingPrefix)
case "interactive":
e.processInteractiveItem(buf, item, headingPrefix)
case "divider":
e.processDividerItem(buf)
default:
e.processUnknownItem(buf, item, headingPrefix)
}
}
// processTextItem handles text content with headings and paragraphs
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
for _, subItem := range item.Items {
if subItem.Heading != "" {
heading := e.htmlCleaner.CleanHTML(subItem.Heading)
if heading != "" {
buf.WriteString(fmt.Sprintf("%s %s\n\n", headingPrefix, heading))
}
}
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
if paragraph != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
}
}
}
}
// processListItem handles list items with bullet points
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
if paragraph != "" {
buf.WriteString(fmt.Sprintf("- %s\n", paragraph))
}
}
}
buf.WriteString("\n")
}
// processMultimediaItem handles multimedia content including videos and images
func (e *MarkdownExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Media Content\n\n", headingPrefix))
for _, subItem := range item.Items {
e.processMediaSubItem(buf, subItem)
}
buf.WriteString("\n")
}
// processMediaSubItem processes individual media items (video/image)
func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Media != nil {
e.processVideoMedia(buf, subItem.Media)
e.processImageMedia(buf, subItem.Media)
}
if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
}
}
// processVideoMedia processes video media content
func (e *MarkdownExporter) processVideoMedia(buf *bytes.Buffer, media *models.Media) {
if media.Video != nil {
buf.WriteString(fmt.Sprintf("**Video**: %s\n", media.Video.OriginalUrl))
if media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf("**Duration**: %d seconds\n", media.Video.Duration))
}
}
}
// processImageMedia processes image media content
func (e *MarkdownExporter) processImageMedia(buf *bytes.Buffer, media *models.Media) {
if media.Image != nil {
buf.WriteString(fmt.Sprintf("**Image**: %s\n", media.Image.OriginalUrl))
}
}
// processImageItem handles standalone image items
func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Image\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Media != nil && subItem.Media.Image != nil {
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
}
if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
}
}
buf.WriteString("\n")
}
// processKnowledgeCheckItem handles quiz questions and knowledge checks
func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Knowledge Check\n\n", headingPrefix))
for _, subItem := range item.Items {
e.processQuestionSubItem(buf, subItem)
}
buf.WriteString("\n")
}
// processQuestionSubItem processes individual question items
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title)
buf.WriteString(fmt.Sprintf("**Question**: %s\n\n", title))
}
e.processAnswers(buf, subItem.Answers)
if subItem.Feedback != "" {
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
buf.WriteString(fmt.Sprintf("\n**Feedback**: %s\n", feedback))
}
}
// processAnswers processes answer choices for quiz questions
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
buf.WriteString("**Answers**:\n")
for i, answer := range answers {
correctMark := ""
if answer.Correct {
correctMark = " ✓"
}
buf.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, answer.Title, correctMark))
}
}
// processInteractiveItem handles interactive content
func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Interactive Content\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title)
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
}
}
}
// processDividerItem handles divider elements
func (e *MarkdownExporter) processDividerItem(buf *bytes.Buffer) {
buf.WriteString("---\n\n")
}
// processUnknownItem handles unknown or unsupported item types
func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
if len(item.Items) > 0 {
buf.WriteString(fmt.Sprintf("%s %s Content\n\n", headingPrefix, strings.Title(item.Type)))
for _, subItem := range item.Items {
e.processGenericSubItem(buf, subItem)
}
}
}
// processGenericSubItem processes sub-items for unknown types
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title)
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
}
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
}
}

View File

@ -0,0 +1,693 @@
// Package exporters_test provides tests for the markdown exporter.
package exporters
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
func TestNewMarkdownExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewMarkdownExporter() returned nil")
}
// Type assertion to check internal structure
markdownExporter, ok := exporter.(*MarkdownExporter)
if !ok {
t.Fatal("NewMarkdownExporter() returned wrong type")
}
if markdownExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestMarkdownExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestMarkdownExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
expected := "markdown"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestMarkdownExporter_Export tests the Export method.
func TestMarkdownExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForMarkdown()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.md")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify main course title
if !strings.Contains(contentStr, "# Test Course") {
t.Error("Output should contain course title as main heading")
}
// Verify course information section
if !strings.Contains(contentStr, "## Course Information") {
t.Error("Output should contain course information section")
}
// Verify course metadata
if !strings.Contains(contentStr, "- **Course ID**: test-course-id") {
t.Error("Output should contain course ID")
}
if !strings.Contains(contentStr, "- **Share ID**: test-share-id") {
t.Error("Output should contain share ID")
}
// Verify lesson content
if !strings.Contains(contentStr, "## Lesson 1: Test Lesson") {
t.Error("Output should contain lesson heading")
}
// Verify section handling
if !strings.Contains(contentStr, "# Test Section") {
t.Error("Output should contain section as main heading")
}
}
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
testCourse := createTestCourseForMarkdown()
// Try to write to invalid path
invalidPath := "/invalid/path/that/does/not/exist/file.md"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Fatal("Expected error for invalid path, got nil")
}
}
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Test Heading</h1>",
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
},
{
Paragraph: "<p>Another paragraph.</p>",
},
},
}
exporter.processTextItem(&buf, item, "###")
result := buf.String()
expected := "### Test Heading\n\nTest paragraph with bold text.\n\nAnother paragraph.\n\n"
if result != expected {
t.Errorf("Expected:\n%q\nGot:\n%q", expected, result)
}
}
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First item</p>"},
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
{Paragraph: "<p>Third item</p>"},
},
}
exporter.processListItem(&buf, item)
result := buf.String()
expected := "- First item\n- Second item with emphasis\n- Third item\n\n"
if result != expected {
t.Errorf("Expected:\n%q\nGot:\n%q", expected, result)
}
}
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "multimedia",
Items: []models.SubItem{
{
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 120,
},
},
Caption: "<p>Video caption</p>",
},
},
}
exporter.processMultimediaItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Media Content") {
t.Error("Should contain media content heading")
}
if !strings.Contains(result, "**Video**: https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "**Duration**: 120 seconds") {
t.Error("Should contain video duration")
}
if !strings.Contains(result, "*Video caption*") {
t.Error("Should contain video caption")
}
}
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "image",
Items: []models.SubItem{
{
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.jpg",
},
},
Caption: "<p>Image caption</p>",
},
},
}
exporter.processImageItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Image") {
t.Error("Should contain image heading")
}
if !strings.Contains(result, "**Image**: https://example.com/image.jpg") {
t.Error("Should contain image URL")
}
if !strings.Contains(result, "*Image caption*") {
t.Error("Should contain image caption")
}
}
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the capital of France?</p>",
Answers: []models.Answer{
{Title: "London", Correct: false},
{Title: "Paris", Correct: true},
{Title: "Berlin", Correct: false},
},
Feedback: "<p>Paris is the capital of France.</p>",
},
},
}
exporter.processKnowledgeCheckItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Knowledge Check") {
t.Error("Should contain knowledge check heading")
}
if !strings.Contains(result, "**Question**: What is the capital of France?") {
t.Error("Should contain question")
}
if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "2. Paris ✓") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
t.Error("Should contain feedback")
}
}
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "interactive",
Items: []models.SubItem{
{Title: "<p>Interactive element title</p>"},
},
}
exporter.processInteractiveItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Interactive Content") {
t.Error("Should contain interactive content heading")
}
if !strings.Contains(result, "**Interactive element title**") {
t.Error("Should contain interactive element title")
}
}
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
exporter.processDividerItem(&buf)
result := buf.String()
expected := "---\n\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Unknown item title</p>",
Paragraph: "<p>Unknown item content</p>",
},
},
}
exporter.processUnknownItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Unknown Content") {
t.Error("Should contain unknown content heading")
}
if !strings.Contains(result, "**Unknown item title**") {
t.Error("Should contain unknown item title")
}
if !strings.Contains(result, "Unknown item content") {
t.Error("Should contain unknown item content")
}
}
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
media := &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 300,
},
}
exporter.processVideoMedia(&buf, media)
result := buf.String()
if !strings.Contains(result, "**Video**: https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "**Duration**: 300 seconds") {
t.Error("Should contain video duration")
}
}
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
media := &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.jpg",
},
}
exporter.processImageMedia(&buf, media)
result := buf.String()
expected := "**Image**: https://example.com/image.jpg\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
answers := []models.Answer{
{Title: "Answer 1", Correct: false},
{Title: "Answer 2", Correct: true},
{Title: "Answer 3", Correct: false},
}
exporter.processAnswers(&buf, answers)
result := buf.String()
if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "1. Answer 1") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "2. Answer 2 ✓") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "3. Answer 3") {
t.Error("Should contain third answer")
}
}
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
tests := []struct {
name string
itemType string
expectedText string
}{
{
name: "text item",
itemType: "text",
expectedText: "", // processTextItem handles empty items
},
{
name: "list item",
itemType: "list",
expectedText: "\n", // Empty list adds newline
},
{
name: "multimedia item",
itemType: "multimedia",
expectedText: "### Media Content",
},
{
name: "image item",
itemType: "image",
expectedText: "### Image",
},
{
name: "knowledgeCheck item",
itemType: "knowledgeCheck",
expectedText: "### Knowledge Check",
},
{
name: "interactive item",
itemType: "interactive",
expectedText: "### Interactive Content",
},
{
name: "divider item",
itemType: "divider",
expectedText: "---",
},
{
name: "unknown item",
itemType: "unknown",
expectedText: "", // Empty unknown items don't add content
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
item := models.Item{Type: tt.itemType}
exporter.processItemToMarkdown(&buf, item, 3)
result := buf.String()
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
t.Errorf("Expected result to contain %q, got %q", tt.expectedText, result)
}
})
}
}
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description.</p>",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Format: "scorm",
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First objective</p>"},
{Paragraph: "<p>Second objective</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn?</p>",
Answers: []models.Answer{
{Title: "Nothing", Correct: false},
{Title: "Everything", Correct: true},
},
Feedback: "<p>Great choice!</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.md")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify various elements are present
checks := []string{
"# Complex Test Course",
"This is a complex course description.",
"- **Export Format**: scorm",
"# Course Section",
"## Lesson 1: Introduction Lesson",
"Introduction to the course",
"### Welcome",
"Welcome to our course!",
"- First objective",
"- Second objective",
"### Knowledge Check",
"**Question**: What will you learn?",
"2. Everything ✓",
"**Feedback**: Great choice!",
}
for _, check := range checks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain: %q", check)
}
}
}
// createTestCourseForMarkdown creates a test course for markdown export testing.
func createTestCourseForMarkdown() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "Test course description",
NavigationMode: "menu",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "Test Heading",
Paragraph: "Test paragraph content",
},
},
},
},
},
},
},
}
}
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
func BenchmarkMarkdownExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
course := createTestCourseForMarkdown()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.md")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Benchmark Heading</h1>",
Paragraph: "<p>Benchmark paragraph with <strong>bold</strong> text.</p>",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
exporter.processTextItem(&buf, item, "###")
}
}

View File

@ -0,0 +1,31 @@
// Package interfaces provides the core contracts for the articulate-parser application.
// It defines interfaces for parsing and exporting Articulate Rise courses.
package interfaces
import "github.com/kjanat/articulate-parser/internal/models"
// Exporter defines the interface for exporting courses to different formats.
// Implementations of this interface handle the conversion of course data to
// specific output formats like Markdown or DOCX.
type Exporter interface {
// Export converts a course to the supported format and writes it to the
// specified output path. It returns an error if the export operation fails.
Export(course *models.Course, outputPath string) error
// GetSupportedFormat returns the name of the format this exporter supports.
// This is used to identify which exporter to use for a given format.
GetSupportedFormat() string
}
// ExporterFactory creates exporters for different formats.
// It acts as a factory for creating appropriate Exporter implementations
// based on the requested format.
type ExporterFactory interface {
// CreateExporter instantiates an exporter for the specified format.
// It returns the appropriate exporter or an error if the format is not supported.
CreateExporter(format string) (Exporter, error)
// GetSupportedFormats returns a list of all export formats supported by this factory.
// This is used to inform users of available export options.
GetSupportedFormats() []string
}

View File

@ -0,0 +1,20 @@
// Package interfaces provides the core contracts for the articulate-parser application.
// It defines interfaces for parsing and exporting Articulate Rise courses.
package interfaces
import "github.com/kjanat/articulate-parser/internal/models"
// CourseParser defines the interface for loading course data.
// It provides methods to fetch course content either from a remote URI
// or from a local file path.
type CourseParser interface {
// FetchCourse loads a course from a URI (typically an Articulate Rise share URL).
// It retrieves the course data from the remote location and returns a parsed Course model.
// Returns an error if the fetch operation fails or if the data cannot be parsed.
FetchCourse(uri string) (*models.Course, error)
// LoadCourseFromFile loads a course from a local file.
// It reads and parses the course data from the specified file path.
// Returns an error if the file cannot be read or if the data cannot be parsed.
LoadCourseFromFile(filePath string) (*models.Course, error)
}

55
internal/models/course.go Normal file
View File

@ -0,0 +1,55 @@
// Package models defines the data structures representing Articulate Rise courses.
// These structures closely match the JSON format used by Articulate Rise.
package models
// Course represents the top-level structure of an Articulate Rise course.
// It contains metadata and the actual course content.
type Course struct {
// ShareID is the unique identifier used in public sharing URLs
ShareID string `json:"shareId"`
// Author is the name of the course creator
Author string `json:"author"`
// Course contains the detailed course information and content
Course CourseInfo `json:"course"`
// LabelSet contains customized labels used in the course
LabelSet LabelSet `json:"labelSet"`
}
// CourseInfo contains the main details and content of an Articulate Rise course.
type CourseInfo struct {
// ID is the internal unique identifier for the course
ID string `json:"id"`
// Title is the name of the course
Title string `json:"title"`
// Description is the course summary or introduction text
Description string `json:"description"`
// Color is the theme color of the course
Color string `json:"color"`
// NavigationMode specifies how users navigate through the course
NavigationMode string `json:"navigationMode"`
// Lessons is an ordered array of all lessons in the course
Lessons []Lesson `json:"lessons"`
// CoverImage is the main image displayed for the course
CoverImage *Media `json:"coverImage,omitempty"`
// ExportSettings contains configuration for exporting the course
ExportSettings *ExportSettings `json:"exportSettings,omitempty"`
}
// ExportSettings defines configuration options for exporting a course.
type ExportSettings struct {
// Title specifies the export title which might differ from course title
Title string `json:"title"`
// Format indicates the preferred export format
Format string `json:"format"`
}
// LabelSet contains customized labels used throughout the course.
// This allows course creators to modify standard terminology.
type LabelSet struct {
// ID is the unique identifier for this label set
ID string `json:"id"`
// Name is the descriptive name of the label set
Name string `json:"name"`
// Labels is a mapping of label keys to their customized values
Labels map[string]string `json:"labels"`
}

96
internal/models/lesson.go Normal file
View File

@ -0,0 +1,96 @@
// Package models defines the data structures representing Articulate Rise courses.
// These structures closely match the JSON format used by Articulate Rise.
package models
// Lesson represents a single lesson or section within an Articulate Rise course.
// Lessons are the main organizational units and contain various content items.
type Lesson struct {
// ID is the unique identifier for the lesson
ID string `json:"id"`
// Title is the name of the lesson
Title string `json:"title"`
// Description is the introductory text for the lesson
Description string `json:"description"`
// Type indicates whether this is a regular lesson or a section header
Type string `json:"type"`
// Icon is the identifier for the icon displayed with this lesson
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 interface{} `json:"position"`
// Ready indicates whether the lesson is marked as complete
Ready bool `json:"ready"`
// CreatedAt is the timestamp when the lesson was created
CreatedAt string `json:"createdAt"`
// UpdatedAt is the timestamp when the lesson was last modified
UpdatedAt string `json:"updatedAt"`
}
// Item represents a content block within a lesson.
// Items can be of various types such as text, multimedia, knowledge checks, etc.
type Item struct {
// ID is the unique identifier for the item
ID string `json:"id"`
// Type indicates the kind of content (text, image, knowledge check, etc.)
Type string `json:"type"`
// Family groups similar item types together
Family string `json:"family"`
// Variant specifies a sub-type within the main type
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 interface{} `json:"settings"`
// Data contains additional structured data for the item
Data interface{} `json:"data"`
// Media contains any associated media for the item
Media *Media `json:"media,omitempty"`
}
// SubItem represents a specific content element within an Item.
// SubItems are the most granular content units like paragraphs, headings, or answers.
type SubItem struct {
// ID is the unique identifier for the sub-item
ID string `json:"id"`
// Type indicates the specific kind of sub-item
Type string `json:"type,omitempty"`
// Title is the name or label of the sub-item
Title string `json:"title,omitempty"`
// Heading is a heading text for this sub-item
Heading string `json:"heading,omitempty"`
// Paragraph contains regular text content
Paragraph string `json:"paragraph,omitempty"`
// Caption is text associated with media elements
Caption string `json:"caption,omitempty"`
// Media contains any associated images or videos
Media *Media `json:"media,omitempty"`
// Answers contains possible answers for question-type sub-items
Answers []Answer `json:"answers,omitempty"`
// Feedback is the response shown after user interaction
Feedback string `json:"feedback,omitempty"`
// Front contains content for the front side of a card-type sub-item
Front *CardSide `json:"front,omitempty"`
// Back contains content for the back side of a card-type sub-item
Back *CardSide `json:"back,omitempty"`
}
// Answer represents a possible response in a knowledge check or quiz item.
type Answer struct {
// ID is the unique identifier for the answer
ID string `json:"id"`
// Title is the text of the answer option
Title string `json:"title"`
// Correct indicates whether this is the right answer
Correct bool `json:"correct"`
// MatchTitle is used in matching-type questions to pair answers
MatchTitle string `json:"matchTitle,omitempty"`
}
// CardSide represents one side of a flipcard-type content element.
type CardSide struct {
// Media is the image or video associated with this side of the card
Media *Media `json:"media,omitempty"`
// Description is the text content for this side of the card
Description string `json:"description,omitempty"`
}

50
internal/models/media.go Normal file
View File

@ -0,0 +1,50 @@
// Package models defines the data structures representing Articulate Rise courses.
// These structures closely match the JSON format used by Articulate Rise.
package models
// Media represents a media element that can be either an image or a video.
// Only one of the fields (Image or Video) will be populated at a time.
type Media struct {
// Image contains metadata for an image element
Image *ImageMedia `json:"image,omitempty"`
// Video contains metadata for a video element
Video *VideoMedia `json:"video,omitempty"`
}
// ImageMedia contains the metadata and properties of an image.
type ImageMedia struct {
// Key is the unique identifier for the image in the Articulate system
Key string `json:"key"`
// Type indicates the image format (jpg, png, etc.)
Type string `json:"type"`
// Width is the pixel width of the image
Width int `json:"width,omitempty"`
// Height is the pixel height of the image
Height int `json:"height,omitempty"`
// CrushedKey is the identifier for a compressed version of the image
CrushedKey string `json:"crushedKey,omitempty"`
// OriginalUrl is the URL to the full-resolution image
OriginalUrl string `json:"originalUrl"`
// UseCrushedKey indicates whether to use the compressed version
UseCrushedKey bool `json:"useCrushedKey,omitempty"`
}
// VideoMedia contains the metadata and properties of a video.
type VideoMedia struct {
// Key is the unique identifier for the video in the Articulate system
Key string `json:"key"`
// URL is the direct link to the video content
URL string `json:"url"`
// Type indicates the video format (mp4, webm, etc.)
Type string `json:"type"`
// Poster is the URL to the static thumbnail image for the video
Poster string `json:"poster,omitempty"`
// Duration is the length of the video in seconds
Duration int `json:"duration,omitempty"`
// InputKey is the original identifier for uploaded videos
InputKey string `json:"inputKey,omitempty"`
// Thumbnail is the URL to a smaller preview image
Thumbnail string `json:"thumbnail,omitempty"`
// OriginalUrl is the URL to the source video file
OriginalUrl string `json:"originalUrl"`
}

View File

@ -0,0 +1,790 @@
// Package models_test provides tests for the data models.
package models
import (
"encoding/json"
"reflect"
"testing"
)
// TestCourse_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Course.
func TestCourse_JSONMarshalUnmarshal(t *testing.T) {
original := Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Description: "A test course description",
Color: "#FF5733",
NavigationMode: "menu",
Lessons: []Lesson{
{
ID: "lesson-1",
Title: "First Lesson",
Description: "Lesson description",
Type: "lesson",
Icon: "icon-1",
Ready: true,
CreatedAt: "2023-01-01T00:00:00Z",
UpdatedAt: "2023-01-02T00:00:00Z",
},
},
ExportSettings: &ExportSettings{
Title: "Export Title",
Format: "scorm",
},
},
LabelSet: LabelSet{
ID: "labelset-1",
Name: "Test Labels",
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Course to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Course
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Course from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Course structs do not match")
t.Logf("Original: %+v", original)
t.Logf("Unmarshaled: %+v", unmarshaled)
}
}
// TestCourseInfo_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of CourseInfo.
func TestCourseInfo_JSONMarshalUnmarshal(t *testing.T) {
original := CourseInfo{
ID: "course-456",
Title: "Another Test Course",
Description: "Another test description",
Color: "#33FF57",
NavigationMode: "linear",
Lessons: []Lesson{
{
ID: "lesson-2",
Title: "Second Lesson",
Type: "section",
Items: []Item{
{
ID: "item-1",
Type: "text",
Family: "text",
Variant: "paragraph",
Items: []SubItem{
{
Title: "Sub Item Title",
Heading: "Sub Item Heading",
Paragraph: "Sub item paragraph content",
},
},
},
},
},
},
CoverImage: &Media{
Image: &ImageMedia{
Key: "img-123",
Type: "jpg",
Width: 800,
Height: 600,
OriginalUrl: "https://example.com/image.jpg",
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal CourseInfo to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled CourseInfo
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal CourseInfo from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled CourseInfo structs do not match")
}
}
// TestLesson_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Lesson.
func TestLesson_JSONMarshalUnmarshal(t *testing.T) {
original := Lesson{
ID: "lesson-test",
Title: "Test Lesson",
Description: "Test lesson description",
Type: "lesson",
Icon: "lesson-icon",
Ready: true,
CreatedAt: "2023-06-01T12:00:00Z",
UpdatedAt: "2023-06-01T13:00:00Z",
Position: map[string]interface{}{"x": 1, "y": 2},
Items: []Item{
{
ID: "item-test",
Type: "multimedia",
Family: "media",
Variant: "video",
Items: []SubItem{
{
Caption: "Video caption",
Media: &Media{
Video: &VideoMedia{
Key: "video-123",
URL: "https://example.com/video.mp4",
Type: "mp4",
Duration: 120,
OriginalUrl: "https://example.com/video.mp4",
},
},
},
},
Settings: map[string]interface{}{"autoplay": false},
Data: map[string]interface{}{"metadata": "test"},
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Lesson to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Lesson
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Lesson from JSON: %v", err)
}
// Compare structures
if !compareLessons(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Lesson structs do not match")
}
}
// TestItem_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Item.
func TestItem_JSONMarshalUnmarshal(t *testing.T) {
original := Item{
ID: "item-json-test",
Type: "knowledgeCheck",
Family: "assessment",
Variant: "multipleChoice",
Items: []SubItem{
{
Title: "What is the answer?",
Answers: []Answer{
{Title: "Option A", Correct: false},
{Title: "Option B", Correct: true},
{Title: "Option C", Correct: false},
},
Feedback: "Well done!",
},
},
Settings: map[string]interface{}{
"allowRetry": true,
"showAnswer": true,
},
Data: map[string]interface{}{
"points": 10,
"weight": 1.5,
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Item to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Item
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Item from JSON: %v", err)
}
// Compare structures
if !compareItem(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Item structs do not match")
}
}
// TestSubItem_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of SubItem.
func TestSubItem_JSONMarshalUnmarshal(t *testing.T) {
original := SubItem{
Title: "Test SubItem Title",
Heading: "Test SubItem Heading",
Paragraph: "Test paragraph with content",
Caption: "Test caption",
Feedback: "Test feedback message",
Answers: []Answer{
{Title: "First answer", Correct: true},
{Title: "Second answer", Correct: false},
},
Media: &Media{
Image: &ImageMedia{
Key: "subitem-img",
Type: "png",
Width: 400,
Height: 300,
OriginalUrl: "https://example.com/subitem.png",
CrushedKey: "crushed-123",
UseCrushedKey: true,
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal SubItem to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled SubItem
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal SubItem from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled SubItem structs do not match")
}
}
// TestAnswer_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Answer.
func TestAnswer_JSONMarshalUnmarshal(t *testing.T) {
original := Answer{
Title: "Test answer text",
Correct: true,
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Answer to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Answer
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Answer from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Answer structs do not match")
}
}
// TestMedia_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Media.
func TestMedia_JSONMarshalUnmarshal(t *testing.T) {
// Test with Image
originalImage := Media{
Image: &ImageMedia{
Key: "media-img-test",
Type: "jpeg",
Width: 1200,
Height: 800,
OriginalUrl: "https://example.com/media.jpg",
CrushedKey: "crushed-media",
UseCrushedKey: false,
},
}
jsonData, err := json.Marshal(originalImage)
if err != nil {
t.Fatalf("Failed to marshal Media with Image to JSON: %v", err)
}
var unmarshaledImage Media
err = json.Unmarshal(jsonData, &unmarshaledImage)
if err != nil {
t.Fatalf("Failed to unmarshal Media with Image from JSON: %v", err)
}
if !reflect.DeepEqual(originalImage, unmarshaledImage) {
t.Errorf("Marshaled and unmarshaled Media with Image do not match")
}
// Test with Video
originalVideo := Media{
Video: &VideoMedia{
Key: "media-video-test",
URL: "https://example.com/media.mp4",
Type: "mp4",
Duration: 300,
Poster: "https://example.com/poster.jpg",
Thumbnail: "https://example.com/thumb.jpg",
InputKey: "input-123",
OriginalUrl: "https://example.com/original.mp4",
},
}
jsonData, err = json.Marshal(originalVideo)
if err != nil {
t.Fatalf("Failed to marshal Media with Video to JSON: %v", err)
}
var unmarshaledVideo Media
err = json.Unmarshal(jsonData, &unmarshaledVideo)
if err != nil {
t.Fatalf("Failed to unmarshal Media with Video from JSON: %v", err)
}
if !reflect.DeepEqual(originalVideo, unmarshaledVideo) {
t.Errorf("Marshaled and unmarshaled Media with Video do not match")
}
}
// TestImageMedia_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of ImageMedia.
func TestImageMedia_JSONMarshalUnmarshal(t *testing.T) {
original := ImageMedia{
Key: "image-media-test",
Type: "gif",
Width: 640,
Height: 480,
OriginalUrl: "https://example.com/image.gif",
CrushedKey: "crushed-gif",
UseCrushedKey: true,
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal ImageMedia to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled ImageMedia
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal ImageMedia from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled ImageMedia structs do not match")
}
}
// TestVideoMedia_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of VideoMedia.
func TestVideoMedia_JSONMarshalUnmarshal(t *testing.T) {
original := VideoMedia{
Key: "video-media-test",
URL: "https://example.com/video.webm",
Type: "webm",
Duration: 450,
Poster: "https://example.com/poster.jpg",
Thumbnail: "https://example.com/thumbnail.jpg",
InputKey: "upload-456",
OriginalUrl: "https://example.com/original.webm",
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal VideoMedia to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled VideoMedia
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal VideoMedia from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled VideoMedia structs do not match")
}
}
// TestExportSettings_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of ExportSettings.
func TestExportSettings_JSONMarshalUnmarshal(t *testing.T) {
original := ExportSettings{
Title: "Custom Export Title",
Format: "xAPI",
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal ExportSettings to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled ExportSettings
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal ExportSettings from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled ExportSettings structs do not match")
}
}
// TestLabelSet_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of LabelSet.
func TestLabelSet_JSONMarshalUnmarshal(t *testing.T) {
original := LabelSet{
ID: "labelset-test",
Name: "Test Label Set",
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal LabelSet to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled LabelSet
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal LabelSet from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled LabelSet structs do not match")
}
}
// TestEmptyStructures tests marshaling and unmarshaling of empty structures.
func TestEmptyStructures(t *testing.T) {
testCases := []struct {
name string
data interface{}
}{
{"Empty Course", Course{}},
{"Empty CourseInfo", CourseInfo{}},
{"Empty Lesson", Lesson{}},
{"Empty Item", Item{}},
{"Empty SubItem", SubItem{}},
{"Empty Answer", Answer{}},
{"Empty Media", Media{}},
{"Empty ImageMedia", ImageMedia{}},
{"Empty VideoMedia", VideoMedia{}},
{"Empty ExportSettings", ExportSettings{}},
{"Empty LabelSet", LabelSet{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Marshal to JSON
jsonData, err := json.Marshal(tc.data)
if err != nil {
t.Fatalf("Failed to marshal %s to JSON: %v", tc.name, err)
}
// Unmarshal from JSON
result := reflect.New(reflect.TypeOf(tc.data)).Interface()
err = json.Unmarshal(jsonData, result)
if err != nil {
t.Fatalf("Failed to unmarshal %s from JSON: %v", tc.name, err)
}
// Basic validation that no errors occurred
if len(jsonData) == 0 {
t.Errorf("%s should produce some JSON output", tc.name)
}
})
}
}
// TestNilPointerSafety tests that nil pointers in optional fields are handled correctly.
func TestNilPointerSafety(t *testing.T) {
course := Course{
ShareID: "nil-test",
Course: CourseInfo{
ID: "nil-course",
Title: "Nil Pointer Test",
CoverImage: nil, // Test nil pointer
ExportSettings: nil, // Test nil pointer
Lessons: []Lesson{
{
ID: "lesson-nil",
Title: "Lesson with nil media",
Items: []Item{
{
ID: "item-nil",
Type: "text",
Items: []SubItem{
{
Title: "SubItem with nil media",
Media: nil, // Test nil pointer
},
},
Media: nil, // Test nil pointer
},
},
},
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(course)
if err != nil {
t.Fatalf("Failed to marshal Course with nil pointers to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Course
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Course with nil pointers from JSON: %v", err)
}
// Basic validation
if unmarshaled.ShareID != "nil-test" {
t.Error("ShareID should be preserved")
}
if unmarshaled.Course.Title != "Nil Pointer Test" {
t.Error("Course title should be preserved")
}
}
// TestJSONTagsPresence tests that JSON tags are properly defined.
func TestJSONTagsPresence(t *testing.T) {
// Test that important fields have JSON tags
courseType := reflect.TypeOf(Course{})
if courseType.Kind() == reflect.Struct {
field, found := courseType.FieldByName("ShareID")
if !found {
t.Error("ShareID field not found")
} else {
tag := field.Tag.Get("json")
if tag == "" {
t.Error("ShareID should have json tag")
}
if tag != "shareId" {
t.Errorf("ShareID json tag should be 'shareId', got '%s'", tag)
}
}
}
// Test CourseInfo
courseInfoType := reflect.TypeOf(CourseInfo{})
if courseInfoType.Kind() == reflect.Struct {
field, found := courseInfoType.FieldByName("NavigationMode")
if !found {
t.Error("NavigationMode field not found")
} else {
tag := field.Tag.Get("json")
if tag == "" {
t.Error("NavigationMode should have json tag")
}
}
}
}
// BenchmarkCourse_JSONMarshal benchmarks JSON marshaling of Course.
func BenchmarkCourse_JSONMarshal(b *testing.B) {
course := Course{
ShareID: "benchmark-id",
Author: "Benchmark Author",
Course: CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Lessons: []Lesson{
{
ID: "lesson-1",
Title: "Lesson 1",
Items: []Item{
{
ID: "item-1",
Type: "text",
Items: []SubItem{
{Title: "SubItem 1"},
},
},
},
},
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(course)
}
}
// BenchmarkCourse_JSONUnmarshal benchmarks JSON unmarshaling of Course.
func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
course := Course{
ShareID: "benchmark-id",
Author: "Benchmark Author",
Course: CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Lessons: []Lesson{
{
ID: "lesson-1",
Title: "Lesson 1",
Items: []Item{
{
ID: "item-1",
Type: "text",
Items: []SubItem{
{Title: "SubItem 1"},
},
},
},
},
},
},
}
jsonData, _ := json.Marshal(course)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result Course
_ = json.Unmarshal(jsonData, &result)
}
}
// compareMaps compares two interface{} values that should be maps
func compareMaps(original, unmarshaled interface{}) bool {
origMap, origOk := original.(map[string]interface{})
unMap, unOk := unmarshaled.(map[string]interface{})
if !origOk || !unOk {
// If not maps, use deep equal
return reflect.DeepEqual(original, unmarshaled)
}
if len(origMap) != len(unMap) {
return false
}
for key, origVal := range origMap {
unVal, exists := unMap[key]
if !exists {
return false
}
// Handle numeric type conversion from JSON
switch origVal := origVal.(type) {
case int:
if unFloat, ok := unVal.(float64); ok {
if float64(origVal) != unFloat {
return false
}
} else {
return false
}
case float64:
if unFloat, ok := unVal.(float64); ok {
if origVal != unFloat {
return false
}
} else {
return false
}
default:
if !reflect.DeepEqual(origVal, unVal) {
return false
}
}
}
return true
}
// compareLessons compares two Lesson structs accounting for JSON type conversion
func compareLessons(original, unmarshaled Lesson) bool {
// Compare all fields except Position and Items
if original.ID != unmarshaled.ID ||
original.Title != unmarshaled.Title ||
original.Description != unmarshaled.Description ||
original.Type != unmarshaled.Type ||
original.Icon != unmarshaled.Icon ||
original.Ready != unmarshaled.Ready ||
original.CreatedAt != unmarshaled.CreatedAt ||
original.UpdatedAt != unmarshaled.UpdatedAt {
return false
}
// Compare Position
if !compareMaps(original.Position, unmarshaled.Position) {
return false
}
// Compare Items
return compareItems(original.Items, unmarshaled.Items)
}
// compareItems compares two Item slices accounting for JSON type conversion
func compareItems(original, unmarshaled []Item) bool {
if len(original) != len(unmarshaled) {
return false
}
for i := range original {
if !compareItem(original[i], unmarshaled[i]) {
return false
}
}
return true
}
// compareItem compares two Item structs accounting for JSON type conversion
func compareItem(original, unmarshaled Item) bool {
// Compare basic fields
if original.ID != unmarshaled.ID ||
original.Type != unmarshaled.Type ||
original.Family != unmarshaled.Family ||
original.Variant != unmarshaled.Variant {
return false
}
// Compare Settings and Data
if !compareMaps(original.Settings, unmarshaled.Settings) {
return false
}
if !compareMaps(original.Data, unmarshaled.Data) {
return false
}
// Compare Items (SubItems)
if len(original.Items) != len(unmarshaled.Items) {
return false
}
for i := range original.Items {
if !reflect.DeepEqual(original.Items[i], unmarshaled.Items[i]) {
return false
}
}
// Compare Media
if !reflect.DeepEqual(original.Media, unmarshaled.Media) {
return false
}
return true
}

76
internal/services/app.go Normal file
View File

@ -0,0 +1,76 @@
// Package services provides the core functionality for the articulate-parser application.
// It implements the interfaces defined in the interfaces package.
package services
import (
"fmt"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
// App represents the main application service that coordinates the parsing
// and exporting of Articulate Rise courses. It serves as the primary entry
// point for the application's functionality.
type App struct {
// parser is responsible for loading course data from files or URLs
parser interfaces.CourseParser
// exporterFactory creates the appropriate exporter for a given format
exporterFactory interfaces.ExporterFactory
}
// NewApp creates a new application instance with dependency injection.
// It takes a CourseParser for loading courses and an ExporterFactory for
// creating the appropriate exporters.
func NewApp(parser interfaces.CourseParser, exporterFactory interfaces.ExporterFactory) *App {
return &App{
parser: parser,
exporterFactory: exporterFactory,
}
}
// 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 {
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)
}
// 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.
func (a *App) ProcessCourseFromURI(uri, format, outputPath string) error {
course, err := a.parser.FetchCourse(uri)
if err != nil {
return fmt.Errorf("failed to fetch course: %w", err)
}
return a.exportCourse(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 {
exporter, err := a.exporterFactory.CreateExporter(format)
if err != nil {
return fmt.Errorf("failed to create exporter: %w", err)
}
if err := exporter.Export(course, outputPath); err != nil {
return fmt.Errorf("failed to export course: %w", err)
}
return nil
}
// GetSupportedFormats returns a list of all export formats supported by the application.
// This information is provided by the ExporterFactory.
func (a *App) GetSupportedFormats() []string {
return a.exporterFactory.GetSupportedFormats()
}

View File

@ -0,0 +1,353 @@
// Package services_test provides tests for the services package.
package services
import (
"errors"
"testing"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
// MockCourseParser is a mock implementation of interfaces.CourseParser for testing.
type MockCourseParser struct {
mockFetchCourse func(uri string) (*models.Course, error)
mockLoadCourseFromFile func(filePath string) (*models.Course, error)
}
func (m *MockCourseParser) FetchCourse(uri string) (*models.Course, error) {
if m.mockFetchCourse != nil {
return m.mockFetchCourse(uri)
}
return nil, errors.New("not implemented")
}
func (m *MockCourseParser) LoadCourseFromFile(filePath string) (*models.Course, error) {
if m.mockLoadCourseFromFile != nil {
return m.mockLoadCourseFromFile(filePath)
}
return nil, errors.New("not implemented")
}
// MockExporter is a mock implementation of interfaces.Exporter for testing.
type MockExporter struct {
mockExport func(course *models.Course, outputPath string) error
mockGetSupportedFormat func() string
}
func (m *MockExporter) Export(course *models.Course, outputPath string) error {
if m.mockExport != nil {
return m.mockExport(course, outputPath)
}
return nil
}
func (m *MockExporter) GetSupportedFormat() string {
if m.mockGetSupportedFormat != nil {
return m.mockGetSupportedFormat()
}
return "mock"
}
// MockExporterFactory is a mock implementation of interfaces.ExporterFactory for testing.
type MockExporterFactory struct {
mockCreateExporter func(format string) (*MockExporter, error)
mockGetSupportedFormats func() []string
}
func (m *MockExporterFactory) CreateExporter(format string) (interfaces.Exporter, error) {
if m.mockCreateExporter != nil {
exporter, err := m.mockCreateExporter(format)
return exporter, err
}
return &MockExporter{}, nil
}
func (m *MockExporterFactory) GetSupportedFormats() []string {
if m.mockGetSupportedFormats != nil {
return m.mockGetSupportedFormats()
}
return []string{"mock"}
}
// createTestCourse creates a sample course for testing purposes.
func createTestCourse() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "This is a test course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Items: []models.Item{
{
ID: "item-1",
Type: "text",
Items: []models.SubItem{
{
ID: "subitem-1",
Title: "Test Title",
Paragraph: "Test paragraph content",
},
},
},
},
},
},
},
}
}
// TestNewApp tests the NewApp constructor.
func TestNewApp(t *testing.T) {
parser := &MockCourseParser{}
factory := &MockExporterFactory{}
app := NewApp(parser, factory)
if app == nil {
t.Fatal("NewApp() returned nil")
}
if app.parser != parser {
t.Error("App parser was not set correctly")
}
// Test that the factory is set (we can't directly compare interface values)
formats := app.GetSupportedFormats()
if len(formats) == 0 {
t.Error("App exporterFactory was not set correctly - no supported formats")
}
}
// TestApp_ProcessCourseFromFile tests the ProcessCourseFromFile method.
func TestApp_ProcessCourseFromFile(t *testing.T) {
testCourse := createTestCourse()
tests := []struct {
name string
filePath string
format string
outputPath string
setupMocks func(*MockCourseParser, *MockExporterFactory, *MockExporter)
expectedError string
}{
{
name: "successful processing",
filePath: "test.json",
format: "markdown",
outputPath: "output.md",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockLoadCourseFromFile = func(filePath string) (*models.Course, error) {
if filePath != "test.json" {
t.Errorf("Expected filePath 'test.json', got '%s'", filePath)
}
return testCourse, nil
}
factory.mockCreateExporter = func(format string) (*MockExporter, error) {
if format != "markdown" {
t.Errorf("Expected format 'markdown', got '%s'", format)
}
return exporter, nil
}
exporter.mockExport = func(course *models.Course, outputPath string) error {
if outputPath != "output.md" {
t.Errorf("Expected outputPath 'output.md', got '%s'", outputPath)
}
if course != testCourse {
t.Error("Expected course to match testCourse")
}
return nil
}
},
},
{
name: "file loading error",
filePath: "nonexistent.json",
format: "markdown",
outputPath: "output.md",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockLoadCourseFromFile = func(filePath string) (*models.Course, error) {
return nil, errors.New("file not found")
}
},
expectedError: "failed to load course from file",
},
{
name: "exporter creation error",
filePath: "test.json",
format: "unsupported",
outputPath: "output.txt",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockLoadCourseFromFile = func(filePath string) (*models.Course, error) {
return testCourse, nil
}
factory.mockCreateExporter = func(format string) (*MockExporter, error) {
return nil, errors.New("unsupported format")
}
},
expectedError: "failed to create exporter",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MockCourseParser{}
exporter := &MockExporter{}
factory := &MockExporterFactory{}
tt.setupMocks(parser, factory, exporter)
app := NewApp(parser, factory)
err := app.ProcessCourseFromFile(tt.filePath, tt.format, tt.outputPath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
})
}
}
// TestApp_ProcessCourseFromURI tests the ProcessCourseFromURI method.
func TestApp_ProcessCourseFromURI(t *testing.T) {
testCourse := createTestCourse()
tests := []struct {
name string
uri string
format string
outputPath string
setupMocks func(*MockCourseParser, *MockExporterFactory, *MockExporter)
expectedError string
}{
{
name: "successful processing",
uri: "https://rise.articulate.com/share/test123",
format: "docx",
outputPath: "output.docx",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockFetchCourse = func(uri string) (*models.Course, error) {
if uri != "https://rise.articulate.com/share/test123" {
t.Errorf("Expected uri 'https://rise.articulate.com/share/test123', got '%s'", uri)
}
return testCourse, nil
}
factory.mockCreateExporter = func(format string) (*MockExporter, error) {
if format != "docx" {
t.Errorf("Expected format 'docx', got '%s'", format)
}
return exporter, nil
}
exporter.mockExport = func(course *models.Course, outputPath string) error {
if outputPath != "output.docx" {
t.Errorf("Expected outputPath 'output.docx', got '%s'", outputPath)
}
return nil
}
},
},
{
name: "fetch error",
uri: "invalid-uri",
format: "docx",
outputPath: "output.docx",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockFetchCourse = func(uri string) (*models.Course, error) {
return nil, errors.New("network error")
}
},
expectedError: "failed to fetch course",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MockCourseParser{}
exporter := &MockExporter{}
factory := &MockExporterFactory{}
tt.setupMocks(parser, factory, exporter)
app := NewApp(parser, factory)
err := app.ProcessCourseFromURI(tt.uri, tt.format, tt.outputPath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
})
}
}
// TestApp_GetSupportedFormats tests the GetSupportedFormats method.
func TestApp_GetSupportedFormats(t *testing.T) {
expectedFormats := []string{"markdown", "docx", "pdf"}
parser := &MockCourseParser{}
factory := &MockExporterFactory{
mockGetSupportedFormats: func() []string {
return expectedFormats
},
}
app := NewApp(parser, factory)
formats := app.GetSupportedFormats()
if len(formats) != len(expectedFormats) {
t.Errorf("Expected %d formats, got %d", len(expectedFormats), len(formats))
}
for i, format := range formats {
if format != expectedFormats[i] {
t.Errorf("Expected format '%s' at index %d, got '%s'", expectedFormats[i], i, format)
}
}
}
// contains checks if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(len(substr) == 0 ||
s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsSubstring(s, substr))))
}
// containsSubstring checks if s contains substr as a substring.
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -0,0 +1,53 @@
// Package services provides the core functionality for the articulate-parser application.
// It implements the interfaces defined in the interfaces package.
package services
import (
"regexp"
"strings"
)
// 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.
type HTMLCleaner struct{}
// NewHTMLCleaner creates a new HTML cleaner instance.
// This service is typically injected into exporters that need to handle
// HTML content from Articulate Rise courses.
func NewHTMLCleaner() *HTMLCleaner {
return &HTMLCleaner{}
}
// CleanHTML removes HTML tags and converts entities, returning clean plain text.
// The function preserves the textual content of the HTML while removing markup.
// It handles common HTML entities like &nbsp;, &amp;, etc., and normalizes whitespace.
//
// Parameters:
// - html: The HTML content to clean
//
// Returns:
// - A plain text string with all HTML elements and entities removed/converted
func (h *HTMLCleaner) CleanHTML(html string) string {
// Remove HTML tags but preserve content
re := regexp.MustCompile(`<[^>]*>`)
cleaned := re.ReplaceAllString(html, "")
// Replace common HTML entities with their character equivalents
cleaned = strings.ReplaceAll(cleaned, "&nbsp;", " ")
cleaned = strings.ReplaceAll(cleaned, "&amp;", "&")
cleaned = strings.ReplaceAll(cleaned, "&lt;", "<")
cleaned = strings.ReplaceAll(cleaned, "&gt;", ">")
cleaned = strings.ReplaceAll(cleaned, "&quot;", "\"")
cleaned = strings.ReplaceAll(cleaned, "&#39;", "'")
cleaned = strings.ReplaceAll(cleaned, "&iuml;", "ï")
cleaned = strings.ReplaceAll(cleaned, "&euml;", "ë")
cleaned = strings.ReplaceAll(cleaned, "&eacute;", "é")
// Clean up extra whitespace by replacing multiple spaces, tabs, and newlines
// with a single space, then trim any leading/trailing whitespace
cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ")
cleaned = strings.TrimSpace(cleaned)
return cleaned
}

View File

@ -0,0 +1,325 @@
// Package services_test provides tests for the HTML cleaner service.
package services
import (
"strings"
"testing"
)
// TestNewHTMLCleaner tests the NewHTMLCleaner constructor.
func TestNewHTMLCleaner(t *testing.T) {
cleaner := NewHTMLCleaner()
if cleaner == nil {
t.Fatal("NewHTMLCleaner() returned nil")
}
}
// TestHTMLCleaner_CleanHTML tests the CleanHTML method with various HTML inputs.
func TestHTMLCleaner_CleanHTML(t *testing.T) {
cleaner := NewHTMLCleaner()
tests := []struct {
name string
input string
expected string
}{
{
name: "plain text (no HTML)",
input: "This is plain text",
expected: "This is plain text",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "simple HTML tag",
input: "<p>Hello world</p>",
expected: "Hello world",
},
{
name: "multiple HTML tags",
input: "<h1>Title</h1><p>Paragraph text</p>",
expected: "TitleParagraph text",
},
{
name: "nested HTML tags",
input: "<div><h1>Title</h1><p>Paragraph with <strong>bold</strong> text</p></div>",
expected: "TitleParagraph with bold text",
},
{
name: "HTML with attributes",
input: "<p class=\"test\" id=\"para1\">Text with attributes</p>",
expected: "Text with attributes",
},
{
name: "self-closing tags",
input: "Line 1<br/>Line 2<hr/>End",
expected: "Line 1Line 2End",
},
{
name: "HTML entities - basic",
input: "AT&amp;T &lt;company&gt; &quot;quoted&quot; &nbsp; text",
expected: "AT&T <company> \"quoted\" text",
},
{
name: "HTML entities - apostrophe",
input: "It&#39;s a test",
expected: "It's a test",
},
{
name: "HTML entities - special characters",
input: "&iuml;ber &euml;lite &eacute;cart&eacute;",
expected: "ïber ëlite écarté",
},
{
name: "HTML entities - nbsp",
input: "Word1&nbsp;&nbsp;&nbsp;Word2",
expected: "Word1 Word2",
},
{
name: "mixed HTML and entities",
input: "<p>Hello &amp; welcome to <strong>our</strong> site!</p>",
expected: "Hello & welcome to our site!",
},
{
name: "multiple whitespace",
input: "Text with\t\tmultiple\n\nspaces",
expected: "Text with multiple spaces",
},
{
name: "whitespace with HTML",
input: "<p> Text with </p> <div> spaces </div> ",
expected: "Text with spaces",
},
{
name: "complex content",
input: "<div class=\"content\"><h1>Course Title</h1><p>This is a <em>great</em> course about &amp; HTML entities like &nbsp; and &quot;quotes&quot;.</p></div>",
expected: "Course TitleThis is a great course about & HTML entities like and \"quotes\".",
},
{
name: "malformed HTML",
input: "<p>Unclosed paragraph<div>Another <span>tag</p></div>",
expected: "Unclosed paragraphAnother tag",
},
{
name: "HTML comments (should be removed)",
input: "Text before<!-- This is a comment -->Text after",
expected: "Text beforeText after",
},
{
name: "script and style tags content",
input: "<script>alert('test');</script>Content<style>body{color:red;}</style>",
expected: "alert('test');Contentbody{color:red;}",
},
{
name: "line breaks and formatting",
input: "<p>Line 1</p>\n<p>Line 2</p>\n<p>Line 3</p>",
expected: "Line 1 Line 2 Line 3",
},
{
name: "only whitespace",
input: " \t\n ",
expected: "",
},
{
name: "only HTML tags",
input: "<div><p></p></div>",
expected: "",
},
{
name: "HTML with newlines",
input: "<p>\n Paragraph with\n line breaks\n</p>",
expected: "Paragraph with line breaks",
},
{
name: "complex nested structure",
input: "<article><header><h1>Title</h1></header><section><p>First paragraph with <a href=\"#\">link</a>.</p><ul><li>Item 1</li><li>Item 2</li></ul></section></article>",
expected: "TitleFirst paragraph with link.Item 1Item 2",
},
{
name: "entities in attributes (should still be processed)",
input: "<p title=\"AT&amp;T\">Content</p>",
expected: "Content",
},
{
name: "special HTML5 entities",
input: "Left arrow &larr; Right arrow &rarr;",
expected: "Left arrow &larr; Right arrow &rarr;", // These are not handled by the cleaner
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleaner.CleanHTML(tt.input)
if result != tt.expected {
t.Errorf("CleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestHTMLCleaner_CleanHTML_LargeContent tests the CleanHTML method with large content.
func TestHTMLCleaner_CleanHTML_LargeContent(t *testing.T) {
cleaner := NewHTMLCleaner()
// Create a large HTML string
var builder strings.Builder
builder.WriteString("<html><body>")
for i := 0; i < 1000; i++ {
builder.WriteString("<p>Paragraph ")
builder.WriteString(string(rune('0' + i%10)))
builder.WriteString(" with some content &amp; entities.</p>")
}
builder.WriteString("</body></html>")
input := builder.String()
result := cleaner.CleanHTML(input)
// Check that HTML tags are removed
if strings.Contains(result, "<") || strings.Contains(result, ">") {
t.Error("Result should not contain HTML tags")
}
// Check that content is preserved
if !strings.Contains(result, "Paragraph") {
t.Error("Result should contain paragraph content")
}
// Check that entities are converted
if strings.Contains(result, "&amp;") {
t.Error("Result should not contain unconverted HTML entities")
}
if !strings.Contains(result, "&") {
t.Error("Result should contain converted ampersand")
}
}
// TestHTMLCleaner_CleanHTML_EdgeCases tests edge cases for the CleanHTML method.
func TestHTMLCleaner_CleanHTML_EdgeCases(t *testing.T) {
cleaner := NewHTMLCleaner()
tests := []struct {
name string
input string
expected string
}{
{
name: "only entities",
input: "&amp;&lt;&gt;&quot;&#39;&nbsp;",
expected: "&<>\"'",
},
{
name: "repeated entities",
input: "&amp;&amp;&amp;",
expected: "&&&",
},
{
name: "entities without semicolon (should not be converted)",
input: "&amp test &lt test",
expected: "&amp test &lt test",
},
{
name: "mixed valid and invalid entities",
input: "&amp; &invalid; &lt; &fake;",
expected: "& &invalid; < &fake;",
},
{
name: "unclosed tag at end",
input: "Content <p>with unclosed",
expected: "Content with unclosed",
},
{
name: "tag with no closing bracket",
input: "Content <p class='test' with no closing bracket",
expected: "Content <p class='test' with no closing bracket",
},
{
name: "extremely nested tags",
input: "<div><div><div><div><div>Deep content</div></div></div></div></div>",
expected: "Deep content",
},
{
name: "empty tags with whitespace",
input: "<p> </p><div>\t\n</div>",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleaner.CleanHTML(tt.input)
if result != tt.expected {
t.Errorf("CleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestHTMLCleaner_CleanHTML_Unicode tests Unicode content handling.
func TestHTMLCleaner_CleanHTML_Unicode(t *testing.T) {
cleaner := NewHTMLCleaner()
tests := []struct {
name string
input string
expected string
}{
{
name: "unicode characters",
input: "<p>Hello 世界! Café naïve résumé</p>",
expected: "Hello 世界! Café naïve résumé",
},
{
name: "unicode with entities",
input: "<p>Unicode: 你好 &amp; emoji: 🌍</p>",
expected: "Unicode: 你好 & emoji: 🌍",
},
{
name: "mixed scripts",
input: "<div>English العربية русский 日本語</div>",
expected: "English العربية русский 日本語",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleaner.CleanHTML(tt.input)
if result != tt.expected {
t.Errorf("CleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// BenchmarkHTMLCleaner_CleanHTML benchmarks the CleanHTML method.
func BenchmarkHTMLCleaner_CleanHTML(b *testing.B) {
cleaner := NewHTMLCleaner()
input := "<div class=\"content\"><h1>Course Title</h1><p>This is a <em>great</em> course about &amp; HTML entities like &nbsp; and &quot;quotes&quot;.</p><ul><li>Item 1</li><li>Item 2</li></ul></div>"
b.ResetTimer()
for i := 0; i < b.N; i++ {
cleaner.CleanHTML(input)
}
}
// BenchmarkHTMLCleaner_CleanHTML_Large benchmarks the CleanHTML method with large content.
func BenchmarkHTMLCleaner_CleanHTML_Large(b *testing.B) {
cleaner := NewHTMLCleaner()
// Create a large HTML string
var builder strings.Builder
for i := 0; i < 100; i++ {
builder.WriteString("<p>Paragraph ")
builder.WriteString(string(rune('0' + i%10)))
builder.WriteString(" with some content &amp; entities &lt;test&gt;.</p>")
}
input := builder.String()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cleaner.CleanHTML(input)
}
}

145
internal/services/parser.go Normal file
View File

@ -0,0 +1,145 @@
// Package services provides the core functionality for the articulate-parser application.
// It implements the interfaces defined in the interfaces package.
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"time"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
// ArticulateParser implements the CourseParser interface specifically for Articulate Rise courses.
// It can fetch courses from the Articulate Rise API or load them from local JSON files.
type ArticulateParser struct {
// BaseURL is the root URL for the Articulate Rise API
BaseURL string
// Client is the HTTP client used to make requests to the API
Client *http.Client
}
// NewArticulateParser creates a new ArticulateParser instance with default settings.
// The default configuration uses the standard Articulate Rise API URL and a
// HTTP client with a 30-second timeout.
func NewArticulateParser() interfaces.CourseParser {
return &ArticulateParser{
BaseURL: "https://rise.articulate.com",
Client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// FetchCourse fetches a course from the given URI.
// It extracts the share ID from the URI, constructs an API URL, and fetches the course data.
// The course data is then unmarshalled into a Course model.
//
// Parameters:
// - uri: The Articulate Rise share URL (e.g., https://rise.articulate.com/share/SHARE_ID)
//
// Returns:
// - A parsed Course model if successful
// - An error if the fetch fails, if the share ID can't be extracted,
// or if the response can't be parsed
func (p *ArticulateParser) FetchCourse(uri string) (*models.Course, error) {
shareID, err := p.extractShareID(uri)
if err != nil {
return nil, err
}
apiURL := p.buildAPIURL(shareID)
resp, err := p.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch course data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var course models.Course
if err := json.Unmarshal(body, &course); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
return &course, nil
}
// LoadCourseFromFile loads an Articulate Rise course from a local JSON file.
// The file should contain a valid JSON representation of an Articulate Rise course.
//
// Parameters:
// - filePath: The path to the JSON file containing the course data
//
// Returns:
// - A parsed Course model if successful
// - An error if the file can't be read or the JSON can't be parsed
func (p *ArticulateParser) LoadCourseFromFile(filePath string) (*models.Course, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var course models.Course
if err := json.Unmarshal(data, &course); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
return &course, nil
}
// extractShareID extracts the share ID from a Rise URI.
// It uses a regular expression to find the share ID in URIs like:
// https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/
//
// Parameters:
// - uri: The Articulate Rise share URL
//
// Returns:
// - The share ID string if found
// - An error if the share ID can't be extracted from the URI
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)
}
// Validate that it's an Articulate Rise domain
if parsedURL.Host != "rise.articulate.com" {
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host)
}
re := regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
matches := re.FindStringSubmatch(uri)
if len(matches) < 2 {
return "", fmt.Errorf("could not extract share ID from URI: %s", uri)
}
return matches[1], nil
}
// buildAPIURL constructs the API URL for fetching course data.
// It combines the base URL with the API path and the share ID.
//
// Parameters:
// - shareID: The extracted share ID from the course URI
//
// Returns:
// - The complete API URL string for fetching the course data
func (p *ArticulateParser) buildAPIURL(shareID string) string {
return fmt.Sprintf("%s/api/rise-runtime/boot/share/%s", p.BaseURL, shareID)
}

View File

@ -0,0 +1,440 @@
// Package services_test provides tests for the parser service.
package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kjanat/articulate-parser/internal/models"
)
// TestNewArticulateParser tests the NewArticulateParser constructor.
func TestNewArticulateParser(t *testing.T) {
parser := NewArticulateParser()
if parser == nil {
t.Fatal("NewArticulateParser() returned nil")
}
// Type assertion to check internal structure
articulateParser, ok := parser.(*ArticulateParser)
if !ok {
t.Fatal("NewArticulateParser() returned wrong type")
}
expectedBaseURL := "https://rise.articulate.com"
if articulateParser.BaseURL != expectedBaseURL {
t.Errorf("Expected BaseURL '%s', got '%s'", expectedBaseURL, articulateParser.BaseURL)
}
if articulateParser.Client == nil {
t.Error("Client should not be nil")
}
expectedTimeout := 30 * time.Second
if articulateParser.Client.Timeout != expectedTimeout {
t.Errorf("Expected timeout %v, got %v", expectedTimeout, articulateParser.Client.Timeout)
}
}
// TestArticulateParser_FetchCourse tests the FetchCourse method.
func TestArticulateParser_FetchCourse(t *testing.T) {
// Create a test course object
testCourse := &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "Test Description",
},
}
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request path
expectedPath := "/api/rise-runtime/boot/share/test-share-id"
if r.URL.Path != expectedPath {
t.Errorf("Expected path '%s', got '%s'", expectedPath, r.URL.Path)
}
// Check request method
if r.Method != http.MethodGet {
t.Errorf("Expected method GET, got %s", r.Method)
}
// Return mock response
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(testCourse); err != nil {
t.Fatalf("Failed to encode test course: %v", err)
}
}))
defer server.Close()
// Create parser with test server URL
parser := &ArticulateParser{
BaseURL: server.URL,
Client: &http.Client{
Timeout: 5 * time.Second,
},
}
tests := []struct {
name string
uri string
expectedError string
}{
{
name: "valid articulate rise URI",
uri: "https://rise.articulate.com/share/test-share-id#/",
},
{
name: "valid articulate rise URI without fragment",
uri: "https://rise.articulate.com/share/test-share-id",
},
{
name: "invalid URI format",
uri: "invalid-uri",
expectedError: "invalid domain for Articulate Rise URI:",
},
{
name: "empty URI",
uri: "",
expectedError: "invalid domain for Articulate Rise URI:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
course, err := parser.FetchCourse(tt.uri)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if course == nil {
t.Fatal("Expected course, got nil")
}
if course.ShareID != testCourse.ShareID {
t.Errorf("Expected ShareID '%s', got '%s'", testCourse.ShareID, course.ShareID)
}
}
})
}
}
// TestArticulateParser_FetchCourse_NetworkError tests network error handling.
func TestArticulateParser_FetchCourse_NetworkError(t *testing.T) {
// Create parser with invalid URL to simulate network error
parser := &ArticulateParser{
BaseURL: "http://localhost:99999", // Invalid port
Client: &http.Client{
Timeout: 1 * time.Millisecond, // Very short timeout
},
}
_, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id")
if err == nil {
t.Fatal("Expected network error, got nil")
}
if !strings.Contains(err.Error(), "failed to fetch course data") {
t.Errorf("Expected error to contain 'failed to fetch course data', got '%s'", err.Error())
}
}
// TestArticulateParser_FetchCourse_InvalidJSON tests invalid JSON response handling.
func TestArticulateParser_FetchCourse_InvalidJSON(t *testing.T) {
// Create test server that returns invalid JSON
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("invalid json"))
}))
defer server.Close()
parser := &ArticulateParser{
BaseURL: server.URL,
Client: &http.Client{
Timeout: 5 * time.Second,
},
}
_, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id")
if err == nil {
t.Fatal("Expected JSON parsing error, got nil")
}
if !strings.Contains(err.Error(), "failed to unmarshal JSON") {
t.Errorf("Expected error to contain 'failed to unmarshal JSON', got '%s'", err.Error())
}
}
// TestArticulateParser_LoadCourseFromFile tests the LoadCourseFromFile method.
func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
// Create a temporary test file
testCourse := &models.Course{
ShareID: "file-test-share-id",
Author: "File Test Author",
Course: models.CourseInfo{
ID: "file-test-course-id",
Title: "File Test Course",
Description: "File Test Description",
},
}
// Create temporary directory and file
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "test-course.json")
// Write test data to file
data, err := json.Marshal(testCourse)
if err != nil {
t.Fatalf("Failed to marshal test course: %v", err)
}
if err := os.WriteFile(tempFile, data, 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser := NewArticulateParser()
tests := []struct {
name string
filePath string
expectedError string
}{
{
name: "valid file",
filePath: tempFile,
},
{
name: "nonexistent file",
filePath: filepath.Join(tempDir, "nonexistent.json"),
expectedError: "failed to read file",
},
{
name: "empty path",
filePath: "",
expectedError: "failed to read file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
course, err := parser.LoadCourseFromFile(tt.filePath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if course == nil {
t.Fatal("Expected course, got nil")
}
if course.ShareID != testCourse.ShareID {
t.Errorf("Expected ShareID '%s', got '%s'", testCourse.ShareID, course.ShareID)
}
}
})
}
}
// TestArticulateParser_LoadCourseFromFile_InvalidJSON tests invalid JSON file handling.
func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
// Create temporary file with invalid JSON
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "invalid.json")
if err := os.WriteFile(tempFile, []byte("invalid json content"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser := NewArticulateParser()
_, err := parser.LoadCourseFromFile(tempFile)
if err == nil {
t.Fatal("Expected JSON parsing error, got nil")
}
if !strings.Contains(err.Error(), "failed to unmarshal JSON") {
t.Errorf("Expected error to contain 'failed to unmarshal JSON', got '%s'", err.Error())
}
}
// TestExtractShareID tests the extractShareID method.
func TestExtractShareID(t *testing.T) {
parser := &ArticulateParser{}
tests := []struct {
name string
uri string
expected string
hasError bool
}{
{
name: "standard articulate rise URI with fragment",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "standard articulate rise URI without fragment",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "URI with trailing slash",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO/",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "short share ID",
uri: "https://rise.articulate.com/share/abc123",
expected: "abc123",
},
{
name: "share ID with hyphens and underscores",
uri: "https://rise.articulate.com/share/test_ID-123_abc",
expected: "test_ID-123_abc",
},
{
name: "invalid URI - no share path",
uri: "https://rise.articulate.com/",
hasError: true,
},
{
name: "invalid URI - wrong domain",
uri: "https://example.com/share/test123",
hasError: true,
},
{
name: "invalid URI - no share ID",
uri: "https://rise.articulate.com/share/",
hasError: true,
},
{
name: "empty URI",
uri: "",
hasError: true,
},
{
name: "malformed URI",
uri: "not-a-uri",
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parser.extractShareID(tt.uri)
if tt.hasError {
if err == nil {
t.Fatalf("Expected error for URI '%s', got nil", tt.uri)
}
} else {
if err != nil {
t.Fatalf("Expected no error for URI '%s', got: %v", tt.uri, err)
}
if result != tt.expected {
t.Errorf("Expected share ID '%s', got '%s'", tt.expected, result)
}
}
})
}
}
// TestBuildAPIURL tests the buildAPIURL method.
func TestBuildAPIURL(t *testing.T) {
parser := &ArticulateParser{
BaseURL: "https://rise.articulate.com",
}
tests := []struct {
name string
shareID string
expected string
}{
{
name: "standard share ID",
shareID: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "short share ID",
shareID: "abc123",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/abc123",
},
{
name: "share ID with special characters",
shareID: "test_ID-123_abc",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/test_ID-123_abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parser.buildAPIURL(tt.shareID)
if result != tt.expected {
t.Errorf("Expected URL '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestBuildAPIURL_DifferentBaseURL tests buildAPIURL with different base URLs.
func TestBuildAPIURL_DifferentBaseURL(t *testing.T) {
parser := &ArticulateParser{
BaseURL: "https://custom.domain.com",
}
shareID := "test123"
expected := "https://custom.domain.com/api/rise-runtime/boot/share/test123"
result := parser.buildAPIURL(shareID)
if result != expected {
t.Errorf("Expected URL '%s', got '%s'", expected, result)
}
}
// BenchmarkExtractShareID benchmarks the extractShareID method.
func BenchmarkExtractShareID(b *testing.B) {
parser := &ArticulateParser{}
uri := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = parser.extractShareID(uri)
}
}
// BenchmarkBuildAPIURL benchmarks the buildAPIURL method.
func BenchmarkBuildAPIURL(b *testing.B) {
parser := &ArticulateParser{
BaseURL: "https://rise.articulate.com",
}
shareID := "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = parser.buildAPIURL(shareID)
}
}

View File

@ -5,7 +5,7 @@ package version
// Version information.
var (
// Version is the current version of the application.
Version = "0.1.0"
Version = "0.3.0"
// BuildTime is the time the binary was built.
BuildTime = "unknown"

649
main.go
View File

@ -1,622 +1,89 @@
// Package main provides the entry point for the articulate-parser application.
// This application fetches Articulate Rise courses from URLs or local files and
// exports them to different formats such as Markdown or DOCX.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/unidoc/unioffice/document"
"github.com/kjanat/articulate-parser/internal/version"
"github.com/kjanat/articulate-parser/internal/exporters"
"github.com/kjanat/articulate-parser/internal/services"
)
// Core data structures based on the Articulate Rise JSON format
type Course struct {
ShareID string `json:"shareId"`
Author string `json:"author"`
Course CourseInfo `json:"course"`
LabelSet LabelSet `json:"labelSet"`
}
type CourseInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
NavigationMode string `json:"navigationMode"`
Lessons []Lesson `json:"lessons"`
CoverImage *Media `json:"coverImage,omitempty"`
ExportSettings *ExportSettings `json:"exportSettings,omitempty"`
}
type Lesson struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Icon string `json:"icon"`
Items []Item `json:"items"`
Position interface{} `json:"position"`
Ready bool `json:"ready"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Item struct {
ID string `json:"id"`
Type string `json:"type"`
Family string `json:"family"`
Variant string `json:"variant"`
Items []SubItem `json:"items"`
Settings interface{} `json:"settings"`
Data interface{} `json:"data"`
Media *Media `json:"media,omitempty"`
}
type SubItem struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Title string `json:"title,omitempty"`
Heading string `json:"heading,omitempty"`
Paragraph string `json:"paragraph,omitempty"`
Caption string `json:"caption,omitempty"`
Media *Media `json:"media,omitempty"`
Answers []Answer `json:"answers,omitempty"`
Feedback string `json:"feedback,omitempty"`
Front *CardSide `json:"front,omitempty"`
Back *CardSide `json:"back,omitempty"`
}
type Answer struct {
ID string `json:"id"`
Title string `json:"title"`
Correct bool `json:"correct"`
MatchTitle string `json:"matchTitle,omitempty"`
}
type CardSide struct {
Media *Media `json:"media,omitempty"`
Description string `json:"description,omitempty"`
}
type Media struct {
Image *ImageMedia `json:"image,omitempty"`
Video *VideoMedia `json:"video,omitempty"`
}
type ImageMedia struct {
Key string `json:"key"`
Type string `json:"type"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
CrushedKey string `json:"crushedKey,omitempty"`
OriginalUrl string `json:"originalUrl"`
UseCrushedKey bool `json:"useCrushedKey,omitempty"`
}
type VideoMedia struct {
Key string `json:"key"`
URL string `json:"url"`
Type string `json:"type"`
Poster string `json:"poster,omitempty"`
Duration int `json:"duration,omitempty"`
InputKey string `json:"inputKey,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
OriginalUrl string `json:"originalUrl"`
}
type ExportSettings struct {
Title string `json:"title"`
Format string `json:"format"`
}
type LabelSet struct {
ID string `json:"id"`
Name string `json:"name"`
Labels map[string]string `json:"labels"`
}
// Parser main struct
type ArticulateParser struct {
BaseURL string
Client *http.Client
}
func NewArticulateParser() *ArticulateParser {
return &ArticulateParser{
BaseURL: "https://rise.articulate.com",
Client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (p *ArticulateParser) ExtractShareID(uri string) (string, error) {
// Extract share ID from URI like: https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/
re := regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
matches := re.FindStringSubmatch(uri)
if len(matches) < 2 {
return "", fmt.Errorf("could not extract share ID from URI: %s", uri)
}
return matches[1], nil
}
func (p *ArticulateParser) BuildAPIURL(shareID string) string {
return fmt.Sprintf("%s/api/rise-runtime/boot/share/%s", p.BaseURL, shareID)
}
func (p *ArticulateParser) FetchCourse(uri string) (*Course, error) {
shareID, err := p.ExtractShareID(uri)
if err != nil {
return nil, err
}
apiURL := p.BuildAPIURL(shareID)
resp, err := p.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch course data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var course Course
if err := json.Unmarshal(body, &course); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
return &course, nil
}
func (p *ArticulateParser) LoadCourseFromFile(filePath string) (*Course, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var course Course
if err := json.Unmarshal(data, &course); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
return &course, nil
}
// HTML cleaner utility
func cleanHTML(html string) string {
// Remove HTML tags but preserve content
re := regexp.MustCompile(`<[^>]*>`)
cleaned := re.ReplaceAllString(html, "")
// Replace HTML entities
cleaned = strings.ReplaceAll(cleaned, "&nbsp;", " ")
cleaned = strings.ReplaceAll(cleaned, "&amp;", "&")
cleaned = strings.ReplaceAll(cleaned, "&lt;", "<")
cleaned = strings.ReplaceAll(cleaned, "&gt;", ">")
cleaned = strings.ReplaceAll(cleaned, "&quot;", "\"")
cleaned = strings.ReplaceAll(cleaned, "&#39;", "'")
cleaned = strings.ReplaceAll(cleaned, "&iuml;", "ï")
cleaned = strings.ReplaceAll(cleaned, "&euml;", "ë")
cleaned = strings.ReplaceAll(cleaned, "&eacute;", "é")
// Clean up extra whitespace
cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ")
cleaned = strings.TrimSpace(cleaned)
return cleaned
}
// Markdown export functions
func (p *ArticulateParser) ExportToMarkdown(course *Course, outputPath string) error {
var buf bytes.Buffer
// Write course header
buf.WriteString(fmt.Sprintf("# %s\n\n", course.Course.Title))
if course.Course.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", cleanHTML(course.Course.Description)))
}
// Add metadata
buf.WriteString("## Course Information\n\n")
buf.WriteString(fmt.Sprintf("- **Course ID**: %s\n", course.Course.ID))
buf.WriteString(fmt.Sprintf("- **Share ID**: %s\n", course.ShareID))
buf.WriteString(fmt.Sprintf("- **Navigation Mode**: %s\n", course.Course.NavigationMode))
if course.Course.ExportSettings != nil {
buf.WriteString(fmt.Sprintf("- **Export Format**: %s\n", course.Course.ExportSettings.Format))
}
buf.WriteString("\n---\n\n")
// Process lessons
for i, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title))
continue
}
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", i+1, lesson.Title))
if lesson.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", cleanHTML(lesson.Description)))
}
// Process lesson items
for _, item := range lesson.Items {
p.processItemToMarkdown(&buf, item, 3)
}
buf.WriteString("\n---\n\n")
}
return os.WriteFile(outputPath, buf.Bytes(), 0644)
}
func (p *ArticulateParser) processItemToMarkdown(buf *bytes.Buffer, item Item, level int) {
headingPrefix := strings.Repeat("#", level)
switch item.Type {
case "text":
for _, subItem := range item.Items {
if subItem.Heading != "" {
heading := cleanHTML(subItem.Heading)
if heading != "" {
buf.WriteString(fmt.Sprintf("%s %s\n\n", headingPrefix, heading))
}
}
if subItem.Paragraph != "" {
paragraph := cleanHTML(subItem.Paragraph)
if paragraph != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
}
}
}
case "list":
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
paragraph := cleanHTML(subItem.Paragraph)
if paragraph != "" {
buf.WriteString(fmt.Sprintf("- %s\n", paragraph))
}
}
}
buf.WriteString("\n")
case "multimedia":
buf.WriteString(fmt.Sprintf("%s Media Content\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Media != nil {
if subItem.Media.Video != nil {
buf.WriteString(fmt.Sprintf("**Video**: %s\n", subItem.Media.Video.OriginalUrl))
if subItem.Media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf("- Duration: %d seconds\n", subItem.Media.Video.Duration))
}
}
if subItem.Media.Image != nil {
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
}
}
if subItem.Caption != "" {
caption := cleanHTML(subItem.Caption)
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
}
}
buf.WriteString("\n")
case "image":
buf.WriteString(fmt.Sprintf("%s Image\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Media != nil && subItem.Media.Image != nil {
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
}
if subItem.Caption != "" {
caption := cleanHTML(subItem.Caption)
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
}
}
buf.WriteString("\n")
case "knowledgeCheck":
buf.WriteString(fmt.Sprintf("%s Knowledge Check\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Title != "" {
title := cleanHTML(subItem.Title)
buf.WriteString(fmt.Sprintf("**Question**: %s\n\n", title))
}
buf.WriteString("**Answers**:\n")
for i, answer := range subItem.Answers {
answerText := cleanHTML(answer.Title)
correctMark := ""
if answer.Correct {
correctMark = " ✓"
}
buf.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, answerText, correctMark))
}
if subItem.Feedback != "" {
feedback := cleanHTML(subItem.Feedback)
buf.WriteString(fmt.Sprintf("\n**Feedback**: %s\n", feedback))
}
}
buf.WriteString("\n")
case "interactive":
buf.WriteString(fmt.Sprintf("%s Interactive Content\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Front != nil && subItem.Front.Description != "" {
desc := cleanHTML(subItem.Front.Description)
buf.WriteString(fmt.Sprintf("**Front**: %s\n", desc))
}
if subItem.Back != nil && subItem.Back.Description != "" {
desc := cleanHTML(subItem.Back.Description)
buf.WriteString(fmt.Sprintf("**Back**: %s\n", desc))
}
}
buf.WriteString("\n")
case "divider":
buf.WriteString("---\n\n")
default:
// Handle unknown types
if len(item.Items) > 0 {
buf.WriteString(fmt.Sprintf("%s %s Content\n\n", headingPrefix, strings.Title(item.Type)))
for _, subItem := range item.Items {
if subItem.Title != "" {
title := cleanHTML(subItem.Title)
buf.WriteString(fmt.Sprintf("- %s\n", title))
}
}
buf.WriteString("\n")
}
}
}
// DOCX export functions
func (p *ArticulateParser) ExportToDocx(course *Course, outputPath string) error {
doc := document.New()
// Add title
title := doc.AddParagraph()
titleRun := title.AddRun()
titleRun.AddText(course.Course.Title)
titleRun.Properties().SetSize(20)
titleRun.Properties().SetBold(true)
// Add description
if course.Course.Description != "" {
desc := doc.AddParagraph()
descRun := desc.AddRun()
descRun.AddText(cleanHTML(course.Course.Description))
}
// Add course metadata
metadata := doc.AddParagraph()
metadataRun := metadata.AddRun()
metadataRun.Properties().SetBold(true)
metadataRun.AddText("Course Information")
courseInfo := doc.AddParagraph()
courseInfoRun := courseInfo.AddRun()
courseInfoText := fmt.Sprintf("Course ID: %s\nShare ID: %s\nNavigation Mode: %s",
course.Course.ID, course.ShareID, course.Course.NavigationMode)
courseInfoRun.AddText(courseInfoText)
// Process lessons
for i, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
section := doc.AddParagraph()
sectionRun := section.AddRun()
sectionRun.AddText(lesson.Title)
sectionRun.Properties().SetSize(18)
sectionRun.Properties().SetBold(true)
continue
}
// Lesson title
lessonTitle := doc.AddParagraph()
lessonTitleRun := lessonTitle.AddRun()
lessonTitleRun.AddText(fmt.Sprintf("Lesson %d: %s", i+1, lesson.Title))
lessonTitleRun.Properties().SetSize(16)
lessonTitleRun.Properties().SetBold(true)
// Lesson description
if lesson.Description != "" {
lessonDesc := doc.AddParagraph()
lessonDescRun := lessonDesc.AddRun()
lessonDescRun.AddText(cleanHTML(lesson.Description))
}
// Process lesson items
for _, item := range lesson.Items {
p.processItemToDocx(doc, item)
}
}
return doc.SaveToFile(outputPath)
}
func (p *ArticulateParser) processItemToDocx(doc *document.Document, item Item) {
switch item.Type {
case "text":
for _, subItem := range item.Items {
if subItem.Heading != "" {
heading := cleanHTML(subItem.Heading)
if heading != "" {
para := doc.AddParagraph()
run := para.AddRun()
run.AddText(heading)
run.Properties().SetBold(true)
}
}
if subItem.Paragraph != "" {
paragraph := cleanHTML(subItem.Paragraph)
if paragraph != "" {
para := doc.AddParagraph()
run := para.AddRun()
run.AddText(paragraph)
}
}
}
case "list":
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
paragraph := cleanHTML(subItem.Paragraph)
if paragraph != "" {
para := doc.AddParagraph()
run := para.AddRun()
run.AddText("• " + paragraph)
}
}
}
case "multimedia", "image":
para := doc.AddParagraph()
run := para.AddRun()
run.AddText("[Media Content]")
run.Properties().SetItalic(true)
for _, subItem := range item.Items {
if subItem.Media != nil {
if subItem.Media.Video != nil {
mediaPara := doc.AddParagraph()
mediaRun := mediaPara.AddRun()
mediaRun.AddText(fmt.Sprintf("Video: %s", subItem.Media.Video.OriginalUrl))
}
if subItem.Media.Image != nil {
mediaPara := doc.AddParagraph()
mediaRun := mediaPara.AddRun()
mediaRun.AddText(fmt.Sprintf("Image: %s", subItem.Media.Image.OriginalUrl))
}
}
if subItem.Caption != "" {
caption := cleanHTML(subItem.Caption)
captionPara := doc.AddParagraph()
captionRun := captionPara.AddRun()
captionRun.AddText(caption)
captionRun.Properties().SetItalic(true)
}
}
case "knowledgeCheck":
for _, subItem := range item.Items {
if subItem.Title != "" {
title := cleanHTML(subItem.Title)
questionPara := doc.AddParagraph()
questionRun := questionPara.AddRun()
questionRun.AddText("Question: " + title)
questionRun.Properties().SetBold(true)
}
for i, answer := range subItem.Answers {
answerText := cleanHTML(answer.Title)
correctMark := ""
if answer.Correct {
correctMark = " [CORRECT]"
}
answerPara := doc.AddParagraph()
answerRun := answerPara.AddRun()
answerRun.AddText(fmt.Sprintf("%d. %s%s", i+1, answerText, correctMark))
}
if subItem.Feedback != "" {
feedback := cleanHTML(subItem.Feedback)
feedbackPara := doc.AddParagraph()
feedbackRun := feedbackPara.AddRun()
feedbackRun.AddText("Feedback: " + feedback)
feedbackRun.Properties().SetItalic(true)
}
}
}
}
// main is the entry point of the application.
// It handles command-line arguments, sets up dependencies,
// and coordinates the parsing and exporting of courses.
func main() {
// Handle version flag
if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version") {
fmt.Printf("articulate-parser %s\n", version.Version)
fmt.Printf("Build time: %s\n", version.BuildTime)
fmt.Printf("Commit: %s\n", version.GitCommit)
os.Exit(0)
}
// Dependency injection setup
htmlCleaner := services.NewHTMLCleaner()
parser := services.NewArticulateParser()
exporterFactory := exporters.NewFactory(htmlCleaner)
app := services.NewApp(parser, exporterFactory)
if len(os.Args) < 3 {
fmt.Println("Usage: articulate-parser <input_uri_or_file> <output_format> [output_path]")
fmt.Println(" articulate-parser -v|--version")
fmt.Println(" input_uri_or_file: Articulate Rise URI or local JSON file path")
fmt.Println(" output_format: md (Markdown) or docx (Word Document)")
fmt.Println(" output_path: Optional output file path")
// Check for required command-line arguments
if len(os.Args) < 4 {
fmt.Printf("Usage: %s <source> <format> <output>\n", os.Args[0])
fmt.Printf(" source: URI or file path to the course\n")
fmt.Printf(" format: export format (%s)\n", joinStrings(app.GetSupportedFormats(), ", "))
fmt.Printf(" output: output file path\n")
fmt.Println("\nExample:")
fmt.Printf(" %s articulate-sample.json markdown output.md\n", os.Args[0])
fmt.Printf(" %s https://rise.articulate.com/share/xyz docx output.docx\n", os.Args[0])
os.Exit(1)
}
input := os.Args[1]
format := strings.ToLower(os.Args[2])
source := os.Args[1]
format := os.Args[2]
output := os.Args[3]
if format != "md" && format != "docx" {
log.Fatal("Output format must be 'md' or 'docx'")
}
parser := NewArticulateParser()
var course *Course
var err error
// Determine if input is a URI or file path
if strings.HasPrefix(input, "http") {
course, err = parser.FetchCourse(input)
// Determine if source is a URI or file path
if isURI(source) {
err = app.ProcessCourseFromURI(source, format, output)
} else {
course, err = parser.LoadCourseFromFile(input)
err = app.ProcessCourseFromFile(source, format, output)
}
if err != nil {
log.Fatalf("Failed to load course: %v", err)
log.Fatalf("Error processing course: %v", err)
}
// Determine output path
var outputPath string
if len(os.Args) > 3 {
outputPath = os.Args[3]
} else {
baseDir := "output"
os.MkdirAll(baseDir, 0755)
// Create safe filename from course title
safeTitle := regexp.MustCompile(`[^a-zA-Z0-9\-_]`).ReplaceAllString(course.Course.Title, "_")
if safeTitle == "" {
safeTitle = "articulate_course"
fmt.Printf("Successfully exported course to %s\n", output)
}
outputPath = filepath.Join(baseDir, fmt.Sprintf("%s.%s", safeTitle, format))
// isURI checks if a string is a URI by looking for http:// or https:// prefixes.
//
// Parameters:
// - str: The string to check
//
// Returns:
// - true if the string appears to be a URI, false otherwise
func isURI(str string) bool {
return len(str) > 7 && (str[:7] == "http://" || str[:8] == "https://")
}
// Export based on format
switch format {
case "md":
err = parser.ExportToMarkdown(course, outputPath)
case "docx":
err = parser.ExportToDocx(course, outputPath)
// joinStrings concatenates a slice of strings using the specified separator.
//
// Parameters:
// - strs: The slice of strings to join
// - sep: The separator to insert between each string
//
// Returns:
// - A single string with all elements joined by the separator
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
}
if len(strs) == 1 {
return strs[0]
}
if err != nil {
log.Fatalf("Failed to export course: %v", err)
result := strs[0]
for i := 1; i < len(strs); i++ {
result += sep + strs[i]
}
fmt.Printf("Course successfully exported to: %s\n", outputPath)
fmt.Printf("Course: %s (%d lessons)\n", course.Course.Title, len(course.Course.Lessons))
return result
}

175
main_test.go Normal file
View File

@ -0,0 +1,175 @@
// Package main_test provides tests for the main package utility functions.
package main
import (
"testing"
)
// TestIsURI tests the isURI function with various input scenarios.
func TestIsURI(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "valid HTTP URI",
input: "http://example.com",
expected: true,
},
{
name: "valid HTTPS URI",
input: "https://example.com",
expected: true,
},
{
name: "valid Articulate Rise URI",
input: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/",
expected: true,
},
{
name: "local file path",
input: "C:\\Users\\test\\file.json",
expected: false,
},
{
name: "relative file path",
input: "./sample.json",
expected: false,
},
{
name: "filename only",
input: "sample.json",
expected: false,
},
{
name: "empty string",
input: "",
expected: false,
},
{
name: "short string",
input: "http",
expected: false,
},
{
name: "malformed URI",
input: "htp://example.com",
expected: false,
},
{
name: "FTP URI",
input: "ftp://example.com",
expected: false,
},
{
name: "HTTP with extra characters",
input: "xhttp://example.com",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isURI(tt.input)
if result != tt.expected {
t.Errorf("isURI(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
// TestJoinStrings tests the joinStrings function with various input scenarios.
func TestJoinStrings(t *testing.T) {
tests := []struct {
name string
strs []string
separator string
expected string
}{
{
name: "empty slice",
strs: []string{},
separator: ", ",
expected: "",
},
{
name: "single string",
strs: []string{"hello"},
separator: ", ",
expected: "hello",
},
{
name: "two strings with comma separator",
strs: []string{"markdown", "docx"},
separator: ", ",
expected: "markdown, docx",
},
{
name: "three strings with comma separator",
strs: []string{"markdown", "md", "docx"},
separator: ", ",
expected: "markdown, md, docx",
},
{
name: "multiple strings with pipe separator",
strs: []string{"option1", "option2", "option3"},
separator: " | ",
expected: "option1 | option2 | option3",
},
{
name: "strings with no separator",
strs: []string{"a", "b", "c"},
separator: "",
expected: "abc",
},
{
name: "strings with newline separator",
strs: []string{"line1", "line2", "line3"},
separator: "\n",
expected: "line1\nline2\nline3",
},
{
name: "empty strings in slice",
strs: []string{"", "middle", ""},
separator: "-",
expected: "-middle-",
},
{
name: "nil slice",
strs: nil,
separator: ", ",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinStrings(tt.strs, tt.separator)
if result != tt.expected {
t.Errorf("joinStrings(%v, %q) = %q, want %q", tt.strs, tt.separator, result, tt.expected)
}
})
}
}
// BenchmarkIsURI benchmarks the isURI function performance.
func BenchmarkIsURI(b *testing.B) {
testStr := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/"
b.ResetTimer()
for i := 0; i < b.N; i++ {
isURI(testStr)
}
}
// BenchmarkJoinStrings benchmarks the joinStrings function performance.
func BenchmarkJoinStrings(b *testing.B) {
strs := []string{"markdown", "md", "docx", "word", "pdf", "html"}
separator := ", "
b.ResetTimer()
for i := 0; i < b.N; i++ {
joinStrings(strs, separator)
}
}

View File

@ -531,6 +531,11 @@ try {
if ($Failed -gt 0) {
exit 1
}
# Clean up environment variables to avoid contaminating future builds
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
} finally {
Pop-Location
}

View File

@ -217,6 +217,14 @@ if [ "$SHOW_TARGETS" = true ]; then
exit 0
fi
# Validate Go installation
if ! command -v go >/dev/null 2>&1; then
echo "Error: Go is not installed or not in PATH"
echo "Please install Go from https://golang.org/dl/"
echo "Or if running on Windows, use the PowerShell script: scripts\\build.ps1"
exit 1
fi
# Validate entry point exists
if [ ! -f "$ENTRYPOINT" ]; then
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
@ -315,7 +323,7 @@ for idx in "${!TARGETS[@]}"; do
fi
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
if GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
update_status $((idx + 1)) '✔' "$BIN done"
rm -f "$OUTDIR/$BIN.log"
else
@ -356,3 +364,6 @@ if [ "$VERBOSE" = true ]; then
echo " ────────────────────────────────────────────────"
printf " Total: %d/%d successful, %s total size\n" "$success_count" "${#TARGETS[@]}" "$(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "${total_size} bytes")"
fi
# Clean up environment variables to avoid contaminating future builds
unset GOOS GOARCH CGO_ENABLED