fix: stabilize markdown notes — internal link modal, rename UI, trash integration

- Replace broken ObjectPickerModal with simple inline modal (Label+URL fields)
- Insert internal link at cursor position in textarea
- Add rename button in note editor header and note cards
- Add delete button on note cards with confirm dialog
- Integrate DeleteNote with shared trash (.verstak/trash/) via files.TrashFile()
- Remove hidden .verstak/trash/notes/ folder — notes use unified trash now
- Fix purgeTrashNode to clean file-record-based trash entries (notes/files)
- Add activity + sync ops to DeleteNote binding
- Add files.TrashFile() public method
- Update i18n keys for note.rename, note.deleteConfirm, internal link modal
- AssertContained: symlink-aware path containment check
- Update tests: shared trash, file record missing flag, collision on rename
- All go test ./... pass, frontend build passes, GUI binary built
This commit is contained in:
2026-06-15 09:19:26 +08:00
parent a193c5a4c6
commit 0fdf77ce03
27 changed files with 2892 additions and 39 deletions
+3 -1
View File
@@ -10,6 +10,7 @@ import (
"verstak/internal/core/activity"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/notes"
"verstak/internal/core/templates"
"verstak/internal/core/util"
)
@@ -173,7 +174,8 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
}
for _, df := range tmpl.DefaultFiles {
fpath := filepath.Join(physPath, df.Path)
// Default files (like Overview.md) go into the Notes/ subfolder
fpath := filepath.Join(physPath, notes.NotesFolder, df.Path)
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
rollbackChildren()
return nil, fmt.Errorf("create directory for %s: %w", df.Path, err)
+26
View File
@@ -67,3 +67,29 @@ func (a *App) SaveNote(noteID, content string) error {
}
return nil
}
func (a *App) RenameNote(noteID, newTitle string) error {
if err := a.requireVault(); err != nil {
return err
}
return a.notes.Rename(noteID, newTitle)
}
func (a *App) DeleteNote(noteID string) error {
if err := a.requireVault(); err != nil {
return err
}
// Record activity and sync op before delete (need node info).
n, _ := a.nodes.GetActive(noteID)
pid := ""
if n != nil && n.ParentID != nil {
pid = *n.ParentID
}
title := ""
if n != nil {
title = n.Title
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteDeleted, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpDelete, nil)
return a.notes.Delete(noteID)
}
+10
View File
@@ -315,9 +315,19 @@ func (a *App) purgeTrashNode(nodeID string) error {
return err
}
for _, id := range ids {
// Try direct trash entry (folder-type nodes: nodeID_title).
if path, err := a.findTrashEntryForNode(id); err == nil {
_ = os.RemoveAll(path)
}
// Try file record trash entries (file/note nodes: fileID_filename).
// These are created by files.trashRecord and not found by findTrashEntryForNode.
if recs, err := a.files.ListTrashedByNode(id); err == nil {
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
_ = os.RemoveAll(trashPath)
}
}
}
tx, err := a.db.Begin()
if err != nil {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-CRc6HR9x.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B1PBee3I.css">
<script type="module" crossorigin src="/assets/main-6mFhgd0M.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bmXj_j_Z.css">
</head>
<body>
<div id="app"></div>
+15 -12
View File
@@ -126,12 +126,17 @@ func TestVaultLayout_CreateProjectTree(t *testing.T) {
t.Error("expected project folder on disk")
}
// 4. Verify template created Overview.md
overviewPath := filepath.Join(serverFolder, "Overview.md")
// 4. Verify template created Overview.md inside Notes/ subfolder
overviewPath := filepath.Join(serverFolder, "Notes", "Overview.md")
if _, err := os.Stat(overviewPath); os.IsNotExist(err) {
t.Log("note: Overview.md from template not created (may not be implemented)")
t.Errorf("expected Overview.md at %s", overviewPath)
}
// Verify no Overview.md in project root
overviewRootPath := filepath.Join(serverFolder, "Overview.md")
if _, err := os.Stat(overviewRootPath); err == nil {
t.Error("Overview.md should not be in project root, only in Notes/")
}
}
func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
app, vault := setupTestApp(t)
@@ -150,16 +155,14 @@ func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
t.Fatal("expected non-nil node and file record")
}
// Verify the note .md file is inside the project folder
expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md")
// Verify the note .md file is inside the project's notes/ subfolder
// SafeDisplayNameToPathSegment preserves spaces: "Моя заметка" stays as-is
expectedPath := filepath.Join(vault, proj.FsPath, "Notes", "Моя заметка.md")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
// Try the safe-display-name variant
expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md")
if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) {
// Show what actually exists
entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath))
t.Errorf("expected note file in project folder, found: %v", listNames(entries))
}
// Show what actually exists in notes subfolder
notesDir := filepath.Join(vault, proj.FsPath, "Notes")
entries, _ := os.ReadDir(notesDir)
t.Errorf("expected note file at %s, found in notes/: %v", expectedPath, listNames(entries))
}
}