summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 09:59:23 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 09:59:23 +0300
commit35a3c860f63403d14c83668dcdd017b6ef517fbf (patch)
tree1b348348fc951747d689cc62bd7bbbc42c94fa11
parentef1eefce9a1515a17490d6624ef88f2ef41330e1 (diff)
refactor(dip): inject archive, models, story, and gui at composition roots
Define Archiver, ModelLister, and StoryRunner interfaces in their packages. gui.New returns App; Application carries an injectable Archiver from Config. cmd/totalrecall wires default implementations via runDeps for tests and DI. Made-with: Cursor
-rw-r--r--cmd/totalrecall/main.go41
-rw-r--r--cmd/totalrecall/story.go2
-rw-r--r--internal/archive/archive.go26
-rw-r--r--internal/gui/app.go23
-rw-r--r--internal/models/lister.go8
-rw-r--r--internal/story/runner.go8
6 files changed, 91 insertions, 17 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index ad03303..94a0508 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -2,6 +2,7 @@ package main
import (
"fmt"
+ "io"
"os"
"path/filepath"
@@ -13,9 +14,29 @@ import (
"codeberg.org/snonux/totalrecall/internal/gui"
"codeberg.org/snonux/totalrecall/internal/models"
"codeberg.org/snonux/totalrecall/internal/processor"
+ "codeberg.org/snonux/totalrecall/internal/story"
"codeberg.org/snonux/totalrecall/internal/video"
)
+// runDeps holds injectable implementations for composition-root wiring (DIP).
+type runDeps struct {
+ Archiver archive.Archiver
+ NewLister func(openAIKey, geminiKey string, out io.Writer) models.ModelLister
+ NewStoryRunner func(flags *cli.Flags) story.StoryRunner
+ NewGUI func(*gui.Config) gui.App
+}
+
+func defaultRunDeps() runDeps {
+ return runDeps{
+ Archiver: archive.DefaultArchiver{},
+ NewLister: func(oa, g string, w io.Writer) models.ModelLister {
+ return models.NewLister(oa, g, w)
+ },
+ NewStoryRunner: newStoryRunner,
+ NewGUI: gui.New,
+ }
+}
+
func main() {
// Create flags instance
flags := cli.NewFlags()
@@ -31,7 +52,7 @@ func main() {
// Set the run function
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {
cli.MarkExplicitFlagValues(cmd, flags)
- return runCommand(cmd, args, flags)
+ return runCommand(cmd, args, flags, defaultRunDeps())
}
// Execute command
@@ -40,12 +61,12 @@ func main() {
}
}
-func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
+func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags, deps runDeps) error {
// Handle --archive flag
if flags.Archive {
home, _ := os.UserHomeDir()
cardsDir := filepath.Join(home, ".local", "state", "totalrecall", "cards")
- if err := archive.ArchiveCards(cardsDir); err != nil {
+ if err := deps.Archiver.ArchiveCards(cardsDir); err != nil {
return fmt.Errorf("failed to archive cards: %w", err)
}
return nil
@@ -53,7 +74,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
// Handle --list-models flag
if flags.ListModels {
- lister := models.NewLister(cli.GetOpenAIKey(), cli.GetGoogleAPIKey(), os.Stdout)
+ lister := deps.NewLister(cli.GetOpenAIKey(), cli.GetGoogleAPIKey(), os.Stdout)
return lister.ListAvailableModels()
}
@@ -61,7 +82,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
// This is deliberately placed before processor creation because it does not
// need the full processor pipeline (no Anki cards, no per-word audio).
if flags.StoryFile != "" {
- runner := newStoryRunner(flags)
+ runner := deps.NewStoryRunner(flags)
if err := runner.Run(flags.StoryFile); err != nil {
return err
}
@@ -92,7 +113,7 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
}
} else {
// No input provided - launch GUI mode by default
- return runGUIMode(proc, flags)
+ return runGUIMode(proc, flags, deps)
}
// Generate Anki file if requested
@@ -111,9 +132,9 @@ func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
}
// runGUIMode launches the GUI application from the cmd/totalrecall package so
-// that gui.New() is called from the composition root rather than from the
-// processor package, reducing the processor→gui import coupling.
-func runGUIMode(proc *processor.Processor, flags *cli.Flags) error {
+// that the GUI factory is invoked from the composition root rather than from
+// the processor package, reducing the processor→gui import coupling.
+func runGUIMode(proc *processor.Processor, flags *cli.Flags, deps runDeps) error {
guiConfig := proc.GUIConfig()
// Only override OutputDir when the user explicitly set a non-default path.
@@ -129,7 +150,7 @@ func runGUIMode(proc *processor.Processor, flags *cli.Flags) error {
guiConfig.GoogleAPIKey = cli.GetGoogleAPIKey()
}
- app := gui.New(guiConfig)
+ app := deps.NewGUI(guiConfig)
app.Run()
return nil
diff --git a/cmd/totalrecall/story.go b/cmd/totalrecall/story.go
index e0bfba3..2977984 100644
--- a/cmd/totalrecall/story.go
+++ b/cmd/totalrecall/story.go
@@ -6,7 +6,7 @@ import (
)
// newStoryRunner wires a story.Runner from CLI flags and API keys.
-func newStoryRunner(flags *cli.Flags) *story.Runner {
+func newStoryRunner(flags *cli.Flags) story.StoryRunner {
return story.NewRunner(&story.RunnerConfig{
APIKey: cli.GetGoogleAPIKey(),
TextModel: flags.NanoBananaTextModel,
diff --git a/internal/archive/archive.go b/internal/archive/archive.go
index 9d58935..8ea7e7a 100644
--- a/internal/archive/archive.go
+++ b/internal/archive/archive.go
@@ -7,8 +7,24 @@ import (
"time"
)
-// ArchiveCards moves the cards directory to an archive with timestamp
-func ArchiveCards(cardsDir string) error {
+// Archiver moves the cards directory into a timestamped archive folder under
+// the parent state directory. Implementations are typically injected at
+// composition roots (cmd, GUI) so callers depend on this abstraction rather
+// than package-level functions alone.
+type Archiver interface {
+ ArchiveCards(cardsDir string) error
+}
+
+// DefaultArchiver implements Archiver using the local filesystem.
+type DefaultArchiver struct{}
+
+// ArchiveCards implements Archiver.
+func (DefaultArchiver) ArchiveCards(cardsDir string) error {
+ return archiveCards(cardsDir)
+}
+
+// archiveCards moves the cards directory to an archive with timestamp.
+func archiveCards(cardsDir string) error {
// Check if cards directory exists
if _, err := os.Stat(cardsDir); os.IsNotExist(err) {
return fmt.Errorf("cards directory does not exist: %s", cardsDir)
@@ -44,3 +60,9 @@ func ArchiveCards(cardsDir string) error {
fmt.Printf("Cards directory archived to: %s\n", archivePath)
return nil
}
+
+// ArchiveCards archives cards using DefaultArchiver. It exists for call sites
+// that do not use dependency injection (e.g. tests and legacy scripts).
+func ArchiveCards(cardsDir string) error {
+ return (DefaultArchiver{}).ArchiveCards(cardsDir)
+}
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 6c1cf21..0c821d4 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -30,6 +30,12 @@ import (
"codeberg.org/snonux/totalrecall/internal/translation"
)
+// App is the runnable GUI application constructed at the composition root
+// (cmd/totalrecall). Callers invoke Run() to start the Fyne event loop.
+type App interface {
+ Run()
+}
+
// Application represents the main GUI application
type Application struct {
// Fyne components
@@ -88,6 +94,7 @@ type Application struct {
// Configuration
config *Config
+ archiver archive.Archiver
audioConfig *audio.Config
phoneticFetcher *phonetic.Fetcher
translator *translation.Translator
@@ -144,6 +151,9 @@ type Config struct {
// constructing new instances from the provider/key fields above.
PhoneticFetcher *phonetic.Fetcher
Translator *translation.Translator
+ // Archiver moves the cards directory to a timestamped archive; nil uses
+ // archive.DefaultArchiver.
+ Archiver archive.Archiver
}
// DefaultConfig returns default GUI configuration
@@ -170,13 +180,17 @@ func DefaultConfig() *Config {
}
}
-// New creates a new GUI application
-// New constructs and returns a fully initialised Application for the given config.
+// New constructs and returns a fully initialised App for the given config.
// A nil config receives all defaults. The Fyne application and UI are created here;
// callers should call Run() to start the event loop.
-func New(config *Config) *Application {
+func New(config *Config) App {
config = applyConfigDefaults(config)
+ arch := config.Archiver
+ if arch == nil {
+ arch = archive.DefaultArchiver{}
+ }
+
if err := os.MkdirAll(config.OutputDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create output directory %q: %v\n", config.OutputDir, err)
}
@@ -188,6 +202,7 @@ func New(config *Config) *Application {
a := &Application{
app: myApp,
config: config,
+ archiver: arch,
ctx: ctx,
cancel: cancel,
savedCards: make([]anki.Card, 0),
@@ -1479,7 +1494,7 @@ func (a *Application) performArchive() {
}
cardsDir := filepath.Join(home, ".local", "state", "totalrecall", "cards")
- if err := archive.ArchiveCards(cardsDir); err != nil {
+ if err := a.archiver.ArchiveCards(cardsDir); err != nil {
dialog.ShowError(err, a.window)
return
}
diff --git a/internal/models/lister.go b/internal/models/lister.go
index 2f05988..08f4f0d 100644
--- a/internal/models/lister.go
+++ b/internal/models/lister.go
@@ -14,6 +14,12 @@ import (
"codeberg.org/snonux/totalrecall/internal/httpctx"
)
+// ModelLister lists available OpenAI and Gemini models to the configured
+// writer. *Lister satisfies this interface.
+type ModelLister interface {
+ ListAvailableModels() error
+}
+
type openAIModelLister interface {
ListModels(context.Context) (openai.ModelsList, error)
}
@@ -32,6 +38,8 @@ type Lister struct {
out io.Writer
}
+var _ ModelLister = (*Lister)(nil)
+
// NewLister creates a new model lister.
func NewLister(openAIKey, geminiKey string, out io.Writer) *Lister {
lister := &Lister{
diff --git a/internal/story/runner.go b/internal/story/runner.go
index c9571a6..07e0e12 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -9,6 +9,12 @@ import (
"codeberg.org/snonux/totalrecall/internal/batch"
)
+// StoryRunner runs the vocabulary story generation pipeline from a batch file
+// path. *Runner satisfies this interface.
+type StoryRunner interface {
+ Run(batchFile string) error
+}
+
// ttsTodoContent is written to story_tts_todo.txt as a fallback when Gemini TTS
// narration fails or no API key is available. It documents the original
// ElevenLabs integration placeholder for reference.
@@ -184,6 +190,8 @@ func (r *Runner) Run(batchFile string) error {
return r.handleNarration(result.StoryText, slug, comicsDir)
}
+var _ StoryRunner = (*Runner)(nil)
+
// drawComicPages generates all 12 comic pages and assembles them into a PDF.
// panelScript carries the explicit per-panel visual descriptions from Gemini so
// each panel illustrates the correct story beat in narrative order.