from __future__ import annotations import shutil from pathlib import Path from typing import Any from Core import GetRoots, IsInternalPath, ResolvePath FileTypeMap = { ".md": "markdown", ".markdown": "markdown", ".txt": "text", ".toml": "config", ".yaml": "config", ".yml": "config", ".json": "config", ".cfg": "config", ".ini": "config", ".html": "html", ".htm": "html", ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image", ".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video", } def ClassifyFile(EntryPath: Path) -> str: """Return a coarse file-type label used to pick a preview renderer.""" return FileTypeMap.get(EntryPath.suffix.lower(), "unknown") def ListRoots() -> list[dict[str, Any]]: """Return the configured roots as a flat list for the frontend.""" return [{"Name": Name} for Name in GetRoots()] def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None: """Create a new empty file or directory at the given path.""" EntryPath = ResolvePath(RootName, RelativePath) if EntryPath.exists(): raise FileExistsError(f"{EntryPath.name} already exists.") if IsDirectory: EntryPath.mkdir(parents=False) else: EntryPath.parent.mkdir(parents=True, exist_ok=True) EntryPath.touch() def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: str) -> None: """Rename or move a file/directory within the same root. Used for both plain renames (same parent, new name) and full moves. """ SourcePath = ResolvePath(RootName, SourceRelativePath) DestinationPath = ResolvePath(RootName, DestinationRelativePath) if not SourcePath.exists(): raise FileNotFoundError(f"{SourcePath.name} does not exist.") if DestinationPath.exists(): raise FileExistsError(f"{DestinationPath.name} already exists.") DestinationPath.parent.mkdir(parents=True, exist_ok=True) try: SourcePath.rename(DestinationPath) except OSError: # Cross-device or other rename failures — fall back to a copy+delete. shutil.move(str(SourcePath), str(DestinationPath)) def DeleteEntry(RootName: str, RelativePath: str) -> None: """Delete a file, or an entire directory tree.""" EntryPath = ResolvePath(RootName, RelativePath) if not EntryPath.exists(): raise FileNotFoundError(f"{EntryPath.name} does not exist.") if EntryPath.is_dir(): shutil.rmtree(EntryPath) else: EntryPath.unlink() def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) -> list[dict[str, Any]]: """List the immediate contents of a directory. SaraTag's internal files are always hidden, regardless of ShowHidden. Dotfiles/dotfolders are hidden unless ShowHidden is True. """ DirectoryPath = ResolvePath(RootName, RelativePath) if not DirectoryPath.is_dir(): raise NotADirectoryError(f"{DirectoryPath} is not a directory.") Entries: list[dict[str, Any]] = [] SortedChildren = sorted( DirectoryPath.iterdir(), key=lambda EntryPath: (EntryPath.is_file(), EntryPath.name.casefold()), ) for EntryPath in SortedChildren: if IsInternalPath(EntryPath): continue if not ShowHidden and EntryPath.name.startswith("."): continue IsDirectory = EntryPath.is_dir() Entries.append( { "Name": EntryPath.name, "IsDirectory": IsDirectory, "FileType": None if IsDirectory else ClassifyFile(EntryPath), "Size": None if IsDirectory else EntryPath.stat().st_size, } ) return Entries