paperless-export 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """paperless-export — Paperless-ngx export wrapper + _Steuer/YYYY tax view."""
2
+
3
+ __version__ = "0.0.2"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m paperless_export`."""
2
+
3
+ from .cli import app
4
+
5
+ app()
@@ -0,0 +1,219 @@
1
+ """Typer CLI: `run` (exporter + views, the nightly job) and `tax-view` (views only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import logging
7
+ import sys
8
+ import traceback
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from . import __version__
16
+ from .errors import EXIT_UNEXPECTED, PaperlessExportError
17
+
18
+ app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
19
+
20
+
21
+ def _version_callback(value: bool) -> None:
22
+ if value:
23
+ try:
24
+ version = importlib.metadata.version("paperless-export")
25
+ except importlib.metadata.PackageNotFoundError:
26
+ version = __version__
27
+ typer.echo(f"paperless-export {version}")
28
+ raise typer.Exit()
29
+
30
+
31
+ @app.callback()
32
+ def main(
33
+ _version_flag: Annotated[
34
+ bool,
35
+ typer.Option("--version", callback=_version_callback, is_eager=True),
36
+ ] = False,
37
+ ) -> None:
38
+ """Paperless-ngx export wrapper + _Steuer/YYYY tax view."""
39
+
40
+
41
+ def _guarded[T](verbose: bool, action: Callable[[], T]) -> T:
42
+ logging.basicConfig(
43
+ level=logging.DEBUG if verbose else logging.INFO,
44
+ format="%(levelname)s %(name)s: %(message)s" if verbose else "%(message)s",
45
+ stream=sys.stderr,
46
+ )
47
+ try:
48
+ return action()
49
+ except PaperlessExportError as exc:
50
+ if verbose:
51
+ traceback.print_exc()
52
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
53
+ raise typer.Exit(code=exc.exit_code) from exc
54
+ except Exception as exc:
55
+ if verbose:
56
+ traceback.print_exc()
57
+ typer.secho(
58
+ f"Unexpected error: {exc} (re-run with --verbose for the full traceback)",
59
+ fg=typer.colors.RED,
60
+ err=True,
61
+ )
62
+ raise typer.Exit(code=EXIT_UNEXPECTED) from exc
63
+
64
+
65
+ def _build_views(
66
+ export_dir: Path,
67
+ *,
68
+ copy: bool,
69
+ tax_tag_prefix: str,
70
+ embed_tags: bool,
71
+ ) -> None:
72
+ from .embed import embed_metadata
73
+ from .manifest import load_documents
74
+ from .taxview import build_tax_view
75
+
76
+ documents = load_documents(export_dir / "manifest.json")
77
+ result = build_tax_view(export_dir, documents, copy=copy, prefix=tax_tag_prefix)
78
+ typer.echo(
79
+ f"_Steuer view: {result.total} documents across years "
80
+ f"{', '.join(sorted(result.years)) or '—'} (see _Steuer/INDEX.csv)"
81
+ )
82
+ for missing in result.missing:
83
+ typer.secho(f" missing on disk, not linked: {missing}", fg=typer.colors.YELLOW, err=True)
84
+ if embed_tags:
85
+ embedded = embed_metadata(export_dir, documents)
86
+ typer.echo(f"Embedded metadata into {embedded} PDFs.")
87
+
88
+
89
+ @app.command()
90
+ def run(
91
+ export_dir: Annotated[
92
+ Path,
93
+ typer.Option(
94
+ "--export-dir", help="Export directory on this host (where manifest.json lands)."
95
+ ),
96
+ ],
97
+ exporter_cmd: Annotated[
98
+ str,
99
+ typer.Option(
100
+ "--exporter-cmd",
101
+ envvar="PAPERLESS_EXPORTER_CMD",
102
+ help="How to invoke document_exporter.",
103
+ ),
104
+ ] = "docker compose exec -T webserver document_exporter",
105
+ exporter_target: Annotated[
106
+ str,
107
+ typer.Option(
108
+ "--exporter-target",
109
+ help="Export path as the exporter process sees it (inside the container).",
110
+ ),
111
+ ] = "../export",
112
+ filename_format: Annotated[
113
+ bool,
114
+ typer.Option(
115
+ "--filename-format/--no-filename-format",
116
+ help="Lay out the export by the storage-path template (--use-filename-format).",
117
+ ),
118
+ ] = True,
119
+ fallback: Annotated[
120
+ bool,
121
+ typer.Option(
122
+ "--fallback/--no-fallback",
123
+ help="On a path-too-long failure, retry as a flat export.",
124
+ ),
125
+ ] = True,
126
+ compare_checksums: Annotated[
127
+ bool,
128
+ typer.Option("--compare-checksums/--no-compare-checksums", help="Incremental re-export."),
129
+ ] = True,
130
+ delete: Annotated[
131
+ bool,
132
+ typer.Option(
133
+ "--delete/--no-delete",
134
+ help="Prune files for documents removed in Paperless (true mirror).",
135
+ ),
136
+ ] = True,
137
+ tax_view: Annotated[
138
+ bool, typer.Option("--tax-view/--no-tax-view", help="Build the _Steuer/YYYY view.")
139
+ ] = True,
140
+ copy: Annotated[
141
+ bool,
142
+ typer.Option("--copy", help="Copy into _Steuer instead of symlinking (FAT/exFAT targets)."),
143
+ ] = False,
144
+ tax_tag_prefix: Annotated[
145
+ str, typer.Option("--tax-tag-prefix", help="Tag prefix marking tax years.")
146
+ ] = "Steuer-",
147
+ embed_tags: Annotated[
148
+ bool,
149
+ typer.Option("--embed-tags", help="Embed tags into the exported PDFs' XMP (needs [pdf])."),
150
+ ] = False,
151
+ url: Annotated[
152
+ str, typer.Option("--url", envvar="PAPERLESS_URL", help="Paperless URL (preflight check).")
153
+ ] = "",
154
+ token: Annotated[
155
+ str, typer.Option("--token", envvar="PAPERLESS_TOKEN", help="Paperless API token.")
156
+ ] = "",
157
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
158
+ ) -> None:
159
+ """Run document_exporter, then build the _Steuer/YYYY tax view (the nightly job)."""
160
+
161
+ def action() -> None:
162
+ from .exporter import run_exporter
163
+ from .preflight import check_api
164
+
165
+ if url:
166
+ check_api(url, token)
167
+ result = run_exporter(
168
+ exporter_cmd,
169
+ exporter_target,
170
+ filename_format=filename_format,
171
+ compare_checksums=compare_checksums,
172
+ delete=delete,
173
+ fallback_on_long_paths=fallback,
174
+ )
175
+ if not result.used_filename_format and filename_format:
176
+ typer.secho(
177
+ "Note: fell back to a flat export (path too long) — see log above.",
178
+ fg=typer.colors.YELLOW,
179
+ err=True,
180
+ )
181
+ typer.echo("document_exporter finished.")
182
+ if tax_view:
183
+ _build_views(
184
+ export_dir, copy=copy, tax_tag_prefix=tax_tag_prefix, embed_tags=embed_tags
185
+ )
186
+
187
+ _guarded(verbose, action)
188
+
189
+
190
+ @app.command(name="tax-view")
191
+ def tax_view_cmd(
192
+ export_dir: Annotated[
193
+ Path,
194
+ typer.Option("--export-dir", help="Existing export directory containing manifest.json."),
195
+ ],
196
+ copy: Annotated[
197
+ bool,
198
+ typer.Option("--copy", help="Copy into _Steuer instead of symlinking (FAT/exFAT targets)."),
199
+ ] = False,
200
+ tax_tag_prefix: Annotated[
201
+ str, typer.Option("--tax-tag-prefix", help="Tag prefix marking tax years.")
202
+ ] = "Steuer-",
203
+ embed_tags: Annotated[
204
+ bool,
205
+ typer.Option("--embed-tags", help="Embed tags into the exported PDFs' XMP (needs [pdf])."),
206
+ ] = False,
207
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
208
+ ) -> None:
209
+ """Rebuild only the _Steuer/YYYY view from an existing export (no exporter run)."""
210
+ _guarded(
211
+ verbose,
212
+ lambda: _build_views(
213
+ export_dir, copy=copy, tax_tag_prefix=tax_tag_prefix, embed_tags=embed_tags
214
+ ),
215
+ )
216
+
217
+
218
+ if __name__ == "__main__":
219
+ app()
@@ -0,0 +1,50 @@
1
+ """Optional: embed Paperless tags/correspondent/type into exported PDFs' XMP.
2
+
3
+ Requires the `pdf` extra (`pipx install 'paperless-export[pdf]'`). Note that
4
+ rewriting a PDF changes its checksum, so embedded files are re-exported (and
5
+ re-embedded) on the next `--compare-checksums` run — manifest.json already
6
+ preserves all metadata, so only enable this if you want tags *inside* the files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ from .errors import ConfigError
15
+ from .manifest import ExportedDocument
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def embed_metadata(export_dir: Path, documents: list[ExportedDocument]) -> int:
21
+ """Write tags/correspondent/type into each exported PDF; returns count embedded."""
22
+ try:
23
+ import pikepdf
24
+ except ImportError as exc:
25
+ raise ConfigError(
26
+ "--embed-tags needs pikepdf — install with: pipx install 'paperless-export[pdf]'"
27
+ ) from exc
28
+
29
+ embedded = 0
30
+ for doc in documents:
31
+ pdf_path = export_dir / doc.file_path
32
+ if pdf_path.suffix.lower() != ".pdf" or not pdf_path.is_file():
33
+ continue
34
+ try:
35
+ with pikepdf.open(pdf_path, allow_overwriting_input=True) as pdf:
36
+ with pdf.open_metadata() as meta:
37
+ if doc.tags:
38
+ meta["dc:subject"] = doc.tags
39
+ meta["pdf:Keywords"] = ", ".join(doc.tags)
40
+ if doc.title:
41
+ meta["dc:title"] = doc.title
42
+ if doc.correspondent:
43
+ meta["dc:creator"] = [doc.correspondent]
44
+ if doc.document_type:
45
+ meta["dc:type"] = [doc.document_type]
46
+ pdf.save(pdf_path)
47
+ embedded += 1
48
+ except Exception as exc: # one broken PDF must not kill the run
49
+ logger.warning("Could not embed metadata into %s: %s", doc.file_path, exc)
50
+ return embedded
@@ -0,0 +1,46 @@
1
+ """User-facing errors with stable exit codes (never a raw traceback by default)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EXIT_UNEXPECTED = 1
6
+ EXIT_CONFIG = 2
7
+ EXIT_UNREACHABLE = 3
8
+ EXIT_OUTPUT = 4
9
+
10
+
11
+ class PaperlessExportError(Exception):
12
+ """Base for all errors that should surface as a one-line human message."""
13
+
14
+ exit_code: int = EXIT_UNEXPECTED
15
+
16
+
17
+ class ConfigError(PaperlessExportError):
18
+ """Invalid flags, missing paths, malformed URL."""
19
+
20
+ exit_code = EXIT_CONFIG
21
+
22
+
23
+ class AuthError(PaperlessExportError):
24
+ """Paperless token rejected (401/403)."""
25
+
26
+ exit_code = EXIT_CONFIG
27
+
28
+
29
+ class ServerUnreachableError(PaperlessExportError):
30
+ """Paperless API or container not reachable."""
31
+
32
+ exit_code = EXIT_UNREACHABLE
33
+
34
+
35
+ class OutputError(PaperlessExportError):
36
+ """Export directory unwritable or missing."""
37
+
38
+ exit_code = EXIT_OUTPUT
39
+
40
+
41
+ class ExporterFailedError(PaperlessExportError):
42
+ """document_exporter exited non-zero; carries its exit code and stderr."""
43
+
44
+ def __init__(self, message: str, exit_code: int) -> None:
45
+ super().__init__(message)
46
+ self.exit_code = exit_code if exit_code != 0 else EXIT_UNEXPECTED
@@ -0,0 +1,120 @@
1
+ """Thin wrapper around Paperless-ngx's built-in `document_exporter`.
2
+
3
+ The exporter already does the heavy lifting (type-tree layout via
4
+ `--use-filename-format`, incremental `--compare-checksums`, mirror `--delete`,
5
+ full `manifest.json`). This module only builds the command, runs it, surfaces
6
+ failures honestly, and falls back to a flat export when the filename-format
7
+ layout exceeds OS path limits.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import shlex
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+
17
+ from .errors import ExporterFailedError, ServerUnreachableError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DEFAULT_EXPORTER_CMD = "docker compose exec -T webserver document_exporter"
22
+ DEFAULT_TARGET = "../export"
23
+
24
+ _PATH_TOO_LONG_MARKERS = (
25
+ "file name too long",
26
+ "name too long",
27
+ "enametoolong",
28
+ "path too long",
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ExporterRun:
34
+ command: list[str]
35
+ used_filename_format: bool
36
+ stdout: str
37
+
38
+
39
+ def build_command(
40
+ exporter_cmd: str,
41
+ target: str,
42
+ *,
43
+ filename_format: bool,
44
+ compare_checksums: bool,
45
+ delete: bool,
46
+ ) -> list[str]:
47
+ command = [*shlex.split(exporter_cmd), target]
48
+ if filename_format:
49
+ command.append("--use-filename-format")
50
+ if compare_checksums:
51
+ command.append("--compare-checksums")
52
+ if delete:
53
+ command.append("--delete")
54
+ return command
55
+
56
+
57
+ def _looks_like_path_too_long(stderr: str) -> bool:
58
+ lowered = stderr.lower()
59
+ return any(marker in lowered for marker in _PATH_TOO_LONG_MARKERS)
60
+
61
+
62
+ def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
63
+ logger.info("Running: %s", shlex.join(command))
64
+ try:
65
+ return subprocess.run(command, capture_output=True, text=True, check=False)
66
+ except FileNotFoundError as exc:
67
+ raise ServerUnreachableError(
68
+ f"Cannot run the exporter: {exc}. Is Docker (or the webserver container) available? "
69
+ "Override the command with --exporter-cmd if Paperless runs differently."
70
+ ) from exc
71
+
72
+
73
+ def run_exporter(
74
+ exporter_cmd: str = DEFAULT_EXPORTER_CMD,
75
+ target: str = DEFAULT_TARGET,
76
+ *,
77
+ filename_format: bool = True,
78
+ compare_checksums: bool = True,
79
+ delete: bool = True,
80
+ fallback_on_long_paths: bool = True,
81
+ ) -> ExporterRun:
82
+ """Run `document_exporter`; on a path-length failure, retry flat once."""
83
+ command = build_command(
84
+ exporter_cmd,
85
+ target,
86
+ filename_format=filename_format,
87
+ compare_checksums=compare_checksums,
88
+ delete=delete,
89
+ )
90
+ proc = _run(command)
91
+ if proc.returncode == 0:
92
+ return ExporterRun(command, used_filename_format=filename_format, stdout=proc.stdout)
93
+
94
+ if filename_format and fallback_on_long_paths and _looks_like_path_too_long(proc.stderr):
95
+ logger.warning(
96
+ "Exporter failed because a path exceeded the OS limit. Falling back to a flat "
97
+ "export (no --use-filename-format) — the folder layout is lost for this run, but "
98
+ "manifest.json still preserves every tag/type/correspondent. Consider shortening "
99
+ "long document titles."
100
+ )
101
+ flat_command = build_command(
102
+ exporter_cmd,
103
+ target,
104
+ filename_format=False,
105
+ compare_checksums=compare_checksums,
106
+ delete=delete,
107
+ )
108
+ flat = _run(flat_command)
109
+ if flat.returncode == 0:
110
+ return ExporterRun(flat_command, used_filename_format=False, stdout=flat.stdout)
111
+ raise ExporterFailedError(
112
+ f"document_exporter failed even without --use-filename-format "
113
+ f"(exit {flat.returncode}):\n{flat.stderr.strip()}",
114
+ flat.returncode,
115
+ )
116
+
117
+ raise ExporterFailedError(
118
+ f"document_exporter failed (exit {proc.returncode}):\n{proc.stderr.strip()}",
119
+ proc.returncode,
120
+ )
@@ -0,0 +1,90 @@
1
+ """Parse the `manifest.json` written by Paperless-ngx's `document_exporter`.
2
+
3
+ The manifest is Django dumpdata format: a JSON array of
4
+ `{"model": ..., "pk": ..., "fields": {...}}` objects. The exporter annotates
5
+ each `documents.document` entry with top-level `__exported_file_name__` /
6
+ `__exported_archive_name__` keys pointing at the files it wrote.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, ConfigDict
17
+
18
+ from .errors import OutputError
19
+
20
+ EXPORTED_FILE_KEY = "__exported_file_name__"
21
+ EXPORTED_ARCHIVE_KEY = "__exported_archive_name__"
22
+
23
+
24
+ class ExportedDocument(BaseModel):
25
+ model_config = ConfigDict(frozen=True)
26
+
27
+ pk: int
28
+ title: str
29
+ correspondent: str | None
30
+ document_type: str | None
31
+ tags: list[str]
32
+ created: str
33
+ """ISO date (YYYY-MM-DD) if present, else empty string."""
34
+ file_path: str
35
+ """Path of the exported original, relative to the export dir."""
36
+ archive_path: str | None
37
+ """Path of the exported PDF/A archive version, if one exists."""
38
+
39
+ def tax_years(self, tag_pattern: re.Pattern[str]) -> list[str]:
40
+ years = []
41
+ for tag in self.tags:
42
+ match = tag_pattern.fullmatch(tag)
43
+ if match:
44
+ years.append(match.group(1))
45
+ return sorted(years)
46
+
47
+
48
+ def _names_by_pk(entries: list[dict[str, Any]], model: str) -> dict[int, str]:
49
+ return {
50
+ entry["pk"]: entry["fields"]["name"] for entry in entries if entry.get("model") == model
51
+ }
52
+
53
+
54
+ def load_documents(manifest_path: Path) -> list[ExportedDocument]:
55
+ if not manifest_path.is_file():
56
+ raise OutputError(
57
+ f"No manifest at {manifest_path} — run the exporter first "
58
+ "(or point --export-dir at an existing export)."
59
+ )
60
+ try:
61
+ entries: list[dict[str, Any]] = json.loads(manifest_path.read_text(encoding="utf-8"))
62
+ except json.JSONDecodeError as exc:
63
+ raise OutputError(f"{manifest_path} is not valid JSON: {exc}") from exc
64
+
65
+ tags = _names_by_pk(entries, "documents.tag")
66
+ correspondents = _names_by_pk(entries, "documents.correspondent")
67
+ doc_types = _names_by_pk(entries, "documents.documenttype")
68
+
69
+ documents: list[ExportedDocument] = []
70
+ for entry in entries:
71
+ if entry.get("model") != "documents.document":
72
+ continue
73
+ fields = entry["fields"]
74
+ file_path = entry.get(EXPORTED_FILE_KEY)
75
+ if not file_path:
76
+ continue # e.g. --data-only export: nothing on disk to link
77
+ created_raw = str(fields.get("created") or "")
78
+ documents.append(
79
+ ExportedDocument(
80
+ pk=entry["pk"],
81
+ title=fields.get("title", ""),
82
+ correspondent=correspondents.get(fields.get("correspondent")),
83
+ document_type=doc_types.get(fields.get("document_type")),
84
+ tags=[tags[pk] for pk in fields.get("tags", []) if pk in tags],
85
+ created=created_raw[:10],
86
+ file_path=file_path,
87
+ archive_path=entry.get(EXPORTED_ARCHIVE_KEY),
88
+ )
89
+ )
90
+ return documents
@@ -0,0 +1,39 @@
1
+ """Optional API preflight: fail fast on a bad token or unreachable Paperless.
2
+
3
+ The exporter itself runs inside the Paperless container and needs no API
4
+ access, so this check only runs when a URL is configured.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlparse
10
+
11
+ import httpx
12
+
13
+ from .errors import AuthError, ConfigError, ServerUnreachableError
14
+
15
+
16
+ def check_api(url: str, token: str, *, timeout: float = 15.0) -> None:
17
+ parsed = urlparse(url)
18
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
19
+ raise ConfigError(f"$PAPERLESS_URL must be an http(s) URL, got {url!r}.")
20
+ if not token:
21
+ raise AuthError("Authentication failed — check $PAPERLESS_TOKEN (it is empty).")
22
+ try:
23
+ response = httpx.get(
24
+ f"{url.rstrip('/')}/api/documents/",
25
+ params={"page_size": 1},
26
+ headers={"Authorization": f"Token {token}"},
27
+ timeout=timeout,
28
+ follow_redirects=True,
29
+ )
30
+ except httpx.TransportError as exc:
31
+ raise ServerUnreachableError(
32
+ f"Cannot reach Paperless at {url} ({exc}) — is the webserver container running?"
33
+ ) from exc
34
+ if response.status_code in (401, 403):
35
+ raise AuthError("Authentication failed — check $PAPERLESS_TOKEN.")
36
+ if response.status_code >= 400:
37
+ raise ServerUnreachableError(
38
+ f"Paperless at {url} answered {response.status_code} — is it healthy?"
39
+ )
File without changes
@@ -0,0 +1,106 @@
1
+ """Materialize the `_Steuer/YYYY/` cross-cutting tax view from the manifest.
2
+
3
+ The view is derived output, rebuilt from scratch on every run (idempotent).
4
+ Cleanup only touches the `_Steuer/` directory itself — never the exported
5
+ documents it points at.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import logging
12
+ import re
13
+ import shutil
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from .manifest import ExportedDocument
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ TAX_VIEW_DIR = "_Steuer"
22
+ INDEX_FILE = "INDEX.csv"
23
+ DEFAULT_TAG_PREFIX = "Steuer-"
24
+
25
+
26
+ def tag_pattern(prefix: str = DEFAULT_TAG_PREFIX) -> re.Pattern[str]:
27
+ return re.compile(rf"{re.escape(prefix)}(\d{{4}})")
28
+
29
+
30
+ @dataclass
31
+ class TaxViewResult:
32
+ linked: int = 0
33
+ copied: int = 0
34
+ missing: list[str] = field(default_factory=list)
35
+ years: set[str] = field(default_factory=set)
36
+
37
+ @property
38
+ def total(self) -> int:
39
+ return self.linked + self.copied
40
+
41
+
42
+ def _clear_view(view_root: Path) -> None:
43
+ if view_root.exists():
44
+ shutil.rmtree(view_root)
45
+ view_root.mkdir(parents=True)
46
+
47
+
48
+ def _unique_name(directory: Path, name: str, pk: int) -> str:
49
+ if not (directory / name).exists() and not (directory / name).is_symlink():
50
+ return name
51
+ stem, dot, suffix = name.rpartition(".")
52
+ return f"{stem}-{pk}.{suffix}" if dot else f"{name}-{pk}"
53
+
54
+
55
+ def build_tax_view(
56
+ export_dir: Path,
57
+ documents: list[ExportedDocument],
58
+ *,
59
+ copy: bool = False,
60
+ prefix: str = DEFAULT_TAG_PREFIX,
61
+ ) -> TaxViewResult:
62
+ """Create `_Steuer/<YYYY>/` links (or copies) + `_Steuer/INDEX.csv`."""
63
+ pattern = tag_pattern(prefix)
64
+ view_root = export_dir / TAX_VIEW_DIR
65
+ _clear_view(view_root)
66
+
67
+ result = TaxViewResult()
68
+ index_rows: list[tuple[str, str, str, str, str]] = []
69
+ use_copy = copy
70
+
71
+ for doc in sorted(documents, key=lambda d: (d.created, d.title)):
72
+ years = doc.tax_years(pattern)
73
+ if not years:
74
+ continue
75
+ source = export_dir / doc.file_path
76
+ if not source.is_file():
77
+ result.missing.append(doc.file_path)
78
+ continue
79
+ for year in years:
80
+ year_dir = view_root / year
81
+ year_dir.mkdir(parents=True, exist_ok=True)
82
+ link = year_dir / _unique_name(year_dir, source.name, doc.pk)
83
+ if use_copy:
84
+ shutil.copy2(source, link)
85
+ result.copied += 1
86
+ else:
87
+ try:
88
+ link.symlink_to(Path("..") / ".." / doc.file_path)
89
+ result.linked += 1
90
+ except OSError as exc:
91
+ logger.warning(
92
+ "Filesystem does not support symlinks (%s) — switching to copies.", exc
93
+ )
94
+ use_copy = True
95
+ shutil.copy2(source, link)
96
+ result.copied += 1
97
+ result.years.add(year)
98
+ index_rows.append(
99
+ (year, doc.title, doc.correspondent or "", doc.created, doc.file_path)
100
+ )
101
+
102
+ with (view_root / INDEX_FILE).open("w", newline="", encoding="utf-8") as fh:
103
+ writer = csv.writer(fh)
104
+ writer.writerow(["year", "title", "correspondent", "created", "original_path"])
105
+ writer.writerows(sorted(index_rows))
106
+ return result
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: paperless-export
3
+ Version: 0.0.2
4
+ Summary: Scheduled wrapper around Paperless-ngx's document_exporter plus a _Steuer/YYYY tax-view post-processor.
5
+ Project-URL: Repository, https://github.com/fileworks/paperless-export
6
+ Project-URL: Issues, https://github.com/fileworks/paperless-export/issues
7
+ Author: gykonik
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: backup,documents,export,paperless-ngx
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: System :: Archiving :: Backup
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.28
20
+ Requires-Dist: pydantic>=2.9
21
+ Requires-Dist: typer>=0.15
22
+ Provides-Extra: pdf
23
+ Requires-Dist: pikepdf>=9.0; extra == 'pdf'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # paperless-export
27
+
28
+ A thin scheduled wrapper around [Paperless-ngx](https://docs.paperless-ngx.com)'s
29
+ built-in `document_exporter`, plus the one thing it doesn't do: a materialized
30
+ **`_Steuer/YYYY/` tax view** built from your `Steuer-YYYY` tags.
31
+
32
+ Paperless's exporter already produces the full no-lock-in export — every
33
+ document laid out by your storage-path template, originals *and* PDF/A archive
34
+ versions, and a complete `manifest.json` (tags, correspondents, types, custom
35
+ fields). This tool deliberately does **not** rebuild any of that. It:
36
+
37
+ 1. runs `document_exporter <target> --use-filename-format --compare-checksums --delete`
38
+ (each flag toggleable), surfacing failures honestly and **falling back to a
39
+ flat export with a clear warning when a path exceeds the OS limit**,
40
+ 2. reads `manifest.json` and builds `_Steuer/<YYYY>/` — one symlink (or copy)
41
+ per document tagged `Steuer-YYYY` — plus a greppable `_Steuer/INDEX.csv`,
42
+ 3. optionally embeds tags/correspondent/type into the exported PDFs' XMP
43
+ (`--embed-tags`, needs the `[pdf]` extra).
44
+
45
+ ```
46
+ export/
47
+ Bescheid/Finanzamt/2024-05-01 Steuerbescheid.pdf # ← document_exporter
48
+ manifest.json # ← document_exporter
49
+ _Steuer/
50
+ 2024/2024-05-01 Steuerbescheid.pdf → ../../Bescheid/Finanzamt/…
51
+ INDEX.csv # year,title,correspondent,created,original_path
52
+ ```
53
+
54
+ ## Install
55
+
56
+ ```sh
57
+ pipx install paperless-export # + 'paperless-export[pdf]' for --embed-tags
58
+ # or
59
+ brew install fileworks/tap/paperless-export
60
+ ```
61
+
62
+ *(Not yet published — first release pending; until then: `uv run paperless-export` from a checkout.)*
63
+
64
+ ## Usage
65
+
66
+ ```sh
67
+ # the nightly job (run from the directory containing your compose file):
68
+ paperless-export run --export-dir /volume1/paperless/export
69
+
70
+ # rebuild only the tax view from an existing export:
71
+ paperless-export tax-view --export-dir /volume1/paperless/export
72
+
73
+ # FAT/exFAT or cloud targets that don't preserve symlinks:
74
+ paperless-export run --export-dir ./export --copy
75
+ ```
76
+
77
+ Notes:
78
+
79
+ - `--exporter-target` (default `../export`) is the path **as the exporter
80
+ process sees it** inside the container; `--export-dir` is the same directory
81
+ **on this host**. With the standard compose setup they're the same bind mount.
82
+ - `PAPERLESS_URL` + `PAPERLESS_TOKEN` (env or flags) enable a preflight check
83
+ so a bad token fails fast with a clear message — they're optional because the
84
+ exporter itself runs inside the container and needs no API access.
85
+ - `--embed-tags` rewrites the exported PDFs, which changes their checksums, so
86
+ those files are re-exported on the next `--compare-checksums` run. The
87
+ manifest already preserves all metadata — only embed if you want tags
88
+ *inside* the files.
89
+
90
+ ## Behavior guarantees
91
+
92
+ - **Read-only against Paperless** — writes only into the export directory.
93
+ - **Idempotent** — the `_Steuer/` view is rebuilt from scratch each run; safe nightly.
94
+ - **Verifiable** — after a run, `_Steuer/2025/` contains exactly the documents
95
+ tagged `Steuer-2025`; `INDEX.csv` matches a manifest query.
96
+ - **Honest failures** — a non-zero `document_exporter` exit surfaces its stderr
97
+ and exit code; symlink-unsupported filesystems auto-switch to copies with a notice.
98
+
99
+ ## Exit codes
100
+
101
+ | Code | Meaning |
102
+ |---|---|
103
+ | 0 | success |
104
+ | 2 | bad configuration / authentication failure |
105
+ | 3 | Paperless (or Docker) not reachable |
106
+ | 4 | export dir missing / manifest unreadable |
107
+ | *n* | `document_exporter` failed with exit code *n* |
108
+
109
+ ## Scheduling on a Synology (DSM Task Scheduler)
110
+
111
+ ```sh
112
+ cd /volume1/docker/paperless && \
113
+ /usr/local/bin/paperless-export run --export-dir /volume1/paperless/export
114
+ ```
115
+
116
+ Nightly, after the Paperless backup window; the export target should live on a
117
+ share covered by your backup chain.
118
+
119
+ ## Development
120
+
121
+ ```sh
122
+ uv sync --all-extras --dev
123
+ uv run ruff check . && uv run ruff format --check . # lint
124
+ uv run mypy # strict types
125
+ uv run pytest # tests
126
+ uv build
127
+ ```
128
+
129
+ Conventional Commits drive releases (`python-semantic-release`): merge to
130
+ `main` → version bump + changelog + GitHub Release + PyPI publish (OIDC) +
131
+ Homebrew formula bump.
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,15 @@
1
+ paperless_export/__init__.py,sha256=J0mUbS79dusyYi2d2DVNFvJDGPJPI2AEGu6ZrTlppXI,104
2
+ paperless_export/__main__.py,sha256=dWWwwAlCqhis0PpSLsjqjosEBzPcO7KVpreNO7vU3FI,71
3
+ paperless_export/cli.py,sha256=mICl0NwfM9UZ-ImcbCYCOcpzxeoIIf_esxd-TSsOYUM,7090
4
+ paperless_export/embed.py,sha256=DrECC6hXurCF5_6Iv05sMrf84jaBkR53HQxYPJDaKB0,1974
5
+ paperless_export/errors.py,sha256=GZgz7uwiNo92gvg48nkZF8_VreCMzbgsauX1SQ_LO2k,1158
6
+ paperless_export/exporter.py,sha256=lQCCwtA0nvuH7yh7w_qchh1-AXNWC6auViVZ6HHKF9c,3877
7
+ paperless_export/manifest.py,sha256=-gzleM3A0Jj326AYXxbY5d-dpr5PQUOMGCqT4g7xBQ8,3168
8
+ paperless_export/preflight.py,sha256=1FgjDqVZBxZxL-3wLvG-CkN-uGi7sGZqIQ__IZGhO-8,1463
9
+ paperless_export/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ paperless_export/taxview.py,sha256=-r4YJypoUImI5ZuQcZtnrV1w3rnPl5rBHVndMn__Gfw,3325
11
+ paperless_export-0.0.2.dist-info/METADATA,sha256=gcxXIwA54pCnHg-QsYH1TAlgZoHcmLfNp1Q-I2iP4H8,5254
12
+ paperless_export-0.0.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ paperless_export-0.0.2.dist-info/entry_points.txt,sha256=_60x-Q2l2iZfj7aJGE3V4baq-xXxuEPc3yjgwFuYJIk,62
14
+ paperless_export-0.0.2.dist-info/licenses/LICENSE,sha256=oG7brPhLE_Yq3DGpgTEyiaP32j3EzC276DEgECB47eY,1071
15
+ paperless_export-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ paperless-export = paperless_export.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niklas Büchel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.