412 lines
12 KiB
Go
412 lines
12 KiB
Go
package notes
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/verstak/verstak-desktop/internal/core/files"
|
||
)
|
||
|
||
// Service provides note operations within a vault.
|
||
// It reuses the files.Service for actual file I/O to keep a single
|
||
// source of truth for vault file access.
|
||
type Service struct {
|
||
files *files.Service
|
||
}
|
||
|
||
// NoteInfo describes a discovered note file.
|
||
type NoteInfo struct {
|
||
Title string `json:"title"`
|
||
Filename string `json:"filename"`
|
||
Path string `json:"path"` // vault-relative path
|
||
ParentPath string `json:"parentPath"` // parent of the Notes/ folder
|
||
IsOverview bool `json:"isOverview"`
|
||
}
|
||
|
||
// NewService creates a new notes service backed by the given file service.
|
||
func NewService(filesSvc *files.Service) *Service {
|
||
return &Service{files: filesSvc}
|
||
}
|
||
|
||
// EnsureOverview creates Notes/Overview.md under the given parent path
|
||
// if it doesn't exist. Returns the vault-relative path of the overview file.
|
||
// parent is a vault-relative directory path (e.g. "Workspace" or "Clients/Acme").
|
||
func (s *Service) EnsureOverview(parent string) (string, error) {
|
||
overviewRel := OverviewPath(parent)
|
||
|
||
// Check if overview already exists
|
||
_, err := s.files.GetVaultFileMetadata(overviewRel)
|
||
if err == nil {
|
||
return overviewRel, nil
|
||
}
|
||
|
||
// Ensure Notes folder exists
|
||
notesRel := NotesPath(parent)
|
||
if err := s.files.CreateVaultFolder(notesRel); err != nil {
|
||
if !isConflictError(err) {
|
||
return "", fmt.Errorf("create notes folder: %w", err)
|
||
}
|
||
}
|
||
|
||
// Use parent basename as default title
|
||
parentName := parent
|
||
if idx := strings.LastIndex(parent, "/"); idx >= 0 {
|
||
parentName = parent[idx+1:]
|
||
}
|
||
if parentName == "" {
|
||
parentName = "Overview"
|
||
}
|
||
defaultContent := "# " + parentName + "\n"
|
||
|
||
if err := s.files.WriteVaultTextFile(overviewRel, defaultContent, files.WriteOptions{
|
||
CreateIfMissing: true,
|
||
Overwrite: false,
|
||
}); err != nil {
|
||
return "", fmt.Errorf("create overview: %w", err)
|
||
}
|
||
|
||
return overviewRel, nil
|
||
}
|
||
|
||
// CreateNote creates a new markdown note under the given parent's Notes/ folder.
|
||
// title is the human-readable note title. The filename is derived via
|
||
// NormalizeTitleToFilename.
|
||
// Returns the vault-relative path of the new note, or an error if:
|
||
// - title is invalid
|
||
// - the filename already exists (conflict)
|
||
// - parent path is unsafe
|
||
func (s *Service) CreateNote(parent, title string, content string) (string, error) {
|
||
if err := ValidateNoteTitle(title); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
filename, err := NormalizeTitleToFilename(title)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
notesRel := NotesPath(parent)
|
||
noteRel := notesRel + "/" + filename
|
||
|
||
// Ensure Notes folder exists
|
||
if err := s.files.CreateVaultFolder(notesRel); err != nil {
|
||
if !isConflictError(err) {
|
||
return "", fmt.Errorf("create notes folder: %w", err)
|
||
}
|
||
}
|
||
|
||
// Check for conflict: file must not already exist
|
||
if _, err := s.files.GetVaultFileMetadata(noteRel); err == nil {
|
||
return "", &ConflictError{
|
||
Path: noteRel,
|
||
Title: title,
|
||
Filename: filename,
|
||
}
|
||
}
|
||
|
||
if content == "" {
|
||
content = "# " + title + "\n"
|
||
}
|
||
|
||
if err := s.files.WriteVaultTextFile(noteRel, content, files.WriteOptions{
|
||
CreateIfMissing: true,
|
||
Overwrite: false,
|
||
}); err != nil {
|
||
return "", fmt.Errorf("create note: %w", err)
|
||
}
|
||
|
||
return noteRel, nil
|
||
}
|
||
|
||
// RenameNote renames a note by changing its title. The filename is derived
|
||
// from the new title. The old note file is renamed to the new filename.
|
||
// If the new filename would conflict with an existing file, a ConflictError is returned.
|
||
//
|
||
// notePath is the current vault-relative path of the note.
|
||
func (s *Service) RenameNote(notePath, newTitle string) (string, error) {
|
||
if err := ValidateNoteTitle(newTitle); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// Check that the note exists
|
||
oldMeta, err := s.files.GetVaultFileMetadata(notePath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("note not found: %w", err)
|
||
}
|
||
if oldMeta.Type != files.FileTypeFile {
|
||
return "", fmt.Errorf("not a file: %s", notePath)
|
||
}
|
||
|
||
newFilename, err := NormalizeTitleToFilename(newTitle)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
oldDir := pathDir(notePath)
|
||
oldName := filepath.Base(notePath)
|
||
_ = oldName
|
||
|
||
// Check if filename would actually change
|
||
if strings.EqualFold(filepath.Base(notePath), newFilename) {
|
||
// Same filename (case-insensitive). If exact case matches, no rename needed.
|
||
if filepath.Base(notePath) == newFilename {
|
||
return notePath, nil
|
||
}
|
||
// Only case differs — proceed (the OS rename will handle case change).
|
||
}
|
||
|
||
newPath := oldDir + "/" + newFilename
|
||
|
||
// Prevent conflict: if the target path already exists and is not the source
|
||
if newPath != notePath {
|
||
if _, err := s.files.GetVaultFileMetadata(newPath); err == nil {
|
||
return "", &ConflictError{
|
||
Path: newPath,
|
||
Title: newTitle,
|
||
Filename: newFilename,
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := s.files.MoveVaultPath(notePath, newPath, files.MoveOptions{
|
||
Overwrite: false,
|
||
}); err != nil {
|
||
return "", fmt.Errorf("rename note: %w", err)
|
||
}
|
||
|
||
return newPath, nil
|
||
}
|
||
|
||
// ReadNote reads the content of a note file.
|
||
func (s *Service) ReadNote(notePath string) (string, error) {
|
||
return s.files.ReadVaultTextFile(notePath)
|
||
}
|
||
|
||
// SaveNote writes content to a note file. Requires overwrite permission.
|
||
func (s *Service) SaveNote(notePath, content string) error {
|
||
return s.files.WriteVaultTextFile(notePath, content, files.WriteOptions{
|
||
CreateIfMissing: false,
|
||
Overwrite: true,
|
||
})
|
||
}
|
||
|
||
// ListNotes returns all markdown notes in the given parent's Notes/ folder
|
||
// (non-recursive). Each NoteInfo has title derived from filename.
|
||
func (s *Service) ListNotes(parent string) ([]NoteInfo, error) {
|
||
notesRel := NotesPath(parent)
|
||
|
||
entries, err := s.files.ListVaultFiles(notesRel)
|
||
if err != nil {
|
||
if os.IsNotExist(err) || isNotFoundError(err) {
|
||
return []NoteInfo{}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
var notes []NoteInfo
|
||
for _, entry := range entries {
|
||
if entry.Type != files.FileTypeFile {
|
||
continue
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(entry.Name))
|
||
if ext != ".md" && ext != ".markdown" {
|
||
continue
|
||
}
|
||
title := TitleFromFilename(entry.Name)
|
||
notes = append(notes, NoteInfo{
|
||
Title: title,
|
||
Filename: entry.Name,
|
||
Path: entry.RelativePath,
|
||
ParentPath: parent,
|
||
IsOverview: strings.EqualFold(entry.Name, CanonicalOverview),
|
||
})
|
||
}
|
||
|
||
sort.Slice(notes, func(i, j int) bool {
|
||
// Overview always first
|
||
if notes[i].IsOverview != notes[j].IsOverview {
|
||
return notes[i].IsOverview
|
||
}
|
||
return strings.ToLower(notes[i].Title) < strings.ToLower(notes[j].Title)
|
||
})
|
||
|
||
return notes, nil
|
||
}
|
||
|
||
// SearchNotes performs a simple case-insensitive search across notes
|
||
// in the vault. It walks known Notes/ folders by scanning the vault root
|
||
// directory for workspace folders that contain Notes/ subdirectories.
|
||
//
|
||
// This is a minimal local search without an index. For a large vault,
|
||
// it should be replaced by a proper search plugin/indexer.
|
||
func (s *Service) SearchNotes(vaultRoot string, query string) ([]NoteInfo, error) {
|
||
query = strings.TrimSpace(query)
|
||
if query == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
var results []NoteInfo
|
||
seen := make(map[string]bool)
|
||
|
||
vaultRoot = filepath.ToSlash(filepath.Clean(vaultRoot))
|
||
rootDir := vaultRoot
|
||
|
||
entries, err := os.ReadDir(rootDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, entry := range entries {
|
||
if !entry.IsDir() {
|
||
continue
|
||
}
|
||
workspaceName := entry.Name()
|
||
if strings.HasPrefix(workspaceName, ".") {
|
||
continue
|
||
}
|
||
|
||
notesDir := filepath.Join(rootDir, workspaceName, CanonicalFolder)
|
||
notesRel := filepath.ToSlash(filepath.Join(workspaceName, CanonicalFolder))
|
||
|
||
notesEntries, err := os.ReadDir(notesDir)
|
||
if err != nil {
|
||
continue // no Notes folder in this workspace
|
||
}
|
||
|
||
for _, noteEntry := range notesEntries {
|
||
if noteEntry.IsDir() {
|
||
continue
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(noteEntry.Name()))
|
||
if ext != ".md" && ext != ".markdown" {
|
||
continue
|
||
}
|
||
|
||
title := TitleFromFilename(noteEntry.Name())
|
||
noteRel := notesRel + "/" + noteEntry.Name()
|
||
|
||
if seen[noteRel] {
|
||
continue
|
||
}
|
||
|
||
if matchSearch(title, query) || matchSearch(noteEntry.Name(), query) {
|
||
results = append(results, NoteInfo{
|
||
Title: title,
|
||
Filename: noteEntry.Name(),
|
||
Path: noteRel,
|
||
ParentPath: workspaceName,
|
||
IsOverview: strings.EqualFold(noteEntry.Name(), CanonicalOverview),
|
||
})
|
||
seen[noteRel] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
sort.Slice(results, func(i, j int) bool {
|
||
return strings.ToLower(results[i].Title) < strings.ToLower(results[j].Title)
|
||
})
|
||
|
||
return results, nil
|
||
}
|
||
|
||
// ConflictError is returned when a note filename conflicts with an existing file.
|
||
type ConflictError struct {
|
||
Path string
|
||
Title string
|
||
Filename string
|
||
}
|
||
|
||
func (e *ConflictError) Error() string {
|
||
return fmt.Sprintf("conflict: a note with filename %q already exists at %q", e.Filename, e.Path)
|
||
}
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────
|
||
|
||
// pathDir returns the parent directory of a relative path, or "" if root.
|
||
func pathDir(rel string) string {
|
||
idx := strings.LastIndex(rel, "/")
|
||
if idx < 0 {
|
||
return ""
|
||
}
|
||
return rel[:idx]
|
||
}
|
||
|
||
func isConflictError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.HasPrefix(err.Error(), "conflict:")
|
||
}
|
||
|
||
func isNotFoundError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.HasPrefix(err.Error(), "not-found:")
|
||
}
|
||
|
||
// matchSearch performs case-insensitive substring match.
|
||
// It also attempts basic RU↔EN layout swap matching for the query.
|
||
func matchSearch(text, query string) bool {
|
||
lower := strings.ToLower(text)
|
||
q := strings.ToLower(query)
|
||
if strings.Contains(lower, q) {
|
||
return true
|
||
}
|
||
// Attempt swapped layout matching (RU↔EN)
|
||
swapped := swapKeyboardLayout(q)
|
||
if swapped != q && strings.Contains(lower, swapped) {
|
||
return true
|
||
}
|
||
// Also try swapping the text and matching the original query
|
||
swappedText := swapKeyboardLayout(lower)
|
||
if swappedText != lower && strings.Contains(swappedText, q) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// swapKeyboardLayout performs simple RU↔EN character swap for common
|
||
// misplaced keyboard layout characters. This is a best-effort mapping
|
||
// for the most common mismatched characters in note titles.
|
||
func swapKeyboardLayout(s string) string {
|
||
var swapped strings.Builder
|
||
for _, r := range s {
|
||
if en, ok := ruToEn[r]; ok {
|
||
swapped.WriteRune(en)
|
||
} else if ru, ok := enToRu[r]; ok {
|
||
swapped.WriteRune(ru)
|
||
} else {
|
||
swapped.WriteRune(r)
|
||
}
|
||
}
|
||
return swapped.String()
|
||
}
|
||
|
||
// ruToEn maps Russian Cyrillic characters to their English QWERTY counterparts.
|
||
var ruToEn = map[rune]rune{
|
||
'а': 'f', 'б': ',', 'в': 'd', 'г': 'u', 'д': 'l', 'е': 't', 'ё': '`',
|
||
'ж': ';', 'з': 'p', 'и': 'b', 'й': 'q', 'к': 'r', 'л': 'k', 'м': 'v',
|
||
'н': 'y', 'о': 'j', 'п': 'g', 'р': 'h', 'с': 'c', 'т': 'n', 'у': 'e',
|
||
'ф': 'a', 'х': '[', 'ц': 'w', 'ч': 'x', 'ш': 'i', 'щ': 'o', 'ъ': ']',
|
||
'ы': 's', 'ь': 'm', 'э': '\'', 'ю': '.', 'я': 'z',
|
||
'А': 'F', 'Б': '<', 'В': 'D', 'Г': 'U', 'Д': 'L', 'Е': 'T', 'Ё': '~',
|
||
'Ж': ':', 'З': 'P', 'И': 'B', 'Й': 'Q', 'К': 'R', 'Л': 'K', 'М': 'V',
|
||
'Н': 'Y', 'О': 'J', 'П': 'G', 'Р': 'H', 'С': 'C', 'Т': 'N', 'У': 'E',
|
||
'Ф': 'A', 'Х': '{', 'Ц': 'W', 'Ч': 'X', 'Ш': 'I', 'Щ': 'O', 'Ъ': '}',
|
||
'Ы': 'S', 'Ь': 'M', 'Э': '"', 'Ю': '>', 'Я': 'Z',
|
||
}
|
||
|
||
// enToRu maps English QWERTY characters to Russian Cyrillic.
|
||
var enToRu map[rune]rune
|
||
|
||
func init() {
|
||
enToRu = make(map[rune]rune, len(ruToEn))
|
||
for ru, en := range ruToEn {
|
||
enToRu[en] = ru
|
||
}
|
||
}
|