summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 08:38:05 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 08:38:05 +0200
commit3a255c0c64f858d5c05797aba9a6d159b0c7d82f (patch)
treedf517cfa866c3ba344c443f5c42a3c1e932ff0dd
parentffa8e0bab35e430ed0e23ccc2383d56559924acc (diff)
fix(task-373): handle runtime cleanup errors in production paths
-rw-r--r--internal/anki/generator.go117
-rw-r--r--internal/gui/audio_player.go25
-rw-r--r--internal/image/download.go10
-rw-r--r--internal/image/openai.go4
4 files changed, 91 insertions, 65 deletions
diff --git a/internal/anki/generator.go b/internal/anki/generator.go
index 85a4155..07ea5df 100644
--- a/internal/anki/generator.go
+++ b/internal/anki/generator.go
@@ -12,22 +12,22 @@ import (
// Card represents a single Anki flashcard
type Card struct {
- Bulgarian string // The Bulgarian word/phrase
- AudioFile string // Path to audio file (for en-bg: Bulgarian audio, for bg-bg: front audio)
- AudioFileBack string // Path to back audio file (only for bg-bg cards)
- ImageFile string // Path to image file
- Translation string // Translation (English for en-bg, Bulgarian definition for bg-bg)
- Notes string // Optional notes
- CardType string // Card type: "en-bg" or "bg-bg"
+ Bulgarian string // The Bulgarian word/phrase
+ AudioFile string // Path to audio file (for en-bg: Bulgarian audio, for bg-bg: front audio)
+ AudioFileBack string // Path to back audio file (only for bg-bg cards)
+ ImageFile string // Path to image file
+ Translation string // Translation (English for en-bg, Bulgarian definition for bg-bg)
+ Notes string // Optional notes
+ CardType string // Card type: "en-bg" or "bg-bg"
}
// GeneratorOptions configures the Anki export
type GeneratorOptions struct {
- OutputPath string // Output CSV file path
- MediaFolder string // Folder containing media files
- IncludeHeaders bool // Include CSV headers
- AudioFormat string // Audio file format (mp3, wav)
- ImageFormat string // Image file format (jpg, png)
+ OutputPath string // Output CSV file path
+ MediaFolder string // Folder containing media files
+ IncludeHeaders bool // Include CSV headers
+ AudioFormat string // Audio file format (mp3, wav)
+ ImageFormat string // Image file format (jpg, png)
}
// DefaultGeneratorOptions returns sensible defaults
@@ -68,19 +68,28 @@ func (g *Generator) GetCards() []Card {
return g.cards
}
-// GenerateCSV creates a CSV file for Anki import
-func (g *Generator) GenerateCSV() error {
+// GenerateCSV creates a CSV file for Anki import.
+func (g *Generator) GenerateCSV() (err error) {
// Create output file
file, err := os.Create(g.options.OutputPath)
if err != nil {
return fmt.Errorf("failed to create CSV file: %w", err)
}
- defer file.Close()
-
+ defer func() {
+ if closeErr := file.Close(); err == nil && closeErr != nil {
+ err = fmt.Errorf("failed to close CSV file: %w", closeErr)
+ }
+ }()
+
// Create CSV writer
writer := csv.NewWriter(file)
- defer writer.Flush()
-
+ defer func() {
+ writer.Flush()
+ if flushErr := writer.Error(); err == nil && flushErr != nil {
+ err = fmt.Errorf("failed to flush CSV file: %w", flushErr)
+ }
+ }()
+
// Write headers if requested
if g.options.IncludeHeaders {
headers := []string{"Bulgarian", "Audio", "Image", "Translation", "Notes"}
@@ -88,7 +97,7 @@ func (g *Generator) GenerateCSV() error {
return fmt.Errorf("failed to write headers: %w", err)
}
}
-
+
// Write cards
for _, card := range g.cards {
record := []string{
@@ -98,12 +107,12 @@ func (g *Generator) GenerateCSV() error {
card.Translation,
card.Notes,
}
-
+
if err := writer.Write(record); err != nil {
return fmt.Errorf("failed to write card: %w", err)
}
}
-
+
return nil
}
@@ -112,14 +121,14 @@ func (g *Generator) formatAudioField(audioFile string) string {
if audioFile == "" {
return ""
}
-
+
// Get card ID from the source path (parent directory name)
cardID := filepath.Base(filepath.Dir(audioFile))
originalFilename := filepath.Base(audioFile)
-
+
// Create filename with card ID prefix for uniqueness in Anki
filename := fmt.Sprintf("%s_%s", cardID, originalFilename)
-
+
// Anki audio format: [sound:filename.mp3]
return fmt.Sprintf("[sound:%s]", filename)
}
@@ -129,14 +138,14 @@ func (g *Generator) formatImageField(imageFile string) string {
if imageFile == "" {
return ""
}
-
+
// Get card ID from the source path (parent directory name)
cardID := filepath.Base(filepath.Dir(imageFile))
originalFilename := filepath.Base(imageFile)
-
+
// Create filename with card ID prefix for uniqueness in Anki
filename := fmt.Sprintf("%s_%s", cardID, originalFilename)
-
+
return fmt.Sprintf(`<img src="%s">`, filename)
}
@@ -254,13 +263,13 @@ func (g *Generator) GeneratePackage(outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
-
+
// Create media directory
mediaDir := filepath.Join(outputDir, "collection.media")
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return fmt.Errorf("failed to create media directory: %w", err)
}
-
+
// Copy media files and update paths
for i, card := range g.cards {
// Copy audio file
@@ -271,7 +280,7 @@ func (g *Generator) GeneratePackage(outputDir string) error {
}
g.cards[i].AudioFile = newPath
}
-
+
// Copy image file
if card.ImageFile != "" {
newPath, err := g.copyMediaFile(card.ImageFile, mediaDir)
@@ -281,10 +290,10 @@ func (g *Generator) GeneratePackage(outputDir string) error {
g.cards[i].ImageFile = newPath
}
}
-
+
// Update output path to package directory
g.options.OutputPath = filepath.Join(outputDir, "import.csv")
-
+
// Generate CSV
return g.GenerateCSV()
}
@@ -293,32 +302,32 @@ func (g *Generator) GeneratePackage(outputDir string) error {
func (g *Generator) GenerateAPKG(outputPath, deckName string) error {
// Create APKG generator
apkgGen := NewAPKGGenerator(deckName)
-
+
// Add all cards
for _, card := range g.cards {
apkgGen.AddCard(card)
}
-
+
// Generate the .apkg file
return apkgGen.GenerateAPKG(outputPath)
}
-// copyMediaFile copies a media file to the destination directory
-func (g *Generator) copyMediaFile(src, destDir string) (string, error) {
+// copyMediaFile copies a media file to the destination directory.
+func (g *Generator) copyMediaFile(src, destDir string) (filename string, err error) {
// Get source file info
srcInfo, err := os.Stat(src)
if err != nil {
return "", err
}
-
+
// Get the card ID from the source path (parent directory name)
cardID := filepath.Base(filepath.Dir(src))
-
+
// Create destination filename with card ID prefix
originalFilename := filepath.Base(src)
- filename := fmt.Sprintf("%s_%s", cardID, originalFilename)
+ filename = fmt.Sprintf("%s_%s", cardID, originalFilename)
destPath := filepath.Join(destDir, filename)
-
+
// Check if file already exists
if _, err := os.Stat(destPath); err == nil {
// File exists, generate unique name
@@ -332,38 +341,46 @@ func (g *Generator) copyMediaFile(src, destDir string) (string, error) {
}
}
}
-
+
// Open source file
srcFile, err := os.Open(src)
if err != nil {
return "", err
}
- defer srcFile.Close()
-
+ defer func() {
+ if closeErr := srcFile.Close(); err == nil && closeErr != nil {
+ err = fmt.Errorf("failed to close source media file: %w", closeErr)
+ }
+ }()
+
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return "", err
}
- defer destFile.Close()
-
+ defer func() {
+ if closeErr := destFile.Close(); err == nil && closeErr != nil {
+ err = fmt.Errorf("failed to close destination media file: %w", closeErr)
+ }
+ }()
+
// Copy content
if _, err := destFile.ReadFrom(srcFile); err != nil {
return "", err
}
-
+
// Preserve file mode
if err := os.Chmod(destPath, srcInfo.Mode()); err != nil {
return "", err
}
-
+
return filename, nil
}
// Stats returns statistics about the card collection
func (g *Generator) Stats() (totalCards, withAudio, withImages int) {
totalCards = len(g.cards)
-
+
for _, card := range g.cards {
if card.AudioFile != "" {
withAudio++
@@ -372,6 +389,6 @@ func (g *Generator) Stats() (totalCards, withAudio, withImages int) {
withImages++
}
}
-
+
return
-} \ No newline at end of file
+}
diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go
index 8ab7f76..a214ee0 100644
--- a/internal/gui/audio_player.go
+++ b/internal/gui/audio_player.go
@@ -23,9 +23,9 @@ type AudioPlayer struct {
container *fyne.Container
playButton *ttwidget.Button
- playButtonLabel *widget.Label // Label for front audio button
- playBackButton *ttwidget.Button // Play back audio for bg-bg cards
- playBackLabel *widget.Label // Label for back audio button
+ playButtonLabel *widget.Label // Label for front audio button
+ playBackButton *ttwidget.Button // Play back audio for bg-bg cards
+ playBackLabel *widget.Label // Label for back audio button
stopButton *ttwidget.Button
statusLabel *widget.Label
phoneticLabel *widget.Label
@@ -46,13 +46,13 @@ func NewAudioPlayer() *AudioPlayer {
// Create controls (tooltips will be set later after tooltip layer is created)
p.playButton = ttwidget.NewButton("", p.onPlay)
p.playButton.Icon = theme.MediaPlayIcon()
-
+
p.playButtonLabel = widget.NewLabel("")
p.playButtonLabel.TextStyle = fyne.TextStyle{Bold: true}
p.playBackButton = ttwidget.NewButton("", p.onPlayBack)
p.playBackButton.Icon = theme.MediaPlayIcon() // Same icon as front button
-
+
p.playBackLabel = widget.NewLabel("")
p.playBackLabel.TextStyle = fyne.TextStyle{Bold: true}
@@ -240,7 +240,7 @@ func (p *AudioPlayer) onPlay() {
fmt.Printf(" - audioFileBack: %s\n", p.audioFileBack)
fmt.Printf(" - isBgBg: %v\n", p.isBgBg)
fmt.Printf(" - isPlaying: %v\n", p.isPlaying)
-
+
if p.audioFile == "" {
fmt.Printf("DEBUG (onPlay): No audioFile set, returning\n")
return
@@ -275,7 +275,7 @@ func (p *AudioPlayer) onPlayBack() {
fmt.Printf(" - audioFileBack: %s\n", p.audioFileBack)
fmt.Printf(" - isBgBg: %v\n", p.isBgBg)
fmt.Printf(" - isPlaying: %v\n", p.isPlaying)
-
+
if p.audioFileBack == "" {
fmt.Printf("DEBUG (onPlayBack): No audioFileBack set, returning\n")
return
@@ -295,7 +295,7 @@ func (p *AudioPlayer) onPlayBack() {
}
p.isPlaying = true
- p.playBackButton.SetIcon(theme.MediaPauseIcon()) // Back button, not front
+ p.playBackButton.SetIcon(theme.MediaPauseIcon()) // Back button, not front
p.stopButton.Enable()
p.statusLabel.SetText(fmt.Sprintf("Playing back audio: %s", filepath.Base(p.audioFileBack)))
fmt.Printf("DEBUG (onPlayBack): Back audio playback started successfully\n")
@@ -304,16 +304,19 @@ func (p *AudioPlayer) onPlayBack() {
// onStop handles stop button click
func (p *AudioPlayer) onStop() {
if p.playCmd != nil && p.playCmd.Process != nil {
- p.playCmd.Process.Kill()
+ if err := p.playCmd.Process.Kill(); err != nil {
+ fmt.Printf("DEBUG (onStop): Failed to kill playback process: %v\n", err)
+ p.statusLabel.SetText(fmt.Sprintf("failed to stop playback: %v", err))
+ }
p.playCmd = nil
}
p.isPlaying = false
// Set correct button icon based on which audio was playing
if p.isBgBg && p.audioFileBack != "" {
- p.playBackButton.SetIcon(theme.MediaPlayIcon()) // Back button if it was playing
+ p.playBackButton.SetIcon(theme.MediaPlayIcon()) // Back button if it was playing
} else {
- p.playButton.SetIcon(theme.MediaPlayIcon()) // Front button otherwise
+ p.playButton.SetIcon(theme.MediaPlayIcon()) // Front button otherwise
}
p.stopButton.Disable()
p.statusLabel.SetText(fmt.Sprintf("Stopped: %s%s", filepath.Base(p.audioFile), p.voiceInfo))
diff --git a/internal/image/download.go b/internal/image/download.go
index 8ea897e..b2af843 100644
--- a/internal/image/download.go
+++ b/internal/image/download.go
@@ -46,8 +46,8 @@ func NewDownloader(searcher ImageSearcher, options *DownloadOptions) *Downloader
}
}
-// DownloadImage downloads a single image to the specified path
-func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, outputPath string) error {
+// DownloadImage downloads a single image to the specified path.
+func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, outputPath string) (err error) {
// Ensure directory exists
dir := filepath.Dir(outputPath)
if dir != "" && dir != "." {
@@ -77,7 +77,11 @@ func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, ou
if err != nil {
return fmt.Errorf("create output file %q: %w", outputPath, err)
}
- defer file.Close()
+ defer func() {
+ if closeErr := file.Close(); err == nil && closeErr != nil {
+ err = fmt.Errorf("close output file %q: %w", outputPath, closeErr)
+ }
+ }()
// Copy with size limit if specified
var written int64
diff --git a/internal/image/openai.go b/internal/image/openai.go
index 5bb7db4..f2db41d 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -197,7 +197,9 @@ func (c *OpenAIClient) Download(ctx context.Context, url string) (io.ReadCloser,
}
if resp.StatusCode != http.StatusOK {
- resp.Body.Close()
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ return nil, fmt.Errorf("HTTP %d: %s (failed to close response body: %v)", resp.StatusCode, resp.Status, closeErr)
+ }
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}