mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 13:42:10 +01:00
Compare commits
2 Commits
48cad7144f
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
b01260e765
|
|||
|
9de7222ec3
|
246
.github/workflows/ci.yml
vendored
246
.github/workflows/ci.yml
vendored
@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "develop" ]
|
||||
branches: [ "master", "develop" ]
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
@ -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
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@ -26,11 +26,37 @@ go.work
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||
|
||||
# Shit
|
||||
.github/TODO
|
||||
|
||||
# Local test files
|
||||
output/
|
||||
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/
|
||||
|
||||
92
README.md
92
README.md
@ -5,10 +5,8 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
|
||||
[][gomod]
|
||||
[][Package documentation]
|
||||
[][Go report]
|
||||
[][Tags]
|
||||
[][Latest release]
|
||||
[](LICENSE)
|
||||
[][Commits]
|
||||
[][Tags] <!-- [][Latest release] -->
|
||||
[][MIT License] <!-- [][Commits] -->
|
||||
[][Commits]
|
||||
[][Issues]
|
||||
[][Build]
|
||||
@ -29,20 +27,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 +82,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 or `docx` for Word Document | None (required) |
|
||||
| `output_path` | Path where output file will be saved. | `./output/` |
|
||||
|
||||
#### Examples
|
||||
|
||||
@ -75,7 +105,7 @@ go run main.go "articulate-sample.json" docx "my-course.docx"
|
||||
3. **Parse from local file and export to Markdown:**
|
||||
|
||||
```bash
|
||||
go run main.go "C:\Users\kjana\Projects\articulate-parser\articulate-sample.json" md
|
||||
go run main.go "articulate-sample.json" md "output.md"
|
||||
```
|
||||
|
||||
### Building the Executable
|
||||
@ -92,9 +122,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 +153,7 @@ Then run:
|
||||
- Media references included
|
||||
- Course metadata at the top
|
||||
|
||||
### Word Document (.docx)
|
||||
### Word Document (`.docx`)
|
||||
|
||||
- Professional document formatting
|
||||
- Bold headings and proper typography
|
||||
@ -167,6 +217,13 @@ 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:
|
||||
@ -188,6 +245,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
9
go.mod
@ -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
12
go.sum
@ -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
189
internal/exporters/docx.go
Normal 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"
|
||||
}
|
||||
679
internal/exporters/docx_test.go
Normal file
679
internal/exporters/docx_test.go
Normal 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 & entities</h1>",
|
||||
Paragraph: "<p>Paragraph with <code> 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)
|
||||
}
|
||||
}
|
||||
63
internal/exporters/factory.go
Normal file
63
internal/exporters/factory.go
Normal file
@ -0,0 +1,63 @@
|
||||
// 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
|
||||
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"}
|
||||
}
|
||||
444
internal/exporters/factory_test.go
Normal file
444
internal/exporters/factory_test.go
Normal file
@ -0,0 +1,444 @@
|
||||
// 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: "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"},
|
||||
}
|
||||
|
||||
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",
|
||||
"html",
|
||||
"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"}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
289
internal/exporters/markdown.go
Normal file
289
internal/exporters/markdown.go
Normal 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))
|
||||
}
|
||||
}
|
||||
693
internal/exporters/markdown_test.go
Normal file
693
internal/exporters/markdown_test.go
Normal 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, "###")
|
||||
}
|
||||
}
|
||||
31
internal/interfaces/exporter.go
Normal file
31
internal/interfaces/exporter.go
Normal 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
|
||||
}
|
||||
20
internal/interfaces/parser.go
Normal file
20
internal/interfaces/parser.go
Normal 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
55
internal/models/course.go
Normal 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
96
internal/models/lesson.go
Normal 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
50
internal/models/media.go
Normal 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"`
|
||||
}
|
||||
790
internal/models/models_test.go
Normal file
790
internal/models/models_test.go
Normal 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
76
internal/services/app.go
Normal 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()
|
||||
}
|
||||
353
internal/services/app_test.go
Normal file
353
internal/services/app_test.go
Normal 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
|
||||
}
|
||||
53
internal/services/html_cleaner.go
Normal file
53
internal/services/html_cleaner.go
Normal 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 , &, 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, " ", " ")
|
||||
cleaned = strings.ReplaceAll(cleaned, "&", "&")
|
||||
cleaned = strings.ReplaceAll(cleaned, "<", "<")
|
||||
cleaned = strings.ReplaceAll(cleaned, ">", ">")
|
||||
cleaned = strings.ReplaceAll(cleaned, """, "\"")
|
||||
cleaned = strings.ReplaceAll(cleaned, "'", "'")
|
||||
cleaned = strings.ReplaceAll(cleaned, "ï", "ï")
|
||||
cleaned = strings.ReplaceAll(cleaned, "ë", "ë")
|
||||
cleaned = strings.ReplaceAll(cleaned, "é", "é")
|
||||
|
||||
// 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
|
||||
}
|
||||
325
internal/services/html_cleaner_test.go
Normal file
325
internal/services/html_cleaner_test.go
Normal 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&T <company> "quoted" text",
|
||||
expected: "AT&T <company> \"quoted\" text",
|
||||
},
|
||||
{
|
||||
name: "HTML entities - apostrophe",
|
||||
input: "It's a test",
|
||||
expected: "It's a test",
|
||||
},
|
||||
{
|
||||
name: "HTML entities - special characters",
|
||||
input: "ïber ëlite écarté",
|
||||
expected: "ïber ëlite écarté",
|
||||
},
|
||||
{
|
||||
name: "HTML entities - nbsp",
|
||||
input: "Word1 Word2",
|
||||
expected: "Word1 Word2",
|
||||
},
|
||||
{
|
||||
name: "mixed HTML and entities",
|
||||
input: "<p>Hello & 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 & HTML entities like and "quotes".</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&T\">Content</p>",
|
||||
expected: "Content",
|
||||
},
|
||||
{
|
||||
name: "special HTML5 entities",
|
||||
input: "Left arrow ← Right arrow →",
|
||||
expected: "Left arrow ← Right arrow →", // 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 & 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, "&") {
|
||||
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: "&<>"' ",
|
||||
expected: "&<>\"'",
|
||||
},
|
||||
{
|
||||
name: "repeated entities",
|
||||
input: "&&&",
|
||||
expected: "&&&",
|
||||
},
|
||||
{
|
||||
name: "entities without semicolon (should not be converted)",
|
||||
input: "& test < test",
|
||||
expected: "& test < test",
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid entities",
|
||||
input: "& &invalid; < &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: 你好 & 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 & HTML entities like and "quotes".</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 & entities <test>.</p>")
|
||||
}
|
||||
input := builder.String()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cleaner.CleanHTML(input)
|
||||
}
|
||||
}
|
||||
145
internal/services/parser.go
Normal file
145
internal/services/parser.go
Normal 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)
|
||||
}
|
||||
440
internal/services/parser_test.go
Normal file
440
internal/services/parser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ package version
|
||||
// Version information.
|
||||
var (
|
||||
// Version is the current version of the application.
|
||||
Version = "0.1.0"
|
||||
Version = "0.2.0"
|
||||
|
||||
// BuildTime is the time the binary was built.
|
||||
BuildTime = "unknown"
|
||||
|
||||
663
main.go
663
main.go
@ -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, " ", " ")
|
||||
cleaned = strings.ReplaceAll(cleaned, "&", "&")
|
||||
cleaned = strings.ReplaceAll(cleaned, "<", "<")
|
||||
cleaned = strings.ReplaceAll(cleaned, ">", ">")
|
||||
cleaned = strings.ReplaceAll(cleaned, """, "\"")
|
||||
cleaned = strings.ReplaceAll(cleaned, "'", "'")
|
||||
cleaned = strings.ReplaceAll(cleaned, "ï", "ï")
|
||||
cleaned = strings.ReplaceAll(cleaned, "ë", "ë")
|
||||
cleaned = strings.ReplaceAll(cleaned, "é", "é")
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
outputPath = filepath.Join(baseDir, fmt.Sprintf("%s.%s", safeTitle, format))
|
||||
}
|
||||
|
||||
// Export based on format
|
||||
switch format {
|
||||
case "md":
|
||||
err = parser.ExportToMarkdown(course, outputPath)
|
||||
case "docx":
|
||||
err = parser.ExportToDocx(course, outputPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to export course: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Course successfully exported to: %s\n", outputPath)
|
||||
fmt.Printf("Course: %s (%d lessons)\n", course.Course.Title, len(course.Course.Lessons))
|
||||
fmt.Printf("Successfully exported course to %s\n", output)
|
||||
}
|
||||
|
||||
// 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://")
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
|
||||
result := strs[0]
|
||||
for i := 1; i < len(strs); i++ {
|
||||
result += sep + strs[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
175
main_test.go
Normal file
175
main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -315,7 +315,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=1 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
|
||||
update_status $((idx + 1)) '✔' "$BIN done"
|
||||
rm -f "$OUTDIR/$BIN.log"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user