verstak-desktop/internal/core/notes/layout.go

97 lines
2.9 KiB
Go

// Package notes provides the Notes layout service, title-to-filename normalization,
// and note CRUD operations for Verstak vaults.
//
// Canonical layout:
//
// <parent>/Notes/ — notes folder for a project/workspace
// <parent>/Notes/Overview.md — overview note
// <parent>/Notes/<title>.md — individual notes
//
// The invariant is: Note title is the source of truth. The filename is derived
// from the title via normalization, never stored independently.
package notes
import (
"path"
"strings"
)
// CanonicalLayout contains the canonical names for the notes layout.
// All code should use these constants; never hardcode "Notes" or "Overview.md".
const (
CanonicalFolder = "Notes" // canonical notes folder name (always title-case)
CanonicalOverview = "Overview.md" // canonical overview filename
// NoteExtension is the file extension for notes.
NoteExtension = ".md"
)
// NotesPath returns the canonical notes folder path relative to parent.
// parent is a vault-relative directory path (e.g. "Workspace" or "Clients/Acme").
func NotesPath(parent string) string {
parent = strings.TrimSpace(parent)
if parent == "" {
return CanonicalFolder
}
return parent + "/" + CanonicalFolder
}
// OverviewPath returns the canonical overview file path relative to parent.
func OverviewPath(parent string) string {
return NotesPath(parent) + "/" + CanonicalOverview
}
// IsInsideNotes checks whether the given vault-relative path is inside
// a canonical Notes folder. It checks any segment named "Notes", not just the first.
func IsInsideNotes(relativePath string) bool {
if relativePath == "" {
return false
}
cleaned := strings.TrimSpace(relativePath)
cleaned = strings.TrimPrefix(cleaned, "./")
cleaned = strings.TrimPrefix(cleaned, "/")
parts := strings.Split(cleaned, "/")
for _, part := range parts {
if part == CanonicalFolder {
return true
}
}
return false
}
// IsOverview checks whether the given vault-relative path is the canonical
// Overview.md inside a Notes folder.
func IsOverview(relativePath string) bool {
if relativePath == "" {
return false
}
cleaned := strings.TrimSpace(relativePath)
if !strings.HasSuffix(cleaned, "/"+CanonicalOverview) &&
cleaned != CanonicalOverview {
return false
}
notesParent := strings.TrimSuffix(cleaned, "/"+CanonicalOverview)
if notesParent == "" {
return true // just "Notes/Overview.md"
}
return IsInsideNotes(notesParent)
}
// ParentFromNotePath extracts the notes parent (the directory containing
// the Notes/ folder) from a note's vault-relative path.
// For example: "Workspace/Notes/MyNote.md" -> "Workspace"
// For example: "Notes/MyNote.md" -> ""
func ParentFromNotePath(notePath string) string {
notePath = strings.TrimSpace(notePath)
parts := strings.Split(notePath, "/")
for i, part := range parts {
if part == CanonicalFolder {
if i == 0 {
return ""
}
return path.Join(parts[:i]...)
}
}
return ""
}