primarysource 0.1.0__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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Managing what is installed: provenance sidecars, verify, remove, prune, du.
|
|
2
|
+
|
|
3
|
+
No network here (that is `catalog.py`). Every function takes the library directories from `library.zim_dirs()`
|
|
4
|
+
and only ever deletes files it found by scanning them: a path typed by a user is never passed to `unlink`.
|
|
5
|
+
|
|
6
|
+
**Provenance.** Each installed ZIM may have a sidecar `<file>.zimlore.json` recording where it came from and the
|
|
7
|
+
SHA-256 it had when it was verified. It is data, not trust: a file without one is `provenance: unknown`, and a
|
|
8
|
+
sidecar written by `verify --adopt` says `added_by: adopt`, leaves `catalogue_name`, `catalogue_date` and
|
|
9
|
+
`fetched_at` empty (nothing observed them) and records only what the file *is* now plus `adopted_at`.
|
|
10
|
+
|
|
11
|
+
**Deleting.** An edition is a ZIM `uuid`: copies of one uuid in two directories are one edition, and two uuids
|
|
12
|
+
sharing a Name and Date are an ambiguity that `remove` and `prune` report and refuse. Editions are ordered by the
|
|
13
|
+
PARSED `Date`; a group with a date that does not parse is never pruned. `prune` deletes only zimlore-managed files
|
|
14
|
+
(`added_by` get, sync or add) unless told `include_unmanaged`, refuses to delete anything while an edition it
|
|
15
|
+
keeps fails verification, and every deleting function returns what it deleted AND what failed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from datetime import UTC, date, datetime
|
|
28
|
+
from functools import partial
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from . import library
|
|
33
|
+
from .errors import ZimloreError
|
|
34
|
+
from .library import Book
|
|
35
|
+
|
|
36
|
+
SIDECAR_SUFFIX = ".zimlore.json"
|
|
37
|
+
SCHEMA = 1
|
|
38
|
+
MANAGED = frozenset({"get", "sync", "add"})
|
|
39
|
+
"""`added_by` values that mean zimlore itself put the file here. `adopt` and no sidecar at all do not."""
|
|
40
|
+
QUIET_SECONDS = 600
|
|
41
|
+
"""A `*.zim.part` or `*.zimlore.json.tmp` modified within the last ten minutes may belong to a live download or
|
|
42
|
+
write, so `prune` and `du` leave it alone (as they do a `.part` with a `.part.lock`)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Provenance:
|
|
47
|
+
source: str # the URL the bytes came from, "" when unknown
|
|
48
|
+
catalogue_name: str
|
|
49
|
+
catalogue_date: str
|
|
50
|
+
sha256: str
|
|
51
|
+
size: int
|
|
52
|
+
fetched_at: str
|
|
53
|
+
added_by: str # "get" | "add" | "sync" | "adopt"
|
|
54
|
+
schema: int = SCHEMA
|
|
55
|
+
adopted_at: str = "" # only `verify --adopt` sets it: when zimlore first saw the file
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sidecar_path(zim: Path) -> Path:
|
|
59
|
+
return zim.with_name(zim.name + SIDECAR_SUFFIX)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sha256_of(path: Path) -> str:
|
|
63
|
+
digest = hashlib.sha256()
|
|
64
|
+
with path.open("rb") as handle:
|
|
65
|
+
while chunk := handle.read(1 << 20):
|
|
66
|
+
digest.update(chunk)
|
|
67
|
+
return digest.hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hash_and_size(path: Path) -> tuple[str, int]:
|
|
71
|
+
"""SHA-256 and size from ONE open handle: the size is the bytes hashed, and a file whose length changed while
|
|
72
|
+
it was read is an error, so a sidecar can never pair one file's size with another's hash."""
|
|
73
|
+
digest = hashlib.sha256()
|
|
74
|
+
total = 0
|
|
75
|
+
with path.open("rb") as handle:
|
|
76
|
+
while chunk := handle.read(1 << 20):
|
|
77
|
+
digest.update(chunk)
|
|
78
|
+
total += len(chunk)
|
|
79
|
+
end = os.fstat(handle.fileno()).st_size
|
|
80
|
+
if end != total:
|
|
81
|
+
raise ZimloreError(
|
|
82
|
+
f"{path} changed while it was being read", "run the command again when nothing writes to it"
|
|
83
|
+
)
|
|
84
|
+
return digest.hexdigest(), total
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def now() -> str:
|
|
88
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_sidecar(zim: Path, record: Provenance) -> Path:
|
|
92
|
+
"""Write the sidecar through a temporary file that is flushed to disk before it replaces the old one."""
|
|
93
|
+
path = sidecar_path(zim)
|
|
94
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
95
|
+
try:
|
|
96
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
|
97
|
+
handle.write(json.dumps(asdict(record), indent=2) + "\n")
|
|
98
|
+
handle.flush()
|
|
99
|
+
os.fsync(handle.fileno())
|
|
100
|
+
temporary.replace(path)
|
|
101
|
+
except OSError:
|
|
102
|
+
temporary.unlink(missing_ok=True)
|
|
103
|
+
raise
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_sidecar(zim: Path) -> tuple[Provenance | None, str]:
|
|
108
|
+
"""(record, problem). `(None, "")` means there is no sidecar; a sidecar that cannot be used is reported in
|
|
109
|
+
`problem` rather than treated as absent, because a corrupt record is not the same as no record."""
|
|
110
|
+
path = sidecar_path(zim)
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
return None, ""
|
|
113
|
+
try:
|
|
114
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
115
|
+
if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
|
|
116
|
+
return None, f"{path.name}: unknown schema {raw.get('schema') if isinstance(raw, dict) else raw!r}"
|
|
117
|
+
return Provenance(**raw), ""
|
|
118
|
+
except (OSError, ValueError, TypeError) as error:
|
|
119
|
+
return None, f"{path.name}: {error}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def provenance_summary(zim: Path) -> dict[str, Any]:
|
|
123
|
+
"""What an agent or a citation may say about where a file came from."""
|
|
124
|
+
record, problem = read_sidecar(zim)
|
|
125
|
+
if record is None:
|
|
126
|
+
return {"state": "unrecorded" if not problem else "unreadable", "note": problem or "no sidecar"}
|
|
127
|
+
summary: dict[str, Any] = {
|
|
128
|
+
"state": "recorded",
|
|
129
|
+
"source": record.source or None,
|
|
130
|
+
"sha256": record.sha256,
|
|
131
|
+
"fetched_at": record.fetched_at or None, # an adopted file was never fetched by zimlore
|
|
132
|
+
"added_by": record.added_by,
|
|
133
|
+
}
|
|
134
|
+
if record.adopted_at:
|
|
135
|
+
summary["adopted_at"] = record.adopted_at
|
|
136
|
+
return summary
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- verify --------------------------------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _selected(name: str) -> list[Book]:
|
|
143
|
+
scanned = library.scan()
|
|
144
|
+
if not scanned.books and not scanned.problems:
|
|
145
|
+
raise library.empty_library_error(scanned)
|
|
146
|
+
if not name:
|
|
147
|
+
return scanned.books
|
|
148
|
+
wanted = library.select(name, scanned)
|
|
149
|
+
if wanted.kind == "newest": # a family named without a version: check every version of it
|
|
150
|
+
return list(wanted.members)
|
|
151
|
+
return [b for b in scanned.books if b.name == wanted.book.name]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def verify(name: str = "", *, hash_files: bool = True, adopt: bool = False) -> dict[str, Any]:
|
|
155
|
+
"""Check installed files against their sidecars and libzim's own checksum.
|
|
156
|
+
|
|
157
|
+
status per file: `ok` (sidecar matches and libzim's checksum holds), `changed` (size or SHA-256 differs from
|
|
158
|
+
the sidecar), `corrupt` (libzim's checksum fails), `unrecorded` (no sidecar; the file is only integrity-checked),
|
|
159
|
+
`adopted` (a sidecar was written because `adopt` was set), `adopted-unchecked` (the same, but the ZIM carries no
|
|
160
|
+
checksum of its own, so nothing vouches for it). `unreadable` files are listed by the scan itself.
|
|
161
|
+
"""
|
|
162
|
+
rows: list[dict[str, Any]] = []
|
|
163
|
+
for book in _selected(name):
|
|
164
|
+
record, problem = read_sidecar(book.path)
|
|
165
|
+
archive = library.open_archive(book.path)
|
|
166
|
+
intact = bool(archive.check()) if archive.has_checksum else None
|
|
167
|
+
row: dict[str, Any] = {"name": book.name, "edition": book.date or None, "file": book.path.name}
|
|
168
|
+
if record is None:
|
|
169
|
+
row["status"] = "corrupt" if intact is False else "unrecorded"
|
|
170
|
+
if problem:
|
|
171
|
+
row["provenance_problem"] = problem
|
|
172
|
+
if adopt and intact is not False and not problem:
|
|
173
|
+
digest, size = hash_and_size(book.path)
|
|
174
|
+
write_sidecar(book.path, Provenance("", "", "", digest, size, "", "adopt", adopted_at=now()))
|
|
175
|
+
row["status"] = "adopted" if intact else "adopted-unchecked"
|
|
176
|
+
if intact is None:
|
|
177
|
+
row["note"] = "this ZIM has no checksum of its own: only the hash recorded now can vouch for it"
|
|
178
|
+
else:
|
|
179
|
+
if hash_files:
|
|
180
|
+
actual, size = hash_and_size(book.path)
|
|
181
|
+
else:
|
|
182
|
+
actual, size = "", book.path.stat().st_size
|
|
183
|
+
if size != record.size:
|
|
184
|
+
row["status"], row["why"] = "changed", f"size {size} != recorded {record.size}"
|
|
185
|
+
elif hash_files and actual != record.sha256:
|
|
186
|
+
row["status"], row["why"] = "changed", f"sha256 {actual[:12]}... != recorded {record.sha256[:12]}..."
|
|
187
|
+
elif intact is False:
|
|
188
|
+
row["status"], row["why"] = "corrupt", "libzim's own checksum fails"
|
|
189
|
+
else:
|
|
190
|
+
row["status"] = "ok"
|
|
191
|
+
if not hash_files and row["status"] == "ok":
|
|
192
|
+
row["note"] = "size only; SHA-256 not recomputed"
|
|
193
|
+
row["libzim_checksum"] = intact
|
|
194
|
+
rows.append(row)
|
|
195
|
+
problems = library.scan().problems # a file libzim cannot open at all (truncated, trailing bytes) is a failure
|
|
196
|
+
bad = [r for r in rows if r["status"] in {"changed", "corrupt"}]
|
|
197
|
+
return {"files": rows, "problems": problems, "failed": len(bad) + len(problems)}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- editions ------------------------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class Edition:
|
|
205
|
+
"""One ZIM (one `uuid`) and every installed copy of it."""
|
|
206
|
+
|
|
207
|
+
uuid: str
|
|
208
|
+
books: list[Book] = field(default_factory=list)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def date_text(self) -> str:
|
|
212
|
+
return self.books[0].date
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def when(self) -> date | None:
|
|
216
|
+
return self.books[0].when
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def editions_of(books: list[Book]) -> list[Edition]:
|
|
220
|
+
"""Group files by `uuid` (copies in two directories are one edition) and order them newest first by parsed
|
|
221
|
+
date, undated last. Ties are broken by uuid only to be deterministic: callers refuse ties before acting."""
|
|
222
|
+
grouped: dict[str, Edition] = {}
|
|
223
|
+
for book in sorted(books, key=lambda b: str(b.path)):
|
|
224
|
+
grouped.setdefault(book.uuid, Edition(book.uuid)).books.append(book)
|
|
225
|
+
return sorted(grouped.values(), key=lambda e: (library.date_key(e.date_text), e.uuid), reverse=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _managed(book: Book) -> bool:
|
|
229
|
+
record, _ = read_sidecar(book.path)
|
|
230
|
+
return record is not None and record.added_by in MANAGED
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _health(book: Book) -> str:
|
|
234
|
+
"""'' when the file passes every check available to it, else why not: libzim's checksum and, when a sidecar
|
|
235
|
+
exists, its size and SHA-256. A file nothing can vouch for is not healthy: it is 'unverifiable'."""
|
|
236
|
+
try:
|
|
237
|
+
archive = library.open_archive(book.path)
|
|
238
|
+
record, _ = read_sidecar(book.path)
|
|
239
|
+
checks = 0
|
|
240
|
+
if archive.has_checksum:
|
|
241
|
+
if not archive.check():
|
|
242
|
+
return "fails libzim's own checksum"
|
|
243
|
+
checks += 1
|
|
244
|
+
if record is not None:
|
|
245
|
+
digest, size = hash_and_size(book.path)
|
|
246
|
+
if size != record.size or digest != record.sha256:
|
|
247
|
+
return "no longer matches its provenance record"
|
|
248
|
+
checks += 1
|
|
249
|
+
except (OSError, ZimloreError) as error:
|
|
250
|
+
return f"cannot be checked ({error})"
|
|
251
|
+
return "" if checks else "cannot be verified: it has no checksum of its own and no provenance record"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _failure(error: OSError) -> str:
|
|
255
|
+
if isinstance(error, PermissionError):
|
|
256
|
+
return "stop any running 'primarysource zim mcp' server or kiwix reader that has it open, then retry"
|
|
257
|
+
return ""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _delete(book: Book) -> int:
|
|
261
|
+
"""Delete one edition file and its sidecar; return the bytes freed. Raises `OSError` (a `FileNotFoundError` for
|
|
262
|
+
a file that is already gone). The sidecar goes FIRST, so a later file of the same name can never inherit it;
|
|
263
|
+
if the ZIM then cannot be deleted (Windows keeps it locked while a handle is open) the sidecar is put back."""
|
|
264
|
+
library.close_path(book.path)
|
|
265
|
+
sidecar = sidecar_path(book.path)
|
|
266
|
+
try:
|
|
267
|
+
saved: bytes | None = sidecar.read_bytes()
|
|
268
|
+
except FileNotFoundError:
|
|
269
|
+
saved = None
|
|
270
|
+
if saved is not None:
|
|
271
|
+
sidecar.unlink()
|
|
272
|
+
try:
|
|
273
|
+
book.path.unlink()
|
|
274
|
+
except FileNotFoundError:
|
|
275
|
+
raise
|
|
276
|
+
except OSError:
|
|
277
|
+
if saved is not None:
|
|
278
|
+
sidecar.write_bytes(saved)
|
|
279
|
+
raise
|
|
280
|
+
return book.size
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _unlink_leftover(path: Path, size: int) -> int:
|
|
284
|
+
path.unlink()
|
|
285
|
+
return size
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _unlink_all(
|
|
289
|
+
books: list[Book], leftovers: list[dict[str, Any]]
|
|
290
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int]:
|
|
291
|
+
"""Delete each file on its own: a file that vanished is success, any other `OSError` is recorded and the rest
|
|
292
|
+
still go. Returns (deleted, failed, bytes freed)."""
|
|
293
|
+
deleted: list[dict[str, Any]] = []
|
|
294
|
+
failed: list[dict[str, Any]] = []
|
|
295
|
+
freed = 0
|
|
296
|
+
work: list[tuple[Path, int, Callable[[], int]]] = [(b.path, b.size, partial(_delete, b)) for b in books]
|
|
297
|
+
for item in leftovers:
|
|
298
|
+
path, size = Path(item["file"]), int(item["bytes"])
|
|
299
|
+
work.append((path, size, partial(_unlink_leftover, path, size)))
|
|
300
|
+
for path, size, action in work:
|
|
301
|
+
try:
|
|
302
|
+
freed += action()
|
|
303
|
+
deleted.append({"file": str(path), "bytes": size})
|
|
304
|
+
except FileNotFoundError:
|
|
305
|
+
deleted.append({"file": str(path), "bytes": 0, "already_gone": True})
|
|
306
|
+
except OSError as error:
|
|
307
|
+
failed.append({"file": str(path), "error": str(error), "hint": _failure(error)})
|
|
308
|
+
return deleted, failed, freed
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _result(
|
|
312
|
+
plan: list[dict[str, Any]],
|
|
313
|
+
applied: bool,
|
|
314
|
+
outcome: tuple[list[dict[str, Any]], list[dict[str, Any]], int],
|
|
315
|
+
refused: list[dict[str, Any]],
|
|
316
|
+
skipped: list[dict[str, Any]],
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
deleted, failed, freed = outcome
|
|
319
|
+
note = ""
|
|
320
|
+
if failed:
|
|
321
|
+
note = f"{len(failed)} file(s) could NOT be deleted and {len(deleted)} were; see 'failed'"
|
|
322
|
+
elif not applied and plan:
|
|
323
|
+
note = "dry run: nothing was deleted; add --yes to delete"
|
|
324
|
+
return {
|
|
325
|
+
"applied": applied,
|
|
326
|
+
"files": plan,
|
|
327
|
+
"deleted": deleted,
|
|
328
|
+
"failed": failed,
|
|
329
|
+
"refused": refused,
|
|
330
|
+
"skipped": skipped,
|
|
331
|
+
"bytes": sum(item["bytes"] for item in plan),
|
|
332
|
+
"freed": freed,
|
|
333
|
+
"ok": not failed and not (applied and refused),
|
|
334
|
+
"note": note,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _plan_item(book: Book) -> dict[str, Any]:
|
|
339
|
+
return {"file": str(book.path), "edition": book.date or None, "uuid": book.uuid, "bytes": book.size}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _leftovers(
|
|
343
|
+
directories: list[Path], *, everywhere: bool = False, moment: float | None = None
|
|
344
|
+
) -> list[dict[str, Any]]:
|
|
345
|
+
"""Files zimlore itself leaves behind: `*.zim.part` (an interrupted download) and `*.zimlore.json.tmp` (a
|
|
346
|
+
sidecar write that died). Only regular files, never a symlink or directory, never one touched in the last
|
|
347
|
+
`QUIET_SECONDS`, never a `.part` with a `.part.lock`, and by default only in the directory zimlore
|
|
348
|
+
downloads to."""
|
|
349
|
+
moment = time.time() if moment is None else moment
|
|
350
|
+
found: list[dict[str, Any]] = []
|
|
351
|
+
for directory in directories if everywhere else directories[:1]:
|
|
352
|
+
if not directory.is_dir():
|
|
353
|
+
continue
|
|
354
|
+
for pattern, kind in (("*.zim.part", "partial download"), ("*.zimlore.json.tmp", "stale sidecar temp file")):
|
|
355
|
+
for path in sorted(directory.glob(pattern)):
|
|
356
|
+
if path.is_symlink() or not path.is_file():
|
|
357
|
+
continue
|
|
358
|
+
if kind == "partial download" and path.with_name(path.name + ".lock").exists():
|
|
359
|
+
continue
|
|
360
|
+
stat = path.stat()
|
|
361
|
+
if moment - stat.st_mtime < QUIET_SECONDS:
|
|
362
|
+
continue
|
|
363
|
+
found.append({"file": str(path), "edition": None, "bytes": stat.st_size, "kind": kind})
|
|
364
|
+
return found
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --- remove / prune / du -------------------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _edition_matches(edition: Edition, text: str) -> bool:
|
|
371
|
+
wanted = library.parse_date(text)
|
|
372
|
+
return text in {edition.uuid, edition.date_text} or (wanted is not None and wanted == edition.when)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def remove(
|
|
376
|
+
name: str, *, edition: str = "", all_editions: bool = False, all_versions: bool = False, apply: bool = False
|
|
377
|
+
) -> dict[str, Any]:
|
|
378
|
+
"""Remove installed editions of one library. Dry run unless `apply`. With several editions installed the
|
|
379
|
+
caller must say which (`edition`: a date or a uuid) or say all (`all_editions`): deleting a user's files is
|
|
380
|
+
never a default. Two different ZIMs sharing the date are an ambiguity, not a pair to delete together. The
|
|
381
|
+
result lists `deleted` and `failed` files even when some deletions fail.
|
|
382
|
+
|
|
383
|
+
A name that means a package with several installed VERSIONS (`cobra`) is refused unless a version is written
|
|
384
|
+
(`cobra v1.8.1`) or `all_versions` says every version goes: 'the newest' is a fine thing to read and a
|
|
385
|
+
surprising thing to delete."""
|
|
386
|
+
scanned = library.scan()
|
|
387
|
+
if not scanned.books:
|
|
388
|
+
raise library.empty_library_error(scanned)
|
|
389
|
+
selected = library.select(name, scanned)
|
|
390
|
+
chosen = selected.book
|
|
391
|
+
if all_versions:
|
|
392
|
+
found = editions_of(list(selected.members))
|
|
393
|
+
elif selected.kind == "newest" and selected.other_versions:
|
|
394
|
+
raise ZimloreError(
|
|
395
|
+
f"{chosen.family} has {len(selected.versions)} versions installed; say which to remove",
|
|
396
|
+
f"write the version (remove '{chosen.family} {chosen.version}'), or --all-versions for every one",
|
|
397
|
+
versions=selected.versions,
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
found = editions_of(library.editions(chosen, scanned.books))
|
|
401
|
+
if edition:
|
|
402
|
+
matching = [e for e in found if _edition_matches(e, edition)]
|
|
403
|
+
if not matching:
|
|
404
|
+
raise ZimloreError(
|
|
405
|
+
f"{chosen.name} has no edition {edition}", "primarysource zim list shows the installed dates"
|
|
406
|
+
)
|
|
407
|
+
if len(matching) > 1:
|
|
408
|
+
raise ZimloreError(
|
|
409
|
+
f"{len(matching)} different ZIMs of {chosen.name} carry the date {edition}; not guessing",
|
|
410
|
+
"name one by its uuid: --edition UUID (candidates are listed); the files are different builds",
|
|
411
|
+
candidates=[{"uuid": e.uuid, "files": [str(b.path) for b in e.books]} for e in matching],
|
|
412
|
+
)
|
|
413
|
+
found = matching
|
|
414
|
+
elif len(found) > 1 and not (all_editions or all_versions):
|
|
415
|
+
raise ZimloreError(
|
|
416
|
+
f"{chosen.name} has {len(found)} editions installed; say which to remove",
|
|
417
|
+
"--edition DATE for one, --all to remove every edition",
|
|
418
|
+
editions=[e.date_text for e in found],
|
|
419
|
+
)
|
|
420
|
+
books = [b for e in found for b in e.books]
|
|
421
|
+
plan = [_plan_item(b) for b in books]
|
|
422
|
+
outcome = _unlink_all(books, []) if apply else ([], [], 0)
|
|
423
|
+
return {"name": chosen.name, **_result(plan, apply, outcome, [], [])}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def prune(
|
|
427
|
+
*, keep: int = 2, apply: bool = False, include_unmanaged: bool = False, moment: float | None = None
|
|
428
|
+
) -> dict[str, Any]:
|
|
429
|
+
"""Delete all but the newest `keep` editions of every library, and zimlore's own stale leftovers. Dry run
|
|
430
|
+
unless `apply`.
|
|
431
|
+
|
|
432
|
+
Refuses (and says why, under `refused`) any library whose editions cannot be ordered safely: a `Date` that does
|
|
433
|
+
not parse, or two different ZIMs sharing one, and any library where an edition it would KEEP fails verification
|
|
434
|
+
(libzim's checksum, the sidecar hash), because the older edition may be the only good copy. Files zimlore did not
|
|
435
|
+
put there (no sidecar, or `added_by: adopt`) are listed under `skipped` and kept unless `include_unmanaged`."""
|
|
436
|
+
if keep < 1:
|
|
437
|
+
raise ZimloreError("keep must be at least 1", "pruning every edition is `primarysource zim remove --all`")
|
|
438
|
+
scanned = library.scan()
|
|
439
|
+
doomed: list[Book] = []
|
|
440
|
+
refused: list[dict[str, Any]] = []
|
|
441
|
+
skipped: list[dict[str, Any]] = []
|
|
442
|
+
for name in sorted({b.name for b in scanned.books}):
|
|
443
|
+
found = editions_of([b for b in scanned.books if b.name == name])
|
|
444
|
+
if len(found) <= keep:
|
|
445
|
+
continue
|
|
446
|
+
files = [str(b.path) for e in found for b in e.books]
|
|
447
|
+
undated = [e for e in found if e.when is None]
|
|
448
|
+
if undated:
|
|
449
|
+
refused.append(
|
|
450
|
+
{
|
|
451
|
+
"name": name,
|
|
452
|
+
"reason": "an edition has no Date that parses, so its age is unknown and nothing is deleted",
|
|
453
|
+
"files": [str(b.path) for e in undated for b in e.books],
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
continue
|
|
457
|
+
dates = [e.when for e in found]
|
|
458
|
+
if len(set(dates)) != len(dates):
|
|
459
|
+
refused.append(
|
|
460
|
+
{
|
|
461
|
+
"name": name,
|
|
462
|
+
"reason": "two different ZIMs (different uuid) share a Name and Date; which is older is unknown",
|
|
463
|
+
"files": files,
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
continue
|
|
467
|
+
broken = None
|
|
468
|
+
for edition in found[:keep]:
|
|
469
|
+
results = [(b, _health(b)) for b in edition.books]
|
|
470
|
+
if all(why for _, why in results):
|
|
471
|
+
broken = results[0]
|
|
472
|
+
break
|
|
473
|
+
if broken is not None:
|
|
474
|
+
refused.append(
|
|
475
|
+
{
|
|
476
|
+
"name": name,
|
|
477
|
+
"reason": f"{broken[0].path} is kept but {broken[1]}; the older one may be the last good copy",
|
|
478
|
+
"files": [str(broken[0].path)],
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
continue
|
|
482
|
+
for edition in found[keep:]:
|
|
483
|
+
for book in edition.books:
|
|
484
|
+
if include_unmanaged or _managed(book):
|
|
485
|
+
doomed.append(book)
|
|
486
|
+
else:
|
|
487
|
+
skipped.append(
|
|
488
|
+
{
|
|
489
|
+
**_plan_item(book),
|
|
490
|
+
"reason": "not installed by zimlore; --include-unmanaged deletes it",
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
leftovers = _leftovers(scanned.directories, everywhere=include_unmanaged, moment=moment)
|
|
494
|
+
plan = [_plan_item(b) for b in doomed] + leftovers
|
|
495
|
+
outcome = _unlink_all(doomed, leftovers) if apply else ([], [], 0)
|
|
496
|
+
result = {"keep": keep, **_result(plan, apply, outcome, refused, skipped)}
|
|
497
|
+
if refused and not result["note"]:
|
|
498
|
+
result["note"] = f"{len(refused)} library(ies) were not pruned; see 'refused'"
|
|
499
|
+
elif skipped and not result["note"]:
|
|
500
|
+
result["note"] = f"{len(skipped)} file(s) were left alone because zimlore did not install them; see 'skipped'"
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def du() -> dict[str, Any]:
|
|
505
|
+
"""Disk use per library (all editions), partial downloads, and free space on the volume. Partial downloads are
|
|
506
|
+
counted by the same rule `prune` deletes them by: stale `*.zim.part` regular files without a live lock."""
|
|
507
|
+
scanned = library.scan()
|
|
508
|
+
per: dict[str, dict[str, Any]] = {}
|
|
509
|
+
for name in sorted({b.name for b in scanned.books}):
|
|
510
|
+
mine = [b for b in scanned.books if b.name == name]
|
|
511
|
+
newest = library.newest(mine)
|
|
512
|
+
per[name] = {
|
|
513
|
+
"name": name,
|
|
514
|
+
"editions": len(editions_of(mine)),
|
|
515
|
+
"bytes": sum(b.size for b in mine),
|
|
516
|
+
"newest": newest.date if newest else "",
|
|
517
|
+
}
|
|
518
|
+
leftovers = _leftovers(scanned.directories)
|
|
519
|
+
partial = sum(item["bytes"] for item in leftovers if item["kind"] == "partial download")
|
|
520
|
+
where = next((d for d in scanned.directories if d.exists()), None)
|
|
521
|
+
free = shutil.disk_usage(where).free if where else None
|
|
522
|
+
libraries = sorted(per.values(), key=lambda r: -r["bytes"])
|
|
523
|
+
return {
|
|
524
|
+
"libraries": libraries,
|
|
525
|
+
"total_bytes": sum(r["bytes"] for r in libraries),
|
|
526
|
+
"partial_bytes": partial,
|
|
527
|
+
"stale_temp_files": sum(item["kind"] != "partial download" for item in leftovers),
|
|
528
|
+
"free_bytes": free,
|
|
529
|
+
"directories": [str(d) for d in scanned.directories],
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# --- add -----------------------------------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _copy_hashed(source: Path, target: Path) -> str:
|
|
537
|
+
digest = hashlib.sha256()
|
|
538
|
+
with source.open("rb") as reader, target.open("wb") as writer:
|
|
539
|
+
while chunk := reader.read(1 << 20):
|
|
540
|
+
writer.write(chunk)
|
|
541
|
+
digest.update(chunk)
|
|
542
|
+
return digest.hexdigest()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def add_local(source: Path, *, link: bool = False, force: bool = False) -> dict[str, Any]:
|
|
546
|
+
"""Install a local ZIM into the first library directory, with a provenance sidecar (`added_by: add`).
|
|
547
|
+
|
|
548
|
+
libzim opens the file first, so an unreadable file is an error with nothing copied; its own checksum must
|
|
549
|
+
hold unless `force`. The file is copied by default (safe on Windows, no dangling links, no lock on the
|
|
550
|
+
original); `link` makes a hard link, then a symbolic link, and says so when the OS refuses both. An existing
|
|
551
|
+
file at the target is never overwritten: an identical one means "already installed", a different one is an
|
|
552
|
+
error. Two files carrying one Name and Date are the same edition, so the second is refused naming `remove`.
|
|
553
|
+
"""
|
|
554
|
+
source = source.expanduser()
|
|
555
|
+
if not source.is_file():
|
|
556
|
+
raise ZimloreError(f"{source} is not a file", "give the path of a .zim file")
|
|
557
|
+
if source.suffix != ".zim":
|
|
558
|
+
raise ZimloreError(f"{source} does not end in .zim", "primarysource zim add takes a .zim file")
|
|
559
|
+
library.close_all()
|
|
560
|
+
try:
|
|
561
|
+
book = library.read_book(source)
|
|
562
|
+
archive = library.open_archive(source)
|
|
563
|
+
intact = bool(archive.check()) if archive.has_checksum else None
|
|
564
|
+
except ZimloreError as error:
|
|
565
|
+
raise ZimloreError(
|
|
566
|
+
error.message, "nothing was copied; check that the file is a complete .zim (re-download or re-copy it)"
|
|
567
|
+
) from error
|
|
568
|
+
finally:
|
|
569
|
+
library.close_all() # Windows keeps the file locked while a handle is open
|
|
570
|
+
if intact is False and not force:
|
|
571
|
+
raise ZimloreError(
|
|
572
|
+
f"{source} fails libzim's own checksum: the file is damaged",
|
|
573
|
+
"nothing was copied; re-obtain the file, or --force to install it anyway (verify will keep flagging it)",
|
|
574
|
+
)
|
|
575
|
+
directory = library.zim_dirs()[0]
|
|
576
|
+
if source.resolve().parent in {d.resolve() for d in library.zim_dirs() if d.is_dir()}:
|
|
577
|
+
raise ZimloreError(
|
|
578
|
+
f"{source} is already in the library directory {source.parent}",
|
|
579
|
+
"nothing to add; primarysource zim verify --adopt records a provenance sidecar for it",
|
|
580
|
+
)
|
|
581
|
+
target = directory / source.name
|
|
582
|
+
base = {"name": book.name, "edition": book.date or None, "file": str(target)}
|
|
583
|
+
if os.path.lexists(target):
|
|
584
|
+
digest = sha256_of(source)
|
|
585
|
+
if target.is_file() and sha256_of(target) == digest:
|
|
586
|
+
return {**base, "status": "already-installed", "mode": "none", "bytes": book.size, "sha256": digest}
|
|
587
|
+
raise ZimloreError(
|
|
588
|
+
f"{target} already exists and is a different file; not overwriting it",
|
|
589
|
+
"rename the source, or remove the installed file first: primarysource zim list shows it, "
|
|
590
|
+
"primarysource zim remove deletes it",
|
|
591
|
+
)
|
|
592
|
+
clash = [b for b in library.scan().books if b.name == book.name and (b.date == book.date or b.uuid == book.uuid)]
|
|
593
|
+
library.close_all()
|
|
594
|
+
if clash:
|
|
595
|
+
raise ZimloreError(
|
|
596
|
+
f"{book.name} edition {book.date or 'undated'} is already installed as {clash[0].path.name}",
|
|
597
|
+
f"replace it deliberately: primarysource zim remove {book.name} --edition {book.date} --yes, "
|
|
598
|
+
"then add again",
|
|
599
|
+
)
|
|
600
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
if link:
|
|
602
|
+
digest = sha256_of(source)
|
|
603
|
+
mode = "hardlink"
|
|
604
|
+
try:
|
|
605
|
+
os.link(source, target)
|
|
606
|
+
except OSError:
|
|
607
|
+
mode = "symlink"
|
|
608
|
+
try:
|
|
609
|
+
os.symlink(source.resolve(), target)
|
|
610
|
+
except OSError as error:
|
|
611
|
+
raise ZimloreError(
|
|
612
|
+
f"the operating system refused both a hard link and a symbolic link to {source} ({error})",
|
|
613
|
+
"omit --link to copy the file instead",
|
|
614
|
+
) from error
|
|
615
|
+
else:
|
|
616
|
+
mode = "copy"
|
|
617
|
+
staged = target.with_name(target.name + ".add.part")
|
|
618
|
+
try:
|
|
619
|
+
digest = _copy_hashed(source, staged)
|
|
620
|
+
os.replace(staged, target)
|
|
621
|
+
except OSError as error:
|
|
622
|
+
staged.unlink(missing_ok=True)
|
|
623
|
+
raise ZimloreError(
|
|
624
|
+
f"could not copy {source} to {target} ({error})", "free disk space and retry"
|
|
625
|
+
) from error
|
|
626
|
+
size = target.stat().st_size
|
|
627
|
+
write_sidecar(target, Provenance(str(source.resolve()), book.name, book.date, digest, size, now(), "add"))
|
|
628
|
+
return {
|
|
629
|
+
**base,
|
|
630
|
+
"status": "added",
|
|
631
|
+
"mode": mode,
|
|
632
|
+
"bytes": size,
|
|
633
|
+
"sha256": digest,
|
|
634
|
+
"checksum": intact,
|
|
635
|
+
"forced": bool(force and intact is False),
|
|
636
|
+
}
|