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") NoteFilePrefix = ".sv_" DefaultNoteExtension = ".md" def IsNoteFile(EntryName: str) -> bool: """A directory's own "file" - editable/viewable like any other file, but hidden from normal listings behind the ShowSvFiles setting. """ return EntryName.startswith(NoteFilePrefix) def FindDirectoryNote(DirectoryPath: Path) -> Path | None: """Find a directory's note file, whatever extension it currently has.""" Prefix = f"{NoteFilePrefix}{DirectoryPath.name}." for Candidate in DirectoryPath.iterdir(): if Candidate.is_file() and Candidate.name.startswith(Prefix): return Candidate return None def EnsureDirectoryNote(DirectoryPath: Path) -> Path: """Find a directory's note file, creating one (defaulting to markdown) if it doesn't have one yet - e.g. a directory that predates this feature. """ Existing = FindDirectoryNote(DirectoryPath) if Existing is not None: return Existing NotePath = DirectoryPath / f"{NoteFilePrefix}{DirectoryPath.name}{DefaultNoteExtension}" NotePath.touch() return NotePath def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None: """Read-only lookup for listings - never creates a note.""" NotePath = FindDirectoryNote(DirectoryPath) if NotePath is None: return None return {"Name": NotePath.name, "FileType": ClassifyFile(NotePath)} def RenameDirectoryNote(DirectoryPath: Path, OldDirectoryName: str) -> None: """After a directory is renamed/moved, keep its note file's name (which embeds the directory's own name) in sync, preserving its extension. """ OldPrefix = f"{NoteFilePrefix}{OldDirectoryName}." for Candidate in DirectoryPath.iterdir(): if Candidate.is_file() and Candidate.name.startswith(OldPrefix): NewName = f"{NoteFilePrefix}{DirectoryPath.name}{Candidate.suffix}" Candidate.rename(DirectoryPath / NewName) return 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) EnsureDirectoryNote(EntryPath) 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.") OldName = SourcePath.name 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)) if DestinationPath.is_dir(): RenameDirectoryNote(DestinationPath, OldName) 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, ShowSvFiles: bool = False ) -> list[dict[str, Any]]: """List the immediate contents of a directory. SaraTag's internal files are always hidden, regardless of ShowHidden. Note files (.sv_*) are hidden unless ShowSvFiles is True - independently of ShowHidden, since they're an implementation detail rather than a normal dotfile. Every directory's Note metadata is still attached regardless of ShowSvFiles, so clicking the directory can open it. Other 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 IsNoteFile(EntryPath.name): if not ShowSvFiles: continue elif 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, "Note": GetDirectoryNoteInfo(EntryPath) if IsDirectory else None, } ) return Entries