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/
This commit is contained in:
mirivlad 2026-06-21 23:22:36 +08:00
parent 0b6b0d0926
commit 03175aa46d
13 changed files with 1367 additions and 4 deletions

2
.gitignore vendored
View File

@ -5,4 +5,4 @@ build/bin/verstak-desktop
smoke-platform
plugins/
vendor/
\nfrontend/e2e-results/
frontend/e2e-results/

View File

@ -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';
})();

View File

@ -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<string>;
export function CloseVault():Promise<void>;
export function CreateNote(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function CreateVault(arg1:string):Promise<void>;
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
@ -26,6 +29,8 @@ export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Prom
export function EnablePlugin(arg1:string):Promise<string>;
export function EnsureOverview(arg1:string):Promise<Record<string, any>|string>;
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
export function GetAppSettings():Promise<Record<string, any>>;
@ -62,6 +67,8 @@ export function GetWorkspaceMetadata(arg1:string):Promise<workspace.Metadata|str
export function GetWorkspaceTree():Promise<Record<string, any>>;
export function ListNotes(arg1:string):Promise<Array<notes.NoteInfo>|string>;
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|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<string>;
export function NormalizeNoteTitle(arg1:string):Promise<Record<string, any>|string>;
export function OpenVault(arg1:string):Promise<void>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function ReadNote(arg1:string):Promise<Record<string, any>|string>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
@ -90,12 +101,18 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise
export function ReloadPlugins():Promise<number|string>;
export function RenameNote(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function ResetSyncKey():Promise<void>;
export function SaveNote(arg1:string,arg2:string):Promise<string>;
export function SearchNotes(arg1:string):Promise<Array<notes.NoteInfo>|string>;
export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>;

View File

@ -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']();
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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