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:
mirivlad 2026-06-15 09:19:26 +08:00
parent a193c5a4c6
commit 0fdf77ce03
27 changed files with 2892 additions and 39 deletions

View File

@ -1,5 +1,41 @@
# Verstak project rules # Verstak project rules
## Project quality rule: no MVP shortcuts
This project is not an investor demo, not a throwaway prototype, and not a “good enough for MVP” product.
Do not justify known inconsistencies, broken invariants, unsafe behavior, architectural shortcuts, or incomplete data-model operations by calling them “MVP acceptable”.
If you find a mismatch between logical state and physical state, treat it as a bug unless the project owner explicitly says otherwise.
For Verstak specifically:
* Rename must keep logical metadata and filesystem representation consistent, or the design must explicitly document why filenames are stable IDs and not display names.
* Delete must remove or tombstone all related state consistently.
* File paths must never depend on unsafe user input.
* Database state and filesystem state must not silently diverge.
* “Works because the file is not lost” is not enough.
* “Can be fixed later” is not an acceptable reason to leave architectural debt.
* If a feature cannot be completed properly in the current step, stop and report it as incomplete, with a concrete fix plan.
Use these categories in reports:
1. Done — implemented and tested.
2. Incomplete — implemented partially, must not be treated as finished.
3. Blocked — cannot continue without a decision.
4. Design decision required — multiple valid approaches exist.
5. Known bug — must be fixed before moving on.
Forbidden report pattern:
> This is acceptable for MVP.
Required replacement:
> This violates the current project invariants. I either fixed it, or I am reporting it as incomplete and proposing the smallest correct fix.
## Project identity ## Project identity
Verstak is a local-first workbench for clients, projects, notes, files, tasks, activity and sync. Verstak is a local-first workbench for clients, projects, notes, files, tasks, activity and sync.

View File

@ -10,6 +10,7 @@ import (
"verstak/internal/core/activity" "verstak/internal/core/activity"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync" syncsvc "verstak/internal/core/sync"
"verstak/internal/core/notes"
"verstak/internal/core/templates" "verstak/internal/core/templates"
"verstak/internal/core/util" "verstak/internal/core/util"
) )
@ -173,7 +174,8 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
} }
for _, df := range tmpl.DefaultFiles { 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 { if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
rollbackChildren() rollbackChildren()
return nil, fmt.Errorf("create directory for %s: %w", df.Path, err) return nil, fmt.Errorf("create directory for %s: %w", df.Path, err)

View File

@ -67,3 +67,29 @@ func (a *App) SaveNote(noteID, content string) error {
} }
return nil 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)
}

View File

@ -315,9 +315,19 @@ func (a *App) purgeTrashNode(nodeID string) error {
return err return err
} }
for _, id := range ids { for _, id := range ids {
// Try direct trash entry (folder-type nodes: nodeID_title).
if path, err := a.findTrashEntryForNode(id); err == nil { if path, err := a.findTrashEntryForNode(id); err == nil {
_ = os.RemoveAll(path) _ = 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() tx, err := a.db.Begin()
if err != nil { 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

View File

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-CRc6HR9x.js"></script> <script type="module" crossorigin src="/assets/main-6mFhgd0M.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B1PBee3I.css"> <link rel="stylesheet" crossorigin href="/assets/main-bmXj_j_Z.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -126,10 +126,15 @@ func TestVaultLayout_CreateProjectTree(t *testing.T) {
t.Error("expected project folder on disk") t.Error("expected project folder on disk")
} }
// 4. Verify template created Overview.md // 4. Verify template created Overview.md inside Notes/ subfolder
overviewPath := filepath.Join(serverFolder, "Overview.md") overviewPath := filepath.Join(serverFolder, "Notes", "Overview.md")
if _, err := os.Stat(overviewPath); os.IsNotExist(err) { 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/")
} }
} }
@ -150,16 +155,14 @@ func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
t.Fatal("expected non-nil node and file record") t.Fatal("expected non-nil node and file record")
} }
// Verify the note .md file is inside the project folder // Verify the note .md file is inside the project's notes/ subfolder
expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md") // SafeDisplayNameToPathSegment preserves spaces: "Моя заметка" stays as-is
expectedPath := filepath.Join(vault, proj.FsPath, "Notes", "Моя заметка.md")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) { if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
// Try the safe-display-name variant // Show what actually exists in notes subfolder
expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md") notesDir := filepath.Join(vault, proj.FsPath, "Notes")
if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) { entries, _ := os.ReadDir(notesDir)
// Show what actually exists t.Errorf("expected note file at %s, found in notes/: %v", expectedPath, listNames(entries))
entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath))
t.Errorf("expected note file in project folder, found: %v", listNames(entries))
}
} }
} }

View File

@ -7,6 +7,15 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": {
"@types/dompurify": "^3.2.0",
"@types/markdown-it": "^14.1.2",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"markdown-it": "^14.2.0",
"markdown-it-task-lists": "^2.1.1",
"marked": "^18.0.5"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"svelte": "^4.2.19", "svelte": "^4.2.19",
@ -878,6 +887,15 @@
"vite": "^5.0.0" "vite": "^5.0.0"
} }
}, },
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -885,6 +903,31 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@vscode/emmet-helper": { "node_modules/@vscode/emmet-helper": {
"version": "2.8.4", "version": "2.8.4",
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz", "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz",
@ -927,6 +970,11 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@ -1026,6 +1074,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/emmet": { "node_modules/emmet": {
"version": "2.4.11", "version": "2.4.11",
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
@ -1043,6 +1099,17 @@
"@emmetio/css-abbreviation": "^2.1.8" "@emmetio/css-abbreviation": "^2.1.8"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@ -1132,6 +1199,14 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@ -1159,6 +1234,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@ -1183,6 +1276,48 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.1",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
},
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdn-data": { "node_modules/mdn-data": {
"version": "2.0.30", "version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@ -1190,6 +1325,11 @@
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1291,6 +1431,14 @@
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"engines": {
"node": ">=6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -1521,6 +1669,11 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@ -16,5 +16,14 @@
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-language-server": "^5.3.0", "typescript-language-server": "^5.3.0",
"vite": "^5.4.21" "vite": "^5.4.21"
},
"dependencies": {
"@types/dompurify": "^3.2.0",
"@types/markdown-it": "^14.1.2",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"markdown-it": "^14.2.0",
"markdown-it-task-lists": "^2.1.1",
"marked": "^18.0.5"
} }
} }

View File

