diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-08 09:59:23 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-08 09:59:23 +0300 |
| commit | 35a3c860f63403d14c83668dcdd017b6ef517fbf (patch) | |
| tree | 1b348348fc951747d689cc62bd7bbbc42c94fa11 | |
| parent | ef1eefce9a1515a17490d6624ef88f2ef41330e1 (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.go | 41 | ||||
| -rw-r--r-- | cmd/totalrecall/story.go | 2 | ||||
| -rw-r--r-- | internal/archive/archive.go | 26 | ||||
| -rw-r--r-- | internal/gui/app.go | 23 | ||||
| -rw-r--r-- | internal/models/lister.go | 8 | ||||
| -rw-r--r-- | internal/story/runner.go | 8 |
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. |
