aichivist 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.
- aichivist/__init__.py +3 -0
- aichivist/__main__.py +5 -0
- aichivist/agent_tools.py +184 -0
- aichivist/apps.py +243 -0
- aichivist/bagit.py +103 -0
- aichivist/catalog.py +136 -0
- aichivist/catalog_page.py +129 -0
- aichivist/checksums.py +45 -0
- aichivist/cli.py +230 -0
- aichivist/commands.py +306 -0
- aichivist/config.py +78 -0
- aichivist/describe.py +161 -0
- aichivist/drives.py +83 -0
- aichivist/duplicates.py +66 -0
- aichivist/fileops.py +188 -0
- aichivist/fulltext.py +183 -0
- aichivist/guard.py +102 -0
- aichivist/i18n.py +51 -0
- aichivist/inventory.py +110 -0
- aichivist/journal.py +39 -0
- aichivist/lang_de.py +288 -0
- aichivist/lifecycle.py +85 -0
- aichivist/markers.py +24 -0
- aichivist/mcp_server.py +97 -0
- aichivist/notify.py +60 -0
- aichivist/ocr.py +48 -0
- aichivist/ocr_mac.js +30 -0
- aichivist/ocr_windows.ps1 +38 -0
- aichivist/photos.py +184 -0
- aichivist/plan.py +188 -0
- aichivist/protect.py +36 -0
- aichivist/retention.py +53 -0
- aichivist/review.py +121 -0
- aichivist/rules.py +70 -0
- aichivist/schedule.py +123 -0
- aichivist/similar.py +49 -0
- aichivist/start.py +135 -0
- aichivist/storage.py +153 -0
- aichivist/structure.py +134 -0
- aichivist/style.py +29 -0
- aichivist/survey.py +242 -0
- aichivist/undo.py +57 -0
- aichivist/update.py +69 -0
- aichivist-0.1.0.dist-info/METADATA +143 -0
- aichivist-0.1.0.dist-info/RECORD +49 -0
- aichivist-0.1.0.dist-info/WHEEL +5 -0
- aichivist-0.1.0.dist-info/entry_points.txt +2 -0
- aichivist-0.1.0.dist-info/licenses/LICENSE +21 -0
- aichivist-0.1.0.dist-info/top_level.txt +1 -0
aichivist/__init__.py
ADDED
aichivist/__main__.py
ADDED
aichivist/agent_tools.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tools for agents without file access of their own (e.g. Claude Desktop via MCP).
|
|
2
|
+
|
|
3
|
+
Every path must lie inside the folders the person shared when starting the server (--root). Paths are resolved
|
|
4
|
+
first, so `..` and symlinks cannot escape. Moving files is only possible through filing plans.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from . import (
|
|
11
|
+
apps,
|
|
12
|
+
catalog,
|
|
13
|
+
config,
|
|
14
|
+
describe,
|
|
15
|
+
duplicates,
|
|
16
|
+
fulltext,
|
|
17
|
+
inventory,
|
|
18
|
+
lifecycle,
|
|
19
|
+
photos,
|
|
20
|
+
plan,
|
|
21
|
+
retention,
|
|
22
|
+
review,
|
|
23
|
+
similar,
|
|
24
|
+
storage,
|
|
25
|
+
structure,
|
|
26
|
+
style,
|
|
27
|
+
survey,
|
|
28
|
+
)
|
|
29
|
+
from .config import full_path
|
|
30
|
+
from .fileops import FilingError
|
|
31
|
+
|
|
32
|
+
PLAN_NAME = re.compile(r"^[\w .-]{1,60}$")
|
|
33
|
+
DEFAULT_MAX_CHARS = 20_000
|
|
34
|
+
LIST_LIMIT = 300
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Workspace:
|
|
38
|
+
def __init__(self, roots: List, rules=None, exclude: Optional[List] = None):
|
|
39
|
+
self.roots = [full_path(r).resolve() for r in roots]
|
|
40
|
+
self.rules = full_path(rules) if rules else None
|
|
41
|
+
self.exclude = [full_path(e).resolve() for e in exclude or []]
|
|
42
|
+
|
|
43
|
+
def excluded(self, path: Path) -> bool:
|
|
44
|
+
return any(path == e or e in path.parents for e in self.exclude)
|
|
45
|
+
|
|
46
|
+
def check(self, path) -> Path:
|
|
47
|
+
resolved = full_path(path).resolve()
|
|
48
|
+
if not any(resolved == r or r in resolved.parents for r in self.roots):
|
|
49
|
+
raise FilingError(f"{path} is outside the folders shared with aichivist")
|
|
50
|
+
if self.excluded(resolved):
|
|
51
|
+
raise FilingError(f"{path} is in an excluded folder; aichivist does not open it")
|
|
52
|
+
return resolved
|
|
53
|
+
|
|
54
|
+
def _plan_file(self, name: str) -> Path:
|
|
55
|
+
if not PLAN_NAME.match(name):
|
|
56
|
+
raise FilingError("plan names may contain letters, digits, spaces, dots, dashes and underscores")
|
|
57
|
+
folder = config.home_dir() / "plans"
|
|
58
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
return folder / f"{name}.md"
|
|
60
|
+
|
|
61
|
+
# ---- reading -------------------------------------------------------------
|
|
62
|
+
def get_rules(self) -> Dict[str, Any]:
|
|
63
|
+
if not self.rules or not self.rules.exists():
|
|
64
|
+
return {"rules": None, "note": "No rules file configured – ask the person where things belong before "
|
|
65
|
+
"proposing a plan, and suggest writing rules (templates/RULES.md)."}
|
|
66
|
+
return {"rules": self.rules.read_text(encoding="utf-8"), "path": str(self.rules)}
|
|
67
|
+
|
|
68
|
+
def survey(self, paths: List[str], stale_years: int = 3) -> Dict[str, Any]:
|
|
69
|
+
report = survey.run([self.check(p) for p in paths], stale_years=stale_years, exclude=self.exclude)
|
|
70
|
+
return survey.as_dict(report)
|
|
71
|
+
|
|
72
|
+
def list_folder(self, path: str) -> Dict[str, Any]:
|
|
73
|
+
folder = self.check(path)
|
|
74
|
+
entries = [{"name": p.name, "kind": "folder" if p.is_dir() else "file",
|
|
75
|
+
"size": p.stat().st_size if p.is_file() else None}
|
|
76
|
+
for p in sorted(folder.iterdir()) if not p.name.startswith(".") and not self.excluded(p.resolve())]
|
|
77
|
+
return {"path": str(folder), "entries": entries[:LIST_LIMIT], "truncated": len(entries) > LIST_LIMIT}
|
|
78
|
+
|
|
79
|
+
def read_document(self, path: str, max_chars: int = DEFAULT_MAX_CHARS) -> Dict[str, Any]:
|
|
80
|
+
file = self.check(path)
|
|
81
|
+
if not file.is_file():
|
|
82
|
+
raise FilingError(f"not a file: {path}")
|
|
83
|
+
text, method = fulltext.extract(file)
|
|
84
|
+
return {"path": str(file), "method": method, "text": text[:max_chars], "characters": len(text),
|
|
85
|
+
"truncated": len(text) > max_chars}
|
|
86
|
+
|
|
87
|
+
def inventory(self, path: str) -> Dict[str, Any]:
|
|
88
|
+
folder = self.check(path)
|
|
89
|
+
changes = inventory.diff(folder)
|
|
90
|
+
by_hand = inventory.manual_moves(folder, changes)
|
|
91
|
+
|
|
92
|
+
def visible(rel: str) -> bool:
|
|
93
|
+
return not self.excluded((folder / rel.rstrip("/")).resolve())
|
|
94
|
+
return {"entries": [e for e in changes.entries if visible(e)], "first_run": changes.first_run,
|
|
95
|
+
"new": [e for e in changes.new if visible(e)],
|
|
96
|
+
"moved": [{"from": o, "to": n, "by_hand": (o, n) in by_hand} for o, n in changes.moved
|
|
97
|
+
if visible(o) and visible(n)]}
|
|
98
|
+
|
|
99
|
+
def find_duplicates(self, paths: List[str], area: str) -> List[Dict[str, str]]:
|
|
100
|
+
found = duplicates.find([self.check(p) for p in paths], self.check(area))
|
|
101
|
+
return [{"source": str(d.source), "match": str(d.match)} for d in found
|
|
102
|
+
if not self.excluded(d.source.resolve()) and not self.excluded(d.match.resolve())]
|
|
103
|
+
|
|
104
|
+
def find_similar(self, paths: List[str], area: Optional[str] = None, threshold: float = 0.8) -> List[Dict]:
|
|
105
|
+
hits = similar.find([self.check(p) for p in paths], self.check(area) if area else None, threshold)
|
|
106
|
+
return [h._asdict() for h in hits]
|
|
107
|
+
|
|
108
|
+
def suggest_name(self, path: str) -> Dict[str, Any]:
|
|
109
|
+
return describe.suggest(self.check(path))
|
|
110
|
+
|
|
111
|
+
def naming_examples(self, area: str) -> Dict[str, Any]:
|
|
112
|
+
return style.examples(self.check(area))
|
|
113
|
+
|
|
114
|
+
def structure_advice(self, area: str) -> Dict[str, Any]:
|
|
115
|
+
report = structure.analyse(self.check(area))
|
|
116
|
+
return {"findings": [f._asdict() for f in report.findings], "summary": structure.render(report)}
|
|
117
|
+
|
|
118
|
+
def retention_due(self, area: str, years: float) -> List[Dict[str, Any]]:
|
|
119
|
+
return [d._asdict() for d in retention.due(self.check(area), years)]
|
|
120
|
+
|
|
121
|
+
# ---- filing plans --------------------------------------------------------
|
|
122
|
+
def plan_add(self, source: str, target: str, reason: str, plan_name: str = "current") -> Dict[str, str]:
|
|
123
|
+
checked_target = target if target == plan.TRASH else str(self.check(target))
|
|
124
|
+
return plan.add(self._plan_file(plan_name), self.check(source), checked_target, reason)._asdict()
|
|
125
|
+
|
|
126
|
+
def plan_show(self, plan_name: str = "current") -> Dict[str, Any]:
|
|
127
|
+
file = self._plan_file(plan_name)
|
|
128
|
+
rows = [r._asdict() for r in plan.read(file)] if file.exists() else []
|
|
129
|
+
return {"plan": str(file), "rows": rows}
|
|
130
|
+
|
|
131
|
+
def plan_check(self, plan_name: str = "current", create_folders: bool = False) -> Dict[str, Any]:
|
|
132
|
+
results = plan.check(self._plan_file(plan_name), create_folders)
|
|
133
|
+
return {"problems": [{"row": i, "source": r.source, "problem": p}
|
|
134
|
+
for i, (r, p) in enumerate(results, 1) if p]}
|
|
135
|
+
|
|
136
|
+
def plan_approve(self, plan_name: str = "current", rows: Optional[List[int]] = None,
|
|
137
|
+
leave_link: bool = False) -> Dict[str, int]:
|
|
138
|
+
return {"approved": plan.approve(self._plan_file(plan_name), rows, leave_link)}
|
|
139
|
+
|
|
140
|
+
def storage_check(self, table_file: str) -> Dict[str, Any]:
|
|
141
|
+
table = self.check(table_file)
|
|
142
|
+
if not table.exists():
|
|
143
|
+
return {"error": "table missing", "example": storage.EXAMPLE}
|
|
144
|
+
return storage.as_dict(storage.check(table))
|
|
145
|
+
|
|
146
|
+
def program_check(self, paths: List[str]) -> Dict[str, List[str]]:
|
|
147
|
+
ctx = apps.context()
|
|
148
|
+
return {p: apps.risks(self.check(p), ctx) for p in paths}
|
|
149
|
+
|
|
150
|
+
def plan_review(self, plan_name: str = "current") -> Dict[str, str]:
|
|
151
|
+
url = review.serve(self._plan_file(plan_name), open_browser=True, block=False)
|
|
152
|
+
return {"url": url, "note": "The person reviews the plan in the browser and approves rows there."}
|
|
153
|
+
|
|
154
|
+
def plan_apply(self, plan_name: str = "current", create_folders: bool = False) -> Dict[str, object]:
|
|
155
|
+
file = self._plan_file(plan_name)
|
|
156
|
+
for row in plan.read(file): # a plan file could have been edited – re-check every approved row
|
|
157
|
+
if row.mark in ("x", "link"):
|
|
158
|
+
self.check(row.source)
|
|
159
|
+
if row.target != plan.TRASH:
|
|
160
|
+
self.check(row.target)
|
|
161
|
+
return plan.apply(file, create_folders)._asdict()
|
|
162
|
+
|
|
163
|
+
def plan_photos(self, inbox: str, photos_root: str, plan_name: str = "current") -> Dict[str, Any]:
|
|
164
|
+
rows = photos.propose(self.check(inbox), self.check(photos_root), self._plan_file(plan_name))
|
|
165
|
+
return {"proposed": len(rows), "rows": [r._asdict() for r in rows]}
|
|
166
|
+
|
|
167
|
+
# ---- catalogs and collections ---------------------------------------------
|
|
168
|
+
def catalog_check(self, catalog_file: str, add_missing: bool = False, prefill: bool = True) -> Dict[str, Any]:
|
|
169
|
+
file = self.check(catalog_file)
|
|
170
|
+
added = catalog.add_missing(file, prefill=prefill) if add_missing else []
|
|
171
|
+
return {"added": added, **catalog.check(file)._asdict()}
|
|
172
|
+
|
|
173
|
+
def catalog_describe(self, catalog_file: str, file: str, content: str, search_terms: str,
|
|
174
|
+
date: str) -> Dict[str, str]:
|
|
175
|
+
return {"row": catalog.describe(self.check(catalog_file), file, content, search_terms, date)}
|
|
176
|
+
|
|
177
|
+
def status(self, path: str) -> Dict[str, Any]:
|
|
178
|
+
return {k: str(v) if v is not None else None for k, v in lifecycle.status(self.check(path))._asdict().items()}
|
|
179
|
+
|
|
180
|
+
def verify(self, path: str) -> Dict[str, List[str]]:
|
|
181
|
+
return lifecycle.verify(self.check(path))
|
|
182
|
+
|
|
183
|
+
def search(self, path: str, query: str) -> List[Dict[str, Any]]:
|
|
184
|
+
return [h._asdict() for h in fulltext.search(self.check(path), query)]
|
aichivist/apps.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Program data: notice before a move that a program depends on a path, so programs keep working afterwards.
|
|
2
|
+
|
|
3
|
+
A file is program data when it lies in a place programs manage (Library, AppData, Program Files, ~/.config …),
|
|
4
|
+
belongs to a program library or project (Photos library, Lightroom catalog, Obsidian vault, Steam, code project …),
|
|
5
|
+
is the target of a link or shortcut, or is open in a running program. aichivist then refuses to move it: the person
|
|
6
|
+
either adjusts the program first (`--app-data`), or moves it with `--leave-link` (plan mark `link`) – a link at the
|
|
7
|
+
old place points to the new one, so the program finds its files as before.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Iterable, List, NamedTuple, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from .config import full_path
|
|
18
|
+
from .i18n import t
|
|
19
|
+
from .inventory import PROJECT_MARKERS
|
|
20
|
+
|
|
21
|
+
LIBRARY_SUFFIXES = {
|
|
22
|
+
".app": "program", ".photoslibrary": "Photos library", ".musiclibrary": "Music library",
|
|
23
|
+
".tvlibrary": "TV library", ".lrcat": "Lightroom catalog", ".lrdata": "Lightroom catalog",
|
|
24
|
+
".fcpbundle": "Final Cut library", ".logicx": "Logic project", ".band": "GarageBand project",
|
|
25
|
+
".aplibrary": "Aperture library", ".vmwarevm": "virtual machine", ".pvm": "virtual machine",
|
|
26
|
+
".vbox": "virtual machine", ".utm": "virtual machine",
|
|
27
|
+
}
|
|
28
|
+
FOLDER_MARKERS = {
|
|
29
|
+
".obsidian": "Obsidian vault", "zotero.sqlite": "Zotero library", "metadata.db": "Calibre library",
|
|
30
|
+
"collection.anki2": "Anki profile", "steamapps": "Steam library", "prefs.js": "Firefox/Thunderbird profile",
|
|
31
|
+
".minecraft": "Minecraft folder", "node_modules": "code project", "iTunes Library.itl": "iTunes library",
|
|
32
|
+
**{marker: "code project" for marker in PROJECT_MARKERS},
|
|
33
|
+
}
|
|
34
|
+
LINK_DEPTH = 3 # how deep below the home folder links and shortcuts are looked for
|
|
35
|
+
SHORTCUT_MAX = 65536 # Windows .lnk files are small; bigger files are not shortcuts
|
|
36
|
+
LSOF_TIMEOUT = 20 # seconds for listing the files open in running programs
|
|
37
|
+
SHOWN_PROGRAMS = 3 # programs named per open file
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Context(NamedTuple):
|
|
41
|
+
"""Collected once per run: links/shortcuts (place → what they point to) and files open in programs."""
|
|
42
|
+
links: List[Tuple[Path, str]]
|
|
43
|
+
open_files: Dict[str, str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def managed_places() -> List[Path]:
|
|
47
|
+
"""Folders that belong to programs or the system, per operating system."""
|
|
48
|
+
home = Path.home()
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
names = ("APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "ProgramFiles", "ProgramFiles(x86)", "SystemRoot")
|
|
51
|
+
return [Path(os.environ[n]) for n in names if os.environ.get(n)]
|
|
52
|
+
if sys.platform == "darwin":
|
|
53
|
+
return [home / "Library", Path("/Library"), Path("/Applications"), Path("/System"), Path("/usr"),
|
|
54
|
+
Path("/opt")]
|
|
55
|
+
return [home / ".config", home / ".local", home / ".var", home / "snap", Path("/usr"), Path("/opt"),
|
|
56
|
+
Path("/etc"), Path("/var/lib")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_places() -> List[Path]:
|
|
60
|
+
"""The person's own cloud folders, even though they lie inside program folders (iCloud Drive in ~/Library …)."""
|
|
61
|
+
home = Path.home()
|
|
62
|
+
return [home / "Library" / "Mobile Documents" / "com~apple~CloudDocs", home / "Library" / "CloudStorage"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _inside(path: Path, folder: Path) -> bool:
|
|
66
|
+
return path == folder or folder in path.parents
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def place_risk(path: Path) -> Optional[str]:
|
|
70
|
+
"""Is the path in a folder that programs manage? Temporary folders are not counted."""
|
|
71
|
+
if any(_inside(path, p) for p in [Path(tempfile.gettempdir()).resolve(), *user_places()]):
|
|
72
|
+
return None
|
|
73
|
+
for place in managed_places():
|
|
74
|
+
if _inside(path, place):
|
|
75
|
+
return t("in the program folder {place}", place=place)
|
|
76
|
+
home = Path.home()
|
|
77
|
+
if _inside(path, home) and path != home:
|
|
78
|
+
parts = path.relative_to(home).parts # programs keep hidden folders directly in the home folder (~/.config);
|
|
79
|
+
if len(parts) > 1 and parts[0].startswith("."): # hidden folders deeper down belong to the person
|
|
80
|
+
return t("in the hidden program folder {place}", place=home / parts[0])
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def kind_of(folder: Path, names: Iterable[str]) -> Optional[str]:
|
|
85
|
+
"""Program library or project, judged from the folder's name and its entry names (no extra disk access)."""
|
|
86
|
+
if folder.suffix.lower() in LIBRARY_SUFFIXES:
|
|
87
|
+
return LIBRARY_SUFFIXES[folder.suffix.lower()]
|
|
88
|
+
present = set(names)
|
|
89
|
+
return next((kind for marker, kind in FOLDER_MARKERS.items() if marker in present), None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _kind(folder: Path) -> Optional[str]:
|
|
93
|
+
if folder.suffix.lower() in LIBRARY_SUFFIXES:
|
|
94
|
+
return LIBRARY_SUFFIXES[folder.suffix.lower()]
|
|
95
|
+
try:
|
|
96
|
+
if not folder.is_dir():
|
|
97
|
+
return None
|
|
98
|
+
return next((kind for marker, kind in FOLDER_MARKERS.items() if (folder / marker).exists()), None)
|
|
99
|
+
except OSError: # a folder that cannot be read (owned by another user) is not judged
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def library_risk(path: Path, look_inside: bool = True) -> Optional[str]:
|
|
104
|
+
"""Is the path a program library or project, part of one, or (with `look_inside`) does it contain one?"""
|
|
105
|
+
for folder in [path, *path.parents]:
|
|
106
|
+
kind = _kind(folder)
|
|
107
|
+
if kind:
|
|
108
|
+
return t("is a {kind}", kind=t(kind)) if folder == path else t("part of a {kind} ({folder})",
|
|
109
|
+
kind=t(kind), folder=folder)
|
|
110
|
+
if look_inside and path.is_dir():
|
|
111
|
+
for dirpath, dirnames, _ in os.walk(path):
|
|
112
|
+
for d in dirnames:
|
|
113
|
+
kind = _kind(Path(dirpath) / d)
|
|
114
|
+
if kind:
|
|
115
|
+
return t("contains a {kind} ({folder})", kind=t(kind), folder=Path(dirpath) / d)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _shortcut_text(lnk: Path) -> str:
|
|
120
|
+
"""Text of a Windows shortcut; its target path is searched in it, no shortcut parser needed."""
|
|
121
|
+
try:
|
|
122
|
+
if lnk.stat().st_size > SHORTCUT_MAX:
|
|
123
|
+
return ""
|
|
124
|
+
data = lnk.read_bytes()
|
|
125
|
+
except OSError:
|
|
126
|
+
return ""
|
|
127
|
+
return "\n".join((data.decode("latin-1"), data.decode("utf-16-le", "ignore"),
|
|
128
|
+
data[1:].decode("utf-16-le", "ignore")))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_links(roots: Iterable[Path], depth: int = LINK_DEPTH) -> List[Tuple[Path, str]]:
|
|
132
|
+
"""Symbolic links (resolved target) and Windows shortcuts (their text) below the roots, skipping program folders."""
|
|
133
|
+
found: List[Tuple[Path, str]] = []
|
|
134
|
+
places = managed_places()
|
|
135
|
+
for root in roots:
|
|
136
|
+
if not root.is_dir():
|
|
137
|
+
continue
|
|
138
|
+
base = len(root.parts)
|
|
139
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
140
|
+
folder = Path(dirpath)
|
|
141
|
+
for name in dirnames + filenames:
|
|
142
|
+
entry = folder / name
|
|
143
|
+
if entry.is_symlink():
|
|
144
|
+
found.append((entry, os.path.realpath(entry)))
|
|
145
|
+
elif name.lower().endswith(".lnk"):
|
|
146
|
+
found.append((entry, _shortcut_text(entry)))
|
|
147
|
+
if len(folder.parts) - base >= depth:
|
|
148
|
+
dirnames.clear()
|
|
149
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".") and not (folder / d).is_symlink()
|
|
150
|
+
and not (place_risk(folder / d) and any(_inside(folder / d, p) for p in places))]
|
|
151
|
+
return found
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def open_files() -> Dict[str, str]:
|
|
155
|
+
"""Files open in the person's running programs (path → program), via lsof where it exists."""
|
|
156
|
+
lsof = shutil.which("lsof")
|
|
157
|
+
if not lsof or not hasattr(os, "getuid"):
|
|
158
|
+
return {} # Windows itself refuses to move files that are open
|
|
159
|
+
try:
|
|
160
|
+
out = subprocess.run([lsof, "-w", "-n", "-u", str(os.getuid()), "-F", "cn"], capture_output=True,
|
|
161
|
+
text=True, errors="replace", timeout=LSOF_TIMEOUT).stdout
|
|
162
|
+
except (OSError, subprocess.SubprocessError):
|
|
163
|
+
return {}
|
|
164
|
+
files: Dict[str, str] = {}
|
|
165
|
+
program = ""
|
|
166
|
+
for line in out.splitlines():
|
|
167
|
+
if line.startswith("c"):
|
|
168
|
+
program = line[1:]
|
|
169
|
+
elif line.startswith("n/"):
|
|
170
|
+
files.setdefault(line[1:], program)
|
|
171
|
+
return files
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def context(roots: Optional[Iterable[Path]] = None) -> Context:
|
|
175
|
+
"""Links below the home folder (and the Windows start menu) plus open files, collected once."""
|
|
176
|
+
if roots is None:
|
|
177
|
+
roots = [Path.home()]
|
|
178
|
+
if sys.platform == "win32" and os.environ.get("APPDATA"):
|
|
179
|
+
roots.append(Path(os.environ["APPDATA"]) / "Microsoft" / "Windows" / "Start Menu")
|
|
180
|
+
return Context(find_links(roots), open_files())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _points_to(link: Path, target: str, path: Path, real: str) -> bool:
|
|
184
|
+
if link.suffix.lower() == ".lnk" and not link.is_symlink():
|
|
185
|
+
return str(path).lower() in target.lower()
|
|
186
|
+
return target == real or target.startswith(real + os.sep)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def risks(path, ctx: Context) -> List[str]:
|
|
190
|
+
"""Reasons why moving or deleting `path` may stop a program from working. Empty list: safe to move."""
|
|
191
|
+
path = full_path(path)
|
|
192
|
+
real = os.path.realpath(path)
|
|
193
|
+
found = [r for r in (place_risk(path), library_risk(path)) if r]
|
|
194
|
+
found += [t("the link {link} points to it", link=link) for link, target in ctx.links
|
|
195
|
+
if link != path and _points_to(link, target, path, real)]
|
|
196
|
+
in_use = sorted({prog for f, prog in ctx.open_files.items() if f == real or f.startswith(real + os.sep)})
|
|
197
|
+
if in_use:
|
|
198
|
+
more = t(" and {n} more", n=len(in_use) - SHOWN_PROGRAMS) if len(in_use) > SHOWN_PROGRAMS else ""
|
|
199
|
+
found.append(t("open in {programs}{more} – close it first", programs=", ".join(in_use[:SHOWN_PROGRAMS]),
|
|
200
|
+
more=more))
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def target_risk(target) -> Optional[str]:
|
|
205
|
+
"""Filing into a program folder or library would hand records over to a program. Other things in the target
|
|
206
|
+
folder do not matter: a project next to the target is not touched."""
|
|
207
|
+
folder = full_path(target).parent
|
|
208
|
+
return place_risk(folder) or library_risk(folder, look_inside=False)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def leave_link(old: Path, new: Path) -> None:
|
|
212
|
+
"""Put a link at the old place that points to the new one, so programs find the files as before.
|
|
213
|
+
Where symbolic links are not allowed (Windows without Developer Mode): a junction for folders, a hard link for
|
|
214
|
+
files (same drive only) – neither needs admin rights."""
|
|
215
|
+
try:
|
|
216
|
+
os.symlink(new, old, target_is_directory=new.is_dir())
|
|
217
|
+
return
|
|
218
|
+
except OSError:
|
|
219
|
+
if new.is_dir() and sys.platform != "win32":
|
|
220
|
+
raise
|
|
221
|
+
if new.is_dir():
|
|
222
|
+
done = subprocess.run(["cmd", "/c", "mklink", "/J", str(old), str(new)], capture_output=True, text=True)
|
|
223
|
+
if done.returncode != 0:
|
|
224
|
+
raise OSError(done.stderr.strip() or "mklink /J failed")
|
|
225
|
+
else:
|
|
226
|
+
os.link(new, old)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_link_to(old: Path, new: Path) -> bool:
|
|
230
|
+
"""Is `old` a link left behind by leave_link that leads to `new`?"""
|
|
231
|
+
if not new.exists() or not (old.is_symlink() or old.exists()):
|
|
232
|
+
return False
|
|
233
|
+
if old.is_symlink() or (old.is_dir() and sys.platform == "win32"):
|
|
234
|
+
return old != new and os.path.realpath(old) == os.path.realpath(new)
|
|
235
|
+
return old.is_file() and new.is_file() and os.path.samefile(old, new)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def remove_link(old: Path) -> None:
|
|
239
|
+
"""Remove only the link itself – never what it points to."""
|
|
240
|
+
try:
|
|
241
|
+
old.unlink()
|
|
242
|
+
except OSError:
|
|
243
|
+
os.rmdir(old) # directory symlinks and junctions on Windows; fails safely on a real, non-empty folder
|
aichivist/bagit.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Export a closed collection as a BagIt 1.0 bag (RFC 8493, Library of Congress) and validate bags.
|
|
2
|
+
|
|
3
|
+
The bag is a self-describing archival package: payload in data/, SHA-256 manifests, bag-info metadata. Any
|
|
4
|
+
BagIt tool (e.g. bagit-python, Bagger) can validate it – no aichivist needed in the future.
|
|
5
|
+
"""
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List
|
|
10
|
+
|
|
11
|
+
from . import __version__, journal, lifecycle
|
|
12
|
+
from .checksums import digest, manifest
|
|
13
|
+
from .config import full_path
|
|
14
|
+
from .fileops import FilingError
|
|
15
|
+
|
|
16
|
+
BAGIT_TXT = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
|
|
17
|
+
PAYLOAD_MANIFEST = "manifest-sha256.txt"
|
|
18
|
+
TAG_MANIFEST = "tagmanifest-sha256.txt"
|
|
19
|
+
TAG_FILES = ("bagit.txt", "bag-info.txt", PAYLOAD_MANIFEST)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encode(path: str) -> str:
|
|
23
|
+
"""Only CR/LF are encoded, as in the reference implementation bagit-python. RFC 8493 also asks for "%" → "%25",
|
|
24
|
+
but bagit-python does not decode it, so such bags would fail validation there; interoperability wins."""
|
|
25
|
+
return path.replace("\r", "%0D").replace("\n", "%0A")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode(path: str) -> str:
|
|
29
|
+
return path.replace("%0A", "\n").replace("%0D", "\r")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_manifest(path: Path, entries: Dict[str, str]) -> None:
|
|
33
|
+
path.write_text("".join(f"{h} {encode(rel)}\n" for rel, h in sorted(entries.items())), encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_manifest(path: Path) -> Dict[str, str]:
|
|
37
|
+
entries = {}
|
|
38
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
39
|
+
if line.strip():
|
|
40
|
+
checksum, rel = line.split(maxsplit=1)
|
|
41
|
+
entries[decode(rel.strip())] = checksum
|
|
42
|
+
return entries
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _copy_payload(root: Path, dest: Path, recorded: Dict[str, str]) -> Dict[str, str]:
|
|
46
|
+
payload = {}
|
|
47
|
+
for rel, checksum in recorded.items():
|
|
48
|
+
target = dest / "data" / rel
|
|
49
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
shutil.copy2(root / rel, target)
|
|
51
|
+
if digest(target) != checksum:
|
|
52
|
+
raise FilingError(f"copy of {rel} does not match the fixity manifest; export aborted")
|
|
53
|
+
payload[f"data/{rel}"] = checksum
|
|
54
|
+
return payload
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def export(area, dest) -> Path:
|
|
58
|
+
info = lifecycle.status(area)
|
|
59
|
+
if info.state != "closed":
|
|
60
|
+
raise FilingError(f"only closed collections can be exported as a bag: {area}")
|
|
61
|
+
root, dest = info.root, full_path(dest)
|
|
62
|
+
if dest.exists():
|
|
63
|
+
raise FilingError(f"target exists, nothing overwritten: {dest}")
|
|
64
|
+
changes = [f"{kind} {rel}" for kind, items in lifecycle.verify(root).items() for rel in items]
|
|
65
|
+
if changes:
|
|
66
|
+
raise FilingError(f"{root} changed since it was closed ({', '.join(changes[:5])}); run `aichivist verify`")
|
|
67
|
+
recorded = lifecycle.recorded_manifest(root)
|
|
68
|
+
try:
|
|
69
|
+
payload = _copy_payload(root, dest, recorded)
|
|
70
|
+
except BaseException:
|
|
71
|
+
shutil.rmtree(dest, ignore_errors=True) # no half-written bag
|
|
72
|
+
raise
|
|
73
|
+
(dest / "bagit.txt").write_text(BAGIT_TXT, encoding="utf-8")
|
|
74
|
+
_write_manifest(dest / PAYLOAD_MANIFEST, payload)
|
|
75
|
+
total = sum((root / rel).stat().st_size for rel in recorded)
|
|
76
|
+
(dest / "bag-info.txt").write_text(
|
|
77
|
+
f"Bagging-Date: {date.today().isoformat()}\n"
|
|
78
|
+
f"Payload-Oxum: {total}.{len(recorded)}\n"
|
|
79
|
+
f"External-Identifier: {root.name}\n"
|
|
80
|
+
f"External-Description: {' '.join((info.note or 'closed collection').split())}\n"
|
|
81
|
+
f"Closed-At: {info.closed_at}\n"
|
|
82
|
+
f"Bag-Software-Agent: aichivist {__version__}\n", encoding="utf-8")
|
|
83
|
+
_write_manifest(dest / TAG_MANIFEST, {name: digest(dest / name) for name in TAG_FILES})
|
|
84
|
+
journal.write(root, dest, "export_bag")
|
|
85
|
+
return dest
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate(bag) -> List[str]:
|
|
89
|
+
"""Problems found in a bag; an empty list means the bag is complete and intact."""
|
|
90
|
+
bag = full_path(bag)
|
|
91
|
+
if not (bag / "bagit.txt").exists() or not (bag / PAYLOAD_MANIFEST).exists():
|
|
92
|
+
return ["not a bag: bagit.txt or manifest-sha256.txt missing"]
|
|
93
|
+
problems = []
|
|
94
|
+
if (bag / TAG_MANIFEST).exists():
|
|
95
|
+
for name, checksum in _read_manifest(bag / TAG_MANIFEST).items():
|
|
96
|
+
if not (bag / name).exists() or digest(bag / name) != checksum:
|
|
97
|
+
problems.append(f"tag file changed: {name}")
|
|
98
|
+
recorded = _read_manifest(bag / PAYLOAD_MANIFEST)
|
|
99
|
+
present = {f"data/{rel}": h for rel, h in manifest(bag / "data").items()} if (bag / "data").exists() else {}
|
|
100
|
+
problems += [f"changed: {rel}" for rel in sorted(recorded) if rel in present and present[rel] != recorded[rel]]
|
|
101
|
+
problems += [f"missing: {rel}" for rel in sorted(recorded) if rel not in present]
|
|
102
|
+
problems += [f"not in manifest: {rel}" for rel in sorted(present) if rel not in recorded]
|
|
103
|
+
return problems
|