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() {