Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ ingest = "directory"
# Local ingest directory (when ingest = "directory")
ingest_dir = "~/paperless-ingest"

# Archive ingested files to prevent re-ingestion on Paperless restart (optional)
# ingest_archive_dir = "~/paperflow-archive"
# ingest_archive_after = "5m"

# Paperless API settings (when ingest = "api")
# paperless_url = "https://paperless.example.com"
# Token is stored separately in ~/.config/paperflow/token
Expand Down Expand Up @@ -168,6 +172,8 @@ Flags override config values for a single run:
| `--watch` | Override watch directory |
| `--ingest` | Override ingestion method |
| `--ingest-dir` | Override ingest directory |
| `--ingest-archive-dir` | Archive directory for ingested files |
| `--ingest-archive-after` | Delay before archiving (default: `5m`) |
| `--paperless-url` | Paperless-ngx base URL (for API ingestion) |
| `--paperless-token-file` | Path to file containing Paperless API token |
| `--config` | Path to config file (default: `$XDG_CONFIG_HOME/paperflow/config.toml`) |
Expand All @@ -184,6 +190,8 @@ Environment variables with the `PAPERFLOW_` prefix override config file values (
| `PAPERFLOW_INGEST` | Override ingestion method |
| `PAPERFLOW_INGEST_DIR` | Override ingest directory |
| `PAPERFLOW_PAPERLESS_URL` | Paperless-ngx base URL |
| `PAPERFLOW_INGEST_ARCHIVE_DIR` | Archive directory for ingested files |
| `PAPERFLOW_INGEST_ARCHIVE_AFTER` | Delay before archiving (e.g. `5m`) |
| `PAPERFLOW_NO_NOTIFY` | Set to `1` or `true` to disable notifications |

Config resolution order: defaults -> config file -> environment variables -> CLI flags.
Expand Down Expand Up @@ -225,6 +233,21 @@ Checks the config file for errors and verifies that:
- Paperless URL is reachable (if using API ingestion)
- Token file exists and has correct permissions

## Ingest archive (directory mode)

When using directory-mode ingestion, Paperless-ngx may re-ingest files from its consume directory after a restart. To prevent this, paperflow can automatically move files from the consume directory to a separate archive after a configurable delay:

```toml
ingest_archive_dir = "~/paperflow-archive"
ingest_archive_after = "5m"
```

After copying a file to the consume directory, paperflow waits for the configured delay (giving Paperless time to pick it up), then moves the file to the archive directory with a timestamp prefix (e.g. `20260421-164532_invoice.pdf`). If Paperless already consumed and deleted the file, the archiver silently skips it.

This is disabled by default. The archive also serves as an audit trail of what was sent to Paperless — files can be manually re-ingested from the archive if needed.

This does not apply to API mode, which uploads directly and has no residual files.

## Ingestible file types

By default, the following file types are forwarded to Paperless when ingestion is enabled:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.1
0.4.0
24 changes: 23 additions & 1 deletion cmd/paperflow/init_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func runInit(f flags) error {
return fmt.Errorf("invalid ingestion method: %s", ingest)
}

var ingestDir, paperlessURL, token string
var ingestDir, archiveDir, archiveAfter, paperlessURL, token string

switch ingest {
case "directory":
Expand All @@ -63,6 +63,24 @@ func runInit(f flags) error {
ingestDir = "~/paperless-ingest"
}

fmt.Print("Archive ingested files to prevent re-ingestion on Paperless restart? [y/N] ")
archiveAnswer, _ := reader.ReadString('\n')
archiveAnswer = strings.TrimSpace(strings.ToLower(archiveAnswer))
if archiveAnswer == "y" || archiveAnswer == "yes" {
fmt.Print("Archive directory [~/paperflow-archive]: ")
archiveDir, _ = reader.ReadString('\n')
archiveDir = strings.TrimSpace(archiveDir)
if archiveDir == "" {
archiveDir = "~/paperflow-archive"
}
fmt.Print("Archive delay [5m]: ")
archiveAfter, _ = reader.ReadString('\n')
archiveAfter = strings.TrimSpace(archiveAfter)
if archiveAfter == "" {
archiveAfter = "5m"
}
}

case "api":
fmt.Print("Paperless URL (e.g. https://paperless.example.com): ")
paperlessURL, _ = reader.ReadString('\n')
Expand All @@ -87,6 +105,10 @@ func runInit(f flags) error {

if ingest == "directory" {
fmt.Fprintf(&b, "ingest_dir = %q\n", ingestDir)
if archiveDir != "" {
fmt.Fprintf(&b, "ingest_archive_dir = %q\n", archiveDir)
fmt.Fprintf(&b, "ingest_archive_after = %q\n", archiveAfter)
}
}
if ingest == "api" {
fmt.Fprintf(&b, "paperless_url = %q\n", paperlessURL)
Expand Down
22 changes: 21 additions & 1 deletion cmd/paperflow/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type flags struct {
watchDir string
ingest string
ingestDir string
ingestArchiveDir string
ingestArchiveAfter string
paperlessURL string
paperlessTokenFile string
config string
Expand Down Expand Up @@ -83,6 +85,8 @@ Flags:
--watch <dir> Override watch directory
--ingest <method> Override ingestion method (directory, api, none)
--ingest-dir <dir> Override ingest directory
--ingest-archive-dir <dir> Archive directory for ingested files
--ingest-archive-after <d> Delay before archiving (default: 5m)
--paperless-url <url> Paperless-ngx base URL (for API ingestion)
--paperless-token-file <p> Path to Paperless API token file
--config <path> Path to config file
Expand All @@ -101,7 +105,7 @@ func findCommand(args []string) string {
if strings.HasPrefix(arg, "--") {
// Flags that take a value.
switch arg {
case "--watch", "--ingest", "--ingest-dir", "--paperless-url", "--paperless-token-file", "--config":
case "--watch", "--ingest", "--ingest-dir", "--ingest-archive-dir", "--ingest-archive-after", "--paperless-url", "--paperless-token-file", "--config":
skip = true
}
continue
Expand Down Expand Up @@ -141,6 +145,16 @@ func parseFlags(args []string) flags {
i++
f.ingestDir = args[i]
}
case "--ingest-archive-dir":
if i+1 < len(args) {
i++
f.ingestArchiveDir = args[i]
}
case "--ingest-archive-after":
if i+1 < len(args) {
i++
f.ingestArchiveAfter = args[i]
}
case "--paperless-url":
if i+1 < len(args) {
i++
Expand Down Expand Up @@ -210,6 +224,12 @@ func loadConfigWithFlags(f flags) (*config.Config, error) {
}
cfg.Token = strings.TrimSpace(string(data))
}
if f.ingestArchiveDir != "" {
cfg.IngestArchiveDir = f.ingestArchiveDir
}
if f.ingestArchiveAfter != "" {
cfg.IngestArchiveAfter = f.ingestArchiveAfter
}
if f.noNotify {
cfg.Notifications.Enabled = false
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/paperflow/service_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ func serviceInstall(f flags) error {
if f.ingestDir != "" {
extraFlags = append(extraFlags, "--ingest-dir", f.ingestDir)
}
if f.ingestArchiveDir != "" {
extraFlags = append(extraFlags, "--ingest-archive-dir", f.ingestArchiveDir)
}
if f.ingestArchiveAfter != "" {
extraFlags = append(extraFlags, "--ingest-archive-after", f.ingestArchiveAfter)
}
if f.paperlessURL != "" {
extraFlags = append(extraFlags, "--paperless-url", f.paperlessURL)
}
Expand Down
20 changes: 20 additions & 0 deletions cmd/paperflow/validate_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"time"

"github.com/alcxyz/paperflow/internal/config"
)
Expand Down Expand Up @@ -53,6 +54,25 @@ func runValidate(f flags) error {
} else {
fmt.Printf(" OK ingest_dir: %s\n", ingestDir)
}

if cfg.IngestArchiveDir != "" {
archiveDir := config.ExpandTilde(cfg.IngestArchiveDir)
if info, err := os.Stat(archiveDir); err != nil {
fmt.Printf(" WARN ingest_archive_dir: %s does not exist (will be created)\n", archiveDir)
warnings++
} else if !info.IsDir() {
fmt.Printf(" FAIL ingest_archive_dir: %s is not a directory\n", archiveDir)
errors++
} else {
fmt.Printf(" OK ingest_archive_dir: %s\n", archiveDir)
}
if _, err := time.ParseDuration(cfg.IngestArchiveAfter); err != nil {
fmt.Printf(" FAIL ingest_archive_after: invalid duration %q\n", cfg.IngestArchiveAfter)
errors++
} else {
fmt.Printf(" OK ingest_archive_after: %s\n", cfg.IngestArchiveAfter)
}
}
case "api":
if cfg.PaperlessURL == "" {
fmt.Println(" FAIL paperless_url: not set")
Expand Down
35 changes: 35 additions & 0 deletions docs/adr/ADR-014-ingest-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ADR-014: Archive ingested files from consume directory

**Status:** Accepted
**Date:** 2026-04-21
**Applies to:** `internal/ingest/archiver.go`, `internal/config/config.go`

## Context

When using directory-mode ingestion, paperflow copies files into Paperless-ngx's consume directory. If Paperless restarts (e.g. container redeployment, auto-update), it re-scans the consume directory and re-ingests any files still present, potentially creating duplicates. API mode does not have this problem since uploads are fire-and-forget.

## Decision

Add an optional archiver that moves files from the consume directory to a separate archive directory after a configurable delay. The delay gives Paperless time to pick up the file first.

Configuration:
- `ingest_archive_dir`: path to the archive directory (empty = disabled, the default)
- `ingest_archive_after`: delay before archiving (default: `"5m"`)

Archive filenames use a flat layout with timestamp prefix: `20260421-164532_invoice.pdf`. The original organized copy in the watch directory is untouched.

If the file is already gone when the timer fires (Paperless consumed and deleted it), the archiver silently skips. On shutdown, all pending archives are flushed immediately.

## Alternatives Considered

- **Delete from consume dir after delay:** Simpler but loses the safety net. If Paperless missed a file, it's gone.
- **Document only, no mitigation:** Honest but doesn't solve the problem for users who can't switch to API mode.
- **Use API mode instead:** Sidesteps the problem entirely but requires network access to Paperless. Some deployments use local directory mounts by design.

## Consequences

- Directory-mode users can opt in to archive and avoid re-ingestion on Paperless restart.
- The archive directory serves as an audit trail of what was sent to Paperless.
- Files can be manually re-ingested from the archive if needed.
- Disabled by default — zero behavior change for existing users.
- Adds a per-file timer goroutine; memory is bounded by the number of files ingested within the delay window.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
| [ADR-011](ADR-011-git-derived-version.md) | ~~Version derived from git, no manual bumping~~ (superseded by ADR-012) | build |
| [ADR-012](ADR-012-version-file-auto-tag.md) | VERSION file with CI auto-tagging | build, CI |
| [ADR-013](ADR-013-systemd-path-injection.md) | Inject PATH into generated systemd unit | service |
| [ADR-014](ADR-014-ingest-archive.md) | Archive ingested files from consume directory | ingest |
17 changes: 15 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ type Config struct {
Ingest string `toml:"ingest"`
IngestDir string `toml:"ingest_dir"`

PaperlessURL string `toml:"paperless_url"`
PaperlessURL string `toml:"paperless_url"`
IngestArchiveDir string `toml:"ingest_archive_dir"`
IngestArchiveAfter string `toml:"ingest_archive_after"`

Notifications NotificationsConfig `toml:"notifications"`
Buckets map[string][]string `toml:"buckets"`
Expand Down Expand Up @@ -51,7 +53,8 @@ func DefaultConfig() *Config {
return &Config{
WatchDir: "~/Documents",
Ingest: "none",
IngestDir: "~/paperless-ingest",
IngestDir: "~/paperless-ingest",
IngestArchiveAfter: "5m",
Notifications: NotificationsConfig{
Enabled: true,
BatchWindow: "3s",
Expand Down Expand Up @@ -130,6 +133,9 @@ func LoadConfig(path string) (*Config, error) {
if cfg.IngestDir == "" {
cfg.IngestDir = defaults.IngestDir
}
if cfg.IngestArchiveAfter == "" {
cfg.IngestArchiveAfter = defaults.IngestArchiveAfter
}
if cfg.Notifications.BatchWindow == "" {
cfg.Notifications = defaults.Notifications
}
Expand All @@ -146,6 +152,7 @@ func LoadConfig(path string) (*Config, error) {
// Expand tildes in paths.
cfg.WatchDir = ExpandTilde(cfg.WatchDir)
cfg.IngestDir = ExpandTilde(cfg.IngestDir)
cfg.IngestArchiveDir = ExpandTilde(cfg.IngestArchiveDir)

// Apply environment variable overrides.
applyEnvOverrides(&cfg)
Expand Down Expand Up @@ -199,6 +206,12 @@ func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("PAPERFLOW_PAPERLESS_URL"); v != "" {
cfg.PaperlessURL = v
}
if v := os.Getenv("PAPERFLOW_INGEST_ARCHIVE_DIR"); v != "" {
cfg.IngestArchiveDir = ExpandTilde(v)
}
if v := os.Getenv("PAPERFLOW_INGEST_ARCHIVE_AFTER"); v != "" {
cfg.IngestArchiveAfter = v
}
if v := os.Getenv("PAPERFLOW_NO_NOTIFY"); v == "1" || v == "true" {
cfg.Notifications.Enabled = false
}
Expand Down
Loading
Loading