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.
Files changed (49) hide show
  1. aichivist/__init__.py +3 -0
  2. aichivist/__main__.py +5 -0
  3. aichivist/agent_tools.py +184 -0
  4. aichivist/apps.py +243 -0
  5. aichivist/bagit.py +103 -0
  6. aichivist/catalog.py +136 -0
  7. aichivist/catalog_page.py +129 -0
  8. aichivist/checksums.py +45 -0
  9. aichivist/cli.py +230 -0
  10. aichivist/commands.py +306 -0
  11. aichivist/config.py +78 -0
  12. aichivist/describe.py +161 -0
  13. aichivist/drives.py +83 -0
  14. aichivist/duplicates.py +66 -0
  15. aichivist/fileops.py +188 -0
  16. aichivist/fulltext.py +183 -0
  17. aichivist/guard.py +102 -0
  18. aichivist/i18n.py +51 -0
  19. aichivist/inventory.py +110 -0
  20. aichivist/journal.py +39 -0
  21. aichivist/lang_de.py +288 -0
  22. aichivist/lifecycle.py +85 -0
  23. aichivist/markers.py +24 -0
  24. aichivist/mcp_server.py +97 -0
  25. aichivist/notify.py +60 -0
  26. aichivist/ocr.py +48 -0
  27. aichivist/ocr_mac.js +30 -0
  28. aichivist/ocr_windows.ps1 +38 -0
  29. aichivist/photos.py +184 -0
  30. aichivist/plan.py +188 -0
  31. aichivist/protect.py +36 -0
  32. aichivist/retention.py +53 -0
  33. aichivist/review.py +121 -0
  34. aichivist/rules.py +70 -0
  35. aichivist/schedule.py +123 -0
  36. aichivist/similar.py +49 -0
  37. aichivist/start.py +135 -0
  38. aichivist/storage.py +153 -0
  39. aichivist/structure.py +134 -0
  40. aichivist/style.py +29 -0
  41. aichivist/survey.py +242 -0
  42. aichivist/undo.py +57 -0
  43. aichivist/update.py +69 -0
  44. aichivist-0.1.0.dist-info/METADATA +143 -0
  45. aichivist-0.1.0.dist-info/RECORD +49 -0
  46. aichivist-0.1.0.dist-info/WHEEL +5 -0
  47. aichivist-0.1.0.dist-info/entry_points.txt +2 -0
  48. aichivist-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. aichivist-0.1.0.dist-info/top_level.txt +1 -0
aichivist/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """aichivist – archival-grade file operations for AI filing agents."""
2
+
3
+ __version__ = "0.1.0"
aichivist/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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