Metadata-Version: 2.5
Name: paperless-export
Version: 2.0.0
Summary: Scheduled wrapper around Paperless-ngx's document_exporter plus a _Steuer/YYYY tax-view post-processor.
Project-URL: Homepage, https://github.com/fileworks/paperless-export
Project-URL: Repository, https://github.com/fileworks/paperless-export
Project-URL: Issues, https://github.com/fileworks/paperless-export/issues
Project-URL: Changelog, https://github.com/fileworks/paperless-export/blob/main/CHANGELOG.md
Author: gykonik
License-Expression: MIT
License-File: LICENSE
Keywords: backup,documents,export,paperless-ngx
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Archiving :: Backup
Requires-Python: >=3.12
Requires-Dist: httpx>=0.28
Requires-Dist: pydantic>=2.9
Requires-Dist: typer>=0.15
Provides-Extra: pdf
Requires-Dist: pikepdf>=9.0; extra == 'pdf'
Description-Content-Type: text/markdown

<img src=".github/icon.svg" alt="" width="72" height="72" align="left">

# paperless-export

## Overview

A thin scheduled wrapper around [Paperless-ngx](https://docs.paperless-ngx.com)'s
built-in `document_exporter`, plus the one thing it doesn't do: a materialized
**`_Steuer/YYYY/` tax view** built from your `Steuer-YYYY` tags.

Paperless's exporter already produces the full no-lock-in export — every
document laid out by your storage-path template, originals *and* PDF/A archive
versions, and a complete `manifest.json` (tags, correspondents, types, custom
fields). This tool deliberately does **not** rebuild any of that. It:

1. runs `document_exporter <target> --use-filename-format --compare-checksums --delete`
   (each flag toggleable), streams progress with bounded diagnostics, and
   **falls back to a flat export with a clear warning when a path exceeds the
   OS limit**,
2. optionally embeds metadata into each distinct exported original/archive PDF
   before any derived copies are created,
3. validates every manifest path inside the export root, then builds
   `_Steuer/<YYYY>/` — one original-based symlink (or copy) per document tagged
   `Steuer-YYYY` — plus a greppable `_Steuer/INDEX.csv`,
4. publishes the derived view only after every required source and staged output
   validates; a failed replacement leaves the prior complete `_Steuer` current.

This is a readable escape copy, not a replacement for tested backups of
Paperless and its database.

```
export/
  Bescheid/Finanzamt/2024-05-01 Steuerbescheid.pdf   # ← document_exporter
  manifest.json                                       # ← document_exporter
  _Steuer/
    2024/2024-05-01 Steuerbescheid.pdf → ../../Bescheid/Finanzamt/…
    INDEX.csv                                         # year,title,correspondent,created,original_path
```

## Status

The latest verified public release is 1.2.2 across GitHub Releases, PyPI, and
the Fileworks Homebrew tap.

## Install

```sh
pipx install paperless-export          # + 'paperless-export[pdf]' for --embed-tags
# or
brew install fileworks/tap/paperless-export
```

Released **1.2.2** on
[PyPI](https://pypi.org/project/paperless-export/1.2.2/),
[GitHub](https://github.com/fileworks/paperless-export/releases/tag/v1.2.2), and
`fileworks/tap` (verified 2026-08-12).

## Quick start

```sh
# from the directory holding your Paperless compose file
paperless-export run --export-dir ~/paperless-export
```

The first run performs a full export. Later runs reuse what is already there and
rebuild only what changed.

## Usage

```sh
# the nightly job (run from the directory containing your compose file):
paperless-export run --export-dir /volume1/paperless/export

# protected secret-file path only; the passphrase value never enters argv/env:
paperless-export run \
  --export-dir /volume1/paperless/export \
  --passphrase-file /run/secrets/paperless-export-passphrase

# rebuild only the tax view from an existing export:
paperless-export tax-view --export-dir /volume1/paperless/export

# FAT/exFAT or cloud targets that don't preserve symlinks:
paperless-export run --export-dir ./export --copy

# embed originals and archive PDFs without creating _Steuer:
paperless-export run --export-dir ./export --no-tax-view --embed-tags
```

Notes:

- `--exporter-target` (default `../export`) is the path **as the exporter
  process sees it** inside the container; `--export-dir` is the same directory
  **on this host**. With the standard compose setup they're the same bind mount.
- `PAPERLESS_URL` + `PAPERLESS_TOKEN` (env or flags) enable a preflight check
  so a bad token fails fast with a clear message — they're optional because the
  exporter itself runs inside the container and needs no API access.
- `PAPERLESS_EXPORT_PASSPHRASE_FILE` is a path-only alias for
  `--passphrase-file`. `-` reads the passphrase once from standard input.
  Protected files must be regular, not symlinks, and mode `0600` on POSIX.
- `PAPERLESS_EXPORT_LOG_FILE` is the environment alias for `--log-file`.
  Without it, a bounded rotating `paperless-export.log` is written beside the
  export directory for unattended-job diagnostics.
- `PAPERLESS_EXPORT_TIMEOUT_SECONDS` is the environment alias for
  `--exporter-timeout`. The reviewed default is six hours; expiry terminates the
  child cleanly, then force-kills only if it will not exit.
- Passphrase transport is supported for the default
  `docker compose exec -T webserver document_exporter` adapter. A custom
  exporter command is rejected when a passphrase is configured unless this
  project gains a separately reviewed stdin adapter for it.
- `--embed-tags` rewrites each distinct original PDF and Paperless PDF/A
  archive. Non-PDF originals are skipped. Embedding happens before `_Steuer`
  copies, so copy mode receives the updated bytes. Rewrites change checksums,
  so Paperless can re-export those files on the next
  `--compare-checksums` run.

## Passphrase and export security

Without a passphrase, the command warns before launching Paperless because
Paperless-ngx 2.20.x may store supported account secrets in plaintext.
Paperless's native passphrase protects these fields:

- mail-account `password` and `refresh_token`;
- social-token `token` and `token_secret`.

The passphrase does **not** encrypt the entire `manifest.json`, exported
documents, or other metadata. Paperless records the cryptographic parameters
in `metadata.json`, and a later import needs the same passphrase. Keep the
passphrase in a secret-manager-mounted file (or provide it over stdin), make
the export directory accessible only to the backup account, and protect backup
copies with storage-level encryption. Do not put the secret value in a shell
argument or environment variable.

Every original and archive path read from `manifest.json` is treated as
untrusted. Absolute paths, Windows drive/UNC forms, empty/malformed components,
parent traversal, and symlink escapes are rejected before `_Steuer` is cleared
or any PDF is opened. The same confinement is repeated immediately before
file operations. Do not modify the export tree concurrently with
post-processing.

## Behavior guarantees

- **Read-only against Paperless** — writes only into the export directory.
- **Atomic derived view** — `_Steuer/` is built in a confined same-filesystem
  sibling stage and journalled through publication. Missing sources, copy/link
  failures, and interruptions preserve the last complete view.
- **Verifiable** — after a run, `_Steuer/2025/` contains exactly the documents
  tagged `Steuer-2025`; `INDEX.csv` matches a manifest query.
- **Honest failures** — exporter, unavailable infrastructure, unsafe output,
  and incomplete PDF metadata have separate stable exit categories. A missing
  tax-view source is a fatal output error because publishing a partial view is
  forbidden.
- **Never silent** — `document_exporter`'s output is relayed live rather than
  buffered until the end. Final errors repeat only the last 64 KiB diagnostic
  tail; path-too-long detection covers the entire stream independently.

## Exit codes

`immich-export`, `paperless-export` and `unpacksort` share one exit-code
vocabulary, so a script can branch on the code without knowing which tool it
ran. The class of outcome is the same everywhere; the specific condition is
this tool's, and the table below is what it means here.

| Code | Name | Meaning |
|---|---|---|
| 0 | `SUCCESS` | everything asked for was done |
| 1 | `PARTIAL` | the exporter succeeded but requested post-processing is incomplete |
| 2 | `USAGE` | bad flags, a path that is missing or malformed, or credentials the server rejected — nothing was attempted |
| 3 | `CONFLICT` | the Paperless API, or its Docker/Compose service or container, is unavailable |
| 4 | `FATAL` | unexpected failure, or a write that failed after work had begun (re-run with `--verbose`) |
| 130 | `INTERRUPTED` | cancelled by the operator |

The line between `2` and `4` is **was anything attempted**. A `--export-dir` that
is missing when you type the command is `2` in every tool: nothing ran, and
nothing was written. A write that fails part-way through leaves the export in a
state nobody can characterise, and that is `4`.

`document_exporter`'s own child code stays in the diagnostics line.

## Scheduling on a Synology (DSM Task Scheduler)

Use the checked-in POSIX/BusyBox-compatible wrapper so Task Scheduler does not
depend on an interactive shell profile:

```sh
PAPERLESS_COMPOSE_DIR=/volume1/docker/paperless \
PAPERLESS_EXPORT_DIR=/volume1/paperless/export \
/bin/sh /volume1/docker/paperless-export/scripts/synology-task.sh
```

Nightly, after the Paperless backup window; the export target should live on a
share covered by your backup chain. Run the task as a dedicated account that can
read the Compose project and write only the export directory. The wrapper sets
`umask 077`, validates absolute/readable locations before launch, and is syntax-
checked with a strict POSIX shell in CI.

## Configuration

Configuration is by flag and environment variable; nothing is stored between
runs.

| Setting | Purpose |
|---|---|
| `--exporter-cmd` | Exporter invocation; defaults to Docker Compose in the working directory |
| `--exporter-target` | Export path as seen inside the exporter container |
| `--passphrase-file` | File holding the export passphrase. The value never enters argv or the environment |
| `--copy` | Use verified copies in `_Steuer` instead of symlinks |
| `--no-tax-view` | Skip building `_Steuer` |
| `--log-file` | Bounded rotating logfile path |
| `--exporter-timeout` | Stop a stuck child after 21600 seconds by default |

`--help` is authoritative.

## Troubleshooting

**The export appears to hang.** Child output is streamed and a five-second
heartbeat reaches both the terminal and rotating logfile during silent work.
The configured exporter timeout stops a genuinely stuck child.

**Exit code 2 for a directory you expected to work.** `tax-view` rebuilds the
views of an export it did not produce, so `--export-dir` has to exist already;
run `paperless-export run` first. `run` will create the directory itself, but
only its last segment, and only when the parent is already there — so a mistyped
path fails loudly instead of quietly becoming a new empty tree.

**Exit code 1 or 4.** One means `document_exporter` succeeded but requested
post-processing (PDF metadata embedding, the `_Steuer` view) is incomplete; the
report names each part that is missing. Four means an unexpected failure, or
output that could not be written once the run was under way — including a
manifest path pointing outside the export root. Both leave Paperless originals
untouched, and a failed tax-view publication retains the previous complete
`_Steuer`.

**Symlinks fail on the target.** Use `--copy`; the tax view is then built
from copies.

## Development

```sh
uv sync --locked --all-extras --all-groups
uv run ruff check . && uv run ruff format --check .   # lint
uv run mypy                                           # strict types
uv run pytest                                         # tests
uv build                                              # sdist + wheel
```

Pull requests run linting, strict types, tests, and builds on Python 3.12, 3.13,
and 3.14, plus Linux/macOS/Windows portability, the Synology contract,
documentation links, dependency auditing, and an installed-wheel smoke test.
Renovate batches routine dependency changes into one Monday PR and automerge is
enabled; a per-repository concurrency limit of one prevents stacked dependency
branches or PRs.

A separate Sunday workflow indexes production-format manifests at 50,000,
100,000, and 500,000 entries, keeping schedule-only work out of pull-request
and `main` CI checks.

Conventional Commits drive `python-semantic-release`. After CI passes on a
`main` push, the release workflow pins the tested SHA, verifies the staged
source, lock, tag, distributions, and installed CLI, then atomically pushes the
release commit and tag. Publication proceeds through the protected
`github-release`, `pypi`, and `homebrew` environments in that order; PyPI uses
trusted publishing and GitHub Releases carry the verified wheel and sdist.

For per-clone paths, commands, or preferences, create an ignored
`CLAUDE.local.md` at the repository root. Do not put credentials or other
secrets in it.

## Security

Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
Registered secrets — the Paperless token and the passphrase — are redacted from
the logfile and from captured exporter output, including values split across
chunk boundaries. A passphrase is accepted only from a protected file or
standard input, never from `argv` or the environment.

## License

MIT
