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:
- Workspace overview notes live under `<workspace>/Notes/`.
- The default overview note is `<workspace>/Notes/Overview.md`.
- Notes live under `<workspace>/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.
- `<workspace>` 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`.

View File

@ -761,7 +761,6 @@ Workspace — это физическая папка верхнего уровн
<vault>/
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 `<workspace>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note

View File

@ -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")

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 {
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)

View File

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

View File

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

View File

@ -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")