Files tab: .md → note editor via CheckFileAction + frontend

Backend:
- FindByFileID: notes+files JOIN query
- LinkFile: INSERT OR IGNORE notes record
- CheckFileAction binding: note/preview/external/auto-link

Frontend (App.svelte):
- Import isMarkdownFile from fileUtils
- openPreview now calls CheckFileAction for .md files
- .md+note → switch to Notes tab + note editor
- .md outside Notes/ → inline preview
- non-.md → unchanged

Tests: 7 new (FindByFileID×3, CheckFileAction×4), all PASS
This commit is contained in:
mirivlad 2026-06-15 18:42:37 +08:00
parent fec35f55b8
commit bfe57ac0ac
8 changed files with 433 additions and 23 deletions

View File

@ -191,6 +191,14 @@ type FileTreeItemDTO struct {
HasKids bool `json:"hasKids"` HasKids bool `json:"hasKids"`
} }
// PreflightFileAction describes what should happen when opening a file from the Files tab.
type PreflightFileAction struct {
Action string `json:"action"` // "note" | "preview" | "external"
NoteID string `json:"noteId,omitempty"`
NoteTitle string `json:"noteTitle,omitempty"`
FileName string `json:"fileName"`
}
type ActionDTO struct { type ActionDTO struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`

View File

@ -1,6 +1,10 @@
package main package main
import ( import (
"fmt"
"path/filepath"
"strings"
"verstak/internal/core/activity" "verstak/internal/core/activity"
"verstak/internal/core/files" "verstak/internal/core/files"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
@ -168,6 +172,44 @@ func (a *App) ValidateName(name string) error {
return files.ValidateName(name) return files.ValidateName(name)
} }
func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
fileRec, err := a.files.Get(fileID)
if err != nil {
return nil, fmt.Errorf("get file: %w", err)
}
name := strings.ToLower(fileRec.Filename)
isMD := strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown")
if !isMD {
return &PreflightFileAction{Action: "external", FileName: fileRec.Filename}, nil
}
// .md file — check for linked note
noteRec, err := a.notes.FindByFileID(fileID)
if err == nil && noteRec != nil {
noteNode, nodeErr := a.nodes.Get(noteRec.NodeID)
title := fileRec.Filename
if nodeErr == nil && noteNode != nil {
title = noteNode.Title
}
return &PreflightFileAction{Action: "note", NoteID: noteRec.NodeID, NoteTitle: title, FileName: fileRec.Filename}, nil
}
// .md inside Notes/ with no note record — auto-link
pathLower := strings.ToLower(fileRec.Path)
insideNotes := strings.Contains(pathLower, string(filepath.Separator)+"notes"+string(filepath.Separator)) ||
strings.HasPrefix(pathLower, "notes"+string(filepath.Separator))
if insideNotes {
noteNode, nodeErr := a.nodes.Get(fileRec.NodeID)
if nodeErr == nil && noteNode != nil {
_ = a.notes.LinkFile(noteNode.ID, fileID, "markdown")
return &PreflightFileAction{Action: "note", NoteID: noteNode.ID, NoteTitle: noteNode.Title, FileName: fileRec.Filename}, nil
}
}
// .md outside Notes/ — internal preview
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) { func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err

View File

@ -19,7 +19,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-DwDG7FeH.js"></script> <script type="module" crossorigin src="/assets/main-CzfuqGWF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css"> <link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head> </head>
<body> <body>

View File

@ -246,3 +246,217 @@ func TestRepairMovesDirectNoteChildrenToNotesFolder(t *testing.T) {
t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path) t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path)
} }
} }
// TestCheckFileAction_NoteLinked verifies that CheckFileAction returns
// Action="note" for a .md file linked to a note record.
func TestCheckFileAction_NoteLinked(t *testing.T) {
app, _ := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find the Overview note — it should be inside Notes folder, linked via notes record
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
notesChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
t.Fatalf("ListChildren(Notes): %v", err)
}
if len(notesChildren) == 0 {
t.Fatal("expected at least one note inside Notes folder")
}
// Get file ID for the Overview note
items, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems: %v", err)
}
var overviewFileID string
for _, item := range items {
if item.Type == "note" && item.Name == "Overview" {
overviewFileID = item.FileID
break
}
}
if overviewFileID == "" {
t.Fatal("Overview note has no FileID")
}
// CheckFileAction should return Action="note"
action, err := app.CheckFileAction(overviewFileID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "note" {
t.Errorf("expected Action=note for linked .md, got %q", action.Action)
}
if action.NoteID == "" {
t.Error("expected non-empty NoteID for linked note")
}
if action.NoteTitle == "" {
t.Error("expected non-empty NoteTitle")
}
if action.FileName == "" {
t.Error("expected non-empty FileName")
}
}
// TestCheckFileAction_ExternalForNonMD verifies that non-.md files return
// Action="external" from CheckFileAction.
func TestCheckFileAction_ExternalForNonMD(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a file node and record for a non-.md file
fileNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "image.png", 0, "", filepath.Join(proj.FsPath, "image.png"))
if err != nil {
t.Fatalf("create file node: %v", err)
}
absPath := filepath.Join(vault, proj.FsPath, "image.png")
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(absPath, []byte("fake-png"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record directly
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','image/png','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-png-"+fileNode.ID, fileNode.ID, "image.png", filepath.Join(proj.FsPath, "image.png"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// CheckFileAction should return Action="external"
action, err := app.CheckFileAction("file-png-" + fileNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "external" {
t.Errorf("expected Action=external for .png, got %q", action.Action)
}
if action.FileName != "image.png" {
t.Errorf("expected FileName=image.png, got %q", action.FileName)
}
}
// TestCheckFileAction_PreviewForMDOutsideNotes verifies that .md files
// outside Notes/ without a note record return Action="preview".
func TestCheckFileAction_PreviewForMDOutsideNotes(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a .md file directly under the project (not inside Notes/)
mdNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "readme.md", 0, "", filepath.Join(proj.FsPath, "readme.md"))
if err != nil {
t.Fatalf("create md node: %v", err)
}
absPath := filepath.Join(vault, proj.FsPath, "readme.md")
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(absPath, []byte("# Readme\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record directly
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-md-"+mdNode.ID, mdNode.ID, "readme.md", filepath.Join(proj.FsPath, "readme.md"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// Do NOT create a notes record — this .md is outside Notes/
action, err := app.CheckFileAction("file-md-" + mdNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "preview" {
t.Errorf("expected Action=preview for .md outside Notes/, got %q", action.Action)
}
}
func TestCheckFileAction_AutoLinkInNotes(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find Notes folder
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
// Create a .md file INSIDE Notes/ but WITHOUT a notes record
mdNode, err := app.nodes.Create(&notesFolder.ID, nodes.TypeFile, "orphan.md", 0, "",
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
if err != nil {
t.Fatalf("create md node: %v", err)
}
notesDir := filepath.Join(vault, proj.FsPath, notes.NotesFolder)
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
absPath := filepath.Join(notesDir, "orphan.md")
if err := os.WriteFile(absPath, []byte("# Orphan Note\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-orphan-"+mdNode.ID, mdNode.ID, "orphan.md",
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// No notes record yet — just file + node
// CheckFileAction should auto-link and return Action="note"
action, err := app.CheckFileAction("file-orphan-" + mdNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "note" {
t.Errorf("expected Action=note for .md inside Notes/, got %q", action.Action)
}
if action.NoteID == "" {
t.Error("expected auto-linked NoteID")
}
if action.NoteTitle == "" {
t.Error("expected NoteTitle for auto-linked note")
}
// Verify notes record was actually created
noteRec, err := app.notes.FindByFileID("file-orphan-" + mdNode.ID)
if err != nil {
t.Fatalf("FindByFileID after auto-link: %v", err)
}
if noteRec == nil {
t.Fatal("expected note record after auto-link")
}
}

View File

@ -14,7 +14,7 @@
import AppHeader from './lib/AppHeader.svelte' import AppHeader from './lib/AppHeader.svelte'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js' import { actionIcon } from './lib/actionIcons.js'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js' import { canPreviewFile, needsBase64Preview, needsTextPreview, isMarkdownFile } from './lib/fileUtils.js'
import { t } from './lib/i18n' import { t } from './lib/i18n'
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte' import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte' import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
@ -622,6 +622,24 @@
// ===== File preview ===== // ===== File preview =====
async function openPreview(item) { async function openPreview(item) {
// For .md files: check if linked to a note, open note editor instead of preview modal
if (item && item.fileId && isMarkdownFile(item)) {
try {
const action = await wailsCall('CheckFileAction', item.fileId)
if (action.action === 'note') {
await openNote({ id: action.noteId, title: action.noteTitle })
return
}
if (action.action === 'external') {
await wailsCall('OpenFile', item.fileId)
return
}
// 'preview' → fall through to normal preview
} catch (e) {
console.warn('CheckFileAction failed, falling back to preview:', e)
}
}
previewItem = item previewItem = item
previewContent = '' previewContent = ''
previewError = '' previewError = ''

View File

@ -122,6 +122,11 @@ func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fi
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc} return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc}
} }
// DB returns the underlying storage.DB for direct queries (used in tests).
func (s *Service) DB() *storage.DB {
return s.db
}
// FindNotesFolder returns the TypeFolder "Notes" node under parentID, or nil. // FindNotesFolder returns the TypeFolder "Notes" node under parentID, or nil.
func (s *Service) FindNotesFolder(parentID string) *nodes.Node { func (s *Service) FindNotesFolder(parentID string) *nodes.Node {
children, err := s.nodes.ListChildren(parentID, false) children, err := s.nodes.ListChildren(parentID, false)
@ -683,6 +688,37 @@ func (s *Service) repairNoteFilePath(noteID, caseID string, res *RepairResult) e
return nil return nil
} }
// FindByFileID looks up the note record associated with the given file ID.
// LinkFile creates a notes record linking an existing node to an existing file record.
// It is a no-op if a record for node_id already exists (idempotent).
func (s *Service) LinkFile(nodeID, fileID, format string) error {
if format == "" {
format = "markdown"
}
_, err := s.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
nodeID, fileID, format,
)
return err
}
func (s *Service) FindByFileID(fileID string) (*Record, error) {
row := s.db.QueryRow(
`SELECT node_id, file_id, format, encrypted FROM notes WHERE file_id=?`, fileID,
)
var rec Record
var enc int
if err := row.Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc); err != nil {
return nil, err
}
rec.Encrypted = enc != 0
return &rec, nil
}
// fileExists returns true if path refers to an existing file or directory. // fileExists returns true if path refers to an existing file or directory.
func fileExists(path string) bool { func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)

View File

@ -1431,3 +1431,95 @@ func TestFilesTabManualMoveRepair(t *testing.T) {
t.Error("Files tab should show Overview note inside Notes folder after repair") t.Error("Files tab should show Overview note inside Notes folder after repair")
} }
} }
func TestFindByFileID_Success(t *testing.T) {
svc, nodeRepo, _, vaultDir := setupRepairTest(t)
caseNode := createCaseNode(t, nodeRepo, "TestCase", "test_case")
// Create a note via the full Create path...
noteNode, fileRec, err := svc.Create(caseNode.ID, "TestNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Verify FindByFileID works
rec, err := svc.FindByFileID(fileRec.ID)
if err != nil {
t.Fatalf("FindByFileID: %v", err)
}
if rec == nil {
t.Fatal("expected non-nil record")
}
if rec.NodeID != noteNode.ID {
t.Errorf("expected NodeID=%s, got %s", noteNode.ID, rec.NodeID)
}
if rec.FileID != fileRec.ID {
t.Errorf("expected FileID=%s, got %s", fileRec.ID, rec.FileID)
}
if rec.Format != "markdown" {
t.Errorf("expected Format=markdown, got %s", rec.Format)
}
_ = vaultDir
}
func TestFindByFileID_NotFound(t *testing.T) {
svc, _, _, _ := setupRepairTest(t)
rec, err := svc.FindByFileID("nonexistent-file-id")
if err == nil {
t.Error("expected error for nonexistent file ID")
}
if rec != nil {
t.Error("expected nil record")
}
}
func TestFindByFileID_AfterLinkFile(t *testing.T) {
svc, nodeRepo, _, vaultDir := setupRepairTest(t)
caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "TestCase", 0, "", "test_case_link")
if err != nil {
t.Fatalf("create case node: %v", err)
}
// Create directory on disk
caseDir := filepath.Join(vaultDir, "test_case_link")
if err := os.MkdirAll(caseDir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Write a physical .md file
mdPath := filepath.Join(caseDir, "mynote.md")
if err := os.WriteFile(mdPath, []byte("# mynote"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Create a file node
fileNode, err := nodeRepo.Create(&caseNode.ID, nodes.TypeFile, "mynote.md", 0, "", "test_case_link/mynote.md")
if err != nil {
t.Fatalf("create file node: %v", err)
}
// Insert file record directly (insertRecord is private, so we use DB())
db := svc.DB()
if db == nil {
t.Fatal("DB() returned nil")
}
_, err = db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-"+fileNode.ID, fileNode.ID, "mynote.md", "test_case_link/mynote.md")
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// Link note record via LinkFile
if err := svc.LinkFile(fileNode.ID, "file-"+fileNode.ID, "markdown"); err != nil {
t.Fatalf("LinkFile: %v", err)
}
// Verify
rec, err := svc.FindByFileID("file-" + fileNode.ID)
if err != nil {
t.Fatalf("FindByFileID: %v", err)
}
if rec == nil {
t.Fatal("expected non-nil record")
}
if rec.NodeID != fileNode.ID {
t.Errorf("expected NodeID=%s, got %s", fileNode.ID, rec.NodeID)
}
_ = vaultDir
}