Remove core notes service

This commit is contained in:
mirivlad 2026-06-27 12:44:02 +08:00
parent 24444a8588
commit 28a4e10e79
11 changed files with 63 additions and 1189 deletions

View File

@ -54,8 +54,9 @@ Canonical rules:
Canonical scoped paths: Canonical scoped paths:
- Workspace overview notes live under `<workspace>/Notes/`. - Notes live under `<workspace>/Notes/`.
- The default overview note is `<workspace>/Notes/Overview.md`. - `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.
- `<workspace>` is the top-level physical folder name under the vault root. - `<workspace>` is the top-level physical folder name under the vault root.
- Files plugin workspace views are scoped with `workspaceRootPath`, which is the - Files plugin workspace views are scoped with `workspaceRootPath`, which is the
selected top-level workspace folder name. selected top-level workspace folder name.
@ -68,8 +69,9 @@ Visibility requirements:
- Outside Verstak, the files must remain useful as normal Markdown. - Outside Verstak, the files must remain useful as normal Markdown.
There is no canonical metadata workspace tree. Adding `note` as a workspace node 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 type is not part of the next milestone. The official Notes plugin can index and
Markdown files inside canonical `Notes/` folders under each top-level workspace. manage Markdown files inside canonical `Notes/` folders under each top-level
workspace.
## Title To Filename Contract ## Title To Filename Contract
@ -99,9 +101,9 @@ Examples:
Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp
suffixes. suffixes.
`CreateNote` and `RenameNote` must return a conflict error if the normalized Creating or renaming a note must return a conflict error if the normalized target
target filename already exists in the target `Notes/` folder. The UI should show a filename already exists in the target `Notes/` folder. The UI should show a clear
clear dialog or notification and ask the user to change the title. dialog or notification and ask the user to change the title.
Required conflict metadata: Required conflict metadata:
@ -159,16 +161,17 @@ Later Files methods:
- `OpenExternal(relativePath)` with explicit permission and UX confirmation. - `OpenExternal(relativePath)` with explicit permission and UX confirmation.
- `RevealInFileManager(relativePath)`. - `RevealInFileManager(relativePath)`.
## Notes Service Model ## Official Notes Plugin Model
Notes API is a semantic layer over Markdown files managed by the Files/path The official Notes plugin is a semantic layer over Markdown files managed through
policy. the public Files plugin API. There is no core desktop Notes service or
`verstak/core/notes/v1` capability in v2.
Rules: Rules:
- A note physically is a `.md` file. - A note physically is a `.md` file.
- Notes API and Files API must not create two sources of truth. - Notes plugin and Files API must not create two sources of truth.
- Notes API reads/writes the same files that Files API lists. - 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. - 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` If frontmatter or a first-heading convention is introduced later, `RenameNote`
must keep that visible title metadata and the filename synchronized. must keep that visible title metadata and the filename synchronized.
@ -178,27 +181,27 @@ Rules:
watcher/indexer must observe it. watcher/indexer must observe it.
- Until watcher/indexer exists, external changes require reload/rescan. - 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. - list Markdown files in a scoped canonical `Notes/` folder;
- `GetNote(notePath)`. - create a Markdown note by writing the normalized filename atomically with
- `CreateNote(scope, title, initialBody)`. `overwrite: false`;
- `RenameNote(notePath, newTitle)`. - rename a note by moving the file to the normalized filename;
- `UpdateNoteBody(notePath, body)`. - open and edit notes through Workbench providers;
- `TrashNote(notePath)`. - never special-case `Overview.md` in the UI.
Later Notes methods: Later Notes plugin behavior:
- `SearchNotes(query, filters)`. - search notes;
- `ListBacklinks(notePath)`. - list backlinks;
- `ResolveNoteLinks(notePath)`. - resolve note links;
- `ExportNote(notePath, format)`. - export notes.
## Notes Vs Files Relationship ## Notes Vs Files Relationship
Files owns safe raw vault file access. Notes owns note semantics. 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. - Files sees `Project/Notes/Overview.md` as a file.
- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`. - Notes sees `Project/Notes/Overview.md` as a note with title `Overview`.
@ -238,7 +241,6 @@ real isolation boundary.
Recommended capabilities: Recommended capabilities:
- `verstak/core/files/v1` - `verstak/core/files/v1`
- `verstak/core/notes/v1`
- `verstak/files/v1` provided by the official Files plugin. - `verstak/files/v1` provided by the official Files plugin.
- `verstak/notes/v1` provided by the official Notes 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. - Atomic text writes and temp-file cleanup on failure.
- Notes `Notes/` folder casing and no lowercase `notes`. - Notes `Notes/` folder casing and no lowercase `notes`.
- Title to filename normalization. - 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. - Notes and Files read the same physical `.md` file.
- Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`. - Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`.

View File

@ -761,7 +761,6 @@ Workspace — это физическая папка верхнего уровн
<vault>/ <vault>/
Workspace/ Workspace/
Notes/ Notes/
Overview.md
Project/ Project/
ClientA/ ClientA/
.verstak/ .verstak/
@ -860,7 +859,7 @@ Deprecated compatibility APIs:
`.verstak`, reserved/internal names, symlink workspaces, conflicts. `.verstak`, reserved/internal names, symlink workspaces, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки - WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку. (`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`. `Project/docs/file.md`, `Test/readme.md`.
- Notes are ordinary Markdown files under `<workspace>/Notes/`; нет - Notes are ordinary Markdown files under `<workspace>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note `.verstak/notes`, UUID note storage или второго source of truth для note

