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:
parent
a193c5a4c6
commit
0fdf77ce03
36
AGENTS.md
36
AGENTS.md
|
|
@ -1,5 +1,41 @@
|
|||
# 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
|
||||
|
||||
Verstak is a local-first workbench for clients, projects, notes, files, tasks, activity and sync.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
|
@ -173,7 +174,8 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
|||
}
|
||||
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fpath := filepath.Join(physPath, df.Path)
|
||||
// Default files (like Overview.md) go into the Notes/ subfolder
|
||||
fpath := filepath.Join(physPath, notes.NotesFolder, df.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create directory for %s: %w", df.Path, err)
|
||||
|
|
|
|||
|
|
@ -67,3 +67,29 @@ func (a *App) SaveNote(noteID, content string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RenameNote(noteID, newTitle string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.notes.Rename(noteID, newTitle)
|
||||
}
|
||||
|
||||
func (a *App) DeleteNote(noteID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record activity and sync op before delete (need node info).
|
||||
n, _ := a.nodes.GetActive(noteID)
|
||||
pid := ""
|
||||
if n != nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
title := ""
|
||||
if n != nil {
|
||||
title = n.Title
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteDeleted, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpDelete, nil)
|
||||
return a.notes.Delete(noteID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,9 +315,19 @@ func (a *App) purgeTrashNode(nodeID string) error {
|
|||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
// Try direct trash entry (folder-type nodes: nodeID_title).
|
||||
if path, err := a.findTrashEntryForNode(id); err == nil {
|
||||
_ = os.RemoveAll(path)
|
||||
}
|
||||
// Try file record trash entries (file/note nodes: fileID_filename).
|
||||
// These are created by files.trashRecord and not found by findTrashEntryForNode.
|
||||
if recs, err := a.files.ListTrashedByNode(id); err == nil {
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
for _, r := range recs {
|
||||
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
||||
_ = os.RemoveAll(trashPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-CRc6HR9x.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-B1PBee3I.css">
|
||||
<script type="module" crossorigin src="/assets/main-6mFhgd0M.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-bmXj_j_Z.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -126,12 +126,17 @@ func TestVaultLayout_CreateProjectTree(t *testing.T) {
|
|||
t.Error("expected project folder on disk")
|
||||
}
|
||||
|
||||
// 4. Verify template created Overview.md
|
||||
overviewPath := filepath.Join(serverFolder, "Overview.md")
|
||||
// 4. Verify template created Overview.md inside Notes/ subfolder
|
||||
overviewPath := filepath.Join(serverFolder, "Notes", "Overview.md")
|
||||
if _, err := os.Stat(overviewPath); os.IsNotExist(err) {
|
||||
t.Log("note: Overview.md from template not created (may not be implemented)")
|
||||
t.Errorf("expected Overview.md at %s", overviewPath)
|
||||
}
|
||||
// Verify no Overview.md in project root
|
||||
overviewRootPath := filepath.Join(serverFolder, "Overview.md")
|
||||
if _, err := os.Stat(overviewRootPath); err == nil {
|
||||
t.Error("Overview.md should not be in project root, only in Notes/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
|
@ -150,16 +155,14 @@ func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
|
|||
t.Fatal("expected non-nil node and file record")
|
||||
}
|
||||
|
||||
// Verify the note .md file is inside the project folder
|
||||
expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md")
|
||||
// Verify the note .md file is inside the project's notes/ subfolder
|
||||
// SafeDisplayNameToPathSegment preserves spaces: "Моя заметка" stays as-is
|
||||
expectedPath := filepath.Join(vault, proj.FsPath, "Notes", "Моя заметка.md")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
// Try the safe-display-name variant
|
||||
expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md")
|
||||
if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) {
|
||||
// Show what actually exists
|
||||
entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath))
|
||||
t.Errorf("expected note file in project folder, found: %v", listNames(entries))
|
||||
}
|
||||
// Show what actually exists in notes subfolder
|
||||
notesDir := filepath.Join(vault, proj.FsPath, "Notes")
|
||||
entries, _ := os.ReadDir(notesDir)
|
||||
t.Errorf("expected note file at %s, found in notes/: %v", expectedPath, listNames(entries))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
"": {
|
||||
"name": "frontend",
|
||||
"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": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.19",
|
||||
|
|
@ -878,6 +887,15 @@
|
|||
"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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
|
@ -885,6 +903,31 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz",
|
||||
|
|
@ -927,6 +970,11 @@
|
|||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
|
|
@ -1026,6 +1074,14 @@
|
|||
"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": {
|
||||
"version": "2.4.11",
|
||||
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
|
||||
|
|
@ -1043,6 +1099,17 @@
|
|||
"@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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
|
|
@ -1132,6 +1199,14 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
|
|
@ -1159,6 +1234,24 @@
|
|||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
|
|
@ -1183,6 +1276,48 @@
|
|||
"@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": {
|
||||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
|
|
@ -1190,6 +1325,11 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
|
|
@ -1521,6 +1669,11 @@
|
|||
"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": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
|
|
|||
|
|
@ -16,5 +16,14 @@
|
|||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.3.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { actionIcon } from './lib/actionIcons.js'
|
||||
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
||||
import { t } from './lib/i18n'
|
||||
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
|
||||
|
||||
// ===== Wails v2 API call helper =====
|
||||
function wailsCall(method, ...args) {
|
||||
|
|
@ -76,6 +77,20 @@
|
|||
let activeTab = 'overview'
|
||||
let notes = []
|
||||
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 actions = []
|
||||
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() {
|
||||
if (noteEditor && noteEditor.dirty) {
|
||||
openConfirm({
|
||||
|
|
@ -1278,11 +1315,12 @@
|
|||
message: t('note.unsavedMessage'),
|
||||
confirmText: t('note.unsavedClose'),
|
||||
danger: false,
|
||||
onConfirm: () => { noteEditor = null }
|
||||
onConfirm: () => { noteEditor = null; noteViewMode = 'edit' }
|
||||
})
|
||||
return
|
||||
}
|
||||
noteEditor = null
|
||||
noteViewMode = 'edit'
|
||||
}
|
||||
|
||||
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 =====
|
||||
function openWorklogModal(entry = null) {
|
||||
editingWorklogEntry = entry
|
||||
|
|
@ -2702,18 +2892,43 @@
|
|||
{/if}
|
||||
|
||||
{#if noteEditor}
|
||||
<!-- Note editor -->
|
||||
<div class="note-editor">
|
||||
<!-- Note editor with markdown preview -->
|
||||
<div class="note-editor-wrapper">
|
||||
<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>
|
||||
<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}
|
||||
<div class="note-editor-actions">
|
||||
<button class="btn btn-primary" on:click={saveCurrentNote}>{t('common.save')}</button>
|
||||
<button class="btn" on:click={closeNoteEditor}>{t('common.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="note-textarea" bind:value={noteEditor.content}
|
||||
on:input={updateNoteContent} placeholder={t('note.placeholder')}></textarea>
|
||||
<NoteEditorPanel
|
||||
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>
|
||||
|
||||
{:else if selectedNode}
|
||||
|
|
@ -2794,9 +3009,19 @@
|
|||
<div class="notes-list">
|
||||
{#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-info">
|
||||
<div class="note-card-title">{note.title}</div>
|
||||
<div class="note-card-date">{formatDate(note.createdAt)}</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}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -3954,6 +4179,27 @@
|
|||
.note-title { font-size: 16px; font-weight: 500; }
|
||||
.dirty-mark { color: #f59e0b; font-size: 10px; }
|
||||
.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; }
|
||||
|
||||
/* 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 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>
|
||||
|
||||
<!-- 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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -178,6 +178,46 @@ export default {
|
|||
'note.unsavedTitle': 'Unsaved changes',
|
||||
'note.unsavedMessage': 'Close the editor? All unsaved changes will be lost.',
|
||||
'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.addFolder': '+ Add folder',
|
||||
'file.newFile': '+ New file',
|
||||
|
|
|
|||
|
|
@ -190,6 +190,45 @@ export default {
|
|||
'note.unsavedMessage': 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||
'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.addFolder': '+ Добавить папку',
|
||||
'file.newFile': '+ Новый файл',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return s.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
|
@ -46,6 +46,14 @@ export function 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) {
|
||||
return window['go']['main']['App']['ListFiles'](arg1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -610,6 +610,14 @@ func (s *Service) trashRecord(r Record) error {
|
|||
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 {
|
||||
records, err := s.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/files"
|
||||
|
|
@ -13,6 +14,58 @@ import (
|
|||
"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).
|
||||
type Record struct {
|
||||
NodeID string `json:"node_id"`
|
||||
|
|
@ -47,25 +100,34 @@ func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.R
|
|||
}
|
||||
filename := seg + ".md"
|
||||
|
||||
var destDir string
|
||||
// Determine the canonical notes directory
|
||||
var parentFsPath string
|
||||
if parentID != "" {
|
||||
parent, err := s.nodes.GetActive(parentID)
|
||||
if err == nil && parent.FsPath != "" {
|
||||
destDir = filepath.Join(s.vaultRoot, parent.FsPath)
|
||||
parentFsPath = parent.FsPath
|
||||
}
|
||||
}
|
||||
if destDir == "" {
|
||||
destDir = s.vaultRoot
|
||||
}
|
||||
destDir := noteFileRoot(s.vaultRoot, parentFsPath)
|
||||
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return nil, nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
filename = fmt.Sprintf("%s_%s.md", seg, node.ID[:8])
|
||||
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 {
|
||||
|
|
@ -155,9 +217,97 @@ func (s *Service) Save(nodeID, content string) error {
|
|||
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 {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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/")
|
||||
}
|
||||
}
|
||||
|
|
@ -49,8 +49,8 @@ func TestCreateAndRead(t *testing.T) {
|
|||
t.Errorf("content = %q", content)
|
||||
}
|
||||
|
||||
// Verify file on disk (in vault root for parentless notes).
|
||||
entries, _ := os.ReadDir(vaultRoot)
|
||||
// Verify file on disk (in vault/notes/ for parentless notes).
|
||||
entries, _ := os.ReadDir(filepath.Join(vaultRoot, "Notes"))
|
||||
var mdFiles int
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && filepath.Ext(e.Name()) == ".md" {
|
||||
|
|
|
|||
Loading…
Reference in New Issue