Files
Vault/src/core/manifest.py

239 lines
7.3 KiB
Python

"""Manifest dataclass for vault metadata."""
import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Literal
from uuid import uuid4
from src.core.file_entry import FileEntry
LocationStatus = Literal["active", "unreachable"]
@dataclass
class Location:
"""Represents a vault replica location.
Attributes:
path: Absolute path to the .vault file
last_seen: Last time this location was accessible
status: Current status (active/unreachable)
"""
path: str
last_seen: datetime
status: LocationStatus = "active"
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"path": self.path,
"last_seen": self.last_seen.isoformat(),
"status": self.status,
}
@classmethod
def from_dict(cls, data: dict) -> "Location":
"""Create Location from dictionary."""
return cls(
path=data["path"],
last_seen=datetime.fromisoformat(data["last_seen"]),
status=data["status"],
)
@dataclass
class Manifest:
"""Vault manifest containing all metadata.
This is stored as .vault/manifest.json inside each vault image.
Attributes:
vault_id: Unique identifier for the vault (UUID)
vault_name: Human-readable name
version: Manifest format version
created: Creation timestamp
last_modified: Last modification timestamp
image_size_mb: Size of the vault image in MB
locations: List of all replica locations
files: List of all files in the vault
"""
vault_id: str
vault_name: str
image_size_mb: int
created: datetime
last_modified: datetime
version: int = 1
locations: list[Location] = field(default_factory=list)
files: list[FileEntry] = field(default_factory=list)
@classmethod
def create_new(cls, vault_name: str, image_size_mb: int, location_path: str) -> "Manifest":
"""Create a new manifest for a fresh vault.
Args:
vault_name: Human-readable name for the vault
image_size_mb: Size of the vault image in MB
location_path: Path to the first .vault file
Returns:
New Manifest instance
"""
now = datetime.now()
resolved = str(Path(location_path).resolve())
return cls(
vault_id=str(uuid4()),
vault_name=vault_name,
image_size_mb=image_size_mb,
created=now,
last_modified=now,
locations=[Location(path=resolved, last_seen=now, status="active")],
files=[],
)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"vault_id": self.vault_id,
"vault_name": self.vault_name,
"version": self.version,
"created": self.created.isoformat(),
"last_modified": self.last_modified.isoformat(),
"image_size_mb": self.image_size_mb,
"locations": [loc.to_dict() for loc in self.locations],
"files": [f.to_dict() for f in self.files],
}
@classmethod
def from_dict(cls, data: dict) -> "Manifest":
"""Create Manifest from dictionary."""
return cls(
vault_id=data["vault_id"],
vault_name=data["vault_name"],
version=data["version"],
created=datetime.fromisoformat(data["created"]),
last_modified=datetime.fromisoformat(data["last_modified"]),
image_size_mb=data["image_size_mb"],
locations=[Location.from_dict(loc) for loc in data["locations"]],
files=[FileEntry.from_dict(f) for f in data["files"]],
)
def save(self, mount_point: Path) -> None:
"""Save manifest to .vault/manifest.json.
Args:
mount_point: Path where the vault is mounted
"""
vault_dir = mount_point / ".vault"
vault_dir.mkdir(exist_ok=True)
manifest_path = vault_dir / "manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
def load(cls, mount_point: Path) -> "Manifest":
"""Load manifest from .vault/manifest.json.
Args:
mount_point: Path where the vault is mounted
Returns:
Loaded Manifest instance
Raises:
FileNotFoundError: If manifest doesn't exist
"""
manifest_path = mount_point / ".vault" / "manifest.json"
with open(manifest_path, "r", encoding="utf-8") as f:
data = json.load(f)
return cls.from_dict(data)
def add_location(self, path: str) -> None:
"""Add a new replica location.
Args:
path: Absolute path to the .vault file
"""
resolved = str(Path(path).resolve())
# Don't add duplicate locations
for loc in self.locations:
if str(Path(loc.path).resolve()) == resolved:
loc.status = "active"
loc.last_seen = datetime.now()
self.last_modified = datetime.now()
return
self.locations.append(
Location(path=resolved, last_seen=datetime.now(), status="active")
)
self.last_modified = datetime.now()
def update_location_status(self, path: str, status: LocationStatus) -> None:
"""Update status of a location.
Args:
path: Path to the location
status: New status
"""
resolved = str(Path(path).resolve())
for loc in self.locations:
if str(Path(loc.path).resolve()) == resolved:
loc.status = status
if status == "active":
loc.last_seen = datetime.now()
break
self.last_modified = datetime.now()
def add_file(self, file_entry: FileEntry) -> None:
"""Add or update a file entry.
Args:
file_entry: File entry to add/update
"""
# Remove existing entry with same path
self.files = [f for f in self.files if f.path != file_entry.path]
self.files.append(file_entry)
self.last_modified = datetime.now()
def add_file_from_path(self, base_path: Path, file_path: Path) -> FileEntry:
"""Add a file entry from a file path.
Args:
base_path: Base path (mount point) for relative path calculation
file_path: Absolute path to the file
Returns:
The created FileEntry
"""
entry = FileEntry.from_path(base_path, file_path)
self.add_file(entry)
return entry
def remove_file(self, path: str) -> None:
"""Remove a file entry by path.
Args:
path: Relative path of the file to remove
"""
self.files = [f for f in self.files if f.path != path]
self.last_modified = datetime.now()
def get_file(self, path: str) -> FileEntry | None:
"""Get file entry by path.
Args:
path: Relative path of the file
Returns:
FileEntry if found, None otherwise
"""
for f in self.files:
if f.path == path:
return f
return None