mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-08-05 20:44:13 +02:00
144125d355
* fix(lint): resolve golangci-lint failures and modernize string handling
CI's golangci-lint (v2.12.2) job was failing with 14 issues, which blocked
the dependent test job. This addresses all of them:
- goconst: extract repeated string literals into constants
- format aliases ("md", "word", "htm") in the exporter factory
- "section" lesson type shared by markdown and HTML exporters
- default Articulate Rise base URL and host in the parser
- reuse existing itemType* constants in the markdown switch
- staticcheck (QF1012): replace buf.WriteString(fmt.Sprintf(...)) with
fmt.Fprintf(...) in the markdown exporter
Also drop the hardcoded `go: "1.24"` from .golangci.yml so the target Go
version is autodetected from go.mod.
* ci: drop test matrix, run a single Go version from go.mod
The test job ran a 1.24.x/1.25.x matrix, but go.mod requires go 1.25.0, so the
1.24 entry just auto-downloaded the 1.25 toolchain and tested the same thing
twice. Replace the matrix with a single job that sources its Go version from
go.mod via go-version-file, and reference the resolved version through the
setup-go step output in summaries, artifact names, and Codecov flags.
* docs: remove emojis from README
* ci: pin modernize tool to gopls v0.21.x for Go 1.25 compatibility
The autofix workflow's `task modernize` step installed the modernize
analyzer from gopls@latest, which as of v0.22.0 requires Go 1.26. The
project targets Go 1.25 (go.mod) and CI runs with GOTOOLCHAIN=local, so
the install failed. Pin to the v0.21.x line, which supports Go >= 1.25.
* ci: keep modernize@latest, fetch its toolchain via GOTOOLCHAIN=auto
Replaces the earlier v0.21.0 pin. The modernize analyzer (gopls v0.22+)
requires Go 1.26, which the project doesn't target yet — and bumping the
module to 1.26 isn't viable because the current golangci-lint release is
built with Go 1.25 and refuses to lint a newer target. Instead, let the
modernize task fetch the toolchain it needs on demand via GOTOOLCHAIN=auto
(setup-go pins GOTOOLCHAIN=local in CI), so we stay on the latest analyzer
without touching the module's Go version.
Also bump golang.org/x/image v0.34.0 -> v0.42.0 via `go get -u ./...`.
* ci: set GOTOOLCHAIN=auto inline for the modernize task
A task-level env: entry does not override GOTOOLCHAIN when setup-go has
already exported GOTOOLCHAIN=local job-wide, so the autofix job still
failed. Set GOTOOLCHAIN=auto inline on the modernize command itself, which
reliably overrides the inherited value and lets Go fetch the toolchain the
modernize analyzer requires.
* ci: bump to Go 1.26 and address PR review feedback
The autofix failure is fixed properly by moving to Go 1.26 instead of the
GOTOOLCHAIN workaround. The official golangci-lint v2.12.2 binary is built
with go1.26.2 and lints a Go 1.26 target fine (the earlier "not ready"
claim was from a locally go-installed binary compiled with Go 1.25), and
the Dockerfiles already use golang:1.26-alpine, so this also aligns the
module with the images.
- go.mod: go 1.25.0 -> 1.26.0, drop the toolchain pin (keeps the lint
target at the go directive).
- Taskfile: revert modernize to plain modernize@latest; on Go 1.26 the
GOTOOLCHAIN dance and its comments are unnecessary.
- ci.yml: pass the resolved Go version through an env var (GO_VERSION)
instead of interpolating steps.setup-go.outputs.go-version directly into
shell scripts (script-injection hygiene); grant the dependency-review job
pull-requests: write so it can post its summary.
- parser.go: derive defaultBaseURL from riseHost instead of duplicating the
host string; drop the redundant per-const comments.
- .golangci.yml: remove the redundant go-version comment.
- regenerate internal/exporters/output.docx.
163 lines
5.0 KiB
Go
163 lines
5.0 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
// Default endpoint configuration for the Articulate Rise API.
|
|
const (
|
|
riseHost = "rise.articulate.com"
|
|
defaultBaseURL = "https://" + riseHost
|
|
)
|
|
|
|
// shareIDRegex is compiled once at package init for extracting share IDs from URIs.
|
|
var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
|
|
|
|
// 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
|
|
// Logger for structured logging
|
|
Logger interfaces.Logger
|
|
}
|
|
|
|
// NewArticulateParser creates a new ArticulateParser instance.
|
|
// If baseURL is empty, uses the default Articulate Rise API URL.
|
|
// If timeout is zero, uses a 30-second timeout.
|
|
func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser {
|
|
if logger == nil {
|
|
logger = NewNoOpLogger()
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
return &ArticulateParser{
|
|
BaseURL: baseURL,
|
|
Client: &http.Client{
|
|
Timeout: timeout,
|
|
},
|
|
Logger: logger,
|
|
}
|
|
}
|
|
|
|
// FetchCourse fetches a course from the given URI and returns the parsed course data.
|
|
// The URI should be an Articulate Rise share URL (e.g., https://rise.articulate.com/share/SHARE_ID).
|
|
// The context can be used for cancellation and timeout control.
|
|
func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models.Course, error) {
|
|
shareID, err := p.extractShareID(uri)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiURL := p.buildAPIURL(shareID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := p.Client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch course data: %w", err)
|
|
}
|
|
// Ensure response body is closed even if ReadAll fails. Close errors are logged
|
|
// but not fatal since the body content has already been read and parsed. In the
|
|
// context of HTTP responses, the body must be closed to release the underlying
|
|
// connection, but a close error doesn't invalidate the data already consumed.
|
|
defer func() {
|
|
if err := resp.Body.Close(); err != nil {
|
|
p.Logger.Warn("failed to close response body", "error", err, "url", apiURL)
|
|
}
|
|
}()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
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.
|
|
func (p *ArticulateParser) LoadCourseFromFile(filePath string) (*models.Course, error) {
|
|
// #nosec G304 - File path is provided by user via CLI argument, which is expected behavior
|
|
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 != riseHost {
|
|
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host)
|
|
}
|
|
|
|
matches := shareIDRegex.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)
|
|
}
|