Added .sv_* files that are files for directories. So you can now edit

directories as files.
This commit is contained in:
sarah committed 2026-09-21 17:19:16 +02:00
1 parent 70a390f7cf
commit 6083e00504
9 files changed
+176 -18

No files matched your search

-3
View File
@@ -10,6 +10,3 @@ Frontend/dist/
.DS_Store
.vscode/
.idea/
# Misc
.Todo/
+76 -3
View File
@@ -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,
}
)
+32 -3
View File
@@ -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
+1
View File
@@ -34,6 +34,7 @@ DefaultSettings: dict[str, Any] = {
"FontSize": 14,
"ThemeName": "CatppuccinMocha",
"ShowHiddenFolders": False,
"ShowSvFiles": False,
"ShowBreadcrumbs": True,
"UseHyphenBullets": True,
"UseAsciiFolderIcons": True,
+5
View File
@@ -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() {
+49 -7
View File
@@ -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() {
<Breadcrumb
RootName={ActiveRoot}
FilePath={ViewPath}
IsDirectory={ViewIsDirectory}
IsDirectory={ViewIsDirectory || Boolean(ViewNoteFilePath)}
OnNavigate={HandleNavigateDirectory}
/>
) : (
@@ -234,7 +274,7 @@ export default function App() {
) : (
<PreviewPane
RootName={ActiveRoot}
FilePath={ViewPath}
FilePath={ViewNoteFilePath || ViewPath}
FileType={ViewFileType}
OnNavigateLink={HandleSelectFile}
ShowGitBlame={Settings ? Settings.ShowGitBlame : false}
@@ -242,7 +282,9 @@ export default function App() {
StickyToolbar={Settings ? Settings.StickyToolbar : true}
/>
)}
{!ViewIsDirectory && <TagPanel RootName={ActiveRoot} FilePath={ViewPath} />}
{!ViewIsDirectory && !ViewNoteFilePath && (
<TagPanel RootName={ActiveRoot} FilePath={ViewPath} />
)}
</div>
</div>
{IsSettingsOpen && (
+3 -1
View File
@@ -149,7 +149,9 @@ export default function DirectoryPane({
<div
className="DirectoryEntry"
key={Entry.Name}
onClick={() => OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)}
onClick={() =>
OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType, Entry.Note)
}
>
<span className="TreeIcon">
{Entry.IsDirectory
@@ -323,6 +323,11 @@ export default function SettingsPanel({
<Switch Checked={Settings.ShowHiddenFolders} OnChange={(Event) => UpdateField("ShowHiddenFolders", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Show directory note files (.sv_*)</label>
<Switch Checked={Settings.ShowSvFiles} OnChange={(Event) => UpdateField("ShowSvFiles", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Show breadcrumbs</label>
<Switch Checked={Settings.ShowBreadcrumbs} OnChange={(Event) => UpdateField("ShowBreadcrumbs", Event.target.checked)} />
+5 -1
View File
@@ -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}