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:
parent
0b6b0d0926
commit
03175aa46d
|
|
@ -5,4 +5,4 @@ build/bin/verstak-desktop
|
||||||
smoke-platform
|
smoke-platform
|
||||||
plugins/
|
plugins/
|
||||||
vendor/
|
vendor/
|
||||||
\nfrontend/e2e-results/
|
frontend/e2e-results/
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
|
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
|
||||||
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
|
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
|
||||||
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
|
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
|
||||||
|
workspaceItems: (contributions.workspaceItems || []).filter(w => w.pluginId === pluginId).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
$: contribSummary = (() => {
|
$: contribSummary = (() => {
|
||||||
|
|
@ -42,6 +43,7 @@
|
||||||
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
|
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.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.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';
|
return parts.length > 0 ? parts.join(', ') : 'none';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import {api} from '../models';
|
||||||
import {permissions} from '../models';
|
import {permissions} from '../models';
|
||||||
import {plugin} from '../models';
|
import {plugin} from '../models';
|
||||||
import {files} from '../models';
|
import {files} from '../models';
|
||||||
|
import {notes} from '../models';
|
||||||
|
|
||||||
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
|
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function CloseVault():Promise<void>;
|
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 CreateVault(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
|
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 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 ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
export function GetAppSettings():Promise<Record<string, any>>;
|
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 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 ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
|
||||||
|
|
||||||
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|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 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 OpenVault(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
|
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 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 ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function ReadPluginSetting(arg1:string,arg2:string):Promise<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 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 RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function ResetSyncKey():Promise<void>;
|
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 SelectDirectory():Promise<string>;
|
||||||
|
|
||||||
export function SelectVaultForOpen():Promise<string>;
|
export function SelectVaultForOpen():Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ export function CloseVault() {
|
||||||
return window['go']['api']['App']['CloseVault']();
|
return window['go']['api']['App']['CloseVault']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CreateNote(arg1, arg2) {
|
||||||
|
return window['go']['api']['App']['CreateNote'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateVault(arg1) {
|
export function CreateVault(arg1) {
|
||||||
return window['go']['api']['App']['CreateVault'](arg1);
|
return window['go']['api']['App']['CreateVault'](arg1);
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +42,10 @@ export function EnablePlugin(arg1) {
|
||||||
return window['go']['api']['App']['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) {
|
export function ExecutePluginCommand(arg1, arg2, arg3) {
|
||||||
return window['go']['api']['App']['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']();
|
return window['go']['api']['App']['GetWorkspaceTree']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListNotes(arg1) {
|
||||||
|
return window['go']['api']['App']['ListNotes'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ListPluginCapabilities(arg1) {
|
export function ListPluginCapabilities(arg1) {
|
||||||
return window['go']['api']['App']['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);
|
return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NormalizeNoteTitle(arg1) {
|
||||||
|
return window['go']['api']['App']['NormalizeNoteTitle'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenVault(arg1) {
|
export function OpenVault(arg1) {
|
||||||
return window['go']['api']['App']['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);
|
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) {
|
export function ReadPluginDataJSON(arg1, arg2) {
|
||||||
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
|
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +186,10 @@ export function ReloadPlugins() {
|
||||||
return window['go']['api']['App']['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) {
|
export function RenameWorkspace(arg1, arg2) {
|
||||||
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
|
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +202,14 @@ export function ResetSyncKey() {
|
||||||
return window['go']['api']['App']['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() {
|
export function SelectDirectory() {
|
||||||
return window['go']['api']['App']['SelectDirectory']();
|
return window['go']['api']['App']['SelectDirectory']();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 namespace permissions {
|
||||||
|
|
||||||
export class Entry {
|
export class Entry {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
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/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
|
|
@ -40,6 +41,7 @@ type App struct {
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
storage *storage.Storage
|
storage *storage.Storage
|
||||||
files *corefiles.Service
|
files *corefiles.Service
|
||||||
|
notes *notes.Service
|
||||||
appSettings *appsettings.Manager
|
appSettings *appsettings.Manager
|
||||||
pluginState *pluginstate.Manager
|
pluginState *pluginstate.Manager
|
||||||
workbench *coreworkbench.Router
|
workbench *coreworkbench.Router
|
||||||
|
|
@ -58,6 +60,7 @@ func NewApp(
|
||||||
vaultService *vault.Vault,
|
vaultService *vault.Vault,
|
||||||
storageService *storage.Storage,
|
storageService *storage.Storage,
|
||||||
filesService *corefiles.Service,
|
filesService *corefiles.Service,
|
||||||
|
notesService *notes.Service,
|
||||||
appSettingsMgr *appsettings.Manager,
|
appSettingsMgr *appsettings.Manager,
|
||||||
pluginStateMgr *pluginstate.Manager,
|
pluginStateMgr *pluginstate.Manager,
|
||||||
workspaceMgr *workspace.Manager,
|
workspaceMgr *workspace.Manager,
|
||||||
|
|
@ -73,6 +76,7 @@ func NewApp(
|
||||||
vault: vaultService,
|
vault: vaultService,
|
||||||
storage: storageService,
|
storage: storageService,
|
||||||
files: filesService,
|
files: filesService,
|
||||||
|
notes: notesService,
|
||||||
appSettings: appSettingsMgr,
|
appSettings: appSettingsMgr,
|
||||||
pluginState: pluginStateMgr,
|
pluginState: pluginStateMgr,
|
||||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||||
|
|
@ -345,6 +349,7 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
"verstak/core/files/v1",
|
"verstak/core/files/v1",
|
||||||
"verstak/core/workbench/v1",
|
"verstak/core/workbench/v1",
|
||||||
|
"verstak/core/notes/v1",
|
||||||
}
|
}
|
||||||
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
|
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
|
||||||
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
|
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
|
||||||
|
|
@ -1158,6 +1163,112 @@ func (a *App) SetCurrentWorkspaceNode(id string) string {
|
||||||
return ""
|
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 ────────────────────────────────
|
// ─── Vault Plugin State API ────────────────────────────────
|
||||||
|
|
||||||
// GetVaultPluginState returns the current vault plugin state.
|
// GetVaultPluginState returns the current vault plugin state.
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/notes"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,7 +234,9 @@ func (r *Router) preferenceFor(request OpenResourceRequest) string {
|
||||||
func resourceContextName(request OpenResourceRequest) string {
|
func resourceContextName(request OpenResourceRequest) string {
|
||||||
ext := strings.ToLower(request.Extension)
|
ext := strings.ToLower(request.Extension)
|
||||||
if ext == ".md" || ext == ".markdown" {
|
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 ContextNotesMarkdown
|
||||||
}
|
}
|
||||||
return ContextGenericMarkdown
|
return ContextGenericMarkdown
|
||||||
|
|
|
||||||
|
|
@ -403,7 +403,7 @@ func TestDetermineContextName(t *testing.T) {
|
||||||
want: ContextGenericMarkdown,
|
want: ContextGenericMarkdown,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "notes markdown",
|
name: "notes markdown with explicit context",
|
||||||
request: OpenResourceRequest{
|
request: OpenResourceRequest{
|
||||||
Kind: "vault-file",
|
Kind: "vault-file",
|
||||||
Path: "Notes/Overview.md",
|
Path: "Notes/Overview.md",
|
||||||
|
|
@ -414,6 +414,30 @@ func TestDetermineContextName(t *testing.T) {
|
||||||
},
|
},
|
||||||
want: ContextNotesMarkdown,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
5
main.go
5
main.go
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
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/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
|
|
@ -92,6 +93,7 @@ func main() {
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
"verstak/core/files/v1",
|
"verstak/core/files/v1",
|
||||||
"verstak/core/workbench/v1",
|
"verstak/core/workbench/v1",
|
||||||
|
"verstak/core/notes/v1",
|
||||||
}
|
}
|
||||||
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
|
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
|
||||||
log.Fatalf("[main] failed to register core capabilities: %v", err)
|
log.Fatalf("[main] failed to register core capabilities: %v", err)
|
||||||
|
|
@ -246,11 +248,12 @@ func main() {
|
||||||
// Create the App struct
|
// Create the App struct
|
||||||
storageService := storage.New(vaultService)
|
storageService := storage.New(vaultService)
|
||||||
filesService := corefiles.NewService(vaultService)
|
filesService := corefiles.NewService(vaultService)
|
||||||
|
notesService := notes.NewService(filesService)
|
||||||
var syncService *syncsvc.Service
|
var syncService *syncsvc.Service
|
||||||
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
||||||
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
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 ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue