From 28a4e10e790936d129a865c69c1f727915e8bc38 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 27 Jun 2026 12:44:02 +0800 Subject: [PATCH] Remove core notes service --- docs/NOTES_FILES_PLUGIN_PLAN.md | 56 +-- docs/PLUGIN_RUNTIME.md | 3 +- internal/api/app_test.go | 7 +- internal/core/notes/layout.go | 96 ----- internal/core/notes/normalize.go | 146 ------- internal/core/notes/service.go | 411 -------------------- internal/core/notes/service_test.go | 493 ------------------------ internal/core/vault/vault_test.go | 7 +- internal/core/workbench/router.go | 18 +- internal/core/workspace/manager.go | 8 +- internal/core/workspace/manager_test.go | 7 +- 11 files changed, 63 insertions(+), 1189 deletions(-) delete mode 100644 internal/core/notes/layout.go delete mode 100644 internal/core/notes/normalize.go delete mode 100644 internal/core/notes/service.go delete mode 100644 internal/core/notes/service_test.go diff --git a/docs/NOTES_FILES_PLUGIN_PLAN.md b/docs/NOTES_FILES_PLUGIN_PLAN.md index 181cf11..dbf4997 100644 --- a/docs/NOTES_FILES_PLUGIN_PLAN.md +++ b/docs/NOTES_FILES_PLUGIN_PLAN.md @@ -54,8 +54,9 @@ Canonical rules: Canonical scoped paths: -- Workspace overview notes live under `/Notes/`. -- The default overview note is `/Notes/Overview.md`. +- Notes live under `/Notes/`. +- `Overview.md` is allowed as an ordinary Markdown filename, but the platform + must not create it automatically or treat it as a dedicated UI entity. - `` is the top-level physical folder name under the vault root. - Files plugin workspace views are scoped with `workspaceRootPath`, which is the selected top-level workspace folder name. @@ -68,8 +69,9 @@ Visibility requirements: - Outside Verstak, the files must remain useful as normal Markdown. There is no canonical metadata workspace tree. Adding `note` as a workspace node -type is not part of the next milestone. The Notes service can index and manage -Markdown files inside canonical `Notes/` folders under each top-level workspace. +type is not part of the next milestone. The official Notes plugin can index and +manage Markdown files inside canonical `Notes/` folders under each top-level +workspace. ## Title To Filename Contract @@ -99,9 +101,9 @@ Examples: Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp suffixes. -`CreateNote` and `RenameNote` must return a conflict error if the normalized -target filename already exists in the target `Notes/` folder. The UI should show a -clear dialog or notification and ask the user to change the title. +Creating or renaming a note must return a conflict error if the normalized target +filename already exists in the target `Notes/` folder. The UI should show a clear +dialog or notification and ask the user to change the title. Required conflict metadata: @@ -159,16 +161,17 @@ Later Files methods: - `OpenExternal(relativePath)` with explicit permission and UX confirmation. - `RevealInFileManager(relativePath)`. -## Notes Service Model +## Official Notes Plugin Model -Notes API is a semantic layer over Markdown files managed by the Files/path -policy. +The official Notes plugin is a semantic layer over Markdown files managed through +the public Files plugin API. There is no core desktop Notes service or +`verstak/core/notes/v1` capability in v2. Rules: - A note physically is a `.md` file. -- Notes API and Files API must not create two sources of truth. -- Notes API reads/writes the same files that Files API lists. +- Notes plugin and Files API must not create two sources of truth. +- Notes plugin reads/writes the same files that Files API lists. - The note title is the semantic source of truth and is projected to the filename. If frontmatter or a first-heading convention is introduced later, `RenameNote` must keep that visible title metadata and the filename synchronized. @@ -178,27 +181,27 @@ Rules: watcher/indexer must observe it. - Until watcher/indexer exists, external changes require reload/rescan. -Minimum Notes methods: +Minimum Notes plugin behavior: -- `ListNotes(scope)` where `scope` resolves to a canonical `Notes/` folder. -- `GetNote(notePath)`. -- `CreateNote(scope, title, initialBody)`. -- `RenameNote(notePath, newTitle)`. -- `UpdateNoteBody(notePath, body)`. -- `TrashNote(notePath)`. +- list Markdown files in a scoped canonical `Notes/` folder; +- create a Markdown note by writing the normalized filename atomically with + `overwrite: false`; +- rename a note by moving the file to the normalized filename; +- open and edit notes through Workbench providers; +- never special-case `Overview.md` in the UI. -Later Notes methods: +Later Notes plugin behavior: -- `SearchNotes(query, filters)`. -- `ListBacklinks(notePath)`. -- `ResolveNoteLinks(notePath)`. -- `ExportNote(notePath, format)`. +- search notes; +- list backlinks; +- resolve note links; +- export notes. ## Notes Vs Files Relationship Files owns safe raw vault file access. Notes owns note semantics. -The same physical note must be visible through both APIs: +The same physical note must be visible through both surfaces: - Files sees `Project/Notes/Overview.md` as a file. - Notes sees `Project/Notes/Overview.md` as a note with title `Overview`. @@ -238,7 +241,6 @@ real isolation boundary. Recommended capabilities: - `verstak/core/files/v1` -- `verstak/core/notes/v1` - `verstak/files/v1` provided by the official Files plugin. - `verstak/notes/v1` provided by the official Notes plugin. @@ -389,7 +391,7 @@ Backend Go tests: - Atomic text writes and temp-file cleanup on failure. - Notes `Notes/` folder casing and no lowercase `notes`. - Title to filename normalization. -- `CreateNote` and `RenameNote` conflict errors without silent suffixes. +- note create/rename conflict errors without silent suffixes. - Notes and Files read the same physical `.md` file. - Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`. diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index fc493d2..090962e 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -761,7 +761,6 @@ Workspace — это физическая папка верхнего уровн / Workspace/ Notes/ - Overview.md Project/ ClientA/ .verstak/ @@ -860,7 +859,7 @@ Deprecated compatibility APIs: `.verstak`, reserved/internal names, symlink workspaces, conflicts. - WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки (`Project`, `ClientA`, etc). Files plugin показывает именно эту папку. -- Files API остаётся raw vault-relative API: `Project/Notes/Overview.md`, +- Files API остаётся raw vault-relative API: `Project/Notes/example.md`, `Project/docs/file.md`, `Test/readme.md`. - Notes are ordinary Markdown files under `/Notes/`; нет `.verstak/notes`, UUID note storage или второго source of truth для note diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 2576dc7..eee8910 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -841,8 +841,11 @@ func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) { if ws.RootPath != "Project" { t.Fatalf("workspace = %+v, want rootPath Project", ws) } - if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil { - t.Fatalf("template file missing: %v", err) + if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes")); err != nil { + t.Fatalf("template notes folder missing: %v", err) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); !os.IsNotExist(err) { + t.Fatalf("template should not create overview file, stat err=%v", err) } meta, errStr := app.GetWorkspaceMetadata("Project") diff --git a/internal/core/notes/layout.go b/internal/core/notes/layout.go deleted file mode 100644 index 5495eb9..0000000 --- a/internal/core/notes/layout.go +++ /dev/null @@ -1,96 +0,0 @@ -// Package notes provides the Notes layout service, title-to-filename normalization, -// and note CRUD operations for Verstak vaults. -// -// Canonical layout: -// -// /Notes/ — notes folder for a project/workspace -// /Notes/Overview.md — overview note -// /Notes/.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 "" -} diff --git a/internal/core/notes/normalize.go b/internal/core/notes/normalize.go deleted file mode 100644 index 728752b..0000000 --- a/internal/core/notes/normalize.go +++ /dev/null @@ -1,146 +0,0 @@ -package notes - -import ( - "fmt" - "regexp" - "strings" - "unicode" -) - -// illegalFilenameChars matches characters that are unsafe or illegal in filenames -// across Linux, macOS, and Windows. We are strict to keep vault portable. -var illegalFilenameChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f\x7f]`) - -// collapseWhitespace matches runs of whitespace. -var collapseWhitespace = regexp.MustCompile(`\s+`) - -// typographicDashSet contains Unicode dash characters to normalize. -var typographicDashSet = []rune{0x2012, 0x2013, 0x2014, 0x2015, 0x2212} - -// NormalizeTitleToFilename converts a note title to a safe filename. -// -// Rules: -// 1. Trim leading/trailing whitespace -// 2. Collapse internal whitespace runs → underscore -// 3. Typographic dashes (en dash, em dash, etc.) → ASCII hyphen -// 4. Remove/replace illegal filename characters -// 5. Preserve letters, digits, Unicode letters, `.`, `_`, `-` -// 6. Replace other characters with underscore -// 7. Ensure result is non-empty -// 8. Append `.md` extension -// -// Returns the normalized filename (with .md) or an error if the result is empty. -func NormalizeTitleToFilename(title string) (string, error) { - s := strings.TrimSpace(title) - - // Strip any existing .md/.markdown extension for normalization, then re-add - extStripped := false - if strings.HasSuffix(strings.ToLower(s), ".markdown") && len(s) > 9 { - s = s[:len(s)-9] - extStripped = true - } else if strings.HasSuffix(strings.ToLower(s), ".md") && len(s) > 3 { - s = s[:len(s)-3] - extStripped = true - } - if s == "" { - return "", fmt.Errorf("title %q normalizes to an empty filename", title) - } - - // Collapse whitespace runs → underscore - s = collapseWhitespace.ReplaceAllString(s, "_") - - // Normalize dashes (typographic → ASCII hyphen) - s = replaceTypographicDashes(s) - - // Remove illegal characters - s = illegalFilenameChars.ReplaceAllString(s, "") - - // Replace any remaining unsafe characters (control chars, etc.) - runes := make([]rune, 0, len(s)) - for _, r := range s { - if r == '.' || r == '_' || r == '-' || unicode.IsLetter(r) || unicode.IsDigit(r) { - runes = append(runes, r) - } else if unicode.IsPrint(r) { - runes = append(runes, '_') - } - // non-printable characters are dropped - } - s = string(runes) - - // Collapse multiple underscores/hyphens/dots (e.g. "foo___bar" → "foo_bar") - s = collapseRepeatedUnderscores(s) - - // Trim leading/trailing dots, spaces, underscores, hyphens - s = strings.Trim(s, "._- ") - - if s == "" { - return "", fmt.Errorf("title %q normalizes to an empty filename", title) - } - - // If the original title had .md/.markdown extension, preserve it exactly - if extStripped { - return s + NoteExtension, nil - } - return s + NoteExtension, nil -} - -// replaceTypographicDashes replaces Unicode dash characters with ASCII hyphen. -func replaceTypographicDashes(s string) string { - var result strings.Builder - for _, r := range s { - isDash := false - for _, d := range typographicDashSet { - if r == d { - result.WriteRune('-') - isDash = true - break - } - } - if !isDash { - result.WriteRune(r) - } - } - return result.String() -} - -func collapseRepeatedUnderscores(s string) string { - var result strings.Builder - lastWasSep := false - for _, r := range s { - if r == '_' || r == '-' || r == '.' { - if !lastWasSep { - result.WriteRune('_') - lastWasSep = true - } - } else { - result.WriteRune(r) - lastWasSep = false - } - } - return result.String() -} - -// TitleFromFilename extracts a human-readable title from a note filename. -// This is the inverse of NormalizeTitleToFilename (best-effort). -func TitleFromFilename(filename string) string { - filename = strings.TrimSpace(filename) - // Remove .md extension - if strings.HasSuffix(strings.ToLower(filename), ".md") { - filename = filename[:len(filename)-3] - } - // Replace underscores → spaces - result := strings.ReplaceAll(filename, "_", " ") - return strings.TrimSpace(result) -} - -// ValidateNoteTitle checks that a title is valid for creating a note. -func ValidateNoteTitle(title string) error { - title = strings.TrimSpace(title) - if title == "" { - return fmt.Errorf("note title must not be empty") - } - if len(title) > 500 { - return fmt.Errorf("note title too long (%d characters, max 500)", len(title)) - } - return nil -} diff --git a/internal/core/notes/service.go b/internal/core/notes/service.go deleted file mode 100644 index 0aea873..0000000 --- a/internal/core/notes/service.go +++ /dev/null @@ -1,411 +0,0 @@ -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 - } -} diff --git a/internal/core/notes/service_test.go b/internal/core/notes/service_test.go deleted file mode 100644 index c50096e..0000000 --- a/internal/core/notes/service_test.go +++ /dev/null @@ -1,493 +0,0 @@ -package notes - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/verstak/verstak-desktop/internal/core/files" - "github.com/verstak/verstak-desktop/internal/core/vault" -) - -// testHarness creates a temporary vault + notes service for testing. -type testHarness struct { - t *testing.T - vault *vault.Vault - files *files.Service - notes *Service - tmpDir string - vaultPath string -} - -func newTestHarness(t *testing.T) *testHarness { - t.Helper() - tmpDir, err := os.MkdirTemp("", "verstak-notes-test-*") - if err != nil { - t.Fatalf("mkdir temp: %v", err) - } - t.Cleanup(func() { os.RemoveAll(tmpDir) }) - - v := vault.NewVault(nil) - if err := v.CreateVault(tmpDir); err != nil { - t.Fatalf("create vault: %v", err) - } - - vaultPath := v.GetVaultPath() - - f := files.NewService(v) - n := NewService(f) - - return &testHarness{ - t: t, - vault: v, - files: f, - notes: n, - tmpDir: tmpDir, - vaultPath: vaultPath, - } -} - -func TestLayoutConstants(t *testing.T) { - if CanonicalFolder != "Notes" { - t.Fatalf("CanonicalFolder = %q, want Notes", CanonicalFolder) - } - if CanonicalOverview != "Overview.md" { - t.Fatalf("CanonicalOverview = %q, want Overview.md", CanonicalOverview) - } - if NoteExtension != ".md" { - t.Fatalf("NoteExtension = %q, want .md", NoteExtension) - } -} - -func TestNotesPath(t *testing.T) { - tests := []struct { - parent string - want string - }{ - {"", "Notes"}, - {"Workspace", "Workspace/Notes"}, - {"Workspace/Project", "Workspace/Project/Notes"}, - } - for _, tt := range tests { - got := NotesPath(tt.parent) - if got != tt.want { - t.Errorf("NotesPath(%q) = %q, want %q", tt.parent, got, tt.want) - } - } -} - -func TestOverviewPath(t *testing.T) { - tests := []struct { - parent string - want string - }{ - {"", "Notes/Overview.md"}, - {"Workspace", "Workspace/Notes/Overview.md"}, - } - for _, tt := range tests { - got := OverviewPath(tt.parent) - if got != tt.want { - t.Errorf("OverviewPath(%q) = %q, want %q", tt.parent, got, tt.want) - } - } -} - -func TestIsInsideNotes(t *testing.T) { - tests := []struct { - path string - want bool - }{ - {"Notes/Overview.md", true}, - {"Workspace/Notes/MyNote.md", true}, - {"Workspace/Notes/Sub/File.md", true}, - {"Workspace/Files/readme.txt", false}, - {"", false}, - {"Notes", true}, // just the folder itself - } - for _, tt := range tests { - got := IsInsideNotes(tt.path) - if got != tt.want { - t.Errorf("IsInsideNotes(%q) = %v, want %v", tt.path, got, tt.want) - } - } -} - -func TestIsOverview(t *testing.T) { - tests := []struct { - path string - want bool - }{ - {"Notes/Overview.md", true}, - {"Workspace/Notes/Overview.md", true}, - {"Workspace/Notes/MyNote.md", false}, - {"readme.md", false}, - {"", false}, - } - for _, tt := range tests { - got := IsOverview(tt.path) - if got != tt.want { - t.Errorf("IsOverview(%q) = %v, want %v", tt.path, got, tt.want) - } - } -} - -func TestParentFromNotePath(t *testing.T) { - tests := []struct { - path string - want string - }{ - {"Workspace/Notes/MyNote.md", "Workspace"}, - {"Notes/MyNote.md", ""}, - {"A/B/Notes/MyNote.md", "A/B"}, - } - for _, tt := range tests { - got := ParentFromNotePath(tt.path) - if got != tt.want { - t.Errorf("ParentFromNotePath(%q) = %q, want %q", tt.path, got, tt.want) - } - } -} - -func TestNormalizeTitleToFilename(t *testing.T) { - tests := []struct { - title string - want string - }{ - {"My Note", "My_Note.md"}, - {"Hello World", "Hello_World.md"}, - {" Trimmed ", "Trimmed.md"}, - {"en–dash—em", "en_dash_em.md"}, // en-dash and em-dash → hyphen → underscore (collapsed) - {"special:chars<>", "specialchars.md"}, // :<> are illegal, removed entirely - {"dots.and.dashes", "dots_and_dashes.md"}, // dots collapsed to underscore - {"UPPERCASE", "UPPERCASE.md"}, - {"русский язык", "русский_язык.md"}, // Cyrillic preserved - {" leading/trailing ", "leadingtrailing.md"}, // / is illegal, removed - {"notes release 2.0", "notes_release_2_0.md"}, // dots collapsed - {"already.md", "already.md"}, // already has .md extension - {"ALREADY.MD", "ALREADY.md"}, // normalized to lowercase .md - {"emoji_😊_test", "emoji_test.md"}, // emoji dropped (non-printable non-letter) - } - for _, tt := range tests { - got, err := NormalizeTitleToFilename(tt.title) - if err != nil { - t.Errorf("NormalizeTitleToFilename(%q) unexpected error: %v", tt.title, err) - continue - } - if got != tt.want { - t.Errorf("NormalizeTitleToFilename(%q) = %q, want %q", tt.title, got, tt.want) - } - } -} - -func TestNormalizeTitleToFilenameEmpty(t *testing.T) { - _, err := NormalizeTitleToFilename("") - if err == nil { - t.Fatal("expected error for empty title") - } - _, err = NormalizeTitleToFilename("___") - if err == nil { - t.Fatal("expected error for title that normalizes to empty") - } -} - -func TestTitleFromFilename(t *testing.T) { - tests := []struct { - filename string - want string - }{ - {"My_Note.md", "My Note"}, - {"Hello.md", "Hello"}, - {"UPPERCASE.MD", "UPPERCASE"}, - } - for _, tt := range tests { - got := TitleFromFilename(tt.filename) - if got != tt.want { - t.Errorf("TitleFromFilename(%q) = %q, want %q", tt.filename, got, tt.want) - } - } -} - -func TestEnsureOverviewCreatesFile(t *testing.T) { - h := newTestHarness(t) - - overviewPath, err := h.notes.EnsureOverview("Workspace") - if err != nil { - t.Fatalf("EnsureOverview: %v", err) - } - - want := "Workspace/Notes/Overview.md" - if overviewPath != want { - t.Fatalf("overview path = %q, want %q", overviewPath, want) - } - - // Verify file exists on disk - fullPath := filepath.Join(h.vaultPath, filepath.FromSlash(overviewPath)) - if _, err := os.Stat(fullPath); err != nil { - t.Fatalf("overview file not found: %v", err) - } - - // Verify content — the workspace template creates "# Overview\n" - content, err := h.notes.ReadNote(overviewPath) - if err != nil { - t.Fatalf("ReadNote: %v", err) - } - if content == "" { - t.Fatal("overview content is empty") - } -} - -func TestEnsureOverviewIdempotent(t *testing.T) { - h := newTestHarness(t) - - p1, err := h.notes.EnsureOverview("Workspace") - if err != nil { - t.Fatalf("first EnsureOverview: %v", err) - } - - p2, err := h.notes.EnsureOverview("Workspace") - if err != nil { - t.Fatalf("second EnsureOverview: %v", err) - } - - if p1 != p2 { - t.Fatalf("paths differ: %q vs %q", p1, p2) - } -} - -func TestCreateNoteCreatesFile(t *testing.T) { - h := newTestHarness(t) - - notePath, err := h.notes.CreateNote("Workspace", "My First Note", "") - if err != nil { - t.Fatalf("CreateNote: %v", err) - } - - want := "Workspace/Notes/My_First_Note.md" - if notePath != want { - t.Fatalf("note path = %q, want %q", notePath, want) - } - - // Verify file exists - fullPath := filepath.Join(h.vaultPath, filepath.FromSlash(notePath)) - if _, err := os.Stat(fullPath); err != nil { - t.Fatalf("note file not found: %v", err) - } - - // Verify content has title - content, err := h.notes.ReadNote(notePath) - if err != nil { - t.Fatalf("ReadNote: %v", err) - } - if !strings.Contains(content, "My First Note") { - t.Fatalf("content should contain title, got: %q", content) - } -} - -func TestCreateNoteRejectsConflict(t *testing.T) { - h := newTestHarness(t) - - _, err := h.notes.CreateNote("Workspace", "My Note", "") - if err != nil { - t.Fatalf("first CreateNote: %v", err) - } - - _, err = h.notes.CreateNote("Workspace", "My Note", "") - if err == nil { - t.Fatal("expected conflict error for duplicate note") - } - var ce *ConflictError - if !asConflictError(err, &ce) { - t.Fatalf("expected ConflictError, got: %T %v", err, err) - } -} - -func TestCreateNoteRejectsEmptyTitle(t *testing.T) { - h := newTestHarness(t) - - _, err := h.notes.CreateNote("Workspace", "", "") - if err == nil { - t.Fatal("expected error for empty title") - } -} - -func TestRenameNoteRenamesFile(t *testing.T) { - h := newTestHarness(t) - - oldPath, err := h.notes.CreateNote("Workspace", "Old Title", "") - if err != nil { - t.Fatalf("CreateNote: %v", err) - } - - newPath, err := h.notes.RenameNote(oldPath, "New Title") - if err != nil { - t.Fatalf("RenameNote: %v", err) - } - - want := "Workspace/Notes/New_Title.md" - if newPath != want { - t.Fatalf("new path = %q, want %q", newPath, want) - } - - // Old file should not exist - oldFull := filepath.Join(h.vaultPath, filepath.FromSlash(oldPath)) - if _, err := os.Stat(oldFull); !os.IsNotExist(err) { - t.Fatalf("old file should not exist, stat error: %v", err) - } - - // New file should exist - newFull := filepath.Join(h.vaultPath, filepath.FromSlash(newPath)) - if _, err := os.Stat(newFull); err != nil { - t.Fatalf("new file should exist: %v", err) - } -} - -func TestRenameNoteRejectsConflict(t *testing.T) { - h := newTestHarness(t) - - _, err := h.notes.CreateNote("Workspace", "Note A", "") - if err != nil { - t.Fatalf("create Note A: %v", err) - } - _, err = h.notes.CreateNote("Workspace", "Note B", "") - if err != nil { - t.Fatalf("create Note B: %v", err) - } - - // Rename Note A to "Note B" — should conflict - _, err = h.notes.RenameNote("Workspace/Notes/Note_A.md", "Note B") - if err == nil { - t.Fatal("expected conflict error") - } - var ce *ConflictError - if !asConflictError(err, &ce) { - t.Fatalf("expected ConflictError, got: %T %v", err, err) - } -} - -func TestListNotes(t *testing.T) { - h := newTestHarness(t) - - // Ensure overview - h.notes.EnsureOverview("Workspace") - - // Create some notes - h.notes.CreateNote("Workspace", "Alpha", "") - h.notes.CreateNote("Workspace", "Beta", "") - h.notes.CreateNote("Workspace", "Gamma", "") - - notes, err := h.notes.ListNotes("Workspace") - if err != nil { - t.Fatalf("ListNotes: %v", err) - } - - if len(notes) != 4 { - t.Fatalf("expected 4 notes, got %d", len(notes)) - } - - // Overview should be first - if notes[0].IsOverview != true { - t.Fatal("first note should be overview") - } - - // The rest should be sorted alphabetically - if notes[1].Title != "Alpha" { - t.Fatalf("second note title = %q, want Alpha", notes[1].Title) - } -} - -func TestSaveAndReadNote(t *testing.T) { - h := newTestHarness(t) - - path, err := h.notes.CreateNote("Workspace", "Test Note", "original content") - if err != nil { - t.Fatalf("CreateNote: %v", err) - } - - content, err := h.notes.ReadNote(path) - if err != nil { - t.Fatalf("ReadNote: %v", err) - } - if content != "original content" { - t.Fatalf("content = %q, want %q", content, "original content") - } - - // Update content - err = h.notes.SaveNote(path, "updated content") - if err != nil { - t.Fatalf("SaveNote: %v", err) - } - - content, err = h.notes.ReadNote(path) - if err != nil { - t.Fatalf("ReadNote after save: %v", err) - } - if content != "updated content" { - t.Fatalf("content after save = %q, want %q", content, "updated content") - } -} - -func TestSearchNotes(t *testing.T) { - h := newTestHarness(t) - - h.notes.CreateNote("Workspace", "Meeting Notes", "") - h.notes.CreateNote("Workspace", "Project Plan", "") - h.notes.CreateNote("Workspace", "Ideas", "") - - results, err := h.notes.SearchNotes(h.vaultPath, "meeting") - if err != nil { - t.Fatalf("SearchNotes: %v", err) - } - - if len(results) != 1 { - t.Fatalf("expected 1 result for 'meeting', got %d", len(results)) - } - if results[0].Title != "Meeting Notes" { - t.Fatalf("title = %q, want 'Meeting Notes'", results[0].Title) - } -} - -func TestSearchNotesBySwappedLayout(t *testing.T) { - h := newTestHarness(t) - - h.notes.CreateNote("Workspace", "Привет", "") - - // Search with English QWERTY equivalent of "привет" -> "ghbdtn" - results, err := h.notes.SearchNotes(h.vaultPath, "ghbdtn") - if err != nil { - t.Fatalf("SearchNotes: %v", err) - } - - if len(results) != 1 { - t.Fatalf("expected 1 result for 'ghbdtn' (swapped layout), got %d", len(results)) - } - if results[0].Title != "Привет" { - t.Fatalf("title = %q, want 'Привет'", results[0].Title) - } -} - -func TestUnsafePathsRejected(t *testing.T) { - h := newTestHarness(t) - - // Try to create a note with path traversal - _, err := h.notes.CreateNote("../outside", "Note", "") - if err == nil { - t.Fatal("expected error for path traversal parent") - } -} - -// ─── helpers ────────────────────────────────────────────────── - -func asConflictError(err error, target **ConflictError) bool { - if err == nil { - return false - } - ce, ok := err.(*ConflictError) - if !ok { - return false - } - if target != nil { - *target = ce - } - return true -} diff --git a/internal/core/vault/vault_test.go b/internal/core/vault/vault_test.go index 687ed01..fc8b252 100644 --- a/internal/core/vault/vault_test.go +++ b/internal/core/vault/vault_test.go @@ -262,8 +262,11 @@ func TestCreateVault_CreatesWorkspace(t *testing.T) { if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil { t.Fatalf("Workspace folder not found: %v", err) } - if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil { - t.Fatalf("default workspace overview not found: %v", err) + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes")); err != nil { + t.Fatalf("default workspace notes folder not found: %v", err) + } + if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); !os.IsNotExist(err) { + t.Fatalf("default workspace should not create overview file, stat err=%v", err) } if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) { t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%v", err) diff --git a/internal/core/workbench/router.go b/internal/core/workbench/router.go index 78bbed7..fcae3e4 100644 --- a/internal/core/workbench/router.go +++ b/internal/core/workbench/router.go @@ -9,7 +9,6 @@ import ( "time" "github.com/verstak/verstak-desktop/internal/core/contribution" - "github.com/verstak/verstak-desktop/internal/core/notes" "github.com/verstak/verstak-desktop/internal/core/plugin" ) @@ -236,7 +235,7 @@ func resourceContextName(request OpenResourceRequest) string { if ext == ".md" || ext == ".markdown" { // Auto-detect Notes context: either explicitly set in request context // or path-based detection using the canonical Notes/ folder layout. - if request.Context.NotesMode || request.Context.IsInsideNotesFolder || notes.IsInsideNotes(request.Path) { + if request.Context.NotesMode || request.Context.IsInsideNotesFolder || isInsideNotesPath(request.Path) { return ContextNotesMarkdown } return ContextGenericMarkdown @@ -247,6 +246,21 @@ func resourceContextName(request OpenResourceRequest) string { return "" } +func isInsideNotesPath(relativePath string) bool { + if relativePath == "" { + return false + } + cleaned := strings.TrimSpace(relativePath) + cleaned = strings.TrimPrefix(cleaned, "./") + cleaned = strings.TrimPrefix(cleaned, "/") + for _, part := range strings.Split(cleaned, "/") { + if part == "Notes" { + return true + } + } + return false +} + func isTextResource(request OpenResourceRequest) bool { if strings.HasPrefix(request.Mime, "text/") { return true diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go index 0865101..c5b7d5c 100644 --- a/internal/core/workspace/manager.go +++ b/internal/core/workspace/manager.go @@ -125,9 +125,7 @@ var builtInTemplates = map[string]templateDefinition{ "notes": "Notes", "files": "Files", }, - Files: map[string]string{ - "Notes/Overview.md": "# Overview\n", - }, + Files: map[string]string{}, }, "client-project": { ID: "client-project", @@ -144,9 +142,7 @@ var builtInTemplates = map[string]templateDefinition{ "files": "Files", "secrets": "Secrets", }, - Files: map[string]string{ - "Notes/Overview.md": "# Overview\n", - }, + Files: map[string]string{}, }, } diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go index 9047e4f..75fa849 100644 --- a/internal/core/workspace/manager_test.go +++ b/internal/core/workspace/manager_test.go @@ -92,8 +92,11 @@ func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testi if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil { t.Fatalf("workspace folder missing: %v", err) } - if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil { - t.Fatalf("default template overview missing: %v", err) + if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes")); err != nil { + t.Fatalf("default template notes folder missing: %v", err) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); !os.IsNotExist(err) { + t.Fatalf("default template should not create overview file, stat err=%v", err) } meta, err := m.GetWorkspaceMetadata("Project")