View File

@ -841,8 +841,11 @@ func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) {
if ws.RootPath != "Project" { if ws.RootPath != "Project" {
t.Fatalf("workspace = %+v, want rootPath Project", ws) t.Fatalf("workspace = %+v, want rootPath Project", ws)
} }
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil { if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes")); err != nil {
t.Fatalf("template file missing: %v", err) 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") meta, errStr := app.GetWorkspaceMetadata("Project")

View File

@ -1,96 +0,0 @@
// 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 ""
}

View File

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

View File

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

View File

@ -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"},
{"endash—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
}

View File

@ -262,8 +262,11 @@ func TestCreateVault_CreatesWorkspace(t *testing.T) {
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil { if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
t.Fatalf("Workspace folder not found: %v", err) t.Fatalf("Workspace folder not found: %v", err)
} }
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil { if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes")); err != nil {
t.Fatalf("default workspace overview not found: %v", err) 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) { 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) t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%v", err)

View File

@ -9,7 +9,6 @@ import (
"time" "time"
"github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/notes"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
) )
@ -236,7 +235,7 @@ func resourceContextName(request OpenResourceRequest) string {
if ext == ".md" || ext == ".markdown" { if ext == ".md" || ext == ".markdown" {
// Auto-detect Notes context: either explicitly set in request context // Auto-detect Notes context: either explicitly set in request context
// or path-based detection using the canonical Notes/ folder layout. // 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 ContextNotesMarkdown
} }
return ContextGenericMarkdown return ContextGenericMarkdown
@ -247,6 +246,21 @@ func resourceContextName(request OpenResourceRequest) string {
return "" 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 { func isTextResource(request OpenResourceRequest) bool {
if strings.HasPrefix(request.Mime, "text/") { if strings.HasPrefix(request.Mime, "text/") {
return true return true

View File

@ -125,9 +125,7 @@ var builtInTemplates = map[string]templateDefinition{
"notes": "Notes", "notes": "Notes",
"files": "Files", "files": "Files",
}, },
Files: map[string]string{ Files: map[string]string{},
"Notes/Overview.md": "# Overview\n",
},
}, },
"client-project": { "client-project": {
ID: "client-project", ID: "client-project",
@ -144,9 +142,7 @@ var builtInTemplates = map[string]templateDefinition{
"files": "Files", "files": "Files",
"secrets": "Secrets", "secrets": "Secrets",
}, },
Files: map[string]string{ Files: map[string]string{},
"Notes/Overview.md": "# Overview\n",
},
}, },
} }

View File

@ -92,8 +92,11 @@ func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testi
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil { if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err) t.Fatalf("workspace folder missing: %v", err)
} }
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil { if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes")); err != nil {
t.Fatalf("default template overview missing: %v", err) 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") meta, err := m.GetWorkspaceMetadata("Project")