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.
- paperless_export/__init__.py +3 -0
- paperless_export/__main__.py +5 -0
- paperless_export/cli.py +219 -0
- paperless_export/embed.py +50 -0
- paperless_export/errors.py +46 -0
- paperless_export/exporter.py +120 -0
- paperless_export/manifest.py +90 -0
- paperless_export/preflight.py +39 -0
- paperless_export/py.typed +0 -0
- paperless_export/taxview.py +106 -0
- paperless_export-0.0.2.dist-info/METADATA +135 -0
- paperless_export-0.0.2.dist-info/RECORD +15 -0
- paperless_export-0.0.2.dist-info/WHEEL +4 -0
- paperless_export-0.0.2.dist-info/entry_points.txt +2 -0
- paperless_export-0.0.2.dist-info/licenses/LICENSE +21 -0
paperless_export/cli.py
ADDED
|
@@ -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,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.
|