@ -16,6 +16,7 @@
import { actionIcon } from './lib/actionIcons.js' import { actionIcon } from './lib/actionIcons.js'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js' import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
import { t } from './lib/i18n' import { t } from './lib/i18n'
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
// ===== Wails v2 API call helper ===== // ===== Wails v2 API call helper =====
function wailsCall(method, ...args) { function wailsCall(method, ...args) {
@ -76,6 +77,20 @@
let activeTab = 'overview' let activeTab = 'overview'
let notes = [] let notes = []
let noteEditor = null let noteEditor = null
let noteViewMode = 'edit'
let showLinkModal = false
let linkModalLabel = ''
let linkModalUrl = ''
// Simple internal link modal state (replaces broken ObjectPickerModal)
let showInternalLinkModal = false
let internalLinkLabel = ''
let internalLinkUrl = 'verstak://secret/sec_example'
let showVerstakToast = false
let verstakToastMessage = ''
// Note rename state
let renamingNoteId = null
let renamingNoteTitle = ''
let renamingNoteOriginalTitle = ''
let files = [] let files = []
let actions = [] let actions = []
let worklog = [] let worklog = []
@ -1271,6 +1286,28 @@
} }
} }
function deleteNote(note) {
openConfirm({
title: t('common.delete'),
message: t('note.deleteConfirm', { title: note.title }) || `Удалить заметку «${note.title}»?`,
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteNote', note.id)
notes = notes.filter(n => n.id !== note.id)
if (noteEditor && noteEditor.id === note.id) {
noteEditor = null
}
// Refresh trash count
try { await refreshTrashCount() } catch {}
} catch (e) {
error = String(e)
}
}
})
}
function closeNoteEditor() { function closeNoteEditor() {
if (noteEditor && noteEditor.dirty) { if (noteEditor && noteEditor.dirty) {
openConfirm({ openConfirm({
@ -1278,11 +1315,12 @@
message: t('note.unsavedMessage'), message: t('note.unsavedMessage'),
confirmText: t('note.unsavedClose'), confirmText: t('note.unsavedClose'),
danger: false, danger: false,
onConfirm: () => { noteEditor = null } onConfirm: () => { noteEditor = null; noteViewMode = 'edit' }
}) })
return return
} }
noteEditor = null noteEditor = null
noteViewMode = 'edit'
} }
function updateNoteContent(e) { function updateNoteContent(e) {
@ -1300,6 +1338,158 @@
} }
} }
// ===== Link modal =====
function openLinkModal() {
linkModalLabel = ''
linkModalUrl = ''
showLinkModal = true
}
function submitLinkModal() {
if (!noteEditor) { showLinkModal = false; return }
const label = linkModalLabel.trim() || linkModalUrl.trim() || 'link'
const url = linkModalUrl.trim() || '#'
const md = `[${label}](${url})`
noteEditor.content = noteEditor.content + (noteEditor.content.endsWith('\n') ? '' : '\n') + md + '\n'
noteEditor.dirty = true
showLinkModal = false
}
// ===== Simple internal link modal =====
function openInternalLinkModal() {
internalLinkLabel = ''
internalLinkUrl = 'verstak://secret/sec_example'
showInternalLinkModal = true
}
function submitInternalLink() {
insertInternalLinkMarkdown()
}
function cancelInternalLink() {
showInternalLinkModal = false
internalLinkLabel = ''
internalLinkUrl = 'verstak://secret/sec_example'
}
// ===== Note rename =====
function startRenameNote(noteId, currentTitle) {
renamingNoteId = noteId
renamingNoteTitle = currentTitle
renamingNoteOriginalTitle = currentTitle
}
function cancelRenameNote() {
renamingNoteId = null
renamingNoteTitle = ''
renamingNoteOriginalTitle = ''
}
async function submitRenameNote() {
if (!renamingNoteId || !renamingNoteTitle.trim()) {
cancelRenameNote()
return
}
if (renamingNoteTitle.trim() === renamingNoteOriginalTitle) {
cancelRenameNote()
return
}
try {
await wailsCall('RenameNote', renamingNoteId, renamingNoteTitle.trim())
// Update note list
notes = notes.map(n => n.id === renamingNoteId ? { ...n, title: renamingNoteTitle.trim() } : n)
// Update editor header if this note is open
if (noteEditor && noteEditor.id === renamingNoteId) {
noteEditor.title = renamingNoteTitle.trim()
}
cancelRenameNote()
} catch (e) {
// Show error
error = String(e)
cancelRenameNote()
}
}
// ===== Internal link modal (ObjectPicker) — legacy, kept for compat =====
// Replaced by simple openInternalLinkModal above.
// ===== Verstak link handler =====
function handleVerstakLink(e) {
const { type, id } = e.detail
let msg = ''
switch (type) {
case 'secret':
msg = t('note.internal.secretNotImplemented')
break
case 'case':
msg = t('note.internal.caseNotImplemented')
break
case 'note':
msg = t('note.internal.noteNotImplemented')
break
case 'file':
msg = t('note.internal.fileNotImplemented')
break
default:
msg = `verstak://${type}/${id}`
}
showVerstakToastMessage(msg)
}
function showVerstakToastMessage(msg) {
verstakToastMessage = msg
showVerstakToast = true
setTimeout(() => { showVerstakToast = false }, 3000)
}
// ===== Note editor panel events =====
function handleNoteContentChange(e) {
if (noteEditor) {
noteEditor.content = e.detail.content
noteEditor.dirty = true
}
}
function handleNoteModeChange(e) {
noteViewMode = e.detail.mode
}
function handleNoteInsertLink() {
openLinkModal()
}
function handleNoteInsertInternalLink() {
openInternalLinkModal()
}
// Insert markdown link at cursor position in the editor textarea.
function insertInternalLinkMarkdown() {
if (!noteEditor) { showInternalLinkModal = false; return }
const label = internalLinkLabel.trim() || internalLinkUrl
const href = internalLinkUrl.trim() || 'verstak://secret/sec_example'
const md = `[${label}](${href})`
// Try to insert at cursor via the hidden textarea used by MarkdownEditor.
// If textarea not available, append to content.
const ta = document.querySelector('.md-textarea')
if (ta) {
const start = ta.selectionStart
const end = ta.selectionEnd
const before = noteEditor.content.substring(0, start)
const after = noteEditor.content.substring(end)
noteEditor.content = before + md + after
noteEditor.dirty = true
// Restore cursor after inserted text
requestAnimationFrame(() => {
ta.focus()
ta.selectionStart = ta.selectionEnd = start + md.length
})
} else {
noteEditor.content = noteEditor.content + (noteEditor.content.endsWith('\n') ? '' : '\n') + md + '\n'
noteEditor.dirty = true
}
showInternalLinkModal = false
}
// ===== Worklog ===== // ===== Worklog =====
function openWorklogModal(entry = null) { function openWorklogModal(entry = null) {
editingWorklogEntry = entry editingWorklogEntry = entry
@ -2702,18 +2892,43 @@
{/if} {/if}
{#if noteEditor} {#if noteEditor}
<!-- Note editor --> <!-- Note editor with markdown preview -->
<div class="note-editor"> <div class="note-editor-wrapper">
<div class="note-editor-header"> <div class="note-editor-header">
{#if renamingNoteId === noteEditor.id}
<div class="note-rename-inline">
<input
type="text"
bind:value={renamingNoteTitle}
on:keydown={(e) => { if (e.key === 'Enter') submitRenameNote(); if (e.key === 'Escape') cancelRenameNote(); }}
class="note-rename-input"
/>
<button class="btn btn-primary btn-sm" on:click={submitRenameNote}>{t('common.save')}</button>
<button class="btn btn-sm" on:click={cancelRenameNote}>{t('common.cancel')}</button>
</div>
{:else}
<span class="note-title">{noteEditor.title}</span> <span class="note-title">{noteEditor.title}</span>
<button class="btn btn-sm note-rename-btn" on:click={() => startRenameNote(noteEditor.id, noteEditor.title)} title={t('common.rename')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
</button>
{#if noteEditor.dirty}<span class="dirty-mark"></span>{/if} {#if noteEditor.dirty}<span class="dirty-mark"></span>{/if}
{/if}
<div class="note-editor-actions"> <div class="note-editor-actions">
<button class="btn btn-primary" on:click={saveCurrentNote}>{t('common.save')}</button> <button class="btn btn-primary" on:click={saveCurrentNote}>{t('common.save')}</button>
<button class="btn" on:click={closeNoteEditor}>{t('common.close')}</button> <button class="btn" on:click={closeNoteEditor}>{t('common.close')}</button>
</div> </div>
</div> </div>
<textarea class="note-textarea" bind:value={noteEditor.content} <NoteEditorPanel
on:input={updateNoteContent} placeholder={t('note.placeholder')}></textarea> content={noteEditor.content}
viewMode={noteViewMode}
placeholder={t('note.placeholder')}
on:content-change={handleNoteContentChange}
on:mode-change={handleNoteModeChange}
on:save={saveCurrentNote}
on:insert-link={handleNoteInsertLink}
on:insert-internal-link={handleNoteInsertInternalLink}
on:verstak-link={handleVerstakLink}
/>
</div> </div>
{:else if selectedNode} {:else if selectedNode}
@ -2794,9 +3009,19 @@
<div class="notes-list"> <div class="notes-list">
{#each notes as note} {#each notes as note}
<div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => openNote(note))}> <div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={onKeyActivate(() => openNote(note))}>
<div class="note-card-info">
<div class="note-card-title">{note.title}</div> <div class="note-card-title">{note.title}</div>
<div class="note-card-date">{formatDate(note.createdAt)}</div> <div class="note-card-date">{formatDate(note.createdAt)}</div>
</div> </div>
<div class="note-card-actions" on:click|stopPropagation>
<button class="note-action-btn" on:click={() => startRenameNote(note.id, note.title)} title={t('common.rename')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
</button>
<button class="note-action-btn note-action-danger" on:click={() => deleteNote(note)} title={t('common.delete')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -3954,6 +4179,27 @@
.note-title { font-size: 16px; font-weight: 500; } .note-title { font-size: 16px; font-weight: 500; }
.dirty-mark { color: #f59e0b; font-size: 10px; } .dirty-mark { color: #f59e0b; font-size: 10px; }
.note-editor-actions { margin-left: auto; display: flex; gap: 8px; } .note-editor-actions { margin-left: auto; display: flex; gap: 8px; }
.note-rename-btn { opacity: 0; margin-left: 6px; color: #666; }
.note-editor-header:hover .note-rename-btn { opacity: 1; }
.note-rename-btn:hover { color: #ccc; }
.note-rename-inline { display: flex; align-items: center; gap: 8px; flex: 1; }
.note-rename-input { flex: 1; padding: 6px 10px; border: 1px solid #2a2a3c; border-radius: 4px; background: #13131f; color: #e4e4ef; font-size: 14px; font-family: inherit; outline: none; }
.note-rename-input:focus { border-color: #818cf8; }
/* Note card actions */
.note-card { position: relative; }
.note-card-info { flex: 1; min-width: 0; }
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
.note-card:hover .note-card-actions { opacity: 1; }
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
.note-action-danger:hover { background: rgba(239, 68, 68, 0.15); color: #f87171; }
/* Form groups in modals */
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; }
.form-group input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; border-radius: 4px; background: #13131f; color: #e4e4ef; font-size: 14px; font-family: inherit; outline: none; box-sizing: border-box; }
.form-group input:focus { border-color: #818cf8; }
.note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; } .note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; }
/* Overview */ /* Overview */
@ -4336,4 +4582,164 @@
.checkbox-label-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #e4e4ef; cursor: pointer; } .checkbox-label-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #e4e4ef; cursor: pointer; }
.checkbox-label-inline input { width: auto; } .checkbox-label-inline input { width: auto; }
/* ===== Note editor wrapper ===== */
.note-editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* ===== Verstak toast ===== */
.verstak-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #1e1e38;
border: 1px solid #3a3a5c;
border-radius: 8px;
padding: 12px 20px;
color: #e4e4ef;
font-size: 13px;
z-index: 9999;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
animation: toast-in 0.2s ease-out;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ===== Modal overlay ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
}
.modal-box {
background: #1a1a2e;
border: 1px solid #2a2a3c;
border-radius: 10px;
padding: 24px;
width: 420px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-box h3 {
font-size: 16px;
font-weight: 600;
color: #e4e4ef;
margin: 0;
}
.modal-box label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #888;
}
.modal-box input[type="text"],
.modal-box select {
padding: 8px 12px;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #13131f;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
outline: none;
}
.modal-box input[type="text"]:focus,
.modal-box select:focus {
border-color: #818cf8;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
</style> </style>
<!-- Link modal -->
{#if showLinkModal}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('note.toolbar.link')} on:click|self={() => showLinkModal = false} on:keydown={(e) => e.key === 'Escape' && (showLinkModal = false)}>
<div class="modal-box" role="document">
<h3>{t('note.toolbar.link')}</h3>
<label>
{t('link.label')}
<input type="text" bind:value={linkModalLabel} placeholder="Example" on:keydown={(e) => { if (e.key === 'Enter') submitLinkModal(); if (e.key === 'Escape') showLinkModal = false; }} />
</label>
<label>
{t('link.url')}
<input type="text" bind:value={linkModalUrl} placeholder={t('link.urlPlaceholder')} on:keydown={(e) => { if (e.key === 'Enter') submitLinkModal(); if (e.key === 'Escape') showLinkModal = false; }} />
</label>
<div class="modal-actions">
<button type="button" class="btn btn-primary" on:click={submitLinkModal}>{t('link.insert')}</button>
<button type="button" class="btn" on:click={() => showLinkModal = false}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<!-- Simple internal link modal (replaces broken ObjectPickerModal) -->
{#if showInternalLinkModal}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('note.internalLink.title')} on:click|self={cancelInternalLink} on:keydown={(e) => e.key === 'Escape' && cancelInternalLink()}>
<div class="modal" style="width: 420px;">
<h3>{t('note.internalLink.title')}</h3>
<div class="form-group">
<label for="il-label">{t('note.internalLink.label')}</label>
<input id="il-label" type="text" bind:value={internalLinkLabel} placeholder={t('note.internalLink.labelPlaceholder')} on:keydown={(e) => e.key === 'Enter' && submitInternalLink()} />
</div>
<div class="form-group">
<label for="il-url">{t('note.internalLink.url')}</label>
<input id="il-url" type="text" bind:value={internalLinkUrl} placeholder={t('note.internalLink.urlPlaceholder')} on:keydown={(e) => e.key === 'Enter' && submitInternalLink()} />
</div>
<div class="modal-actions">
<button class="btn" on:click={cancelInternalLink}>{t('common.cancel')}</button>
<button class="btn btn-primary" on:click={submitInternalLink}>{t('common.save')}</button>
</div>
</div>
</div>
{/if}
<!-- Note rename modal (for rename from notes list) -->
{#if renamingNoteId && renamingNoteId !== noteEditor?.id}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('note.rename')} on:click|self={cancelRenameNote} on:keydown={(e) => e.key === 'Escape' && cancelRenameNote()}>
<div class="modal" style="width: 380px;">
<h3>{t('note.rename')}</h3>
<div class="form-group">
<label for="rn-title">{t('note.title')}</label>
<input id="rn-title" type="text" bind:value={renamingNoteTitle} on:keydown={(e) => { if (e.key === 'Enter') submitRenameNote(); if (e.key === 'Escape') cancelRenameNote(); }} />
</div>
<div class="modal-actions">
<button class="btn" on:click={cancelRenameNote}>{t('common.cancel')}</button>
<button class="btn btn-primary" on:click={submitRenameNote}>{t('common.save')}</button>
</div>
</div>
</div>
{/if}
<!-- Verstak toast -->
{#if showVerstakToast}
<div class="verstak-toast" role="alert" aria-live="polite">
{verstakToastMessage}
</div>
{/if}

View File

@ -0,0 +1,373 @@
<script>
import { createEventDispatcher } from 'svelte';
import { t } from '../../i18n';
export let content = '';
export let viewMode = 'edit';
export let placeholder = '';
const dispatch = createEventDispatcher();
let textareaRef;
// ─── Core insert helper ──────────────────────────────────────────
function insertAtCursor(before, after = '', placeholder = '') {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = content.substring(start, end);
const insertText = selected || placeholder;
const replacement = before + insertText + after;
const newContent = content.substring(0, start) + replacement + content.substring(end);
content = newContent;
// Dispatch change
dispatch('content-change', { content: newContent });
// Restore focus and set cursor position
requestAnimationFrame(() => {
ta.focus();
if (selected) {
// Wrap mode: select the inserted content (excluding markers)
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
} else {
// No selection: place cursor inside the wrapper
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + placeholder.length;
}
});
}
function insertLinePrefix(prefix) {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
// Find the start of the line(s) covered by selection
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = end;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const selectedLines = content.substring(lineStart, lineEnd);
const lines = selectedLines.split('\n');
const prefixedLines = lines.map(l => {
// Don't double-prefix
if (l.startsWith(prefix)) return l;
return prefix + l;
});
const replacement = prefixedLines.join('\n');
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + replacement.length;
});
}
// ─── Toolbar handlers ────────────────────────────────────────────
function handleBold() { insertAtCursor('**', '**', 'bold text'); }
function handleItalic() { insertAtCursor('*', '*', 'italic text'); }
function handleInlineCode() { insertAtCursor('`', '`', 'code'); }
function handleLink() { dispatch('insert-link'); }
function handleHeading() { insertLinePrefix('## '); }
function handleQuote() { insertLinePrefix('> '); }
function handleBulletList() { insertLinePrefix('- '); }
function handleNumberedList() {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = ta.selectionEnd;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const selectedLines = content.substring(lineStart, lineEnd);
const lines = selectedLines.split('\n');
let counter = 1;
const prefixedLines = lines.map(l => {
const trimmed = l.trim();
// Remove existing numbered list prefix
const cleaned = l.replace(/^\s*\d+\.\s*/, '');
return `${counter++}. ${trimmed || 'item'}`;
});
const replacement = prefixedLines.join('\n');
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => { ta.focus(); ta.selectionStart = lineStart; ta.selectionEnd = lineStart + replacement.length; });
}
function handleCheckbox() { insertLinePrefix('- [ ] '); }
function handleCodeBlock() {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = content.substring(start, end);
const replacement = selected
? '```\n' + selected + '\n```'
: '```\ncode here\n```';
const newContent = content.substring(0, start) + replacement + content.substring(end);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
if (selected) {
ta.selectionStart = start + 4;
ta.selectionEnd = start + 4 + selected.length;
} else {
ta.selectionStart = start + 4;
ta.selectionEnd = start + 4 + 'code here'.length;
}
});
}
function handleInternalLink() { dispatch('insert-internal-link'); }
// ─── Textarea events ─────────────────────────────────────────────
function handleInput(e) {
content = e.target.value;
dispatch('content-change', { content });
}
function handleKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
dispatch('save');
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const ta = e.target;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (e.shiftKey) {
// Unindent: remove up to 2 spaces at start of selected lines
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = end;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const block = content.substring(lineStart, lineEnd);
const dedented = block.replace(/^ {1,2}/gm, '');
const newContent = content.substring(0, lineStart) + dedented + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + dedented.length;
});
} else {
// Indent: insert 2 spaces at cursor or start of each selected line
if (start === end) {
// No selection: insert 2 spaces at cursor
const newContent = content.substring(0, start) + ' ' + content.substring(end);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = ta.selectionEnd = start + 2;
});
} else {
// Selection: indent each line
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = end;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const block = content.substring(lineStart, lineEnd);
const indented = block.replace(/^/gm, ' ');
const newContent = content.substring(0, lineStart) + indented + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + indented.length;
});
}
}
}
}
</script>
<div class="md-editor" class:mode-edit={viewMode === 'edit' || viewMode === 'split'}>
<!-- Toolbar -->
<div class="md-toolbar" role="toolbar" aria-label="Markdown formatting">
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn" on:click={handleBold} title={t('note.toolbar.bold')} aria-label={t('note.toolbar.bold')}>
<span class="md-btn-label md-btn-bold">B</span>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleItalic} title={t('note.toolbar.italic')} aria-label={t('note.toolbar.italic')}>
<span class="md-btn-label md-btn-italic">I</span>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleHeading} title={t('note.toolbar.heading')} aria-label={t('note.toolbar.heading')}>
<span class="md-btn-label">H</span>
</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn" on:click={handleLink} title={t('note.toolbar.link')} aria-label={t('note.toolbar.link')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleInlineCode} title={t('note.toolbar.code')} aria-label={t('note.toolbar.code')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleCodeBlock} title={t('note.toolbar.codeBlock')} aria-label={t('note.toolbar.codeBlock')}>
<span class="md-btn-label md-btn-sm">{'</>'}</span>
</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn" on:click={handleBulletList} title={t('note.toolbar.bulletList')} aria-label={t('note.toolbar.bulletList')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleNumberedList} title={t('note.toolbar.numberedList')} aria-label={t('note.toolbar.numberedList')}>
<span class="md-btn-label md-btn-sm">1.</span>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleCheckbox} title={t('note.toolbar.checkbox')} aria-label={t('note.toolbar.checkbox')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleQuote} title={t('note.toolbar.quote')} aria-label={t('note.toolbar.quote')}>
<span class="md-btn-label md-btn-sm md-btn-quote">"</span>
</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn md-toolbar-btn--internal" on:click={handleInternalLink} title={t('note.toolbar.internalLink')} aria-label={t('note.toolbar.internalLink')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M4.93 4.93l1.41 1.41"/><path d="M17.66 17.66l1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="M6.34 17.66l-1.41 1.41"/><path d="M19.07 4.93l-1.41 1.41"/></svg>
</button>
</div>
</div>
<!-- Editor textarea -->
<textarea
class="md-textarea"
bind:value={content}
bind:this={textareaRef}
{placeholder}
on:input={handleInput}
on:keydown={handleKeydown}
spellcheck="true"
></textarea>
</div>
<style>
.md-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.md-editor.mode-edit {
height: auto;
}
/* Toolbar */
.md-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 10px;
border-bottom: 1px solid #2a2a3c;
background: #16161f;
flex-shrink: 0;
flex-wrap: wrap;
}
.md-toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.md-toolbar-sep {
width: 1px;
height: 18px;
background: #2a2a3c;
margin: 0 4px;
flex-shrink: 0;
}
.md-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #999;
cursor: pointer;
transition: background 0.12s, color 0.12s;
padding: 0;
}
.md-toolbar-btn:hover {
background: #2a2a3c;
color: #e4e4ef;
}
.md-toolbar-btn:active {
background: #333350;
}
.md-toolbar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 1px;
}
.md-toolbar-btn--internal {
color: #67e8f9;
}
.md-toolbar-btn--internal:hover {
background: rgba(103, 232, 249, 0.1);
color: #a5f3fc;
}
.md-btn-label {
font-size: 13px;
font-weight: 700;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1;
}
.md-btn-bold { font-weight: 900; }
.md-btn-italic { font-style: italic; font-family: Georgia, serif; }
.md-btn-sm { font-size: 10px; }
.md-btn-quote { font-size: 16px; font-family: Georgia, serif; }
/* Textarea */
.md-textarea {
flex: 1;
width: 100%;
min-height: 0;
border: none;
outline: none;
background: #13131f;
color: #e4e4ef;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.65;
padding: 16px 20px;
resize: none;
tab-size: 2;
overflow-y: auto;
}
.md-textarea::placeholder {
color: #444;
font-style: italic;
}
</style>

