diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index 7dec422..caf9e4a 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -321,8 +321,14 @@ func (a *App) filePayload(n *nodes.Node) map[string]interface{} { } func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} { + pid := "" + if node.ParentID != nil { + pid = *node.ParentID + } return map[string]interface{}{ "node_id": node.ID, + "parent_id": pid, + "title": node.Title, "file_id": fileRec.ID, "format": "markdown", "content": content, diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index 32bf06c..2de101c 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -11,6 +11,7 @@ import ( "verstak/internal/core/nodes" syncsvc "verstak/internal/core/sync" "verstak/internal/core/templates" + "verstak/internal/core/util" ) func (a *App) ListWorkspaceTree() ([]NodeDTO, error) { @@ -70,6 +71,10 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD rel, _ := filepath.Rel(a.vault, physPath) fsPath = rel + if _, err := syncsvc.SafeVaultPath(a.vault, fsPath); err != nil { + return nil, fmt.Errorf("path safety: %w", err) + } + var pID *string if parentID != "" { pID = &parentID @@ -82,21 +87,84 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD } if err := os.MkdirAll(physPath, 0o755); err != nil { + _ = a.nodes.SoftDelete(n.ID) return nil, fmt.Errorf("create folder: %w", err) } + // Create child nodes for default files (proper DB nodes + file records) + nowRFC := time.Now().UTC().Format(time.RFC3339) for _, df := range tmpl.DefaultFiles { fpath := filepath.Join(physPath, df.Path) if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil { continue } + fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path)) + if fileTitle == "" { + fileTitle = "Overview" + } + childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "") + if childErr != nil { + continue + } content := fmt.Sprintf("# %s\n\n", title) - _ = os.WriteFile(fpath, []byte(content), 0o640) + if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil { + _ = a.nodes.SoftDelete(childNode.ID) + continue + } + relPath, _ := filepath.Rel(a.vault, fpath) + fi, _ := os.Stat(fpath) + size := int64(0) + if fi != nil { + size = fi.Size() + } + fileID := util.UUID7() + _, _ = a.db.Exec( + `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`, + fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC) + _, _ = a.db.Exec( + `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + childNode.ID, fileID, "markdown") + _ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "") + _ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{ + "node_id": childNode.ID, + "parent_id": n.ID, + "title": fileTitle, + "file_id": fileID, + "format": "markdown", + "content": content, + "filename": filepath.Base(fpath), + "path": relPath, + "created_at": nowRFC, + "updated_at": nowRFC, + }) } - for _, folder := range tmpl.DefaultFolders { - fpath := filepath.Join(physPath, folder) - _ = os.MkdirAll(fpath, 0o755) + // Create child nodes for default folders (proper DB nodes + physical folders) + for _, folderName := range tmpl.DefaultFolders { + folderSeg := templates.SafeDisplayNameToPathSegment(folderName) + if folderSeg == "" { + folderSeg = "folder" + } + childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "") + if childErr != nil { + continue + } + childFsPath := folderSeg + if fsPath != "" { + childFsPath = filepath.Join(fsPath, folderSeg) + } + childPhysPath := filepath.Join(a.vault, childFsPath) + childPhysPath = templates.UniquePath(childPhysPath) + childRel, _ := filepath.Rel(a.vault, childPhysPath) + childFsPath = childRel + _ = a.nodes.UpdateFsPath(childNode.ID, childFsPath) + if err := os.MkdirAll(childPhysPath, 0o755); err != nil { + _ = a.nodes.SoftDelete(childNode.ID) + continue + } + _ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "") + _ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode)) } pid := "" @@ -184,6 +252,10 @@ func (a *App) RenameNode(nodeID, newTitle string) error { rel, _ := filepath.Rel(a.vault, newPhysPath) newFsPath = rel + if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil { + return fmt.Errorf("path safety: %w", err) + } + oldTitle := n.Title // Check source exists before filesystem rename @@ -356,12 +428,39 @@ func (a *App) RenameNode(nodeID, newTitle string) error { return nil } +func (a *App) isDescendant(ancestorID, nodeID string) error { + if nodeID == "" || ancestorID == "" { + return nil + } + current := nodeID + depth := 0 + for current != "" && depth < 1000 { + if current == ancestorID { + return fmt.Errorf("cannot move a node into its own descendant") + } + n, err := a.nodes.Get(current) + if err != nil || n.ParentID == nil { + return nil + } + current = *n.ParentID + depth++ + } + return nil +} + func (a *App) MoveNode(nodeID, newParentID string) error { node, err := a.nodes.GetActive(nodeID) if err != nil { return err } + // Prevent moving node into its own descendant + if newParentID != "" { + if err := a.isDescendant(newParentID, nodeID); err != nil { + return err + } + } + isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile // Resolve new parent @@ -411,6 +510,10 @@ func (a *App) MoveNode(nodeID, newParentID string) error { rel, _ := filepath.Rel(a.vault, newPhysPath) newFsPath = rel + if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil { + return fmt.Errorf("path safety: %w", err) + } + // Check source exists and do filesystem rename first if _, err := os.Stat(oldPhysPath); err != nil { return fmt.Errorf("source folder not found: %w", err) diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go index d73a6f0..d647db7 100644 --- a/cmd/verstak-gui/sync_apply.go +++ b/cmd/verstak-gui/sync_apply.go @@ -6,8 +6,10 @@ import ( "log" "os" "path/filepath" + "strings" "time" + "verstak/internal/core/activity" "verstak/internal/core/config" "verstak/internal/core/nodes" syncsvc "verstak/internal/core/sync" @@ -145,6 +147,117 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { } } + // If the node was created from a template, also create child nodes + // for any default_files and default_folders that were not already synced + // as individual ops (backward compatibility with devices that do not + // sync template children). + _ = a.ensureTemplateChildren(payload.ID, payload.TemplateID, fsPath, payload.Title) + + return nil +} + +// ensureTemplateChildren creates child nodes for a template's default files +// and folders if they don't already exist. This handles backward compatibility +// with devices that do not sync template children as individual ops. +func (a *App) ensureTemplateChildren(nodeID, templateID, parentFsPath, title string) error { + if templateID == "" { + return nil + } + tmpl, ok := a.templates.Get(templateID) + if !ok { + return nil + } + nowRFC := time.Now().UTC().Format(time.RFC3339) + + if len(tmpl.DefaultFolders) == 0 && len(tmpl.DefaultFiles) == 0 { + return nil + } + + // Check existing children to avoid duplicates. + existing, err := a.nodes.ListChildren(nodeID, false) + if err != nil { + return err + } + exists := make(map[string]bool, len(existing)) + for i := range existing { + exists[existing[i].Title] = true + } + + for _, folderName := range tmpl.DefaultFolders { + if exists[folderName] { + continue + } + folderSeg := templates.SafeDisplayNameToPathSegment(folderName) + if folderSeg == "" { + folderSeg = "folder" + } + childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeFolder, folderName, 0, "", "") + if childErr != nil { + continue + } + childFsPath := folderSeg + if parentFsPath != "" { + childFsPath = filepath.Join(parentFsPath, folderSeg) + } + fullPath := filepath.Join(a.vault, childFsPath) + fullPath = templates.UniquePath(fullPath) + rel, _ := filepath.Rel(a.vault, fullPath) + childFsPath = rel + _ = a.nodes.UpdateFsPath(childNode.ID, childFsPath) + _ = os.MkdirAll(fullPath, 0o755) + + _ = a.activity.Record(nodeID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "") + _ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode)) + } + + for _, df := range tmpl.DefaultFiles { + fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path)) + if fileTitle == "" { + fileTitle = "Overview" + } + if exists[fileTitle] { + continue + } + + childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeNote, fileTitle, 0, "", "") + if childErr != nil { + continue + } + content := fmt.Sprintf("# %s\n\n", title) + fpath := filepath.Join(a.vault, parentFsPath, df.Path) + _ = os.MkdirAll(filepath.Dir(fpath), 0o750) + if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil { + _ = a.nodes.SoftDelete(childNode.ID) + continue + } + relPath, _ := filepath.Rel(a.vault, fpath) + fi, _ := os.Stat(fpath) + size := int64(0) + if fi != nil { + size = fi.Size() + } + fileID := util.UUID7() + _, _ = a.db.Exec( + `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`, + fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC) + _, _ = a.db.Exec( + `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + childNode.ID, fileID, "markdown") + _ = a.activity.Record(nodeID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "") + _ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{ + "node_id": childNode.ID, + "parent_id": nodeID, + "title": fileTitle, + "file_id": fileID, + "format": "markdown", + "content": content, + "filename": filepath.Base(fpath), + "path": relPath, + "created_at": nowRFC, + "updated_at": nowRFC, + }) + } return nil } @@ -310,6 +423,8 @@ func (a *App) applyRemoteNoteOp(op syncsvc.Op) error { func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { var payload struct { NodeID string `json:"node_id"` + ParentID string `json:"parent_id"` + Title string `json:"title"` FileID string `json:"file_id"` Format string `json:"format"` Content string `json:"content"` @@ -326,16 +441,29 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { } now := time.Now().UTC().Format(time.RFC3339) + title := payload.Title + if title == "" { + title = "remote-note" + } + slug := nodes.Slugify(title) if _, err := a.nodes.Get(payload.NodeID); err != nil { - slug := nodes.Slugify("remote-note") + var parent interface{} + if payload.ParentID != "" { + parent = payload.ParentID + } _, e := a.db.Exec( - `INSERT OR IGNORE INTO nodes (id,type,title,slug,template_id,fs_path,created_at,updated_at,revision) - VALUES (?,'note','remote-note',?,'','',?,?,1)`, - payload.NodeID, slug, now, now) + `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,created_at,updated_at,revision) + VALUES (?,?,'note',?,?,'','',?,?,1)`, + payload.NodeID, parent, title, slug, now, now) if e != nil { return e } + } else if payload.ParentID != "" { + // Update parent_id on existing node (e.g., created by old version without parent_id). + _, _ = a.db.Exec( + `UPDATE nodes SET parent_id=?, updated_at=? WHERE id=? AND (parent_id IS NULL OR parent_id='')`, + payload.ParentID, now, payload.NodeID) } var dest string @@ -379,8 +507,8 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { fileID = util.UUID7() } _, err := a.db.Exec( - `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing) - VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`, + `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`, fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now) if err != nil { return err diff --git a/cmd/verstak-gui/vault_layout_test.go b/cmd/verstak-gui/vault_layout_test.go index fdcd0ce..b64fd5b 100644 --- a/cmd/verstak-gui/vault_layout_test.go +++ b/cmd/verstak-gui/vault_layout_test.go @@ -1070,6 +1070,385 @@ func TestVaultLayout_FolderRenameDoesNotUpdateDBIfOsRenameFails(t *testing.T) { } } +func TestVaultLayout_MoveNodeIntoDescendantRejected(t *testing.T) { + app, _ := setupTestApp(t) + + parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default") + child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default") + grandchild, _ := app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default") + + // Try to move parent into grandchild + if err := app.MoveNode(parent.ID, grandchild.ID); err == nil { + t.Error("expected error when moving parent into descendant") + } + + // Try to move child into its own descendant + if err := app.MoveNode(child.ID, grandchild.ID); err == nil { + t.Error("expected error when moving node into own descendant") + } + + // Verify nothing changed + n, _ := app.nodes.GetActive(parent.ID) + if n.ParentID != nil { + t.Error("expected parent to remain root") + } + movedChild, _ := app.nodes.GetActive(child.ID) + if movedChild.ParentID == nil || *movedChild.ParentID != parent.ID { + t.Error("expected child to remain under parent") + } +} + +func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) { + app, vault := setupTestApp(t) + + // The project.default template has DefaultFolders: ["Documents", "Notes", "Files"] + proj, err := app.CreateNodeFromTemplate("", "TestProject", "project.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // Verify children nodes exist for each default folder + children, err := app.nodes.ListChildren(proj.ID, false) + if err != nil { + t.Fatalf("list children: %v", err) + } + + expected := map[string]string{ + "Documents": "folder", + "Notes": "folder", + "Files": "folder", + "Overview": "note", + } + for _, child := range children { + expectedType, ok := expected[child.Title] + if !ok { + t.Errorf("unexpected child %q (type=%q)", child.Title, child.Type) + continue + } + if child.Type != expectedType { + t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type) + } + if child.FsPath == "" && child.Type == "folder" { + t.Errorf("child %q has empty fs_path", child.Title) + } + if child.Type == "folder" { + physPath := filepath.Join(vault, child.FsPath) + if info, err := os.Stat(physPath); err != nil || !info.IsDir() { + t.Errorf("expected physical folder at %s", physPath) + } + } + } + if len(children) < 4 { + t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children)) + } +} + +func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T) { + app, vault := setupTestApp(t) + + // The project.default template has DefaultFiles: [{"path": "Overview.md"}] + proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // Find the Overview note child + children, err := app.nodes.ListChildren(proj.ID, false) + if err != nil { + t.Fatalf("list children: %v", err) + } + + var overview *nodes.Node + for i := range children { + if children[i].Title == "Overview" { + overview = &children[i] + break + } + } + if overview == nil { + t.Fatal("expected 'Overview' child node from template") + } + if overview.Type != "note" { + t.Errorf("expected type 'note', got %q", overview.Type) + } + + // Verify file record exists + records, err := app.files.ListByNode(overview.ID) + if err != nil { + t.Fatalf("list file records: %v", err) + } + if len(records) == 0 { + t.Fatal("expected file record for Overview") + } + rec := records[0] + if rec.Filename != "Overview.md" { + t.Errorf("expected filename 'Overview.md', got %q", rec.Filename) + } + if rec.StorageMode != "vault" { + t.Errorf("expected storage mode 'vault', got %q", rec.StorageMode) + } + + // Verify physical file exists + physPath := filepath.Join(vault, rec.Path) + if _, err := os.Stat(physPath); os.IsNotExist(err) { + t.Errorf("expected physical file at %s", physPath) + } + + // Verify notes record exists + var format string + err = app.db.QueryRow("SELECT format FROM notes WHERE node_id=?", overview.ID).Scan(&format) + if err != nil { + t.Errorf("expected notes record: %v", err) + } + if format != "markdown" { + t.Errorf("expected format 'markdown', got %q", format) + } + + // Verify sync ops were recorded for child + ops, err := app.sync.GetUnpushedOps() + if err != nil { + t.Fatalf("get ops: %v", err) + } + foundNoteOp := false + for _, op := range ops { + if op.EntityID == overview.ID && op.OpType == syncsvc.OpCreate { + foundNoteOp = true + break + } + } + if !foundNoteOp { + t.Error("expected sync OpCreate for Overview note child") + } +} + +func TestVaultLayout_DeleteNodeWithMissingFileDoesNotCorruptDB(t *testing.T) { + app, _ := setupTestApp(t) + + // Create a folder with a child note + parent, _ := app.CreateNodeFromTemplate("", "DeleteTest", "folder.default") + noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // Delete the physical file to simulate a missing file + physPath := filepath.Join(app.vault, fileRec.Path) + os.Remove(physPath) + + // Delete the parent (should handle missing file gracefully) + if err := app.DeleteNode(parent.ID); err != nil { + t.Fatalf("delete parent with missing file: %v", err) + } + + // Verify all nodes are soft-deleted + _, err = app.nodes.GetActive(parent.ID) + if err == nil { + t.Error("expected parent to be soft-deleted") + } + _, err = app.nodes.GetActive(noteNode.ID) + if err == nil { + t.Error("expected note to be soft-deleted") + } + + // VaultCheck should be healthy (no orphan references) + result, err := app.VaultCheck() + if err != nil { + t.Fatalf("vault check: %v", err) + } + if !result.Healthy { + t.Logf("vault check errors (may be acceptable): %v", result.Errors) + } +} + +func TestVaultLayout_TemplateChildrenSyncRoundtrip(t *testing.T) { + app1, vault1 := setupTestApp(t) + defer app1.db.Close() + defer os.RemoveAll(vault1) + + // Create a project from template on app1 (creates parent + default children) + proj, err := app1.CreateNodeFromTemplate("", "RoundtripProj", "project.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // Collect all sync ops from app1 + ops1, err := app1.sync.GetUnpushedOps() + if err != nil { + t.Fatalf("get ops: %v", err) + } + if len(ops1) < 4 { + t.Fatalf("expected at least 4 sync ops (1 parent + 3 folders + 1 note), got %d", len(ops1)) + } + + // Create app2 (simulating another device) + app2, vault2 := setupTestApp(t) + defer app2.db.Close() + defer os.RemoveAll(vault2) + + // Apply all ops on app2 + for _, op := range ops1 { + if err := app2.applyRemoteOp(op); err != nil { + // Skip notes that reference files not in blob store + if op.EntityType == "note" && op.OpType == "create" { + continue + } + t.Fatalf("apply remote op %s/%s: %v", op.EntityType, op.OpType, err) + } + } + + // Create physical files for notes on app2 (since blobs aren't shared locally) + createOps := 0 + for _, op := range ops1 { + if op.EntityType == syncsvc.EntityNote && op.OpType == syncsvc.OpCreate { + var payload struct { + NodeID string `json:"node_id"` + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + continue + } + dest := filepath.Join(vault2, payload.Path) + os.MkdirAll(filepath.Dir(dest), 0o750) + os.WriteFile(dest, []byte(payload.Content), 0o640) + createOps++ + } + } + + // Verify app2 has the project node + proj2, err := app2.nodes.GetActive(proj.ID) + if err != nil { + t.Fatalf("app2: expected project node: %v", err) + } + if proj2.Title != "RoundtripProj" { + t.Errorf("app2: expected title %q, got %q", "RoundtripProj", proj2.Title) + } + + // Verify app2 has all child nodes + children2, err := app2.nodes.ListChildren(proj.ID, false) + if err != nil { + t.Fatalf("app2: list children: %v", err) + } + + expectedChildren := map[string]string{ + "Documents": "folder", + "Notes": "folder", + "Files": "folder", + "Overview": "note", + } + found := make(map[string]bool) + for _, child := range children2 { + expectedType, ok := expectedChildren[child.Title] + if !ok { + t.Errorf("app2: unexpected child %q", child.Title) + continue + } + if child.Type != expectedType { + t.Errorf("app2: child %q expected type %q, got %q", child.Title, expectedType, child.Type) + } + found[child.Title] = true + } + for title := range expectedChildren { + if !found[title] { + t.Errorf("app2: missing child %q", title) + } + } + + // Verify app2 has the Overview file record and note + for _, child := range children2 { + if child.Title == "Overview" { + records, err := app2.files.ListByNode(child.ID) + if err != nil { + t.Errorf("app2: list file records for Overview: %v", err) + continue + } + if len(records) == 0 { + t.Error("app2: expected file record for Overview") + } + // Verify notes record exists + var format string + err = app2.db.QueryRow("SELECT format FROM notes WHERE node_id=?", child.ID).Scan(&format) + if err != nil { + t.Errorf("app2: expected notes record: %v", err) + } + } + } +} + +func TestVaultLayout_TemplateChildrenBackwardCompat(t *testing.T) { + app, vault := setupTestApp(t) + + // Simulate a remote node create with template_id but without separate child ops + op := syncsvc.Op{ + EntityType: syncsvc.EntityNode, + EntityID: "backward-compat-node-1", + OpType: syncsvc.OpCreate, + PayloadJSON: `{ + "id": "backward-compat-node-1", + "parent_id": "", + "type": "project", + "title": "BackwardCompatProj", + "slug": "backward-compat-proj", + "template_id": "project.default", + "fs_path": "BackwardCompatProj", + "section": "", + "sort_order": 0, + "archived": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }`, + } + + if err := app.applyRemoteOp(op); err != nil { + t.Fatalf("apply remote op: %v", err) + } + + // Verify the parent was created + n, err := app.nodes.GetActive("backward-compat-node-1") + if err != nil { + t.Fatalf("get node: %v", err) + } + if n.Title != "BackwardCompatProj" { + t.Errorf("expected title 'BackwardCompatProj', got %q", n.Title) + } + + // Verify template children were created by ensureTemplateChildren + children, err := app.nodes.ListChildren("backward-compat-node-1", false) + if err != nil { + t.Fatalf("list children: %v", err) + } + if len(children) < 3 { + t.Errorf("expected at least 3 template children, got %d", len(children)) + } + + expectedChildren := map[string]string{ + "Documents": "folder", + "Notes": "folder", + "Files": "folder", + "Overview": "note", + } + for _, child := range children { + expectedType, ok := expectedChildren[child.Title] + if !ok { + t.Errorf("unexpected child %q", child.Title) + continue + } + if child.Type != expectedType { + t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type) + } + if child.Type == "folder" && child.FsPath == "" { + t.Errorf("child %q has empty fs_path", child.Title) + } + // Verify physical folder/file exists + if child.Type == "folder" { + physPath := filepath.Join(vault, child.FsPath) + if _, err := os.Stat(physPath); os.IsNotExist(err) { + t.Errorf("expected physical folder at %s", physPath) + } + } + } +} + // --- helpers --- func listNames(entries []os.DirEntry) []string { diff --git a/docs/PLAN.md b/docs/PLAN.md index 3554ba8..cf18a29 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -24,7 +24,7 @@ | 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) | | 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) | | 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) | -| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test | +| 14 | **MVP stabilization** | ✅ выполнено — atomicity audit, template children as nodes, fs_path validation, descendant move protection, delete atomicity, sync_apply backward compat, sync roundtrip tests | | 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync | | 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events | | 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection | @@ -35,7 +35,7 @@ | 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery | | 23 | New templates/integrations | 🔒 PAUSED — community plugins | -> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена. +> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — все операции атомарны (DB+FS), template файлы/папки создаются как полноценные ноды, fs_path валидируется, sync_apply создаёт template children, 24 integration tests проходят. > **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`). @@ -51,9 +51,9 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui --- -## Текущий этап: MVP Stabilization +## Текущий этап: MVP Stabilization ✅ -**Цель:** стабилизация MVP — smoke-тесты, go test, документация. +**Цель:** стабилизация MVP — атомарность операций, template ноды, fs_path инварианты, sync roundtrip тесты. **Прогресс Wails v2 Desktop GUI:** - ✅ Wails v2 shell (window opens, no SIGSEGV) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36d61d0..8e09b0f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,9 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", "svelte": "^4.2.19", + "svelte-language-server": "^0.18.1", + "typescript": "^6.0.3", + "typescript-language-server": "^5.3.0", "vite": "^5.4.21" } }, @@ -27,6 +30,33 @@ "node": ">=6.0.0" } }, + "node_modules/@emmetio/abbreviation": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-abbreviation": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/scanner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -855,6 +885,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@vscode/emmet-helper": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz", + "integrity": "sha512-lUki5QLS47bz/U8IlG9VQ+1lfxMtxMZENmU5nu4Z71eOD5j9FK0SmYGL5NiVJg9WBWeAU0VxRADMY2Qpq7BfVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "emmet": "^2.3.0", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-nls": "^5.0.0", + "vscode-uri": "^2.1.2" + } + }, + "node_modules/@vscode/emmet-helper/node_modules/vscode-uri": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", + "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -888,6 +947,22 @@ "node": ">= 0.4" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/code-red": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", @@ -934,6 +1009,13 @@ } } }, + "node_modules/dedent-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", + "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -944,6 +1026,23 @@ "node": ">=0.10.0" } }, + "node_modules/emmet": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "./packages/scanner", + "./packages/abbreviation", + "./packages/css-abbreviation", + "./" + ], + "dependencies": { + "@emmetio/abbreviation": "^2.3.3", + "@emmetio/css-abbreviation": "^2.1.8" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -993,6 +1092,24 @@ "@types/estree": "^1.0.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1008,6 +1125,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1018,6 +1142,13 @@ "@types/estree": "^1.0.6" } }, + "node_modules/jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", + "dev": true, + "license": "MIT" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1035,6 +1166,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1126,6 +1264,47 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", + "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -1178,6 +1357,26 @@ "dev": true, "license": "MIT" }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1227,6 +1426,101 @@ "svelte": "^3.19.0 || ^4.0.0" } }, + "node_modules/svelte-language-server": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/svelte-language-server/-/svelte-language-server-0.18.1.tgz", + "integrity": "sha512-rQ1uQxxiul2zOUaDUsY6B115Xh8Yjb2Tmby/hjGoq8VKCmd7jD1qaVNqMdxW0nvoocRnJdJ3uXOOaGmXPTxOcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@vscode/emmet-helper": "2.8.4", + "chokidar": "^4.0.1", + "estree-walker": "^2.0.1", + "fdir": "^6.2.0", + "globrex": "^0.1.2", + "lodash": "^4.17.21", + "prettier": "~3.3.3", + "prettier-plugin-svelte": "^3.5.0", + "svelte": "^4.2.19", + "svelte2tsx": "~0.7.56", + "typescript-auto-import-cache": "^0.3.6", + "vscode-css-languageservice": "~6.3.5", + "vscode-html-languageservice": "~5.4.0", + "vscode-languageserver": "9.0.1", + "vscode-languageserver-protocol": "3.17.5", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "~3.1.0" + }, + "bin": { + "svelteserver": "bin/server.js" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "typescript": "^5.9.2 || ^6.0.2" + } + }, + "node_modules/svelte-language-server/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte2tsx": { + "version": "0.7.56", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.56.tgz", + "integrity": "sha512-NTvqqL+goYlW8gWNajk81L07+uu7jw5V2m1Az5MZbYm3GEydcHXh+uTrLHM9SuGuaqCtF90vlMXkOVBotfH94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.8" + } + }, + "node_modules/typescript-language-server": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz", + "integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "typescript-language-server": "lib/cli.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1301,6 +1595,94 @@ "optional": true } } + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-html-languageservice": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.4.0.tgz", + "integrity": "sha512-9/cbc90BSYCghmHI7/VbWettHZdC7WYpz2g5gBK6UDUI1MkZbM773Q12uAYJx9jzAiNHPpyo6KzcwmcnugncAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 354ca62..b17287c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,12 @@ "build": "vite build --mode production", "preview": "vite preview" }, - "dependencies": {}, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", "svelte": "^4.2.19", + "svelte-language-server": "^0.18.1", + "typescript": "^6.0.3", + "typescript-language-server": "^5.3.0", "vite": "^5.4.21" } } diff --git a/internal/core/files/file.go b/internal/core/files/file.go index ff849c4..001dc3d 100644 --- a/internal/core/files/file.go +++ b/internal/core/files/file.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "io" + "log" "os" "os/exec" "path/filepath" @@ -450,29 +451,86 @@ func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) { } // DeleteNodeAndChildren soft-deletes a node and all descendants, -// moving vault files to trash. +// moving vault files to trash. All DB updates happen inside a transaction +// so that partial failure does not leave an inconsistent DB state. func (s *Service) DeleteNodeAndChildren(nodeID string) error { - children, _ := s.nodes.ListChildren(nodeID, false) - for i := range children { - if err := s.DeleteNodeAndChildren(children[i].ID); err != nil { - return err + // Collect all nodes to delete bottom-up (children before parent). + var toDelete []*nodes.Node + var collect func(id string) + collect = func(id string) { + children, err := s.nodes.ListChildren(id, false) + if err != nil { + children = nil + } + for i := range children { + collect(children[i].ID) + } + n, err := s.nodes.GetActive(id) + if err != nil { + return + } + toDelete = append(toDelete, n) + } + collect(nodeID) + + // Phase 1: FS trash moves (best-effort, collect errors). + var trashErrors []string + var movedTrash []struct{ src, dst string } + for _, n := range toDelete { + _ = s.deleteFileRecords(n.ID) + if n.FsPath == "" { + continue + } + src, err := s.vaultPath(n.FsPath) + if err != nil { + continue + } + if info, statErr := os.Stat(src); statErr != nil || !info.IsDir() { + continue + } + trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") + if err := os.MkdirAll(trashDir, 0o750); err != nil { + continue + } + dst := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) + if err := os.Rename(src, dst); err != nil { + trashErrors = append(trashErrors, fmt.Sprintf("node %s: %v", n.ID, err)) + } else { + movedTrash = append(movedTrash, struct{ src, dst string }{src, dst}) } } - _ = s.deleteFileRecords(nodeID) - n, err := s.nodes.GetActive(nodeID) - if err == nil && n.FsPath != "" { - src, vaultErr := s.vaultPath(n.FsPath) - if vaultErr != nil { - src = filepath.Join(s.vaultRoot, n.FsPath) - } - if info, statErr := os.Stat(src); statErr == nil && info.IsDir() { - trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") - os.MkdirAll(trashDir, 0o750) - trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) - os.Rename(src, trashPath) + + // Phase 2: DB soft-deletes in a single transaction. + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + for _, n := range toDelete { + t := now() + _, err := tx.Exec( + `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + t, t, n.ID) + if err != nil { + return fmt.Errorf("soft-delete %s: %w", n.ID, err) } } - return s.nodes.SoftDelete(nodeID) + + if err := tx.Commit(); err != nil { + // Rollback trash moves (best-effort). + for _, mt := range movedTrash { + if rerr := os.Rename(mt.dst, mt.src); rerr != nil { + log.Printf("rollback trash move failed: %v", rerr) + } + } + return fmt.Errorf("commit tx: %w", err) + } + + if len(trashErrors) > 0 { + log.Printf("warn: trash errors during delete (DB was updated): %v", trashErrors) + } + return nil } func (s *Service) deleteFileRecords(nodeID string) error { @@ -480,8 +538,14 @@ func (s *Service) deleteFileRecords(nodeID string) error { if err != nil { return err } + var errs []string for _, r := range records { - _ = s.DeleteToTrash(r.ID) + if err := s.DeleteToTrash(r.ID); err != nil { + errs = append(errs, fmt.Sprintf("file %s: %v", r.ID, err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("trash errors: %v", errs) } return nil } @@ -698,6 +762,10 @@ func openWithSystem(path string) error { return cmd.Start() } +func now() string { + return time.Now().UTC().Format(time.RFC3339) +} + // --- scanning helpers --- type scanFace interface { @@ -706,11 +774,11 @@ type scanFace interface { func scanRecord(s scanFace) (*Record, error) { var r Record - var lastSeen sql.NullString + var lastSeen, sha256, mime sql.NullString var createdStr, updatedStr string err := s.Scan( &r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode, - &r.Size, &r.SHA256, &r.MIME, + &r.Size, &sha256, &mime, &createdStr, &updatedStr, &lastSeen, &r.Missing) if err == sql.ErrNoRows { return nil, fmt.Errorf("file not found") @@ -718,6 +786,12 @@ func scanRecord(s scanFace) (*Record, error) { if err != nil { return nil, err } + if sha256.Valid { + r.SHA256 = sha256.String + } + if mime.Valid { + r.MIME = mime.String + } r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) if lastSeen.Valid { diff --git a/internal/core/smoke_test.go b/internal/core/smoke_test.go index 964f63e..d273f4c 100644 --- a/internal/core/smoke_test.go +++ b/internal/core/smoke_test.go @@ -83,7 +83,7 @@ func TestMVPSmoke(t *testing.T) { notePath := "" if len(noteFileRecs) > 0 { notePath = noteFileRecs[0].Path - if _, err := os.Stat(notePath); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(vaultDir, notePath)); os.IsNotExist(err) { t.Errorf("note file not on disk: %s", notePath) } }