diff --git a/.gitignore b/.gitignore index 24f1b08..2b179d1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,3 @@ Frontend/dist/ .DS_Store .vscode/ .idea/ - -# Misc -.Todo/ diff --git a/Backend/Explorer.py b/Backend/Explorer.py index 8c45b52..a9d82a5 100644 --- a/Backend/Explorer.py +++ b/Backend/Explorer.py @@ -38,6 +38,64 @@ def ClassifyFile(EntryPath: Path) -> str: 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()] @@ -52,6 +110,7 @@ def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None: if IsDirectory: EntryPath.mkdir(parents=False) + EnsureDirectoryNote(EntryPath) else: EntryPath.parent.mkdir(parents=True, exist_ok=True) EntryPath.touch() @@ -70,6 +129,7 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s if DestinationPath.exists(): raise FileExistsError(f"{DestinationPath.name} already exists.") + OldName = SourcePath.name DestinationPath.parent.mkdir(parents=True, exist_ok=True) try: @@ -78,6 +138,9 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s # 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.""" @@ -92,11 +155,17 @@ def DeleteEntry(RootName: str, RelativePath: str) -> None: EntryPath.unlink() -def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) -> list[dict[str, Any]]: +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. - Dotfiles/dotfolders are hidden unless ShowHidden is True. + 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) @@ -114,7 +183,10 @@ def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) -> if IsInternalPath(EntryPath): continue - if not ShowHidden and EntryPath.name.startswith("."): + if IsNoteFile(EntryPath.name): + if not ShowSvFiles: + continue + elif not ShowHidden and EntryPath.name.startswith("."): continue IsDirectory = EntryPath.is_dir() @@ -124,6 +196,7 @@ def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) -> "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, } ) diff --git a/Backend/Main.py b/Backend/Main.py index d70fc94..4fc8393 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -18,7 +18,15 @@ from BookReader import ReadBookForFile from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave from Core import ResolvePath from Editor import WriteTextFile -from Explorer import ClassifyFile, CreateEntry, DeleteEntry, ListDirectory, ListRoots, MoveEntry +from Explorer import ( + ClassifyFile, + CreateEntry, + DeleteEntry, + EnsureDirectoryNote, + ListDirectory, + ListRoots, + MoveEntry, +) from Groups import ( CreateGroup, DeleteGroup, @@ -378,8 +386,10 @@ def GetTreeEndpoint( RequirePathAccessible(Root, Path, CurrentUser) try: - ShowHidden = LoadSettings(CurrentUser["Username"]).get("ShowHiddenFolders", False) - Entries = ListDirectory(Root, Path, ShowHidden) + UserSettings = LoadSettings(CurrentUser["Username"]) + ShowHidden = UserSettings.get("ShowHiddenFolders", False) + ShowSvFiles = UserSettings.get("ShowSvFiles", False) + Entries = ListDirectory(Root, Path, ShowHidden, ShowSvFiles) except (ValueError, NotADirectoryError, FileNotFoundError) as Error: raise HTTPException(status_code=400, detail=str(Error)) @@ -400,6 +410,25 @@ def GetTreeEndpoint( ] +@Application.post("/api/note") +def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + """Find (or create, for a directory that predates this feature) the + directory's own note file, so it can be opened like any other file. + """ + RequirePathAccessible(Root, Path, CurrentUser) + + try: + DirectoryPath = ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + if not DirectoryPath.is_dir(): + raise HTTPException(status_code=400, detail="Not a directory.") + + NotePath = EnsureDirectoryNote(DirectoryPath) + return {"Name": NotePath.name, "FileType": ClassifyFile(NotePath)} + + class CreateEntryRequest(BaseModel): IsDirectory: bool diff --git a/Backend/Settings.py b/Backend/Settings.py index 0bd15c5..4856d2f 100644 --- a/Backend/Settings.py +++ b/Backend/Settings.py @@ -34,6 +34,7 @@ DefaultSettings: dict[str, Any] = { "FontSize": 14, "ThemeName": "CatppuccinMocha", "ShowHiddenFolders": False, + "ShowSvFiles": False, "ShowBreadcrumbs": True, "UseHyphenBullets": True, "UseAsciiFolderIcons": True, diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index ae82629..d5e62da 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -194,6 +194,11 @@ export function DeleteEntry(RootName, RelativePath) { return FetchJson(`/tree?${Params}`, { method: "DELETE" }); } +export function EnsureDirectoryNote(RootName, RelativePath) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return FetchJson(`/note?${Params}`, { method: "POST" }); +} + // --- Settings / themes ------------------------------------------------- export function FetchSettings() { diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 0ac93f8..ce15426 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -7,7 +7,14 @@ import DirectoryPane from "./Components/DirectoryPane.jsx"; import TagPanel from "./Components/TagPanel.jsx"; import SettingsPanel from "./Components/SettingsPanel.jsx"; import LoginScreen from "./Components/LoginScreen.jsx"; -import { FetchAuthStatus, FetchMe, FetchRoots, FetchSettings, FetchTheme } from "./Api.js"; +import { + EnsureDirectoryNote, + FetchAuthStatus, + FetchMe, + FetchRoots, + FetchSettings, + FetchTheme, +} from "./Api.js"; import { PaletteRoles } from "./ThemeRoles.js"; function ApplyTheme(Palette) { @@ -41,6 +48,7 @@ export default function App() { const [ViewPath, SetViewPath] = useState(""); const [ViewIsDirectory, SetViewIsDirectory] = useState(true); const [ViewFileType, SetViewFileType] = useState(null); + const [ViewNoteFilePath, SetViewNoteFilePath] = useState(null); const [CurrentDirectoryPath, SetCurrentDirectoryPath] = useState(""); const [SidebarKey, SetSidebarKey] = useState(0); const [Settings, SetSettings] = useState(null); @@ -104,6 +112,7 @@ export default function App() { SetViewPath(FilePath); SetViewIsDirectory(false); SetViewFileType(FileType); + SetViewNoteFilePath(null); SetCurrentDirectoryPath(GetParentPath(FilePath)); } @@ -111,6 +120,7 @@ export default function App() { SetViewPath(DirectoryPath); SetViewIsDirectory(true); SetViewFileType(null); + SetViewNoteFilePath(null); SetCurrentDirectoryPath(DirectoryPath); } @@ -118,9 +128,34 @@ export default function App() { SetCurrentDirectoryPath(DirectoryPath); } - function HandleEntrySelect(EntryPath, IsDirectory, FileType) { + // A directory is opened the same way a file is - via its own note file - + // rather than by drilling into its listing. Browsing a directory's + // contents happens separately, through the sidebar tree. + function HandleSelectDirectoryNote(DirectoryPath, NoteName, NoteFileType) { + SetViewPath(DirectoryPath); + SetViewIsDirectory(false); + SetViewFileType(NoteFileType); + SetViewNoteFilePath(`${DirectoryPath}/${NoteName}`); + SetCurrentDirectoryPath(DirectoryPath); + } + + async function OpenDirectoryNote(DirectoryPath, Note) { + if (Note) { + HandleSelectDirectoryNote(DirectoryPath, Note.Name, Note.FileType); + return; + } + + try { + const Created = await EnsureDirectoryNote(ActiveRoot, DirectoryPath); + HandleSelectDirectoryNote(DirectoryPath, Created.Name, Created.FileType); + } catch (Error) { + window.alert(Error.message); + } + } + + function HandleEntrySelect(EntryPath, IsDirectory, FileType, Note) { if (IsDirectory) { - HandleNavigateDirectory(EntryPath); + OpenDirectoryNote(EntryPath, Note); } else { HandleSelectFile(EntryPath, FileType); } @@ -133,7 +168,11 @@ export default function App() { function HandleEntryRenamed(OldPath, NewPath, IsDirectory) { RefreshSidebar(); - if (!ViewIsDirectory && ViewPath === OldPath) { + if (ViewNoteFilePath && ViewPath === OldPath) { + // Viewing this directory's own note - the server already renamed the + // note file to match, so just re-resolve it under the new path. + OpenDirectoryNote(NewPath, null); + } else if (!ViewIsDirectory && ViewPath === OldPath) { HandleSelectFile(NewPath, ViewFileType); } else if (ViewIsDirectory && ViewPath === OldPath) { HandleNavigateDirectory(NewPath); @@ -178,6 +217,7 @@ export default function App() { CurrentDirectoryPath={CurrentDirectoryPath} CurrentUser={CurrentUser} OnSelectFile={HandleSelectFile} + OnSelectDirectory={OpenDirectoryNote} OnNavigateDirectory={HandleNavigateDirectory} OnBrowseDirectory={HandleBrowseDirectory} OnEntryRenamed={HandleEntryRenamed} @@ -197,7 +237,7 @@ export default function App() { ) : ( @@ -234,7 +274,7 @@ export default function App() { ) : ( )} - {!ViewIsDirectory && } + {!ViewIsDirectory && !ViewNoteFilePath && ( + + )} {IsSettingsOpen && ( diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx index 56c993c..5e9fb1c 100644 --- a/Frontend/src/Components/DirectoryPane.jsx +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -149,7 +149,9 @@ export default function DirectoryPane({
OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)} + onClick={() => + OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType, Entry.Note) + } > {Entry.IsDirectory diff --git a/Frontend/src/Components/SettingsPanel.jsx b/Frontend/src/Components/SettingsPanel.jsx index de09ea8..f7f8988 100644 --- a/Frontend/src/Components/SettingsPanel.jsx +++ b/Frontend/src/Components/SettingsPanel.jsx @@ -323,6 +323,11 @@ export default function SettingsPanel({ UpdateField("ShowHiddenFolders", Event.target.checked)} />
+
+ + UpdateField("ShowSvFiles", Event.target.checked)} /> +
+
UpdateField("ShowBreadcrumbs", Event.target.checked)} /> diff --git a/Frontend/src/Components/Sidebar.jsx b/Frontend/src/Components/Sidebar.jsx index 98846be..1e461c8 100644 --- a/Frontend/src/Components/Sidebar.jsx +++ b/Frontend/src/Components/Sidebar.jsx @@ -20,6 +20,7 @@ function TreeNode({ SelectedFile, CurrentDirectoryPath, OnSelectFile, + OnSelectDirectory, OnNavigateDirectory, OnBrowseDirectory, OnEntryRenamed, @@ -65,7 +66,7 @@ function TreeNode({ OnBrowseDirectory(FullPath); if (NavigateIntoDirectories) { - OnNavigateDirectory(FullPath); + OnSelectDirectory(FullPath, Entry.Note); } } @@ -156,6 +157,7 @@ function TreeNode({ SelectedFile={SelectedFile} CurrentDirectoryPath={CurrentDirectoryPath} OnSelectFile={OnSelectFile} + OnSelectDirectory={OnSelectDirectory} OnNavigateDirectory={OnNavigateDirectory} OnBrowseDirectory={OnBrowseDirectory} OnEntryRenamed={OnEntryRenamed} @@ -213,6 +215,7 @@ export default function Sidebar({ CurrentDirectoryPath, CurrentUser, OnSelectFile, + OnSelectDirectory, OnNavigateDirectory, OnBrowseDirectory, OnEntryRenamed, @@ -319,6 +322,7 @@ export default function Sidebar({ SelectedFile={SelectedFile} CurrentDirectoryPath={CurrentDirectoryPath} OnSelectFile={OnSelectFile} + OnSelectDirectory={OnSelectDirectory} OnNavigateDirectory={OnNavigateDirectory} OnBrowseDirectory={OnBrowseDirectory} OnEntryRenamed={OnEntryRenamed}