Skip to content

Commit 13b7fd4

Browse files
committed
Release v1.0.0
1 parent f8a1b02 commit 13b7fd4

29 files changed

Lines changed: 2602 additions & 1055 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
!README.md
1818
!LICENSE
19+
!CHANGELOG.md
1920

2021
!Taskfile.yaml
2122
!.goreleaser.yaml
@@ -32,4 +33,3 @@ dist/
3233
!devbox.json
3334
!devbox.lock
3435
!.envrc
35-

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6+
7+
## [1.0.0] - 2026-01-08
8+
9+
### Added
10+
- Thread-safe tag caching to prevent race conditions during concurrent uploads
11+
- Filename sanitization for disk writes (removes invalid filesystem characters)
12+
- ZIP slip vulnerability protection for archive extraction
13+
- File validation at process start to prevent deadlocks
14+
- Comprehensive unit tests (coverage: 25.9% → 40.1%)
15+
16+
### Fixed
17+
- Worker early termination bug where `break` was used instead of `continue`
18+
- Tag cache infinite retry loop in integration tests
19+
- Error chain preservation (replaced `%v` with `%w` throughout codebase)
20+
- HTTP client timeout consistency (100s → 10s)
21+
22+
### Security
23+
- Path traversal protection in ZIP file extraction
24+
- Invalid filename character handling for disk operations
25+
26+
[1.0.0]: https://github.com/yourusername/enex2paperless/releases/tag/v1.0.0

Taskfile.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,10 @@ tasks:
7474
desc: Run all tests with verbose output
7575
dir: .
7676
cmds:
77-
- gotest -v ./...
77+
- gotest -count=1 -v ./...
78+
79+
test:integration:
80+
desc: Run integration tests (requires Paperless instance)
81+
dir: .
82+
cmds:
83+
- gotest -count=1 -v -tags=integration ./test/integration/...

cmd/main/main.go

Lines changed: 76 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,72 @@ package main
22

33
import (
44
"bufio"
5-
"enex2paperless/internal/config"
6-
"enex2paperless/internal/logging"
7-
"enex2paperless/pkg/enex"
85
"fmt"
96
"log/slog"
107
"os"
118
"path/filepath"
129
"strings"
13-
"sync"
10+
11+
"enex2paperless/internal/config"
12+
"enex2paperless/internal/logging"
13+
"enex2paperless/pkg/enex"
1414

1515
"github.com/spf13/cobra"
1616
)
1717

