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

412 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}