View File

@ -0,0 +1,313 @@
<script>
import { renderMarkdown, isInternalLink, parseVerstakUrl, isAllowedVerstakType } from '../../markdown/markdown';
import { t } from '../../i18n';
export let content = '';
let html = '';
let error = '';
$: {
try {
html = renderMarkdown(content || '');
error = '';
} catch (e) {
error = String(e);
html = '';
}
}
function handleClick(e) {
const link = e.target.closest('[data-verstak-href]');
if (!link) return;
const href = link.getAttribute('data-verstak-href');
if (!href) return;
e.preventDefault();
e.stopPropagation();
const parsed = parseVerstakUrl(href);
if (!parsed || !isAllowedVerstakType(parsed.type)) return;
link.dispatchEvent(new CustomEvent('verstak-link', {
bubbles: true,
detail: { type: parsed.type, id: parsed.id, href },
}));
}
</script>
<div class="markdown-body" on:click={handleClick} role="article">
{#if error}
<div class="md-error">
<p>⚠️ {t('note.preview.error')}</p>
<p class="md-error-detail">{error}</p>
</div>
{:else if !content?.trim()}
<div class="md-empty">{t('note.preview.empty')}</div>
{:else}
{@html html}
{/if}
</div>
<style>
.markdown-body {
color: #e4e4ef;
font-size: 15px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 720px;
}
.md-empty {
color: #555;
font-style: italic;
padding: 24px 0;
}
.md-error {
background: #3a2222;
border: 1px solid #5a2a2a;
border-radius: 8px;
padding: 16px;
color: #ff8888;
}
.md-error-detail {
font-size: 12px;
color: #cc6666;
margin-top: 8px;
}
/* Headings */
:global(.markdown-body h1) {
font-size: 28px;
font-weight: 700;
margin: 32px 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #2a2a3c;
color: #f0f0f8;
}
:global(.markdown-body h2) {
font-size: 22px;
font-weight: 600;
margin: 28px 0 14px;
padding-bottom: 6px;
border-bottom: 1px solid #22223a;
color: #e8e8f0;
}
:global(.markdown-body h3) {
font-size: 18px;
font-weight: 600;
margin: 24px 0 12px;
color: #ddd;
}
:global(.markdown-body h4) {
font-size: 16px;
font-weight: 600;
margin: 20px 0 10px;
color: #ccc;
}
:global(.markdown-body h5),
:global(.markdown-body h6) {
font-size: 14px;
font-weight: 600;
margin: 16px 0 8px;
color: #bbb;
}
/* Paragraphs */
:global(.markdown-body p) {
margin: 12px 0;
}
/* Links */
:global(.markdown-body .md-link) {
color: #818cf8;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s, color 0.15s;
cursor: pointer;
}
:global(.markdown-body .md-link:hover) {
color: #a5b4fc;
border-bottom-color: #818cf8;
}
:global(.markdown-body .md-link--internal) {
color: #67e8f9;
font-weight: 500;
}
:global(.markdown-body .md-link--internal:hover) {
color: #a5f3fc;
border-bottom-color: #67e8f9;
}
:global(.markdown-body .md-link--blocked) {
color: #666;
text-decoration: line-through;
cursor: not-allowed;
}
/* Bold / Italic / Strikethrough */
:global(.markdown-body strong) { font-weight: 700; color: #f0f0f8; }
:global(.markdown-body em) { font-style: italic; }
:global(.markdown-body del) { text-decoration: line-through; color: #888; }
/* Lists */
:global(.markdown-body ul) {
padding-left: 24px;
margin: 12px 0;
list-style-type: disc;
}
:global(.markdown-body ol) {
padding-left: 24px;
margin: 12px 0;
list-style-type: decimal;
}
:global(.markdown-body li) {
margin: 4px 0;
line-height: 1.6;
}
:global(.markdown-body li > ul),
:global(.markdown-body li > ol) {
margin: 4px 0;
}
/* Task lists */
:global(.markdown-body .md-task-item) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0;
}
:global(.markdown-body .md-task-checkbox) {
margin-top: 3px;
flex-shrink: 0;
accent-color: #818cf8;
}
:global(.markdown-body .md-task-text) {
flex: 1;
}
/* Blockquote */
:global(.markdown-body blockquote) {
margin: 16px 0;
padding: 12px 16px;
border-left: 3px solid #818cf8;
background: rgba(129, 140, 248, 0.06);
border-radius: 0 6px 6px 0;
color: #bbb;
font-style: italic;
}
:global(.markdown-body blockquote p) {
margin: 4px 0;
}
/* Inline code */
:global(.markdown-body .md-code-inline) {
background: #1e1e38;
color: #f59e0b;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
}
/* Code blocks — dark theme */
:global(.markdown-body .md-code-block) {
background: #0d1117;
border: 1px solid #21262d;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
overflow-x: auto;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.55;
color: #c9d1d9;
}
:global(.markdown-body .md-code-block code) {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
}
/* highlight.js dark theme (GitHub Dark) */
:global(.markdown-body .hljs) { color: #c9d1d9; }
:global(.markdown-body .hljs-comment) { color: #8b949e; }
:global(.markdown-body .hljs-keyword) { color: #ff7b72; }
:global(.markdown-body .hljs-string) { color: #a5d6ff; }
:global(.markdown-body .hljs-number) { color: #79c0ff; }
:global(.markdown-body .hljs-function) { color: #d2a8ff; }
:global(.markdown-body .hljs-class) { color: #ffa657; }
:global(.markdown-body .hljs-variable) { color: #ffa657; }
:global(.markdown-body .hljs-operator) { color: #ff7b72; }
:global(.markdown-body .hljs-punctuation) { color: #c9d1d9; }
:global(.markdown-body .hljs-tag) { color: #7ee787; }
:global(.markdown-body .hljs-attr) { color: #79c0ff; }
:global(.markdown-body .hljs-title) { color: #d2a8ff; }
:global(.markdown-body .hljs-built_in) { color: #ffa657; }
:global(.markdown-body .hljs-literal) { color: #79c0ff; }
:global(.markdown-body .hljs-type) { color: #ffa657; }
:global(.markdown-body .hljs-params) { color: #c9d1d9; }
:global(.markdown-body .hljs-property) { color: #79c0ff; }
:global(.markdown-body .hljs-regexp) { color: #a5d6ff; }
:global(.markdown-body .hljs-meta) { color: #8b949e; }
:global(.markdown-body .hljs-doctag) { color: #ff7b72; }
:global(.markdown-body .hljs-name) { color: #7ee787; }
:global(.markdown-body .hljs-section) { color: #1f6feb; font-weight: 700; }
:global(.markdown-body .hljs-symbol) { color: #79c0ff; }
:global(.markdown-body .hljs-bullet) { color: #f2cc60; }
:global(.markdown-body .hljs-code) { color: #c9d1d9; }
:global(.markdown-body .hljs-emphasis) { font-style: italic; }
:global(.markdown-body .hljs-strong) { font-weight: 700; }
:global(.markdown-body .hljs-addition) { color: #aff5b4; background: #033a16; }
:global(.markdown-body .hljs-deletion) { color: #ffdcd7; background: #67060c; }
/* Tables */
:global(.markdown-body table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
}
:global(.markdown-body thead) {
background: #1a1a2e;
}
:global(.markdown-body th) {
padding: 10px 14px;
text-align: left;
font-weight: 600;
color: #c4c4d4;
border-bottom: 2px solid #2a2a3c;
}
:global(.markdown-body td) {
padding: 8px 14px;
border-bottom: 1px solid #1e1e30;
}
:global(.markdown-body tbody tr:hover) {
background: rgba(129, 140, 248, 0.04);
}
/* Horizontal rule */
:global(.markdown-body hr) {
border: none;
border-top: 1px solid #2a2a3c;
margin: 24px 0;
}
/* Blocked images placeholder */
:global(.markdown-body .md-image-blocked) {
display: inline-block;
background: #1a1a2e;
border: 1px dashed #3a3a5c;
border-radius: 6px;
padding: 8px 14px;
color: #666;
font-size: 13px;
font-style: italic;
margin: 8px 0;
}
</style>

View File

@ -0,0 +1,178 @@
<script>
import { createEventDispatcher } from 'svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import MarkdownPreview from './MarkdownPreview.svelte';
import { t } from '../../i18n';
export let content = '';
export let viewMode = 'edit';
export let placeholder = '';
const dispatch = createEventDispatcher();
function setMode(mode) {
dispatch('mode-change', { mode });
}
function handleContentChange(e) {
content = e.detail.content;
dispatch('content-change', e.detail);
}
function handleSave() {
dispatch('save');
}
function handleInsertLink() {
dispatch('insert-link');
}
function handleInsertInternalLink() {
dispatch('insert-internal-link');
}
function handleVerstakLink(e) {
dispatch('verstak-link', e.detail);
}
</script>
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
<!-- Mode switcher -->
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
{t('note.mode.edit')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
{t('note.mode.preview')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
{t('note.mode.split')}
</button>
</div>
<!-- Content area -->
<div class="panel-content">
{#if viewMode === 'edit'}
<MarkdownEditor
{content}
{placeholder}
viewMode="edit"
on:content-change={handleContentChange}
on:save={handleSave}
on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink}
/>
{:else if viewMode === 'preview'}
<div class="preview-pane">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
{:else if viewMode === 'split'}
<div class="split-pane">
<div class="split-editor">
<MarkdownEditor
{content}
{placeholder}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink}
/>
</div>
<div class="split-preview">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
</div>
{/if}
</div>
</div>
<style>
.note-editor-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.mode-switcher {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 12px;
border-bottom: 1px solid #2a2a3c;
background: #14141f;
flex-shrink: 0;
}
.mode-btn {
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
font-weight: 500;
}
.mode-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 1px;
}
.panel-content {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.mode-preview .panel-content {
overflow-y: auto;
}
.preview-pane {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.split-pane {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.split-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #2a2a3c;
overflow: hidden;
}
.split-preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 20px 24px;
background: #11111c;
}
</style>

View File

@ -0,0 +1,337 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { t } from '../../i18n';
export let visible = false;
export let objectType = 'note'; // 'case' | 'note' | 'file' | 'secret'
const dispatch = createEventDispatcher();
let searchQuery = '';
let results = [];
let loading = false;
let error = '';
let selectedIndex = 0;
let inputRef;
const objectTypes = [
{ id: 'case', label: 'Дело', searchable: true },
{ id: 'note', label: 'Заметка', searchable: true },
{ id: 'file', label: 'Файл', searchable: true },
{ id: 'secret', label: 'Секрет', searchable: false },
];
$: currentTypeInfo = objectTypes.find(o => o.id === objectType) || objectTypes[0];
function wailsCall(method, ...args) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const fn = window['go']['main']['App'][method];
if (typeof fn === 'function') {
return fn(...args);
}
}
} catch (e) {
console.error('Wails call error:', method, e);
}
return Promise.reject(new Error('Wails not connected: ' + method));
}
async function search() {
if (!searchQuery.trim() || !currentTypeInfo.searchable) {
results = [];
return;
}
loading = true;
error = '';
try {
let items = [];
if (objectType === 'note') {
// Search across all notes
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
items = (searchResults || []).filter(n => n.type === 'note' || n.type === 'case');
} else if (objectType === 'case') {
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
items = (searchResults || []).filter(n => n.type === 'case' || n.type === 'project' || n.type === 'client');
} else if (objectType === 'file') {
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
items = (searchResults || []).filter(n => n.type === 'file');
}
results = items;
selectedIndex = 0;
} catch (e) {
error = String(e);
results = [];
} finally {
loading = false;
}
}
function handleTypeChange(newType) {
objectType = newType;
searchQuery = '';
results = [];
error = '';
if (inputRef) inputRef.focus();
}
function handleSelect(item) {
dispatch('select', { type: objectType, id: item.id, title: item.title, path: item.path || '' });
}
function handleKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
} else if (e.key === 'Escape') {
dispatch('close');
}
}
function handleInput() {
search();
}
$: if (visible && inputRef) {
setTimeout(() => inputRef.focus(), 50);
}
</script>
{#if visible}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label="Select object" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
<div class="modal-box object-picker-modal" role="document">
<h3>Вставить внутреннюю ссылку</h3>
<!-- Object type selector -->
<div class="type-selector" role="tablist" aria-label="Object type">
{#each objectTypes as type}
<button
type="button"
class="type-btn"
class:active={objectType === type.id}
class:disabled={!type.searchable}
role="tab"
aria-selected={objectType === type.id}
on:click={() => type.searchable && handleTypeChange(type.id)}
disabled={!type.searchable}
>
{type.label}
</button>
{/each}
</div>
{#if !currentTypeInfo.searchable}
<div class="coming-soon">
<p>{t('note.internal.secretNotImplemented')}</p>
<p class="hint">Object picker for this type is coming soon.</p>
</div>
{:else}
<!-- Search input -->
<div class="search-wrapper">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
bind:this={inputRef}
bind:value={searchQuery}
on:input={handleInput}
on:keydown={handleKeydown}
placeholder="Поиск..."
class="search-input"
aria-label="Search objects"
aria-autocomplete="list"
aria-controls="object-picker-results"
aria-activedescendant={results[selectedIndex] ? `result-${selectedIndex}` : null}
/>
</div>
<!-- Results -->
<div class="results-list" id="object-picker-results" role="listbox">
{#if loading}
<div class="results-loading">{t('common.loading')}</div>
{:else if error}
<div class="results-error">{error}</div>
{:else if searchQuery.trim() && results.length === 0}
<div class="results-empty">{t('search.noResults')}</div>
{:else}
{#each results as item, i}
<button
type="button"
class="result-item"
class:selected={i === selectedIndex}
id="result-{i}"
role="option"
aria-selected={i === selectedIndex}
on:click={() => handleSelect(item)}
>
<span class="result-title">{item.title}</span>
{#if item.path}
<span class="result-path">{item.path}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
<div class="modal-actions">
<button type="button" class="btn" on:click={() => dispatch('close')}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<style>
.object-picker-modal {
width: 480px;
max-width: 95vw;
}
.type-selector {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.type-btn {
padding: 5px 12px;
border: 1px solid #2a2a3c;
border-radius: 4px;
background: #1a1a28;
color: #999;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.type-btn:hover:not(:disabled) {
background: #22223a;
color: #ccc;
}
.type-btn.active {
background: #2a2a4a;
color: #e4e4ef;
border-color: #6366f1;
}
.type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #666;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 32px;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #13131f;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.search-input:focus {
border-color: #818cf8;
}
.results-list {
max-height: 240px;
overflow-y: auto;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #11111c;
}
.results-loading,
.results-error,
.results-empty {
padding: 16px;
text-align: center;
color: #666;
font-size: 13px;
}
.results-error {
color: #ff8888;
}
.result-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 12px;
border: none;
border-bottom: 1px solid #1a1a28;
background: transparent;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover,
.result-item.selected {
background: #1e1e38;
}
.result-title {
font-weight: 500;
}
.result-path {
font-size: 11px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coming-soon {
padding: 24px;
text-align: center;
color: #666;
}
.coming-soon .hint {
font-size: 12px;
color: #555;
margin-top: 8px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>

View File

@ -178,6 +178,46 @@ export default {
'note.unsavedTitle': 'Unsaved changes', 'note.unsavedTitle': 'Unsaved changes',
'note.unsavedMessage': 'Close the editor? All unsaved changes will be lost.', 'note.unsavedMessage': 'Close the editor? All unsaved changes will be lost.',
'note.unsavedClose': 'Close', 'note.unsavedClose': 'Close',
'note.mode.edit': 'Edit',
'note.mode.preview': 'Preview',
'note.mode.split': 'Split',
'note.toolbar.bold': 'Bold (Ctrl+B)',
'note.toolbar.italic': 'Italic (Ctrl+I)',
'note.toolbar.heading': 'Heading',
'note.toolbar.link': 'Link',
'note.toolbar.code': 'Code',
'note.toolbar.codeBlock': 'Code block',
'note.toolbar.bulletList': 'Bullet list',
'note.toolbar.numberedList': 'Numbered list',
'note.toolbar.checkbox': 'Checkbox',
'note.toolbar.quote': 'Quote',
'note.toolbar.internalLink': 'Verstak internal link',
'note.internal.secretNotImplemented': 'Secrets vault is not yet implemented',
'note.internal.caseNotImplemented': 'Case navigation is not yet implemented',
'note.internal.noteNotImplemented': 'Note navigation is not yet implemented',
'note.internal.fileNotImplemented': 'File navigation is not yet implemented',
'note.rename': 'Rename note',
'note.deleteConfirm': 'Delete note "{title}"?',
'note.renameError': 'Failed to rename note',
'note.internalLink.title': 'Insert internal link',
'note.internalLink.label': 'Link text',
'note.internalLink.url': 'Link address',
'note.internalLink.labelPlaceholder': 'e.g. Admin panel',
'note.internalLink.urlPlaceholder': 'verstak://secret/sec_...',
'note.preview.empty': 'Empty note. Switch to edit mode to start writing.',
'note.preview.error': 'Markdown render error',
'link.label': 'Link text',
'link.url': 'URL or verstak:// link',
'link.urlPlaceholder': 'https://... or verstak://secret/sec_...',
'link.insert': 'Insert',
'file.addFile': '+ Add file', 'file.addFile': '+ Add file',
'file.addFolder': '+ Add folder', 'file.addFolder': '+ Add folder',
'file.newFile': '+ New file', 'file.newFile': '+ New file',

View File

@ -190,6 +190,45 @@ export default {
'note.unsavedMessage': 'Закрыть редактор? Все несохранённые изменения будут потеряны.', 'note.unsavedMessage': 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
'note.unsavedClose': 'Закрыть', 'note.unsavedClose': 'Закрыть',
'note.mode.edit': 'Редактор',
'note.mode.preview': 'Просмотр',
'note.mode.split': 'Разделённый',
'note.toolbar.bold': 'Жирный (Ctrl+B)',
'note.toolbar.italic': 'Курсив (Ctrl+I)',
'note.toolbar.heading': 'Заголовок',
'note.toolbar.link': 'Ссылка',
'note.toolbar.code': 'Код',
'note.toolbar.codeBlock': 'Блок кода',
'note.toolbar.bulletList': 'Маркированный список',
'note.toolbar.numberedList': 'Нумерованный список',
'note.toolbar.checkbox': 'Чекбокс',
'note.toolbar.quote': 'Цитата',
'note.toolbar.internalLink': 'Внутренняя ссылка Verstak',
'note.internal.secretNotImplemented': 'Сейф доступов ещё не реализован',
'note.internal.caseNotImplemented': 'Переход к делу ещё не реализован',
'note.internal.noteNotImplemented': 'Переход к заметке ещё не реализован',
'note.internal.fileNotImplemented': 'Переход к файлу ещё не реализован',
'note.rename': 'Переименовать заметку',
'note.deleteConfirm': 'Удалить заметку «{title}»?',
'note.renameError': 'Не удалось переименовать заметку',
'note.internalLink.title': 'Вставить внутреннюю ссылку',
'note.internalLink.label': 'Текст ссылки',
'note.internalLink.url': 'Адрес ссылки',
'note.internalLink.labelPlaceholder': 'Например: Админка',
'note.internalLink.urlPlaceholder': 'verstak://secret/sec_...',
'note.preview.empty': 'Пустая заметка. Переключитесь в режим редактора, чтобы начать писать.',
'note.preview.error': 'Ошибка рендеринга markdown',
'link.label': 'Текст ссылки',
'link.url': 'URL или verstak:// ссылка',
'link.urlPlaceholder': 'https://... или verstak://secret/sec_...',
'link.insert': 'Вставить',
'file.addFile': '+ Добавить файл', 'file.addFile': '+ Добавить файл',
'file.addFolder': '+ Добавить папку', 'file.addFolder': '+ Добавить папку',
'file.newFile': '+ Новый файл', 'file.newFile': '+ Новый файл',

View File

@ -0,0 +1,51 @@
import { parseVerstakUrl } from './markdown';
export type InternalLinkType = 'case' | 'note' | 'file' | 'secret' | 'unknown';
export interface InternalLinkInfo {
type: InternalLinkType;
id: string;
label: string;
icon: string;
}
/**
* Parse a verstak:// href into structured info.
*/
export function getInternalLinkInfo(href: string): InternalLinkInfo | null {
const parsed = parseVerstakUrl(href);
if (!parsed) return null;
const map: Record<string, { type: InternalLinkType; icon: string }> = {
case: { type: 'case', icon: '📁' },
note: { type: 'note', icon: '📄' },
file: { type: 'file', icon: '📎' },
secret: { type: 'secret', icon: '🔐' },
};
const entry = map[parsed.type];
if (!entry) {
return { type: 'unknown', id: parsed.id, label: parsed.id, icon: '🔗' };
}
return {
type: entry.type,
id: parsed.id,
label: parsed.id,
icon: entry.icon,
};
}
/**
* Get a human-readable label for an internal link type.
* These should be overridden via i18n in the UI layer.
*/
export function getDefaultLabel(type: InternalLinkType, id: string): string {
switch (type) {
case 'secret': return 'Секрет';
case 'case': return 'Дело';
case 'note': return 'Заметка';
case 'file': return 'Файл';
default: return id;
}
}

View File

@ -0,0 +1,178 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import go from 'highlight.js/lib/languages/go';
import bash from 'highlight.js/lib/languages/bash';
import json from 'highlight.js/lib/languages/json';
import yaml from 'highlight.js/lib/languages/yaml';
import markdown from 'highlight.js/lib/languages/markdown';
import css from 'highlight.js/lib/languages/css';
import sql from 'highlight.js/lib/languages/sql';
import xml from 'highlight.js/lib/languages/xml';
// Register only the languages we need
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('go', go);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('json', json);
hljs.registerLanguage('yaml', yaml);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('css', css);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('xml', xml);
// ─── Allowed URL schemes ───────────────────────────────────────────
const ALLOWED_SCHEMES = /^(https?|mailto|verstak):/i;
const ALLOWED_VERSTAK_TYPES = new Set(['case', 'note', 'file', 'secret']);
// ─── DOMPurify hooks ───────────────────────────────────────────────
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
const href = (node.getAttribute('href') || '').trim();
if (!ALLOWED_SCHEMES.test(href)) {
node.removeAttribute('href');
node.setAttribute('data-blocked-href', href);
node.classList.add('md-link--blocked');
node.setAttribute('role', 'link');
node.setAttribute('aria-disabled', 'true');
}
}
// Remove all images for now — no internal asset mechanism yet
if (node.tagName === 'IMG') {
node.remove();
}
});
// ─── Marked renderer ───────────────────────────────────────────────
const renderer = new marked.Renderer();
// Links: route verstak:// through internal handler, block unknown schemes
renderer.link = function ({ href, title, text }) {
const trimmedHref = (href || '').trim();
// Block dangerous schemes
if (!ALLOWED_SCHEMES.test(trimmedHref)) {
const escapedText = escapeHtml(text);
const escapedHref = escapeAttr(trimmedHref);
return `<span class="md-link--blocked" data-blocked-href="${escapedHref}" role="link" aria-disabled="true">${escapedText}</span>`;
}
// Internal verstak:// links
if (trimmedHref.startsWith('verstak://')) {
const parsed = parseVerstakUrl(trimmedHref);
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
const escapedHref = escapeAttr(trimmedHref);
const escapedText = escapeHtml(text);
return `<a href="javascript:void(0)" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
}
// Unknown verstak type — render as blocked
const escapedText = escapeHtml(text);
const escapedHref = escapeAttr(trimmedHref);
return `<span class="md-link--blocked" data-blocked-href="${escapedHref}">${escapedText}</span>`;
}
// External links
const escapedHref = escapeAttr(trimmedHref);
const escapedText = escapeHtml(text);
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapedHref}" class="md-link md-link--external" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapedText}</a>`;
};
// Images: disabled — no internal asset mechanism
renderer.image = function ({ href, title, text }) {
const escapedAlt = escapeHtml(text);
return `<span class="md-image-blocked" title="Images are not supported yet">[Image: ${escapedAlt}]</span>`;
};
// Code blocks with highlight.js
renderer.code = function ({ text, lang }) {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
let highlighted;
try {
highlighted = hljs.highlight(text, { language }).value;
} catch {
highlighted = escapeHtml(text);
}
const escapedLang = escapeAttr(language);
return `<pre class="md-code-block"><code class="hljs language-${escapedLang}">${highlighted}</code></pre>`;
};
// Inline code
renderer.codespan = function ({ text }) {
return `<code class="md-code-inline">${escapeHtml(text)}</code>`;
};
// Task lists
renderer.listitem = function ({ text, task, checked }) {
if (task) {
const checkedAttr = checked ? 'checked' : '';
return `<li class="md-task-item"><input type="checkbox" disabled ${checkedAttr} class="md-task-checkbox"/><span class="md-task-text">${text}</span></li>`;
}
return `<li>${text}</li>`;
};
// ─── Marked options ────────────────────────────────────────────────
marked.setOptions({
renderer,
gfm: true,
breaks: false,
pedantic: false,
});
// ─── Public API ────────────────────────────────────────────────────
/**
* Render markdown string to sanitized HTML string.
*/
export function renderMarkdown(md) {
const raw = marked.parse(md);
return DOMPurify.sanitize(raw, {
ADD_ATTR: ['target', 'class', 'data-verstak-href', 'data-verstak-type', 'data-verstak-id', 'data-blocked-href', 'role', 'aria-disabled', 'checked'],
ADD_TAGS: ['input', 'span'],
ALLOW_UNKNOWN_PROTOCOLS: true,
});
}
/**
* Check if a URL is an internal verstak:// link.
*/
export function isInternalLink(href) {
return href.startsWith('verstak://');
}
/**
* Parse a verstak:// URL into its components.
* Returns { type, id } or null if invalid.
*/
export function parseVerstakUrl(href) {
const match = href.match(/^verstak:\/\/([a-zA-Z_]+)\/(.+)$/);
if (!match) return null;
return { type: match[1], id: match[2] };
}
/**
* Check if a verstak:// type is supported.
*/
export function isAllowedVerstakType(type) {
return ALLOWED_VERSTAK_TYPES.has(type);
}
// ─── Helpers ───────────────────────────────────────────────────────
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(s) {
return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

View File

@ -46,6 +46,14 @@ export function SaveNote(arg1, arg2) {
return window['go']['main']['App']['SaveNote'](arg1, arg2); return window['go']['main']['App']['SaveNote'](arg1, arg2);
} }
export function RenameNote(arg1, arg2) {
return window['go']['main']['App']['RenameNote'](arg1, arg2);
}
export function DeleteNote(arg1) {
return window['go']['main']['App']['DeleteNote'](arg1);
}
export function ListFiles(arg1) { export function ListFiles(arg1) {
return window['go']['main']['App']['ListFiles'](arg1); return window['go']['main']['App']['ListFiles'](arg1);
} }

View File

@ -610,6 +610,14 @@ func (s *Service) trashRecord(r Record) error {
return err return err
} }
// TrashFile moves a single file record's physical file to .verstak/trash/
// and marks the record as missing=1. This is the same mechanism used by
// deleteFileRecords, exposed as a public method for individual file trashing
// (e.g. from notes.Delete).
func (s *Service) TrashFile(rec *Record) error {
return s.trashRecord(*rec)
}
func (s *Service) deleteFileRecords(nodeID string) error { func (s *Service) deleteFileRecords(nodeID string) error {
records, err := s.ListByNode(nodeID) records, err := s.ListByNode(nodeID)
if err != nil { if err != nil {

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"verstak/internal/core/files" "verstak/internal/core/files"
@ -13,6 +14,58 @@ import (
"verstak/internal/core/util" "verstak/internal/core/util"
) )
// NotesFolder is the canonical name for the notes subdirectory inside a case/project.
const NotesFolder = "Notes"
// noteFileRoot returns the absolute path to the notes subdirectory for a given parent.
// For parentless notes it returns <vaultRoot>/Notes.
func noteFileRoot(vaultRoot, parentFsPath string) string {
if parentFsPath != "" {
return filepath.Join(vaultRoot, parentFsPath, NotesFolder)
}
return filepath.Join(vaultRoot, NotesFolder)
}
// assertContained verifies that targetPath is strictly under rootDir.
// It resolves symlinks in the target path to prevent symlink-based escapes.
// Returns an error if targetPath escapes rootDir via .. or symlinks.
func assertContained(rootDir, targetPath string) error {
cleanRoot := filepath.Clean(rootDir)
cleanTarget := filepath.Clean(targetPath)
// Resolve symlinks in the target path to get the real path.
// If the path doesn't exist yet (e.g. for Create), we resolve as much
// as possible and check the unresolved remainder separately.
resolvedTarget, err := filepath.EvalSymlinks(cleanTarget)
if err != nil {
// Path doesn't exist — resolve the parent directory instead.
dir := filepath.Dir(cleanTarget)
resolvedDir, dirErr := filepath.EvalSymlinks(dir)
if dirErr != nil {
// Parent doesn't exist either — fall back to Clean-based check.
rel, relErr := filepath.Rel(cleanRoot, cleanTarget)
if relErr != nil {
return fmt.Errorf("path containment check failed: %w", relErr)
}
if strings.HasPrefix(rel, "..") {
return fmt.Errorf("path %q escapes root %q", cleanTarget, cleanRoot)
}
return nil
}
// Reconstruct target with resolved parent + original base name.
resolvedTarget = filepath.Join(resolvedDir, filepath.Base(cleanTarget))
}
rel, err := filepath.Rel(cleanRoot, resolvedTarget)
if err != nil {
return fmt.Errorf("path containment check failed: %w", err)
}
if strings.HasPrefix(rel, "..") {
return fmt.Errorf("path %q (resolved: %q) escapes root %q", cleanTarget, resolvedTarget, cleanRoot)
}
return nil
}
// Record represents a note entry (links a node to a file). // Record represents a note entry (links a node to a file).
type Record struct { type Record struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
@ -47,25 +100,34 @@ func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.R
} }
filename := seg + ".md" filename := seg + ".md"
var destDir string // Determine the canonical notes directory
var parentFsPath string
if parentID != "" { if parentID != "" {
parent, err := s.nodes.GetActive(parentID) parent, err := s.nodes.GetActive(parentID)
if err == nil && parent.FsPath != "" { if err == nil && parent.FsPath != "" {
destDir = filepath.Join(s.vaultRoot, parent.FsPath) parentFsPath = parent.FsPath
} }
} }
if destDir == "" { destDir := noteFileRoot(s.vaultRoot, parentFsPath)
destDir = s.vaultRoot
}
if err := os.MkdirAll(destDir, 0o750); err != nil { if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, nil, fmt.Errorf("mkdir: %w", err) return nil, nil, fmt.Errorf("mkdir: %w", err)
} }
dest := filepath.Join(destDir, filename) dest := filepath.Join(destDir, filename)
// Path containment check: the resolved file must stay under destDir
if err := assertContained(destDir, dest); err != nil {
return nil, nil, err
}
if _, err := os.Stat(dest); err == nil { if _, err := os.Stat(dest); err == nil {
filename = fmt.Sprintf("%s_%s.md", seg, node.ID[:8]) filename = fmt.Sprintf("%s_%s.md", seg, node.ID[:8])
dest = filepath.Join(destDir, filename) dest = filepath.Join(destDir, filename)
// Re-check containment after rename
if err := assertContained(destDir, dest); err != nil {
return nil, nil, err
}
} }
if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil { if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil {
@ -155,9 +217,97 @@ func (s *Service) Save(nodeID, content string) error {
return err return err
} }
// Delete soft-deletes the note node. // Delete soft-deletes the note node and moves the backing .md file to the
// shared vault trash directory (<vault>/.verstak/trash/) using the same
// trashRecord mechanism as files.DeleteNodeAndChildren. This ensures the
// deleted note appears in the unified Trash UI and can be restored/permanently
// deleted through the existing trash workflow.
func (s *Service) Delete(nodeID string) error { func (s *Service) Delete(nodeID string) error {
return s.nodes.SoftDelete(nodeID) // Load the note record to find the file.
rec, err := s.Load(nodeID)
if err != nil {
return fmt.Errorf("load note: %w", err)
}
// Get the full file record for trashRecord.
fileRec, err := s.files.Get(rec.FileID)
if err != nil {
return fmt.Errorf("get file record: %w", err)
}
// Soft-delete the node first.
if err := s.nodes.SoftDelete(nodeID); err != nil {
return fmt.Errorf("soft-delete node: %w", err)
}
// Move the .md file to the shared trash using the existing trashRecord.
// This places the file in <vault>/.verstak/trash/<fileID>_<filename>
// and marks the file record as missing=1 so it can be restored later.
if err := s.files.TrashFile(fileRec); err != nil {
return fmt.Errorf("trash file: %w", err)
}
return nil
}
// Rename changes the note title and renames the backing .md file on disk.
// If a file with the target name already exists, the operation is rejected.
func (s *Service) Rename(nodeID, newTitle string) error {
if err := s.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return fmt.Errorf("update title: %w", err)
}
// Load the note record to find the file.
rec, err := s.Load(nodeID)
if err != nil {
return fmt.Errorf("load note: %w", err)
}
// Get the current file record.
var oldPath, oldFilename, storageMode string
err = s.db.QueryRow(
`SELECT path, filename, storage_mode FROM files WHERE id = ?`, rec.FileID,
).Scan(&oldPath, &oldFilename, &storageMode)
if err != nil {
return fmt.Errorf("query file: %w", err)
}
// Build old and new absolute paths.
var oldAbs string
if storageMode == "vault" {
oldAbs = filepath.Join(s.vaultRoot, oldPath)
} else {
oldAbs = oldPath
}
oldDir := filepath.Dir(oldAbs)
seg := templates.SafeDisplayNameToPathSegment(newTitle)
if seg == "" {
seg = "note"
}
newFilename := seg + ".md"
newAbs := filepath.Join(oldDir, newFilename)
// Collision check: reject if target exists and is different from source.
if newAbs != oldAbs {
if _, err := os.Stat(newAbs); err == nil {
return fmt.Errorf("file %q already exists", newFilename)
}
if err := os.Rename(oldAbs, newAbs); err != nil {
return fmt.Errorf("rename file: %w", err)
}
// Update file record.
newRel, _ := filepath.Rel(s.vaultRoot, newAbs)
_, err = s.db.Exec(
`UPDATE files SET filename=?, path=?, updated_at=? WHERE id=?`,
newFilename, newRel, utcNow(), rec.FileID,
)
if err != nil {
return fmt.Errorf("update file record: %w", err)
}
}
return nil
} }
// Load looks up the note record for a node. // Load looks up the note record for a node.

View File

@ -0,0 +1,137 @@
package notes
import (
"os"
"path/filepath"
"testing"
)
func TestAssertContained(t *testing.T) {
tests := []struct {
name string
root string
target string
wantErr bool
}{
{
name: "file inside root",
root: "/tmp/vault/Notes",
target: "/tmp/vault/Notes/test.md",
wantErr: false,
},
{
name: "file at root boundary",
root: "/tmp/vault/Notes",
target: "/tmp/vault/Notes",
wantErr: false,
},
{
name: "path traversal via ..",
root: "/tmp/vault/Notes",
target: "/tmp/vault/Notes/../../../etc/passwd",
wantErr: true,
},
{
name: "path traversal to parent",
root: "/tmp/vault/Notes",
target: "/tmp/vault/other/file.md",
wantErr: true,
},
{
name: "completely different path",
root: "/tmp/vault/Notes",
target: "/etc/passwd",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := filepath.Clean(tt.root)
target := filepath.Clean(tt.target)
err := assertContained(root, target)
if (err != nil) != tt.wantErr {
t.Errorf("assertContained(%q, %q) error = %v, wantErr %v", root, target, err, tt.wantErr)
}
})
}
}
func TestAssertContainedSymlinkEscape(t *testing.T) {
dir := t.TempDir()
// Create a real directory structure.
notesDir := filepath.Join(dir, "vault", "Notes")
os.MkdirAll(notesDir, 0o750)
// Create a directory outside the vault.
outsideDir := filepath.Join(dir, "outside")
os.MkdirAll(outsideDir, 0o750)
// Create a file outside the vault.
outsideFile := filepath.Join(outsideDir, "secret.md")
os.WriteFile(outsideFile, []byte("secret"), 0o640)
// Create a symlink inside Notes/ pointing outside.
symlinkPath := filepath.Join(notesDir, "escape.md")
if err := os.Symlink(outsideFile, symlinkPath); err != nil {
t.Skipf("cannot create symlink: %v", err)
}
// assertContained should detect the symlink escape.
err := assertContained(notesDir, symlinkPath)
if err == nil {
t.Error("expected error for symlink escape, got nil")
}
}
func TestAssertContainedNonExistentPath(t *testing.T) {
dir := t.TempDir()
notesDir := filepath.Join(dir, "vault", "Notes")
os.MkdirAll(notesDir, 0o750)
// Non-existent file inside root — should pass.
err := assertContained(notesDir, filepath.Join(notesDir, "newfile.md"))
if err != nil {
t.Errorf("expected no error for non-existent file inside root: %v", err)
}
// Non-existent file outside root — should fail.
err = assertContained(notesDir, filepath.Join(dir, "outside", "file.md"))
if err == nil {
t.Error("expected error for non-existent file outside root")
}
}
func TestNoteFileRoot(t *testing.T) {
tests := []struct {
name string
vaultRoot string
parentFsPath string
want string
}{
{
name: "parentless note",
vaultRoot: "/tmp/vault",
parentFsPath: "",
want: "/tmp/vault/Notes",
},
{
name: "note inside project",
vaultRoot: "/tmp/vault",
parentFsPath: "Projects/MyProject",
want: "/tmp/vault/Projects/MyProject/Notes",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := noteFileRoot(tt.vaultRoot, tt.parentFsPath)
got = filepath.Clean(got)
want := filepath.Clean(tt.want)
if got != want {
t.Errorf("noteFileRoot(%q, %q) = %q, want %q", tt.vaultRoot, tt.parentFsPath, got, want)
}
})
}
}

View File

@ -0,0 +1,311 @@
package notes
import (
"os"
"path/filepath"
"strings"
"testing"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
func setupRenameService(t *testing.T) (*Service, *nodes.Repository, string) {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, dir, nodeRepo)
svc := NewService(db, dir, nodeRepo, fileSvc)
return svc, nodeRepo, dir
}
func TestRenameNote(t *testing.T) {
svc, nodeRepo, _ := setupRenameService(t)
node, _, err := svc.Create("", "Original Title", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Rename
err = svc.Rename(node.ID, "New Title")
if err != nil {
t.Fatalf("Rename: %v", err)
}
// Verify node title updated
updated, err := nodeRepo.GetActive(node.ID)
if err != nil {
t.Fatalf("GetActive: %v", err)
}
if updated.Title != "New Title" {
t.Errorf("title = %q, want %q", updated.Title, "New Title")
}
// Verify slug updated
if !strings.Contains(updated.Slug, "new") {
t.Errorf("slug = %q, want containing 'new'", updated.Slug)
}
}
func TestRenameNoteRenamesFile(t *testing.T) {
svc, nodeRepo, vaultRoot := setupRenameService(t)
node, _, err := svc.Create("", "Original Title", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Write content.
content := "# Original Title\n\nSome content."
if err := svc.Save(node.ID, content); err != nil {
t.Fatalf("Save: %v", err)
}
// Rename should rename the file on disk.
if err := svc.Rename(node.ID, "Renamed Title"); err != nil {
t.Fatalf("Rename: %v", err)
}
// Verify node title updated.
updated, err := nodeRepo.GetActive(node.ID)
if err != nil {
t.Fatalf("GetActive: %v", err)
}
if updated.Title != "Renamed Title" {
t.Errorf("title = %q, want %q", updated.Title, "Renamed Title")
}
// Verify old file no longer exists.
oldPath := filepath.Join(vaultRoot, "Notes", "Original Title.md")
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Error("old file should not exist after rename")
}
// Verify new file exists with correct content.
newPath := filepath.Join(vaultRoot, "Notes", "Renamed Title.md")
data, err := os.ReadFile(newPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != content {
t.Errorf("content = %q, want %q", string(data), content)
}
}
func TestRenameNoteCollisionRejected(t *testing.T) {
svc, _, _ := setupRenameService(t)
// Create two notes.
node1, _, err := svc.Create("", "Note Alpha", "")
if err != nil {
t.Fatalf("Create note1: %v", err)
}
_, _, err = svc.Create("", "Note Beta", "")
if err != nil {
t.Fatalf("Create note2: %v", err)
}
// Renaming note1 to "Note Beta" should fail — file already exists.
err = svc.Rename(node1.ID, "Note Beta")
if err == nil {
t.Error("expected error for rename collision, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("expected 'already exists' error, got: %v", err)
}
}
func TestRenameNoteEmptyTitle(t *testing.T) {
svc, _, _ := setupRenameService(t)
node, _, err := svc.Create("", "Valid Title", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Try to rename with empty title — should fail
err = svc.Rename(node.ID, "")
if err == nil {
t.Error("expected error for empty title")
}
}
func TestDeleteNoteSoftDeletesNode(t *testing.T) {
svc, nodeRepo, _ := setupRenameService(t)
node, _, err := svc.Create("", "To Delete", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Delete
if err := svc.Delete(node.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Verify node is soft-deleted
_, err = nodeRepo.GetActive(node.ID)
if err == nil {
t.Error("expected deleted node to be inactive")
}
}
func TestDeleteNoteDoesNotAffectOtherNotes(t *testing.T) {
svc, _, vaultRoot := setupRenameService(t)
// Create two notes
node1, _, err := svc.Create("", "Note One", "")
if err != nil {
t.Fatalf("Create note1: %v", err)
}
node2, _, err := svc.Create("", "Note Two", "")
if err != nil {
t.Fatalf("Create note2: %v", err)
}
// Save content to both
svc.Save(node1.ID, "content one")
svc.Save(node2.ID, "content two")
// Delete note1
if err := svc.Delete(node1.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Verify note2 content is still readable
content, err := svc.Read(node2.ID)
if err != nil {
t.Fatalf("Read note2: %v", err)
}
if content != "content two" {
t.Errorf("note2 content = %q, want %q", content, "content two")
}
_ = vaultRoot
}
func TestPathTraversalBlocked(t *testing.T) {
svc, _, vaultRoot := setupRenameService(t)
// Try to create a note with path traversal in title
node, _, err := svc.Create("", "../../../etc/passwd", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Verify the file was created with sanitized name, not traversing
content, err := svc.Read(node.ID)
if err != nil {
t.Fatalf("Read: %v", err)
}
_ = content
// Check that no file exists outside vault
suspicious := filepath.Join(vaultRoot, "..", "..", "..", "etc", "passwd.md")
if _, err := os.Stat(suspicious); err == nil {
t.Error("path traversal succeeded — file created outside vault")
}
}
func TestDeleteNoteMovesFileToSharedTrash(t *testing.T) {
svc, _, vaultRoot := setupRenameService(t)
node, _, err := svc.Create("", "To Delete", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Write content
content := "# To Delete\n\nThis content should survive deletion."
if err := svc.Save(node.ID, content); err != nil {
t.Fatalf("Save: %v", err)
}
// Get the file record to know the trash file name.
rec, err := svc.Load(node.ID)
if err != nil {
t.Fatalf("Load: %v", err)
}
fileRec, err := svc.files.Get(rec.FileID)
if err != nil {
t.Fatalf("Get file: %v", err)
}
// Delete (soft-delete + move to shared trash)
if err := svc.Delete(node.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Verify original file no longer exists at original location
origPath := filepath.Join(vaultRoot, "Notes", "To Delete.md")
if _, err := os.Stat(origPath); !os.IsNotExist(err) {
t.Error("original file should not exist at original location after delete")
}
// Verify file exists in shared trash (not in trash/notes/)
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
trashFile := filepath.Join(trashDir, fileRec.ID+"_"+fileRec.Filename)
data, err := os.ReadFile(trashFile)
if err != nil {
t.Fatalf("ReadFile trash: %v", err)
}
if string(data) != content {
t.Errorf("trash content = %q, want %q", string(data), content)
}
// Verify file record is marked missing=1
updatedRec, err := svc.files.Get(rec.FileID)
if err != nil {
t.Fatalf("Get file after delete: %v", err)
}
if !updatedRec.Missing {
t.Error("file record should be marked missing=1 after delete")
}
}
func TestDeleteNoteNoActiveNodeForOrphan(t *testing.T) {
svc, nodeRepo, vaultRoot := setupRenameService(t)
node, _, err := svc.Create("", "Orphan Test", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Delete
if err := svc.Delete(node.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Node should not be active
_, err = nodeRepo.GetActive(node.ID)
if err == nil {
t.Error("deleted node should not be returned by GetActive")
}
// File should be in shared trash, not in Notes/
notesPath := filepath.Join(vaultRoot, "Notes", "Orphan Test.md")
if _, err := os.Stat(notesPath); !os.IsNotExist(err) {
t.Error("file should not remain in Notes/ after delete")
}
// Verify file is in shared trash (not trash/notes/)
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
entries, _ := os.ReadDir(trashDir)
found := false
for _, e := range entries {
if e.Name() != "" {
found = true
break
}
}
if !found {
t.Error("file should exist in shared trash/")
}
}

View File

@ -49,8 +49,8 @@ func TestCreateAndRead(t *testing.T) {
t.Errorf("content = %q", content) t.Errorf("content = %q", content)
} }
// Verify file on disk (in vault root for parentless notes). // Verify file on disk (in vault/notes/ for parentless notes).
entries, _ := os.ReadDir(vaultRoot) entries, _ := os.ReadDir(filepath.Join(vaultRoot, "Notes"))
var mdFiles int var mdFiles int
for _, e := range entries { for _, e := range entries {
if !e.IsDir() && filepath.Ext(e.Name()) == ".md" { if !e.IsDir() && filepath.Ext(e.Name()) == ".md" {