18+
// CLI flag variables
19+
var (
20+
howMany int
21+
verbose bool
22+
nocolor bool
23+
outputfolder string
24+
tags []string
25+
useFilenameAsTag bool
26+
)
27+
1828
func main() {
1929
// define root command
2030
rootCmd := &cobra.Command{
2131
Use: "enex2paperless [file path]",
2232
Short: "ENEX to Paperless-NGX parser",
2333
Long: `An ENEX file parser for Paperless-NGX. https://github.com/kevinzehnder/enex2paperless`,
24-
Args: cobra.MinimumNArgs(1),
25-
PreRun: func(cmd *cobra.Command, args []string) {
26-
// this block will execute after flag parsing and before the main Run
34+
Args: cobra.ExactArgs(1),
35+
PreRunE: func(cmd *cobra.Command, args []string) error {
36+
// validate concurrent workers
37+
if howMany < 1 {
38+
return fmt.Errorf("concurrent workers must be at least 1, got %d", howMany)
39+
}
40+
41+
// validate output folder if specified
42+
if outputfolder != "" {
43+
info, err := os.Stat(outputfolder)
44+
if err != nil {
45+
if os.IsNotExist(err) {
46+
return fmt.Errorf("output folder does not exist: %s", outputfolder)
47+
}
48+
return fmt.Errorf("cannot access output folder: %w", err)
49+
}
50+
if !info.IsDir() {
51+
return fmt.Errorf("output folder is not a directory: %s", outputfolder)
52+
}
53+
}
2754

28-
// configure SLOG with the determined log level from verbose flag
29-
verbose, err := cmd.Flags().GetBool("verbose") // Ensure to get the flag value correctly
30-
if err != nil {
31-
fmt.Println("Error retrieving verbose flag:", err)
32-
os.Exit(1)
55+
// validate input file exists
56+
if _, err := os.Stat(args[0]); err != nil {
57+
if os.IsNotExist(err) {
58+
return fmt.Errorf("input file does not exist: %s", args[0])
59+
}
60+
return fmt.Errorf("cannot access input file: %w", err)
3361
}
3462

35-
// set log level
63+
// set log level based on verbose flag
3664
var logLevel slog.Level
3765
if verbose {
3866
logLevel = slog.LevelDebug
3967
} else {
4068
logLevel = slog.LevelInfo
4169
}
4270

43-
// nocolor option
44-
nocolor, err := cmd.Flags().GetBool("nocolor")
45-
if err != nil {
46-
fmt.Println("Error retrieving nocolor flag:", err)
47-
os.Exit(1)
48-
}
49-
5071
opts := &slog.HandlerOptions{
5172
Level: logLevel,
5273
}
@@ -55,76 +76,25 @@ func main() {
5576
logger := slog.New(logging.NewHandler(opts, nocolor))
5677
slog.SetDefault(logger)
5778

58-
// handle configuration
59-
settings, err := config.GetConfig()
60-
if err != nil {
61-
slog.Error("configuration error:", "error", err)
62-
os.Exit(1)
63-
}
64-
slog.Debug(fmt.Sprintf("configuration: %v", settings))
65-
66-
// add to configuration
67-
outputfolder, err := cmd.Flags().GetString("outputfolder")
68-
if err != nil {
69-
fmt.Println("Error retrieving outputfolder flag:", err)
70-
os.Exit(1)
71-
}
72-
73-
if outputfolder != "" {
74-
config.SetOutputFolder(outputfolder)
75-
}
76-
77-
// Set additional tags if provided
78-
tags, err := cmd.Flags().GetStringSlice("tags")
79-
if err != nil {
80-
fmt.Println("Error retrieving tag flag:", err)
81-
os.Exit(1)
82-
}
83-
84-
useFilenameAsTag, err := cmd.Flags().GetBool("use-filename-tag")
85-
if err != nil {
86-
fmt.Println("Error retrieving tag flag:", err)
87-
os.Exit(1)
88-
}
89-
if useFilenameAsTag {
90-
// Extract filename without path and extension
91-
baseName := filepath.Base(args[0])
92-
tagName := strings.TrimSuffix(baseName, filepath.Ext(baseName))
93-
tags = append(tags, tagName)
94-
}
95-
96-
if len(tags) > 0 {
97-
config.SetAdditionalTags(tags)
98-
}
99-
79+
return nil
10080
},
10181

10282
// run main function
10383
Run: importENEX,
10484
}
10585

10686
// add flags
107-
var howMany int
10887
rootCmd.PersistentFlags().IntVarP(&howMany, "concurrent", "c", 1, "Number of concurrent consumers")
109-
110-
var verbose bool
11188
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging")
112-
113-
var nocolor bool
11489
rootCmd.PersistentFlags().BoolVarP(&nocolor, "nocolor", "n", false, "Disable colored output")
115-
116-
var outputfolder string
11790
rootCmd.PersistentFlags().StringVarP(&outputfolder, "outputfolder", "o", "", "Output attachements to this folder, NOT paperless.")
118-
119-
rootCmd.PersistentFlags().StringSliceP("tags", "t", nil, "Additional tags to add to all documents.")
120-
121-
var useFilenameAsTag bool
91+
rootCmd.PersistentFlags().StringSliceVarP(&tags, "tags", "t", nil, "Additional tags to add to all documents.")
12292
rootCmd.PersistentFlags().BoolVarP(&useFilenameAsTag, "use-filename-tag", "T", false, "Add the ENEX filename as tag to all documents.")
12393

12494
// run root command
12595
err := rootCmd.Execute()
12696
if err != nil {
127-
fmt.Println("Error executing command:", err)
97+
// cobra prints error message, we just handle exit code
12898
os.Exit(1)
12999
}
130100
}
@@ -133,114 +103,46 @@ func importENEX(cmd *cobra.Command, args []string) {
133103
slog.Debug("starting importENEX")
134104
settings, _ := config.GetConfig()
135105

136-
if settings.OutputFolder != "" {
137-
slog.Info(fmt.Sprintf("Output to local storage is enabled. Target is: %v", settings.OutputFolder))
106+
// Apply flag overrides to config
107+
if outputfolder != "" {
108+
settings.OutputFolder = outputfolder
138109
}
139110

140-
// determine how many concurrent uploaders we want
141-
howMany, err := cmd.Flags().GetInt("concurrent")
142-
if err != nil {
143-
slog.Error("failed to read flag", "error", err)
144-
os.Exit(1)
111+
if useFilenameAsTag {
112+
baseName := filepath.Base(args[0])
113+
tagName := strings.TrimSuffix(baseName, filepath.Ext(baseName))
114+
tags = append(tags, tagName)
145115
}
146-
147-
// prepare input file with initialized channels
148-
filePath := args[0]
149-
inputFile := enex.NewEnexFile(filePath)
150-
151-
// Failure Catcher
152-
var failedNotes []enex.Note
153-
go func() {
154-
inputFile.FailedNoteCatcher(&failedNotes)
155-
inputFile.FailedNoteSignal <- true
156-
}()
157-
158-
// Producer
159-
go func() {
160-
err := inputFile.ReadFromFile()
161-
if err != nil {
162-
slog.Error("failed to read from file", "error", err)
163-
os.Exit(1)
164-
}
165-
}()
166-
167-
// Consumers
168-
var wg sync.WaitGroup
169-
wg.Add(howMany)
170-
171-
for i := 0; i < howMany; i++ {
172-
go func() {
173-
err := inputFile.UploadFromNoteChannel(settings.OutputFolder)
174-
if err != nil {
175-
slog.Error("failed to upload resources", "error", err)
176-
os.Exit(1)
177-
}
178-
179-
wg.Done()
180-
}()
116+
if len(tags) > 0 {
117+
settings.AdditionalTags = tags
181118
}
182-
slog.Debug("waiting for Consumers (WaitGroup)")
183-
wg.Wait()
184-
185-
// close failedNoteChannel when consumers are done
186-
close(inputFile.FailedNoteChannel)
187119

188-
// wait for FailedNoteCatcher
189-
slog.Debug("waiting for FailedNoteCatcher")
190-
<-inputFile.FailedNoteSignal
120+
if settings.OutputFolder != "" {
121+
slog.Info(fmt.Sprintf("Output to local storage is enabled. Target is: %v", settings.OutputFolder))
122+
}
191123

192-
// log results
193-
slog.Info("ENEX processing done",
194-
slog.Int("numberOfNotes", int(inputFile.NumNotes.Load())),
195-
slog.Int("totalFiles", int(inputFile.Uploads.Load())),
196-
)
124+
// Prepare input file with initialized channels
125+
filePath := args[0]
126+
inputFile := enex.NewEnexFile(filePath, settings)
127+
128+
// Process the ENEX file with retry prompts
129+
result, err := inputFile.Process(enex.ProcessOptions{
130+
ConcurrentWorkers: howMany,
131+
OutputFolder: settings.OutputFolder,
132+
RetryPromptFunc: func(failedCount int) bool {
133+
// Prompt user whether to retry failed notes
134+
slog.Warn("there have been errors, starting retry cycle", "errors", failedCount)
135+
PressKeyToContinue()
136+
return true
137+
},
138+
})
197139

198-
for {
199-
// if we still have failedNotes in this iteration, keep going
200-
if len(failedNotes) == 0 {
201-
break
140+
if err != nil {
141+
slog.Error("processing completed with errors", "error", err)
142+
if len(result.FailedNotes) > 0 {
143+
slog.Error("some notes could not be processed", "failedCount", len(result.FailedNotes))
202144
}
203-
204-
slog.Warn("there have been errors, starting retry cycle", "errors", len(failedNotes))
205-
PressKeyToContinue()
206-
207-
// all failed notes are now in failedNotes slice
208-
// push notes that failed this Cycle into failedThisCycle slice
209-
failedThisCycle := []enex.Note{}
210-
211-
// Create a fresh EnexFile for the retry - empty file path since we're not reading a file
212-
inputFile = enex.NewEnexFile("")
213-
214-
// this feeds the failedNotes slice into the failedNoteChannel
215-
go func() {
216-
inputFile.FailedNoteCatcher(&failedThisCycle)
217-
inputFile.FailedNoteSignal <- true
218-
}()
219-
220-
// this feeds the failedNotes into the Retry Channel
221-
go inputFile.RetryFeeder(&failedNotes)
222-
223-
// this works on the retry channel
224-
wg.Add(1)
225-
go func() {
226-
err = inputFile.UploadFromNoteChannel(settings.OutputFolder)
227-
if err != nil {
228-
slog.Error("failed to upload resources", "error", err)
229-
os.Exit(1)
230-
}
231-
wg.Done()
232-
}()
233-
wg.Wait()
234-
235-
// when the uploader is done, we can close the failedNoteChannel
236-
// to signal to the FailedNote Catcher that it can stop
237-
close(inputFile.FailedNoteChannel)
238-
239-
// then we wait for the FailedNoteCatcher to stop
240-
<-inputFile.FailedNoteSignal
241-
242-
// we move the notes that failed this cycle into the failedNotes variable
243-
failedNotes = failedThisCycle
145+
os.Exit(1)
244146
}
245147

246148
slog.Info("all notes processed successfully")

cmd/main/main_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package main
22

33
import (
4+
"enex2paperless/internal/config"
45
"enex2paperless/pkg/enex"
56
"sync"
67
"testing"
78

89
"github.com/spf13/afero"
910
)
1011

12+
var (
13+
testConfig config.Config
14+
)
15+
1116
type testCase struct {
1217
Name string
1318
MockEnexData string
@@ -108,7 +113,7 @@ func TestReadFromFile(t *testing.T) {
108113
afero.WriteFile(mockFs, "test.enex", []byte(tc.MockEnexData), 0644)
109114

110115
// Create an EnexFile with channels
111-
enexFile := enex.NewEnexFile("test.enex")
116+
enexFile := enex.NewEnexFile("test.enex", testConfig)
112117
enexFile.Fs = mockFs
113118

114119
// Use a wait group to synchronize test

0 commit comments

Comments
 (0)