From 03175aa46d1d8a39b422d754ab33dc0fbe7bd9f4 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 21 Jun 2026 23:22:36 +0800 Subject: [PATCH] feat: Notes core service + Notes API + router auto-detect notes context - Add internal/core/notes/ service (Service, Layout, Normalize, tests) - Register verstak/core/notes/v1 capability - Inject NotesService into App, expose 8 Notes API endpoints (CreateNote, RenameNote, ReadNote, SaveNote, EnsureOverview, ListNotes, SearchNotes, NormalizeNoteTitle) - Router: auto-detect Notes context via path (IsInsideNotes) - PluginCard: show workspaceItems contribution count - Regenerate Wails bindings (App.d.ts, App.js, models.ts with notes.NoteInfo) - Fix .gitignore pattern for e2e-results/ --- .gitignore | 2 +- .../src/lib/plugin-manager/PluginCard.svelte | 2 + frontend/wailsjs/go/api/App.d.ts | 17 + frontend/wailsjs/go/api/App.js | 32 ++ frontend/wailsjs/go/models.ts | 25 + internal/api/app.go | 111 ++++ 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/workbench/router.go | 5 +- internal/core/workbench/routing_test.go | 26 +- main.go | 5 +- 13 files changed, 1367 insertions(+), 4 deletions(-) create mode 100644 internal/core/notes/layout.go create mode 100644 internal/core/notes/normalize.go create mode 100644 internal/core/notes/service.go create mode 100644 internal/core/notes/service_test.go diff --git a/.gitignore b/.gitignore index 138c3b1..f8b7a48 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ build/bin/verstak-desktop smoke-platform plugins/ vendor/ -\nfrontend/e2e-results/ +frontend/e2e-results/ diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte index 03d9cc9..30f94db 100644 --- a/frontend/src/lib/plugin-manager/PluginCard.svelte +++ b/frontend/src/lib/plugin-manager/PluginCard.svelte @@ -33,6 +33,7 @@ sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length, statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length, openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length, + workspaceItems: (contributions.workspaceItems || []).filter(w => w.pluginId === pluginId).length, }; $: contribSummary = (() => { @@ -42,6 +43,7 @@ if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : '')); if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : '')); if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : '')); + if (contribCounts.workspaceItems > 0) parts.push(contribCounts.workspaceItems + ' workspace' + (contribCounts.workspaceItems !== 1 ? 's' : '')); return parts.length > 0 ? parts.join(', ') : 'none'; })(); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 1d71584..91caaef 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -7,11 +7,14 @@ import {api} from '../models'; import {permissions} from '../models'; import {plugin} from '../models'; import {files} from '../models'; +import {notes} from '../models'; export function ArchiveWorkspaceNode(arg1:string):Promise; export function CloseVault():Promise; +export function CreateNote(arg1:string,arg2:string):Promise|string>; + export function CreateVault(arg1:string):Promise; export function CreateVaultFolder(arg1:string,arg2:string):Promise; @@ -26,6 +29,8 @@ export function EditWorkbenchResource(arg1:string,arg2:Record):Prom export function EnablePlugin(arg1:string):Promise; +export function EnsureOverview(arg1:string):Promise|string>; + export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record):Promise|string>; export function GetAppSettings():Promise>; @@ -62,6 +67,8 @@ export function GetWorkspaceMetadata(arg1:string):Promise>; +export function ListNotes(arg1:string):Promise|string>; + export function ListPluginCapabilities(arg1:string):Promise|string>; export function ListVaultFiles(arg1:string,arg2:string):Promise|string>; @@ -72,12 +79,16 @@ export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.Mov export function MoveWorkspaceNode(arg1:string,arg2:string):Promise; +export function NormalizeNoteTitle(arg1:string):Promise|string>; + export function OpenVault(arg1:string):Promise; export function OpenWorkbenchResource(arg1:string,arg2:Record):Promise; export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record):Promise; +export function ReadNote(arg1:string):Promise|string>; + export function ReadPluginDataJSON(arg1:string,arg2:string):Promise>; export function ReadPluginSetting(arg1:string,arg2:string):Promise; @@ -90,12 +101,18 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise export function ReloadPlugins():Promise; +export function RenameNote(arg1:string,arg2:string):Promise|string>; + export function RenameWorkspace(arg1:string,arg2:string):Promise; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; export function ResetSyncKey():Promise; +export function SaveNote(arg1:string,arg2:string):Promise; + +export function SearchNotes(arg1:string):Promise|string>; + export function SelectDirectory():Promise; export function SelectVaultForOpen():Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 4cf0d28..88c1302 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -10,6 +10,10 @@ export function CloseVault() { return window['go']['api']['App']['CloseVault'](); } +export function CreateNote(arg1, arg2) { + return window['go']['api']['App']['CreateNote'](arg1, arg2); +} + export function CreateVault(arg1) { return window['go']['api']['App']['CreateVault'](arg1); } @@ -38,6 +42,10 @@ export function EnablePlugin(arg1) { return window['go']['api']['App']['EnablePlugin'](arg1); } +export function EnsureOverview(arg1) { + return window['go']['api']['App']['EnsureOverview'](arg1); +} + export function ExecutePluginCommand(arg1, arg2, arg3) { return window['go']['api']['App']['ExecutePluginCommand'](arg1, arg2, arg3); } @@ -110,6 +118,10 @@ export function GetWorkspaceTree() { return window['go']['api']['App']['GetWorkspaceTree'](); } +export function ListNotes(arg1) { + return window['go']['api']['App']['ListNotes'](arg1); +} + export function ListPluginCapabilities(arg1) { return window['go']['api']['App']['ListPluginCapabilities'](arg1); } @@ -130,6 +142,10 @@ export function MoveWorkspaceNode(arg1, arg2) { return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2); } +export function NormalizeNoteTitle(arg1) { + return window['go']['api']['App']['NormalizeNoteTitle'](arg1); +} + export function OpenVault(arg1) { return window['go']['api']['App']['OpenVault'](arg1); } @@ -142,6 +158,10 @@ export function PublishPluginEvent(arg1, arg2, arg3) { return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3); } +export function ReadNote(arg1) { + return window['go']['api']['App']['ReadNote'](arg1); +} + export function ReadPluginDataJSON(arg1, arg2) { return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2); } @@ -166,6 +186,10 @@ export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } +export function RenameNote(arg1, arg2) { + return window['go']['api']['App']['RenameNote'](arg1, arg2); +} + export function RenameWorkspace(arg1, arg2) { return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); } @@ -178,6 +202,14 @@ export function ResetSyncKey() { return window['go']['api']['App']['ResetSyncKey'](); } +export function SaveNote(arg1, arg2) { + return window['go']['api']['App']['SaveNote'](arg1, arg2); +} + +export function SearchNotes(arg1) { + return window['go']['api']['App']['SearchNotes'](arg1); +} + export function SelectDirectory() { return window['go']['api']['App']['SelectDirectory'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b8a18ac..fad07c5 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -380,6 +380,31 @@ export namespace files { } +export namespace notes { + + export class NoteInfo { + title: string; + filename: string; + path: string; + parentPath: string; + isOverview: boolean; + + static createFrom(source: any = {}) { + return new NoteInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.title = source["title"]; + this.filename = source["filename"]; + this.path = source["path"]; + this.parentPath = source["parentPath"]; + this.isOverview = source["isOverview"]; + } + } + +} + export namespace permissions { export class Entry { diff --git a/internal/api/app.go b/internal/api/app.go index 11acf09..54b1e93 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -18,6 +18,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" corefiles "github.com/verstak/verstak-desktop/internal/core/files" + "github.com/verstak/verstak-desktop/internal/core/notes" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" @@ -40,6 +41,7 @@ type App struct { vault *vault.Vault storage *storage.Storage files *corefiles.Service + notes *notes.Service appSettings *appsettings.Manager pluginState *pluginstate.Manager workbench *coreworkbench.Router @@ -58,6 +60,7 @@ func NewApp( vaultService *vault.Vault, storageService *storage.Storage, filesService *corefiles.Service, + notesService *notes.Service, appSettingsMgr *appsettings.Manager, pluginStateMgr *pluginstate.Manager, workspaceMgr *workspace.Manager, @@ -73,6 +76,7 @@ func NewApp( vault: vaultService, storage: storageService, files: filesService, + notes: notesService, appSettings: appSettingsMgr, pluginState: pluginStateMgr, workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), @@ -345,6 +349,7 @@ func (a *App) ReloadPlugins() (int, string) { "verstak/core/events/v1", "verstak/core/files/v1", "verstak/core/workbench/v1", + "verstak/core/notes/v1", } if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil { log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err) @@ -1158,6 +1163,112 @@ func (a *App) SetCurrentWorkspaceNode(id string) string { return "" } +// ─── Notes API ─────────────────────────────────────────────── + +// EnsureOverview creates or returns the path to Notes/Overview.md under parent. +func (a *App) EnsureOverview(parent string) (map[string]interface{}, string) { + if a.notes == nil { + return nil, "notes service not initialized" + } + path, err := a.notes.EnsureOverview(parent) + if err != nil { + return nil, err.Error() + } + return map[string]interface{}{"path": path}, "" +} + +// CreateNote creates a new note under the given parent's Notes/ folder. +// Returns the vault-relative path of the new note. +func (a *App) CreateNote(parent, title string) (map[string]interface{}, string) { + if a.notes == nil { + return nil, "notes service not initialized" + } + path, err := a.notes.CreateNote(parent, title, "") + if err != nil { + if _, ok := err.(*notes.ConflictError); ok { + return map[string]interface{}{"conflict": true, "path": "", "error": err.Error()}, "" + } + return nil, err.Error() + } + return map[string]interface{}{"path": path, "conflict": false}, "" +} + +// RenameNote renames a note by changing its title. File is renamed accordingly. +// Returns the new vault-relative path. +func (a *App) RenameNote(notePath, newTitle string) (map[string]interface{}, string) { + if a.notes == nil { + return nil, "notes service not initialized" + } + newPath, err := a.notes.RenameNote(notePath, newTitle) + if err != nil { + if _, ok := err.(*notes.ConflictError); ok { + return map[string]interface{}{"conflict": true, "path": "", "error": err.Error()}, "" + } + return nil, err.Error() + } + return map[string]interface{}{"path": newPath, "conflict": false}, "" +} + +// ReadNote reads the content of a note file. +func (a *App) ReadNote(notePath string) (string, string) { + if a.notes == nil { + return "", "notes service not initialized" + } + content, err := a.notes.ReadNote(notePath) + if err != nil { + return "", err.Error() + } + return content, "" +} + +// SaveNote writes content to a note file. +func (a *App) SaveNote(notePath, content string) string { + if a.notes == nil { + return "notes service not initialized" + } + if err := a.notes.SaveNote(notePath, content); err != nil { + return err.Error() + } + return "" +} + +// ListNotes returns all notes in the given parent's Notes/ folder. +func (a *App) ListNotes(parent string) ([]notes.NoteInfo, string) { + if a.notes == nil { + return nil, "notes service not initialized" + } + noteList, err := a.notes.ListNotes(parent) + if err != nil { + return nil, err.Error() + } + return noteList, "" +} + +// SearchNotes performs a case-insensitive search across all notes in the vault. +func (a *App) SearchNotes(query string) ([]notes.NoteInfo, string) { + if a.notes == nil { + return nil, "notes service not initialized" + } + vaultPath := a.vaultPath() + if vaultPath == "" { + return nil, "vault not open" + } + results, err := a.notes.SearchNotes(vaultPath, query) + if err != nil { + return nil, err.Error() + } + return results, "" +} + +// NormalizeNoteTitle converts a note title to a safe filename (including .md extension). +func (a *App) NormalizeNoteTitle(title string) (string, string) { + filename, err := notes.NormalizeTitleToFilename(title) + if err != nil { + return "", err.Error() + } + return filename, "" +} + // ─── Vault Plugin State API ──────────────────────────────── // GetVaultPluginState returns the current vault plugin state. diff --git a/internal/core/notes/layout.go b/internal/core/notes/layout.go new file mode 100644 index 0000000..5495eb9 --- /dev/null +++ b/internal/core/notes/layout.go @@ -0,0 +1,96 @@ +// 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 new file mode 100644 index 0000000..728752b --- /dev/null +++ b/internal/core/notes/normalize.go @@ -0,0 +1,146 @@ +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 new file mode 100644 index 0000000..0aea873 --- /dev/null +++ b/internal/core/notes/service.go @@ -0,0 +1,411 @@ +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 new file mode 100644 index 0000000..c50096e --- /dev/null +++ b/internal/core/notes/service_test.go @@ -0,0 +1,493 @@ +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/workbench/router.go b/internal/core/workbench/router.go index 2c23f6a..78bbed7 100644 --- a/internal/core/workbench/router.go +++ b/internal/core/workbench/router.go @@ -9,6 +9,7 @@ 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" ) @@ -233,7 +234,9 @@ func (r *Router) preferenceFor(request OpenResourceRequest) string { func resourceContextName(request OpenResourceRequest) string { ext := strings.ToLower(request.Extension) if ext == ".md" || ext == ".markdown" { - if request.Context.NotesMode || request.Context.IsInsideNotesFolder { + // 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) { return ContextNotesMarkdown } return ContextGenericMarkdown diff --git a/internal/core/workbench/routing_test.go b/internal/core/workbench/routing_test.go index c07536b..84e4e5e 100644 --- a/internal/core/workbench/routing_test.go +++ b/internal/core/workbench/routing_test.go @@ -403,7 +403,7 @@ func TestDetermineContextName(t *testing.T) { want: ContextGenericMarkdown, }, { - name: "notes markdown", + name: "notes markdown with explicit context", request: OpenResourceRequest{ Kind: "vault-file", Path: "Notes/Overview.md", @@ -414,6 +414,30 @@ func TestDetermineContextName(t *testing.T) { }, want: ContextNotesMarkdown, }, + { + name: "notes markdown auto-detected from path", + request: OpenResourceRequest{ + Kind: "vault-file", + Path: "Workspace/Notes/MyNote.md", + }, + want: ContextNotesMarkdown, + }, + { + name: "notes overview auto-detected", + request: OpenResourceRequest{ + Kind: "vault-file", + Path: "Notes/Overview.md", + }, + want: ContextNotesMarkdown, + }, + { + name: "non-notes markdown stays generic", + request: OpenResourceRequest{ + Kind: "vault-file", + Path: "Workspace/Files/readme.md", + }, + want: ContextGenericMarkdown, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/main.go b/main.go index caf2336..3bcf6f2 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" corefiles "github.com/verstak/verstak-desktop/internal/core/files" + "github.com/verstak/verstak-desktop/internal/core/notes" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" @@ -92,6 +93,7 @@ func main() { "verstak/core/events/v1", "verstak/core/files/v1", "verstak/core/workbench/v1", + "verstak/core/notes/v1", } if err := capRegistry.Register(corePluginID, coreCaps); err != nil { log.Fatalf("[main] failed to register core capabilities: %v", err) @@ -246,11 +248,12 @@ func main() { // Create the App struct storageService := storage.New(vaultService) filesService := corefiles.NewService(vaultService) + notesService := notes.NewService(filesService) var syncService *syncsvc.Service if vaultService.GetVaultStatus() == vault.StatusOpen { syncService = syncsvc.NewService(vaultService.GetVaultPath(), "") } - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled) + app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, notesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled) // ─── Wails App ─────────────────────────────────────────── err := wails.Run(&options.App{