Author SHA1 Message Date
mirivlad 2b3de296b9 fix(frontend): restore capture/drag overlay state vars
Same class of bug as cd982f2: 9878ab4 (extract FilesTab) also
removed 'let' declarations for capture/drag-drop state that's still
used by the inbox capture flow and CaptureDropOverlay:

  captureDropActive, captureDropLabel, captureDragDepth,
  lastCaptureDragOverAt, captureDragResetTimer,
  dropRootValid, inboxDropValid

Without them, Svelte runtime throws ReferenceError on first render
('captureDropActive is not defined'), leaving the WebView empty.

Verified with headless render smoke: .app + .sidebar mount,
sidebar shows system views + workspace, welcome screen renders.

Rebuild embedded frontend bundle.
2026-06-15 17:46:10 +00:00
mirivlad cd982f2f28 fix(frontend): restore trash preview state vars after FilesTab extraction
After 9878ab4 moved file-preview state into FilesTab, App.svelte still
references previewItem/previewContent/previewLoading/previewError in
two places that were not migrated:

- openTrashPreview() (trash file preview flow, ~line 1334)
- {#if previewItem} <FilePreviewModal ...> block (~line 3563)

Without these 'let' bindings, vite-plugin-svelte warned
'previewError is not defined' and the Svelte runtime threw a
ReferenceError on first render, leaving the WebView empty.

Restore the four 'let' declarations in App.svelte (kept here because
trash lives outside FilesTab) and rebuild the embedded frontend bundle.
2026-06-15 17:30:38 +00:00
mirivlad 9878ab4fb3 refactor(frontend): extract FilesTab from App.svelte
• Create lib/components/files/FilesTab.svelte (616 lines)
  - Self-contained file browser with internal state
  - Folder navigation, breadcrumbs, file list
  - File preview with CheckFileAction for .md → note editor flow
  - Import dialog (copy/link)
  - Drag-and-drop, selection, keyboard shortcuts
  - Cut/copy/paste, rename, delete, duplicate
  - Public API: resetToNode(), filesHandleKeydown()
• Remove ~594 lines from App.svelte (4794 → 4200)
  - Removed: loadingFiles, currentFolderId, folderStack, fileItems,
    previewItem/Content/Loading/Error, clipboard, selectedIds, dragIds,
    importing, importSummary, showImportDialog, pendingImportPath/Parent,
    treeItems, expanded, childrenMap, dropRootValid, inboxDropValid,
    captureDropActive/Label/DragDepth/lastCaptureDragOverAt/captureDragResetTimer
  - Removed: loadTree, loadFolder, navigateToFolder, navigateBack,
    navigateToBreadcrumb, openPreview, closePreview, createFile,
    duplicateItem, renameItem, cutItem, copyItem, pasteItem,
    toggleSelection, selectOne, selectAll, rangeSelect, clearSelection,
    getTargetIds, deleteSelected, cutSelected, copySelected,
    onDragStart, onDragOver, onDrop, openSelected, openSelectedExternal,
    addFile, addFolder, startImport, confirmImport, cancelImport, deleteFile
  - Keyboard handler delegates to filesTabRef.filesHandleKeydown()
  - selectNode() calls filesTabRef.resetToNode()
• Update docs/frontend-architecture.md — Files Flow section
• Update docs/frontend-change-map.md — Phase 2 & 5 marked complete
• Build passes: npm run build ✓, go test ✓, build.sh gui ✓
2026-06-15 20:31:23 +08:00
mirivlad 490a3dd624 refactor(frontend): modularise App.svelte — Phase 1-4
• Create docs/frontend-architecture.md and docs/frontend-change-map.md
• Extract API layer: lib/services/ (wails, notes, files, search, inbox,
  trash, sync, journal, actions, links, activity, nodes, suggestions,
  today, browserEvents)
• Extract ErrorBanner.svelte component
• Extract CaptureDropOverlay.svelte component
• Extract OverviewTab.svelte component
• Extract NotesTab.svelte component
• Wire all extracted components into App.svelte
• Build passes (npm run build ✓)
2026-06-15 19:03:07 +08:00
mirivlad bfe57ac0ac Files tab: .md → note editor via CheckFileAction + frontend
Backend:
- FindByFileID: notes+files JOIN query
- LinkFile: INSERT OR IGNORE notes record
- CheckFileAction binding: note/preview/external/auto-link

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

Tests: 7 new (FindByFileID×3, CheckFileAction×4), all PASS
2026-06-15 18:42:37 +08:00
mirivlad fec35f55b8 Notes: sync templates, fix Create layout, repair direct children, ListItems+ListNotes merge
=== Breaking ===
- CreateNote for unsupported parents (file/note/action/secret/worklog/link) -> error
- EnsureNotesFolder validates parent supports notes before creating Notes/

=== Templates (system_templates.json) ===
- folder.default: +notes module, +Notes folder in default_folders
- document.default: +notes module, +Notes folder, +Overview.md default file
- recipe.default: +Notes folder in default_folders
All container types now consistently declare notes support.

=== CreateNodeFromTemplate layout fix ===
- DefaultFolders created BEFORE DefaultFiles (so Notes/ exists)
- DefaultFile nodes now parented inside Notes folder, not the container
- File path, file record, notes record all canonical: Notes/Overview.md
- No root-level Overview.md created

=== ListItems (Files tab) ===
- bindings_files.go: ListItems now includes TypeNote (not just TypeFolder+TypeFile)
- Notes folder visible in Files tab tree
- Overview.md inside Notes shown with type='note', Mime='text/markdown'

=== ListNotes merge ===
- Collects from both Notes folder (canonical) and direct TypeNote children (compat)
- Duplicates excluded via seen set
- Canonical layout takes priority

=== RepairNotesLayout ===
- Moves direct TypeNote children into Notes folder via nodes.Move
- Updates files.path/files.filename for moved notes
- Skips non-container parents (file/note/etc)

=== Tests ===
- note_repair_test.go: 37 tests (24 old + 13 new)
  - SupportsNotes for containers/non-containers
  - EnsureNotesFolder rejects for unsupported parents
  - Create for file/note parent -> error, no state leak
  - Repair: skips non-containers, creates Notes folder, moves notes
  - Files tab: Notes folder visible, Overview content preserved after repair
- vault_layout_notes_files_test.go: 3 new ListItems/repair tests
  - ListItems shows Notes folder
  - ListItems inside Notes shows Overview with FileID
  - Repair moves direct children, ListItems reflects new layout
- Updated: suggest_test.go, trash_test.go, vault_layout_test.go expectations

=== Misc ===
- nodes/repository.go: ListByType helper for test use
- bindings_files.go: TypeNote in ListItems, Mime=text/markdown
2026-06-15 17:24:56 +08:00
mirivlad 2cbb2986c1 fix: use Svelte dispatch for verstak-link events in MarkdownPreview
Problem: MarkdownPreview dispatched DOM CustomEvent via link.dispatchEvent()
which doesn't propagate through Svelte's event system. The on:verstak-link
handler on <MarkdownPreview> in NoteEditorPanel only catches Svelte dispatch()
events, not DOM CustomEvents from {@html} content.

Fix: Replaced DOM CustomEvent dispatch with Svelte createEventDispatcher.
Now handleClick() in MarkdownPreview calls dispatch('verstak-link', {...})
which properly propagates through NoteEditorPanel → App.svelte chain.

Also: removed unused importInternalLink import (was unused after
InternalLinkPicker replaced the manual modal).
2026-06-15 12:36:32 +08:00
mirivlad db961ff0c3 feat: internal verstak:// link navigation (case/note/file)
1. handleVerstakLink now performs real navigation:
   - case/<id>: GetNodeDetail → selectNode (selects node in tree)
   - note/<id>: GetNodeDetail → find parent → selectNode(parent) → setActiveTab('notes') → openNote
   - file/<id>: GetNodeDetail → find parent → selectNode(parent) → setActiveTab('files') → loadFolder → openPreview
   - secret/<id>: toast 'Seaf access not implemented'
   - missing ids: toast 'Not found'

2. Changed href from about:blank to # (safe, DOMPurify won't strip)
   e.preventDefault() in click handler prevents scroll-to-top

3. Added i18n keys: caseNotFound, noteNotFound, fileNotFound, fileFound

4. All existing tests pass, build OK
2026-06-15 12:27:59 +08:00
mirivlad c8c5531c0c fix: internal verstak:// links in markdown preview now clickable
Root cause: DOMPurify afterSanitizeAttributes hook was treating verstak://
links as blocked because hash-based href didnt match ALLOWED_SCHEMES regex.

Fix:
1. afterSanitizeAttributes hook now checks data-verstak-href first and
   returns early for internal links - they never get blocked
2. Changed href from hash-based to about:blank (safe value that
   DOMPurify wont strip, unlike javascript:void(0))
3. Click handler already uses data-verstak-href, not href

Added unit test: markdown.test.js (27 tests for renderer.link output)
2026-06-15 12:15:42 +08:00
mirivlad 077d25a269 fix: keyboard layout map, picker type tabs as filters, add unit tests
1. Fix RU_TO_EN keyboard map in keyboardLayout.ts:
   - ч was mapped to 'c' (wrong), now correctly 'x'
   - с was mapped to 'v' (wrong), now correctly 'c'
   - ю was mapped to '.' (wrong), now correctly ','
   - Full rewrite with standard QWERTY/ЙЦУКЕН positional mapping
   - Examples: dthcnfr→верстак, руддщ→hello, цщкдв→world all correct now

2. Root cause of search breaking after 3-4 chars:
   Old map had ч:'c', с:'v' swapped. So dthcnfr → 'верчиак' (wrong).
   Each character was mapped to wrong Cyrillic equivalent.

3. Add unit tests: keyboardLayout.test.js (39 tests, node-runner):
   - EN→RU: dthcnfr, ghbdtn, ntcn
   - RU→EN: руддщ, цщкдв, ышеш
   - Unicode safety: Latin c (U+0063) ≠ Cyrillic с (U+0441)
   - expandKeyboardVariants for mixed inputs
   - Edge cases: empty, single char, mixed case, numbers

4. InternalLinkPicker type tabs → filters (not search modes):
   - Store rawResults (all) + filtered results by activeType
   - Switching type tab no longer clears query or triggers new search
   - Just filters existing rawResults by selected type
   - Shows 'Нет результатов для этого типа' when filtered empty

5. Both GlobalSearch and InternalLinkPicker use same expandKeyboardVariants()

All tests PASS, full build OK.
2026-06-15 11:37:35 +08:00
mirivlad 700e4dae5b fix: global search case-insensitive + keyboard layout swap
Unified search normalization across InternalLinkPicker and GlobalSearch:

1. GlobalSearch.svelte: multi-variant search (same as InternalLinkPicker)
   - expandKeyboardVariants() for RU/EN layout swap
   - Parallel Search queries with dedup by type+nodeId+targetId+title
   - 180ms debounce preserved

2. Backend: fix LOWER() in SQL for links/actions
   - Replace LOWER(column) LIKE with lowercased columns (title_lower, url_lower, etc.)
   - Migration 020: add lowercased columns + indexes for links and actions
   - BackfillLinksLower() + BackfillActionsLower() in storage.go
   - Update INSERT in bindings_links.go and action.go to populate lowercased columns

3. FTS5 search: Unicode case-insensitive
   - Index lowercased title/content/tags in search_index
   - sanitizeFTS() now lowercases query before MATCH
   - RebuildFTS() called after migrations

4. Case-insensitive search for nodes (already done in previous commit, verified):
   - title_lower column with Go strings.ToLower
   - Search() queries title_lower with lowercased query

All test suites PASS, full build OK.
2026-06-15 10:52:34 +08:00
mirivlad 88eb99e9af fix: verstak:// links in preview, case-insensitive search, keyboard layout swap
1. Fix verstak:// links rendered as blocked/strikethrough in markdown preview:
   - Changed href from 'javascript:void(0)' to hash-based '#verstak-type-id'
   - DOMPurify no longer strips the link; click handler uses data-verstak-href
   - CSS already handles .md-link--internal with cyan color, no strikethrough

2. Add markdown label escaping for internal link picker:
   - New escapeMarkdownLabel() in markdown.ts escapes [ ] ( )
   - Applied in InternalLinkPicker.selectResult() before inserting markdown

3. Fix case-insensitive search for RU/EN:
   - Add title_lower column (migration 019) populated by Go strings.ToLower
   - BackfillTitleLower() runs after migrations to populate existing rows
   - Search() now queries title_lower with Go-level lowercase (Unicode-aware)
   - insertNode() and UpdateTitle() populate title_lower automatically
   - New migration 019 + BackfillTitleLower in storage.go
   - Tests: TestSearchCaseInsensitive, TestSearchFindsCreatedNode

4. Add keyboard layout swap search support:
   - New keyboardLayout.ts utility with RU↔EN QWERTY mapping
   - expandKeyboardVariants() generates original + swapped + lowercased variants
   - InternalLinkPicker.search() queries all variants in parallel, deduplicates by ID
   - Examples: dthcnfr → верстак, руддщ → hello

Files changed:
- markdown.ts: hash href + escapeMarkdownLabel export
- InternalLinkPicker.svelte: label escaping + layout swap search
- keyboardLayout.ts: new RU/EN layout swap utility
- repository.go: title_lower in Search/insertNode/UpdateTitle
- storage.go: migration019 + BackfillTitleLower
- migrations_019.sql.go: new migration
- search_test.go, repository_test.go: new tests
2026-06-15 10:39:44 +08:00
mirivlad 7521eea109 feat: full Internal Link Picker with search and type filter
Replace broken ObjectPickerModal and manual modal with proper
InternalLinkPicker component:

- Search field with debounced SearchNodes API calls
- Type tabs: Дело, Заметка, Файл, Секрет (disabled)
- Results list showing title + path, keyboard navigation
- Inserts [Title](verstak://type/id) at cursor position
- No layout breakage — picker is a normal modal via position:fixed
- Escape/Cancel close picker cleanly
- bind:this on NoteEditorPanel → MarkdownEditor.insertText()

Also:
- MarkdownEditor: added public insertText() method + bind:this
- NoteEditorPanel: added bind:this on MarkdownEditor + public insertText()
- Removed manual modal, insertInternalLinkMarkdown(), document.querySelector
2026-06-15 09:57:11 +08:00
mirivlad 39d3b82199 fix: correct frontend deps check in build script
Use 'package-lock.json -nt node_modules/.package-lock.json' to detect
when lockfile changed (e.g. after git pull). Use 'npm ci' for
deterministic install instead of 'npm install'.
2026-06-15 09:32:01 +08:00
mirivlad 767bf5c140 fix: auto-install frontend deps in build script
Add npm install check to build_gui() — if node_modules/ is missing
or doesn't match package-lock.json, install dependencies before building.
2026-06-15 09:25:28 +08:00
mirivlad 0fdf77ce03 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
2026-06-15 09:19:26 +08:00
mirivlad a193c5a4c6 fix: Firefox extension 1.0.4 — secret handling, force ping, queue flush bugs
- withAuth skips check when server secret is empty
- flushQueue doesn't send X-Verstak-Secret header when secret is empty/undefined
- popup force-pings server on open (not relying on cached bridgeReachable)
- flushQueue updates bridgeReachable on every result
- Triple-state status: true/false/undefined→'Проверка...'
- Tests: verify events POST works without auth header, with empty header, full ping→events flow
2026-06-09 01:15:12 +08:00
mirivlad e1505e1334 test(bridge): add integration tests for empty/undefined secret, full flow 2026-06-09 01:11:51 +08:00
mirivlad b002005a42 fix(browser-bridge): don't send X-Verstak-Secret when empty/undefined; add integration tests 2026-06-09 01:02:37 +08:00
mirivlad fa5001341e fix(browser-bridge): auth bypass when secret empty, popup status fix, force ping on open 2026-06-09 00:44:51 +08:00
mirivlad 58751945eb fix(firefox): v1.0.2 signed XPI, proper contents without node_modules 2026-06-08 21:55:06 +08:00
mirivlad b4de2dec7a fix(release): RPM spec use %{_topdir}/SOURCES; fix Firefox manifest for FF115 2026-06-08 19:36:04 +08:00
mirivlad c1434a0b61 fix(release): use %{_sourcedir} in RPM spec instead of absolute paths
rpmbuild runs %install from its own working directory where
 is undefined. Copy artifacts into SOURCES and use
rpmbuild's %{_sourcedir} macro to locate them.
2026-06-08 18:27:58 +08:00
mirivlad 1dbb1f8c68 feat(calendar): restore full calendar UI in iframe panel
Replaced debug placeholder in render() with real calendar rendering:
header (nav + view tabs), categories bar with filter, and month/week/day
views. Full CRUD modals were already present in the HTML.
2026-06-08 16:36:53 +08:00
mirivlad 7a95943ad7 fix(plugins): empty Lua tables now serialize as [] instead of {} in luaValueToGo
Empty Lua tables from DB queries (e.g. get_events with no results)
are ambiguous — they could be [] or {}. Frontend expects arrays
(with .length), so we default empty tables to [] instead of {}.
2026-06-08 14:15:37 +08:00
mirivlad 21130c6f1e debug: log typeof events and JSON.stringify in both parent and iframe 2026-06-08 13:57:45 +08:00
mirivlad 82c2588449 debug: replace render() with minimal test to verify innerHTML works 2026-06-08 13:46:11 +08:00
mirivlad 4a5dab49b5 debug: add try-catch around iframe render() to catch any JS errors 2026-06-08 13:43:13 +08:00
mirivlad f4a25128ae debug: add WriteDebugLog inside iframe to confirm calendar-data delivery + log.Printf to WriteDebugLog 2026-06-08 12:34:14 +08:00
mirivlad c03e2e2961 debug: add WriteDebugLog after postToIframe to confirm delivery 2026-06-08 12:31:45 +08:00
mirivlad f892d377a0 debug: add log.Printf in CallPluginFunction Go binding to trace calls 2026-06-08 12:26:43 +08:00
mirivlad 35e23d75fa debug: add step-by-step logging in loadCalendarData to trace where error occurs 2026-06-08 12:23:51 +08:00
mirivlad 5069472e19 debug: add logging to get_categories + better error details in CalendarPluginPage 2026-06-08 12:01:29 +08:00
mirivlad a202c5d079 fix(calendar): add type guards and debug logging for get_events SQL error
- Added debug prints to log params type in get_events
- Added type guard in backward compat path (reject non-string end_date)
- Added type guard for start_date/end_date before passing to SQL
2026-06-08 11:54:00 +08:00
mirivlad d6e28d7b1f fix(plugins): restore plugin runtimes on startup + graceful shutdown
Root cause: initVault() called Discover() but never called
SyncConfig(), InitRuntimes(), CallInitHooks(), or StartSchedulers().
On restart, plugins were discovered from disk but their config state
(Installed/Enabled) was not restored and no Lua VMs were created.

This caused:
- Settings → Plugins showing plugins as 'not installed' after restart
- Sidebar showing calendar item (from enabledSet config) but
  GetPluginPanelHTML failing (only checked p.Active)
- Toggle working in-session but state lost on restart
- closeVault() not stopping plugin schedulers/shutdown/VM (leak)

Fixes:
- bindings_config.go: add SyncConfig(appCfg) after Discover()
- bindings_config.go: add InitRuntimes() + CallInitHooks() + StartSchedulers()
  after watcher start
- bindings_config.go: add StopSchedulers + CallShutdownHooks + CloseRuntimes
  to closeVault() (before db.Close since plugins use DB)
- bindings_plugins.go: GetPluginPanelHTML now checks enabledSet || p.Active,
  consistent with ListSystemViewsWithPlugins and ListPlugins
2026-06-08 11:45:35 +08:00
mirivlad f769daa617 fix(plugins): JSON-serialize CallFunctionJSON return values + backward compat Lua args
Root cause: CallFunctionJSON used .String() on Lua return values, which
for tables produces 'table: 0x...' — not valid JSON. Frontend does
JSON.parse() on the result and silently caught the parse error.

Fix:
- runtime.go: convert Lua return value to JSON via luaValueToGo +
  json.Marshal so tables become proper JSON arrays/objects
- main.lua: add backward compat in get_events() and update_event()
  to accept both positional args (start, end) and table params
- CalendarPluginPage.svelte: show errors in UI instead of silent catch;
  restructure template to always show iframe + error overlay
2026-06-08 11:31:18 +08:00
mirivlad fddbd3a98a fix calendar: table params for Lua functions + remove duplicate header
- CalendarPluginPage.svelte: removed <h2>pluginName — pageLabel</h2> (AppHeader already shows title)
- main.lua: all API functions now accept a single table parameter:
  - get_events(params) — reads params.start_date or params.start
  - get_event(params) — reads params.id
  - update_event(params) — reads params.id + mutable fields
  - delete_event(params) — reads params.id
  - get_expanded_events(params), get_calendar_events(params) — same
  - get_events_day(params) — reads params.date
  - create_event(opts) — already worked, no change
- Backward compatible: get_events accepts both start/start_date and end/end_date keys
2026-06-08 11:18:28 +08:00
mirivlad b1d1defebe release infra: build scripts, Firefox signing, plugin fixes
- .gitignore: release/, .env, *.xpi, node_modules/
- .env.example: template for AMO credentials
- extension-firefox/package.json: web-ext scripts (lint, sign)
- extension-firefox/manifest.json: gecko.id + update_url + data_collection_permissions
- scripts/build.sh: renamed binaries (verstak, verstak-server), release target
- scripts/sign-firefox-xpi.sh: AMO signing with --self-hosted
- scripts/release-firefox-xpi.sh: signed XPI + updates.json generation
- scripts/release.sh: full release pipeline (DEB/RPM/checksums/git tag)
- VERSION: 0.1.0
- README.md: Firefox extension & release sections

Plugin fixes:
- internal/core/plugins/manager.go: auto-create .verstak/plugins/ dir
- frontend/src/lib/SettingsSidebar.svelte: remove 'plugins' from disabled + active left border
- frontend/src/lib/SettingsPlugins.svelte: force ListPlugins refresh on toggle
- frontend/src/lib/SettingsWindow.svelte: onPluginToggle callback
- frontend/src/App.svelte: refreshSystemViews on plugin toggle
- frontend/src/lib/CalendarPluginPage.svelte: visible error icon
2026-06-08 11:07:29 +08:00
mirivlad 3b79754f45 fix: rollback Enabled on activation failure + fatal on_init + rollback test
1. SetPluginEnabled(true): after DeactivatePlugin, also call Disable(name)
   to rollback in-memory Enabled state (not just config).
2. on_init failure is now fatal for ActivatePlugin — returns error
   and rolls back scheduler + VM (was incorrectly non-fatal).
3. TestSetPluginEnabled_BrokenPlugin_Rollback: end-to-end test with
   broken plugin (invalid interval), verifies error + not Active +
   not Enabled + not in config.
2026-06-08 00:14:49 +08:00
mirivlad 45cfe1b0a6 fix: финальный cleanup Lua plugin lifecycle
1. ActivatePlugin → error return:
   - Возвращает ошибки при создании VM, загрузке main.lua, scheduler setup
   - on_init failure = non-fatal (logged, activation continues)
   - SetPluginEnabled сохраняет EnabledPlugins в config ТОЛЬКО после успешной активации
   - При ошибке активации — rollback (deactivate + не сохраняем в config)

2. CallPluginFunction fully thread-safe:
   - Новый метод LuaVM.CallFunctionJSON(segments, paramsJSON)
   - JSON→Lua conversion происходит под vm.mu (внутри lock)
   - Убраны parseParamsToLua/goToLua из bindings_plugins.go
   - goToLua перенесён в runtime.go (под lock)

3. PluginPage → CalendarPluginPage:
   - Компонент явно календарный (get-events/create-event/update-event/delete-event)
   - Переименован для ясности
   - Console log префиксы обновлены

4. Тесты:
   - TestSetPluginEnabled_ActivateFails_NoConfigSave: проверяет что при ошибке
     активации плагин НЕ сохраняется в EnabledPlugins
   - TestActivatePlugin_ErrorReturn: проверяет все режимы ошибок
   - TestCallFunctionJSON_ThreadSafe: JSON object/array/empty params
   - TestDeactivatePlugin_Idempotent: двойная деактивация = no-op
   - TestInitRuntimes_SkipsDisabled: только Enabled плагины активируются
2026-06-07 22:58:26 +08:00
mirivlad d83c8c80e1 fix: второй стабилизационный проход Lua plugin lifecycle
1. Enabled/Active state separation:
   - Enable() sets Enabled=true (persisted in config), does NOT create runtime
   - ActivatePlugin() checks Enabled && !Active, creates VM + scheduler
   - DeactivatePlugin() stops runtime, keeps Enabled=true
   - InitRuntimes() iterates Enabled plugins, sets Active=true after creation
   - SyncConfig() restores Enabled from config, does NOT touch Active

2. ActivatePlugin: добавлен vm.SetServices(m.Services)

3. Discover: атомарная замена списка (newPlugins slice), нет дублирования

4. CallPluginFunction: thread-safe через LuaVM.CallFunction (vm.mu + callWithTimeout)

5. Uninstall активного плагина: полная деактивация (StopScheduler → on_shutdown → CloseVM → Active=false)

6. GetPluginPanelHTML: валидация panel path (no absolute, no .., must be .html, must be within plugin dir)

7. PluginPage: убран hardcoded 'calendar-plugin', используется funcPrefix из pluginName

Тесты:
- security_test.go: +8 тестов (FullLifecycle, ActivatePlugin_Services, Discover_Idempotent,
  ReloadPlugins_NoDuplicates, CallPluginFunction_Timeout, Uninstall_ActivePlugin,
  GetPluginPanelHTML_PathTraversal, FullLifecycle_EndToEnd)
- manager_test.go: обновлены тесты под новую семантику Enabled/Active
2026-06-07 20:49:43 +08:00
mirivlad 4df83cd361 security: стабилизационный аудит Lua plugin system
Исправления:
- Install: идемпотентность (no duplicates in InstalledPlugins)
- ReloadPlugins: StopSchedulers + CallShutdownHooks перед CloseRuntimes
- StopSchedulers: обнуление scheduler=nil после остановки
- Scheduler.Stop: обнуление tasks после wg.Wait
- Lua sandbox: блокировка package.loadlib/seeall/preload/loaders/loaded/path/cpath/config/searchpath
- Lua sandbox: блокировка load (глобальная функция)
- CallPluginFunction: валидация funcName (regex [a-zA-Z_][a-zA-Z0-9_]*, max 3 segments)
- CallPluginFunction: убрана строковая сборка Lua-кодa, вызов через PCall напрямую
- PluginPage.svelte: проверка e.source === iframeEl.contentWindow
- PluginPage.svelte: type checking для msg.source, msg.action

Тесты:
- security_test.go: 18 новых тестов (sandbox, lifecycle, validation)
- Все существующие тесты проходят

Документация:
- docs/plugins-security.md: модель безопасности, sandbox, протокол, lifecycle
2026-06-07 19:19:44 +08:00
mirivlad c443ca23c5 fix: PluginPage.svelte — замена CallPluginAction на CallPluginFunction с dotted path
PluginPage.svelte использовал несуществующий Wails binding CallPluginAction.
Заменён на CallPluginFunction с правильным dotted path (calendar.get_events и т.д.),
что соответствует сигнатуре bindings_plugins.go.

Frontend пересобран, go build + go test ./... — всё зелёное.
2026-06-07 16:56:28 +08:00
mirivlad 7b9c9647ac test: add TestCallPluginFunction + final run - all 13 tests pass
- TestCallPluginFunction: verifies DoString calls Lua functions correctly
  - add(3,4) → 7, greet('World') → 'Hello, World', get_table() → table
- Full suite: 13 tests, 0 failures
- go build ./... clean
2026-06-07 16:41:47 +08:00
mirivlad a1d7c7b88b feat: PluginPage iframe bridge + CallPluginFunction binding
- PluginPage.svelte: bidirectional postMessage bridge with iframe
  - Handles: ready, get-events, create-event, update-event, delete-event
  - Queues messages until iframe is ready
  - Exports handleDrop() for drag-and-drop from parent
- CallPluginFunction binding: calls arbitrary Lua functions on active plugins
  - Supports dotted paths: 'calendar.create_event' → _G.calendar.create_event
  - JSON params → Lua table conversion
- LuaVM: added DoString(), LState(), VM() public methods
- Plugin: added VM() getter for external access
2026-06-07 16:37:32 +08:00
mirivlad 308772dee8 fix: simplify ReloadPlugins - remove redundant deactivate loop
- SyncConfig already sets Active correctly from config
- InitRuntimes only processes Active plugins
- No need for separate deactivate pass
2026-06-07 16:01:28 +08:00
mirivlad 3f787ec66d fix: only plugins with on_install are managed - skip others entirely
- Discover(): skip plugins without on_install hook (not shown in UI)
- Enable(): simplified - just check Installed flag
- SyncConfig(): simplified - no HasInstall special case
- Frontend: simplified toggle logic (only installed/uninstalled states)
- Tests: updated all test fixtures to include on_install hook
2026-06-07 15:55:04 +08:00
mirivlad 5c769c92a0 fix: simplify plugin lifecycle - no install/uninstall = just toggle
- Plugins without on_install hook: always 'installed', toggle works directly
- Plugins with on_install: must Install first, then toggle, then Uninstall available
- No data cleanup on Disable (only on Uninstall via on_uninstall hook)
- Old plugins without lifecycle hooks simply don't get install/uninstall UI
2026-06-07 15:38:04 +08:00
mirivlad e99ff984b1 feat: plugin install/uninstall lifecycle + UI buttons
- AppConfig: add InstalledPlugins []string
- Manager.Discover(): no config dependency, all plugins start inactive
- Manager.SyncConfig(): apply installed/enabled state from AppConfig
- Manager.Enable(): works for plugins without on_install hook
- Manager.Install/Uninstall(): run on_install/on_uninstall hooks
- ActivatePlugin: skip if HasInstall && !Installed
- ReloadPlugins: Discover → SyncConfig → InitRuntimes
- Bindings: InstallPlugin, UninstallPlugin
- SettingsPlugins: install/uninstall buttons, toggle only after install
- Calendar: migration moved from on_init to on_install, on_uninstall drops tables
- Tests: all 12 pass (manager + runtime + calendar)
2026-06-07 15:28:37 +08:00
mirivlad b80941f908 feat: плагин-система Lua + Calendar reference plugin
- Lua VM runtime: gopher-lua с песочницей, хуки on_init/on_tick/on_shutdown
- API: verstak.node.* / verstak.db.* / verstak.config.* / verstak.state.*
- API: verstak.worklog.* / verstak.activity.* / verstak.file.*
- API: verstak.schedule.* / verstak.http.* / verstak.ui.*
- Менеджер плагинов: жизненный цикл, инициализация, шаблоны
- Scheduler: фоновые задачи с интервалами
- PluginPage.svelte: контейнер для iframe-панелей плагинов
- Calendar plugin: миграция, категории CRUD, события CRUD
- Calendar: расширенный рекарренс (daily/weekly/monthly/yearly)
- Calendar: связь с узлами Верстака, напоминания, HTTP-праздники
- Calendar: Lua-тест-сьют (15 тестов), Go-интеграционный тест
- fix: query_row использует реальные Column() вместо guessColumns
2026-06-07 14:59:46 +08:00
mirivlad 8cbc87cdad feat: настройки Browser Bridge в Verstak и extension
Verstak (GUI):
- SettingsBrowserBridge.svelte — новая секция в Settings:
  toggle вкл/выкл сервера, поле порта (default 9786),
  статус (Запущен/Остановлен), кнопки Сохранить/Перезапустить
- SettingsWindow + SettingsSidebar — подключена секция browserBridge
- BridgeConfig: добавлено поле Enabled (default true)
- RestartBridge() — новый биндинг для перезапуска сервера
- initVault: проверяет bc.Enabled перед запуском bridge

Extension (Chrome + Firefox):
- popup: панель настроек с полем порта (default 9786)
- кнопка «Проверить» — fetch /api/ping с таймаутом 3с
- кнопка «Сохранить» — сохраняет port в chrome.storage.local
- статус соединения: ✓ Сервер отвечает / ✗ Недоступен / ✗ Таймаут
- оба расширения работают только с 127.0.0.1
2026-06-07 01:03:35 +08:00
mirivlad 1cc0c407b1 fix: исправление 6 пунктов из ревью
Critical:
- bridge: AutoGenPort=false по умолчанию, не генерируем secret если пустой
  → extension и bridge совпадают на port 9786 и empty secret
- bridgeConfig: убрана авто-генерация secret, убран secret из BridgeInfo

High:
- extension/background.js + extension-firefox/background.js:
  все chrome.* listeners вынесены в global scope (не внутри onInstalled/onStartup)
  → MV3 service worker корректно перезапускается
- UI: acceptBrowserEvent вызывает AcceptBrowserEvent, attachBrowserEvent вызывает
  AttachBrowserEventToNode (к текущему selectedNode), а не DismissBrowserEvent
- watcher: при Create проверяется isUnderVault(absPath, vaultRoot) —
  если файл уже в vault, используется AddExternal вместо CopyIntoVault
  → нет дублирования файлов с timestamp-суффиксом

Medium:
- bridge.Event: добавлено поле DeviceID, handleEvents обогащает events из batch.DeviceID
  → device_id сохраняется в DB как chrome-*/firefox-*, а не evt_*
- config: FileWatcher изменён на *bool — nil означает default true,
  false = явно выключено → старые config.json без поля file_watcher получают true
2026-06-07 00:15:34 +08:00
mirivlad b676ac675a feat: иконки для Chrome и Firefox расширений
- extension/icons/ + extension-firefox/icons/
  - icon16.png (16x16), icon48.png (48x48), icon128.png (128x128)
  - взяты из frontend/public/assets/app-icons/
  - 48x48 сгенерирован из 64x64 через ImageMagick
- Манифесты: default_icon + icons секции для обоих браузеров
- build.sh: иконки включаются в zip/xpi архивы
2026-06-06 19:21:28 +08:00
mirivlad f6c61c32e3 build: упаковка расширений в scripts/build.sh
- build.sh: новые команды extensions, chrome, firefox
- Chrome: build/verstak-bridge-chrome.zip
- Firefox: build/verstak-bridge-firefox.xpi
- Архивы содержат только нужные файлы (manifest, background, popup)
- Исключены .DS_Store, Thumbs.db, __MACOSX, .git
2026-06-06 19:15:24 +08:00
mirivlad c5505ee43c feat: Firefox-расширение Verstak Bridge
- extension-firefox/manifest.json — Manifest V3 для Firefox
  (browser_specific_settings.gecko, background.scripts)
- extension-firefox/background.js — browser.* API с chrome.* полифиллом
  - Стабильный device_id через crypto.getRandomValues (6 байт hex)
  - Фильтрация about:, moz-extension:, resource: и пр. внутренних URL
  - device_id с префиксом 'firefox-' для различения в activity
- extension-firefox/popup/ — общие popup HTML/CSS/JS (копия Chrome)
2026-06-06 19:08:31 +08:00
mirivlad fc429ac26e feat: ШАГ 4 — UI для browser events в TodayScreen
Go bindings:
- bindings_browser.go: ListBrowserEvents, CountPendingBrowserEvents,
  AcceptBrowserEvent, DismissBrowserEvent, AttachBrowserEventToNode

Frontend:
- BrowserEvents.svelte — компонент списка событий с действиями
  (принять/прикрепить/удалить), статусы, домены, длительность
- TodayScreen.svelte — новая вкладка «Браузер» с badge
- App.svelte — loadBrowserEvents, acceptBrowserEvent,
  dismissBrowserEvent, attachBrowserEvent handlers
2026-06-06 18:58:39 +08:00
mirivlad 6bd6c9c5ff feat: ШАГ 3 — Chrome-расширение Verstak Bridge 2026-06-06 18:28:52 +08:00
mirivlad 84d9725b17 feat: ШАГ 2 — Staging-таблица browser_events + Store 2026-06-06 18:27:00 +08:00
mirivlad 358c649b42 feat: ШАГ 1 — Bridge HTTP-сервер для браузерного расширения
- internal/core/bridge/ — лёгкий HTTP-сервер на 127.0.0.1
  - POST /api/events — приём батча событий от расширения
  - GET /api/ping — healthcheck для расширения
  - X-Verstak-Secret — аутентификация по shared-secret
  - AutoGenPort — случайный порт если 9786 занят
- config.BridgeConfig — порт, секрет, auto_gen_port
- App: интеграция startBridge/stopBridge при open/close vault
- bindings_bridge.go — BridgeInfo(), startBridge(), saveBridgeConfig()
- Тесты: ping, auth, success, empty batch, secret gen, auto-port
2026-06-06 18:23:47 +08:00
mirivlad f88376264d fix: reorder journal worklog sections 2026-06-06 02:53:36 +08:00
mirivlad 40c0953904 fix: skip deleted entries in navigation history 2026-06-06 02:42:20 +08:00
mirivlad 0cd8a79049 feat: restore global search in app header 2026-06-06 02:39:29 +08:00
mirivlad cf770262e5 fix: reset capture drag state reliably 2026-06-06 02:30:54 +08:00
mirivlad 6033ccffa9 Add Verstak agent project rules 2026-06-06 01:33:39 +08:00
mirv a37afd3b67 fix: trash integrity for TypeFile nodes — file record soft-delete, correct preview/restore 2026-06-05 17:31:18 +08:00
mirv 64e6c6f735 fix: trash file preview, visual CSS, virtual folder model
- Added resolveTrashPath() backend function: walks ancestor chain to find
  files inside deleted/moved folders (flat trash directory)
- Added TrashFsPath to TrashNodeDTO, computed during ListTrash via
  parent-to-child propagation of physical trash path
- Fixed visual CSS for trash rows: button reset (no white bg, transparent,
  inherit font/color), hover styles match app dark theme
- Root view filters out descendant nodes (only shows top-level items)
2026-06-05 17:05:35 +08:00
mirv 5257789a4d fix: trash duplicate path, journal tabs, today undefined, mouse back, inbox search
- Trash: removed duplicate fsPath display, folders/files open by clicking icon/name
- Trash: removed separate 'Open' button, only restore/delete right-aligned
- Trash: added ReadTrashFileContent binding + preview for files
- Trash: breadcrumb path under title, compact date display
- Journal: split into 'Предложения' + 'Журнал работы' tabs
- Journal: suggestions tab opens by default
- Today: fixed undefined in feed (null-guard on eventType/title)
- Today: improved event type label readability (blue badge style)
- Today: folder_deleted clicks navigate to trash
- Mouse Back: added mouseup fallback for Wails compat, handle button 3/4
- SearchNodes: case-insensitive with LOWER()
- Inbox assign: added search hint placeholder
2026-06-05 16:49:00 +08:00
mirv c512ada386 cleanup: remove stale frontend-dist assets, fix warnings 2026-06-05 16:21:21 +08:00
172 changed files with 24642 additions and 1389 deletions
+21
View File
@@ -0,0 +1,21 @@
[mcp_servers.go_lsp]
command = "mcp-language-server"
args = [
"--workspace", "/home/mirivlad/git/verstak",
"--lsp", "gopls"
]
enabled = true
default_tools_approval_mode = "approve"
tool_timeout_sec = 30
[mcp_servers.ts_lsp]
command = "mcp-language-server"
args = [
"--workspace", "/home/mirivlad/git/verstak",
"--lsp", "typescript-language-server",
"--",
"--stdio"
]
enabled = true
default_tools_approval_mode = "approve"
tool_timeout_sec = 30
+22
View File
@@ -0,0 +1,22 @@
# Verstak Firefox Extension — environment template
# Copy to .env and fill in real values.
# DO NOT commit .env — it contains AMO secrets.
# AMO API credentials (from https://addons.mozilla.org/developers/)
WEB_EXT_API_KEY=
WEB_EXT_API_SECRET=
# Build channel: "unlisted" for self-distributed builds
WEB_EXT_CHANNEL=unlisted
# Source directory for the Firefox extension
WEB_EXT_SOURCE_DIR=extension-firefox
# Output directory for signed XPI artifacts
WEB_EXT_ARTIFACTS_DIR=web-ext-artifacts
# Base URL for Firefox self-hosted updates
VERSTAK_FIREFOX_UPDATE_BASE_URL=https://mirv.top/verstak/firefox
# Optional: HTTP proxy for AMO API requests (e.g. http://localhost:12334)
# WEB_EXT_API_PROXY=
+14
View File
@@ -49,3 +49,17 @@ server-data/
# Build output
build/
build.log
# Release artifacts
release/
# Environment / secrets
.env
.env.*
!.env.example
# Firefox extension
web-ext-artifacts/
*.xpi
*.zip
node_modules/
+119
View File
@@ -1,3 +1,105 @@
# 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.
It must remain practical, simple, and filesystem-aware.
## Stack
- Backend: Go
- Storage: SQLite
- GUI: Wails v2
- Frontend: Svelte 4
- Build tooling: Vite 5
- Do not migrate to Wails v3, Svelte 5, or Vite 8 unless explicitly asked.
## Architecture rules
- Keep local-first behavior.
- Do not turn the project into SaaS.
- Do not replace SQLite with another database.
- Do not introduce cloud storage assumptions.
- Preserve recursive folder import semantics.
- Preserve stable node IDs.
- Do not duplicate nodes when moving items.
- Do not create parallel state systems for the same entity.
## UI rules
- Fix GUI behavior at root cause.
- Do not redesign the whole interface unless explicitly asked.
- Preserve active tab state correctly.
- Context menus must open near the cursor.
- Drag-and-drop must show clear visual target feedback.
- Moving nodes must never duplicate the same ID in two places.
- Nested selection must not collapse the parent unexpectedly.
## Files
- File view is not a tree.
- Sidebar shows logical hierarchy.
- Vault filesystem layout must remain human-readable without the app.
- Drag-and-drop folders must perform real recursive copy/move into the vault.
- Do not fake folder support with external links.
## Sync
- Sync settings belong in Settings.
- Main UI may keep only manual sync/status controls.
- Existing URL + login/password device registration flow should be preserved unless explicitly changed.
- Secrets must not be logged.
## Verification
For backend changes:
- Run `go test ./...` if possible.
For frontend changes:
- Run the relevant frontend build/check command if available.
- If unsure, inspect package scripts first.
For GUI bugs:
- Add targeted tests only where practical.
- If manual GUI clicking is required and unavailable, state exact manual verification steps for the user.
# Session summary
## Bugs fixed (this session)
@@ -37,6 +139,22 @@
34. **Template enable/disable** — `AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
## Bugs fixed (this session)
1. **Trash preview "trash file not found" for TypeFile nodes** — `resolveTrashPath` only searched `<nodeID>_*` in trash dir, but TypeFile node files are moved by file record ID (`<recordID>_<filename>`). Added file record fallback via `ListTrashedByNode` + `ReadTrashFile(trashFsPath)` binding.
2. **Trash restore creates empty files** — `DeleteToTrash` permanently `DELETE`'d file records from DB and `restoreTrashPath` silently `return nil` for `FsPath=""` nodes. Changed `deleteFileRecords` to `trashRecord` (sets `missing=1`, keeps record). `restoreTrashPath` now restores file records: moves file back from trash, sets `missing=0`.
3. **`resolveTrashPath` inner loop corrupts `anc` variable** — `anc = child` inside the inner loop caused wrong path computation for nesting depth > 2. Replaced with direct `chain[0].FsPath` prefix computation.
4. **`ListTrash` missing `TrashFsPath` for TypeFile nodes** — Phase 1 only checked `<nodeID>_*` entries. Added `ListTrashedByNode` fallback to set `TrashFsPath` for TypeFile nodes.
5. **`ListByNode` returning trashed records** — Added `AND missing != 1` filter to exclude trashed file records from active node file listings.
## Key patterns (this session)
- **TypeFile node trash**: files are moved by file record ID (`<recordID>_<filename>`), NOT by node ID. Never search by `<nodeID>_*` alone — always fall back to file records via `ListTrashedByNode`.
- **File record soft-trash**: use `UPDATE files SET missing=1` instead of `DELETE` to keep records restorable. Restore via `UPDATE ... SET missing=0` + `os.Rename` from trash.
- `ReadTrashFile(trashFsPath)` is preferred over `ReadTrashFileContent(nodeID)` — frontend has `trashFsPath` precomputed by `ListTrash`.
- **`restoreTrashPath` for TypeFile nodes**: when `fsPath == ""`, find file records with `missing=1` and restore each one.
- **Full test coverage**: `TestTrashTypeFilePreviewAndRestore`, `TestTrashTypeFileInsideFolderRestorePreservesContent`, `TestTrashTypeFileMultipleRecords`.
## Key patterns
- Always use explicit toggle icons (▸/▾) on expandable rows.
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
@@ -66,3 +184,4 @@ go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak
```bash
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
```
+73 -7
View File
@@ -10,7 +10,7 @@
| Слой | Технология |
|------|------------|
| GUI | Wails v2 + Svelte 4 |
| CLI/TUI | Go (bubbletea-like) |
| CLI | Go |
| Backend | Go |
| Хранилище | SQLite (индекс) + файловая система (vault) |
| Плагины | Lua |
@@ -70,28 +70,94 @@
Дополнительно запускается headless Chromium smoke через Wails-mock: проверяются first-run, recovery, основное окно, Settings, workspace, вкладки дела, файлы, журнал, активность и мобильный viewport. Smoke выполняет реальные UI-действия: создание заметки, запись worklog, создание узла, вход в папку и возврат назад, а также Sync Now с предупреждениями о conflicts/applyErrors. Скриншоты пишутся в `/tmp/verstak-gui-smoke`.
Бинарники попадают в `build/`:
- `verstak-gui-linux-amd64` — GUI-приложение
- `verstak-server-linux-amd64` — опциональный сервер синхронизации
- `verstak` — GUI-приложение
- `verstak-server` — опциональный сервер синхронизации
### Запуск
```bash
# GUI (после сборки)
./build/verstak-gui-linux-amd64
./build/verstak
# Сервер (после сборки)
./build/verstak-server-linux-amd64 --help
./build/verstak-server --help
# CLI
go run ./cmd/verstak/ --help
```
## Firefox Extension
Расширение для Firefox — `extension-firefox/`. Распространяется как signed XPI: **Mozilla только подписывает** XPI через AMO (unlisted channel), а мы самостоятельно хостим signed XPI и управляем обновлениями через `updates.json`.
### Сборка (unsigned)
```bash
./scripts/build.sh firefox
# → build/verstak-bridge-firefox-unsigned.zip
```
### Подпись (требуются AMO-токены)
```bash
# 1. Скопировать .env.example → .env и заполнить WEB_EXT_API_KEY / WEB_EXT_API_SECRET
cp .env.example .env
# 2. Установить зависимости
cd extension-firefox && npm install && cd ..
# 3. Подписать XPI
./scripts/sign-firefox-xpi.sh
# → web-ext-artifacts/*.xpi
# 4. Полный релиз: подпись + release/firefox/ + updates.json
./scripts/release-firefox-xpi.sh
# → release/firefox/verstak-firefox-VERSION.xpi
# → release/firefox/updates.json
```
### Firefox Release Artifacts
```
release/firefox/verstak-firefox-VERSION.xpi
release/firefox/updates.json
```
Обновления: Firefox проверяет `update_url` из manifest.json, указывающий на наш `updates.json`. При выходе новой версии достаточно:
1. Подписать новый XPI
2. Заменить файл на сервере
3. Обновить `updates.json`
## Release
```bash
# Полная сборка с упаковкой в DEB/RPM
./scripts/release.sh # dry-run (без git tag); требует AMO токены
./scripts/release.sh --publish # с git tag + GitHub release
# Без подписи Firefox
./scripts/release.sh --skip-firefox-sign
```
Артефакты релиза (после `./scripts/release.sh`):
```
release/linux/verstak # GUI binary
release/linux/verstak-server # Server binary
release/linux/verstak.deb # DEB-пакет GUI
release/linux/verstak-server.deb # DEB-пакет сервера
release/linux/verstak.rpm # RPM-пакет GUI
release/linux/verstak-server.rpm # RPM-пакет сервера
release/firefox/verstak-firefox-VERSION.xpi # Signed XPI
release/firefox/updates.json # Firefox update manifest
```
## Структура проекта
```
.
├── cmd/ # Точки входа
│ ├── verstak/ # CLI/TUI
│ ├── verstak/ # CLI
│ ├── verstak-gui/ # Wails GUI
│ └── verstak-server/ # Sync server
├── internal/
@@ -151,7 +217,7 @@ Vault открывается в любом файловом менеджере
| [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync |
| [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы |
| [Синхронизация](docs/04_Sync_Backup_Activity.md) | Sync, backup, activity |
| [UI/UX](docs/05_UI_UX.md) | Экраны GUI/TUI |
| [UI/UX](docs/05_UI_UX.md) | Экраны GUI |
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
+1
View File
@@ -0,0 +1 @@
0.1.0
+33 -16
View File
@@ -13,6 +13,8 @@ import (
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/bridge"
"verstak/internal/core/browser"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
@@ -22,6 +24,7 @@ import (
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
@@ -31,18 +34,21 @@ type App struct {
mu sync.RWMutex
vaultOpen bool
db *storage.DB
nodes *nodes.Repository
templates *templates.Registry
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
vault string
db *storage.DB
nodes *nodes.Repository
templates *templates.Registry
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
fileWatcher *watcher.Service
bridge *bridge.Server
browser *browser.Store
vault string
}
// requireVault returns an error if no vault is open and services are not initialized.
@@ -185,6 +191,14 @@ type FileTreeItemDTO struct {
HasKids bool `json:"hasKids"`
}
// PreflightFileAction describes what should happen when opening a file from the Files tab.
type PreflightFileAction struct {
Action string `json:"action"` // "note" | "preview" | "external"
NoteID string `json:"noteId,omitempty"`
NoteTitle string `json:"noteTitle,omitempty"`
FileName string `json:"fileName"`
}
type ActionDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
@@ -209,10 +223,13 @@ type WorklogDTO struct {
}
type SearchResultDTO struct {
NodeID string `json:"nodeId"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Type string `json:"type"`
NodeID string `json:"nodeId"`
TargetID string `json:"targetId,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Type string `json:"type"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
}
type EventDTO struct {
+1
View File
@@ -13,6 +13,7 @@ import (
type SystemViewDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
}
func (a *App) ListSystemViews() []SystemViewDTO {
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"fmt"
"log"
"verstak/internal/core/bridge"
"verstak/internal/core/browser"
"verstak/internal/core/config"
)
// startBridge creates and starts the local HTTP bridge for browser extension.
func (a *App) startBridge(appCfg *config.AppConfig) {
// Determine bridge config
bc := a.bridgeConfig(appCfg)
handler := func(events []bridge.Event) {
// Convert to browser events and store in staging.
be := make([]browser.Event, 0, len(events))
for _, ev := range events {
be = append(be, bridgeToBrowser(ev))
}
n, err := a.browser.InsertEvents(be)
if err != nil {
log.Printf("[bridge] store events: %v", err)
return
}
if n > 0 {
log.Printf("[bridge] stored %d/%d events", n, len(be))
}
}
srv := bridge.NewServer(bc.Secret, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
AutoGenPort: bc.AutoGenPort,
Secret: bc.Secret,
})
if err != nil {
log.Printf("[bridge] failed to start: %v", err)
return
}
// Save the actual port back to config if auto-generated.
if bc.AutoGenPort {
bc.Port = port
}
a.saveBridgeConfig(appCfg, bc)
a.mu.Lock()
a.bridge = srv
a.mu.Unlock()
}
// bridgeConfig extracts bridge config from app config.
func (a *App) bridgeConfig(appCfg *config.AppConfig) *config.BridgeConfig {
if appCfg != nil && appCfg.Vault.Bridge.Port != 0 {
return &appCfg.Vault.Bridge
}
return &config.BridgeConfig{
Port: 9786,
AutoGenPort: false,
}
}
// saveBridgeConfig persists the bridge config to disk.
func (a *App) saveBridgeConfig(appCfg *config.AppConfig, bc *config.BridgeConfig) {
if appCfg == nil {
// Load or create fresh
loaded, err := config.LoadAppConfig()
if err != nil || loaded == nil {
loaded = config.DefaultAppConfig()
}
appCfg = loaded
}
appCfg.Vault.Bridge = *bc
if err := config.SaveAppConfig(appCfg); err != nil {
log.Printf("[bridge] save config: %v", err)
}
}
// BridgeInfo returns the current bridge server status.
func (a *App) BridgeInfo() map[string]interface{} {
info := map[string]interface{}{
"running": false,
"port": 0,
}
if a.bridge != nil {
info["running"] = a.bridge.Running()
info["port"] = a.bridge.Port()
}
return info
}
// bridgeToBrowser converts a bridge.Event to a browser.Event.
func bridgeToBrowser(ev bridge.Event) browser.Event {
return browser.Event{
ID: ev.ID,
DeviceID: ev.DeviceID,
Type: ev.Type,
URL: ev.URL,
Title: ev.Title,
Domain: ev.Domain,
ActiveSeconds: ev.ActiveSeconds,
TSStart: ev.TSStart,
TSEnd: ev.TSEnd,
TS: ev.TS,
SelectedText: ev.SelectedText,
Note: ev.Note,
}
}
// RestartBridge stops and restarts the bridge server with current config.
func (a *App) RestartBridge() error {
// Stop existing server outside the lock to avoid blocking other bindings.
a.mu.Lock()
oldBridge := a.bridge
a.bridge = nil
a.mu.Unlock()
if oldBridge != nil {
oldBridge.Stop()
}
// Load config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
bc := a.bridgeConfig(appCfg)
if !bc.Enabled {
return nil
}
handler := func(events []bridge.Event) {
be := make([]browser.Event, 0, len(events))
for _, ev := range events {
be = append(be, bridgeToBrowser(ev))
}
n, err := a.browser.InsertEvents(be)
if err != nil {
log.Printf("[bridge] store events: %v", err)
return
}
if n > 0 {
log.Printf("[bridge] stored %d/%d events", n, len(be))
}
}
srv := bridge.NewServer(bc.Secret, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
AutoGenPort: bc.AutoGenPort,
Secret: bc.Secret,
})
if err != nil {
return fmt.Errorf("bridge restart: %w", err)
}
a.mu.Lock()
a.bridge = srv
a.mu.Unlock()
log.Printf("[bridge] restarted on port %d", port)
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"log"
"verstak/internal/core/browser"
)
// ListBrowserEvents returns staged browser events, optionally filtered by status.
func (a *App) ListBrowserEvents(status string, limit, offset int) ([]browser.Event, error) {
if status == "" || status == "all" {
return a.browser.ListAll(limit, offset)
}
if status == "pending" {
return a.browser.ListPending(limit, offset)
}
return a.browser.ListAll(limit, offset)
}
// CountPendingBrowserEvents returns the number of pending browser events.
func (a *App) CountPendingBrowserEvents() (int, error) {
return a.browser.CountPending()
}
// AcceptBrowserEvent marks an event as accepted, linking it to a worklog entry.
func (a *App) AcceptBrowserEvent(eventID, worklogID string) error {
log.Printf("[browser] accept event %s -> worklog %s", eventID, worklogID)
return a.browser.Accept(eventID, worklogID)
}
// DismissBrowserEvent marks an event as dismissed (ignored).
func (a *App) DismissBrowserEvent(eventID string) error {
log.Printf("[browser] dismiss event %s", eventID)
return a.browser.Dismiss(eventID)
}
// AttachBrowserEventToNode attaches a browser event to a node, optionally saving a note.
func (a *App) AttachBrowserEventToNode(eventID, nodeID string) error {
log.Printf("[browser] attach event %s -> node %s", eventID, nodeID)
return a.browser.Attach(eventID, nodeID)
}
+76
View File
@@ -8,6 +8,7 @@ import (
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/browser"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
@@ -18,6 +19,7 @@ import (
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/vault"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
@@ -197,6 +199,14 @@ func (a *App) initVault(vaultPath string) error {
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
pm := plugins.NewManager(abs)
pm.Services = &plugins.CoreServices{
NodeRepo: nodeRepo,
DB: db,
ActivitySvc: activitySvc,
WorklogSvc: worklogSvc,
FilesSvc: fileSvc,
VaultPath: abs,
}
pm.Discover()
templatesReg := templates.NewRegistry()
@@ -218,6 +228,9 @@ func (a *App) initVault(vaultPath string) error {
}
}
// Apply installed/enabled state from config to discovered plugins
pm.SyncConfig(appCfg)
// Sync service
deviceID := ""
_ = appCfg // will store sync settings
@@ -229,6 +242,29 @@ func (a *App) initVault(vaultPath string) error {
}
syncSvc := syncsvc.NewService(db, deviceID)
// File watcher service
watcherSvc := watcher.NewService(abs, nodeRepo, fileSvc, activitySvc)
// Determine if real-time watching is enabled.
// Priority: CLI --no-watcher > env VERSTAK_NO_WATCHER > config file > default (true)
fileWatcherEnabled := true
if appCfg != nil && appCfg.Vault.FileWatcher != nil {
fileWatcherEnabled = *appCfg.Vault.FileWatcher
}
// Env override
if os.Getenv("VERSTAK_NO_WATCHER") == "1" {
fileWatcherEnabled = false
log.Println("[watcher] disabled by VERSTAK_NO_WATCHER=1")
}
// CLI override
for _, arg := range os.Args[1:] {
if arg == "--no-watcher" {
fileWatcherEnabled = false
log.Println("[watcher] disabled by --no-watcher")
break
}
}
a.mu.Lock()
a.db = db
a.nodes = nodeRepo
@@ -241,13 +277,36 @@ func (a *App) initVault(vaultPath string) error {
a.plugins = pm
a.templates = templatesReg
a.sync = syncSvc
a.fileWatcher = watcherSvc
a.browser = browser.NewStore(db)
a.vault = abs
a.vaultOpen = true
a.mu.Unlock()
// Snapshot scan (always runs). Real-time watcher depends on config.
scanResult, err := watcherSvc.Start(fileWatcherEnabled)
if err != nil {
log.Printf("[watcher] start error: %v", err)
} else {
log.Printf("[watcher] snapshot: %d missing, %d restored, %d modified, %d new",
scanResult.MissingFiles, scanResult.RestoredFiles, scanResult.ModifiedFiles, scanResult.NewFiles)
}
// Start auto-sync loop
go a.autoSyncLoop()
// Start plugin runtimes for enabled plugins (creates VM, loads scripts, calls on_init)
pm.InitRuntimes()
pm.CallInitHooks()
pm.StartSchedulers()
// Start bridge server for browser extension integration (if enabled).
if appCfg == nil || appCfg.Vault.Bridge.Enabled {
a.startBridge(appCfg)
} else {
log.Println("[bridge] disabled by config")
}
return nil
}
@@ -258,6 +317,20 @@ func (a *App) closeVault() {
if !a.vaultOpen {
return
}
// Stop file watcher first.
if a.fileWatcher != nil {
a.fileWatcher.Stop()
}
// Stop plugin runtimes (schedulers → on_shutdown → close VMs)
if a.plugins != nil {
a.plugins.StopSchedulers()
a.plugins.CallShutdownHooks()
a.plugins.CloseRuntimes()
}
// Stop bridge server.
if a.bridge != nil {
a.bridge.Stop()
}
if a.db != nil {
a.db.Close()
}
@@ -272,6 +345,9 @@ func (a *App) closeVault() {
a.plugins = nil
a.templates = nil
a.sync = nil
a.fileWatcher = nil
a.bridge = nil
a.browser = nil
a.vault = ""
a.vaultOpen = false
}
+2
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
@@ -13,6 +14,7 @@ func (a *App) WriteDebugLog(msg string) {
if !a.IsReady() {
return
}
log.Printf("[js] %s", msg)
logPath := filepath.Join(a.vault, ".verstak", "debug.log")
line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), msg)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+55 -4
View File
@@ -1,6 +1,10 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
@@ -42,24 +46,33 @@ func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
}
result := make([]FileTreeItemDTO, 0, len(children))
for i := range children {
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
typ := children[i].Type
if typ != nodes.TypeFolder && typ != nodes.TypeFile && typ != nodes.TypeNote {
continue
}
item := FileTreeItemDTO{
ID: children[i].ID,
Name: children[i].Title,
Type: children[i].Type,
Type: typ,
}
if children[i].Type == nodes.TypeFolder {
if typ == nodes.TypeFolder {
kids, _ := a.nodes.ListChildren(children[i].ID, false)
item.HasKids = len(kids) > 0
} else if children[i].Type == nodes.TypeFile {
item.Mime = "inode/directory"
} else if typ == nodes.TypeFile {
records, _ := a.files.ListByNode(children[i].ID)
if len(records) > 0 {
item.FileID = records[0].ID
item.Size = records[0].Size
item.Mime = records[0].MIME
}
} else if typ == nodes.TypeNote {
records, _ := a.files.ListByNode(children[i].ID)
if len(records) > 0 {
item.FileID = records[0].ID
item.Size = records[0].Size
item.Mime = "text/markdown"
}
}
result = append(result, item)
}
@@ -159,6 +172,44 @@ func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
fileRec, err := a.files.Get(fileID)
if err != nil {
return nil, fmt.Errorf("get file: %w", err)
}
name := strings.ToLower(fileRec.Filename)
isMD := strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown")
if !isMD {
return &PreflightFileAction{Action: "external", FileName: fileRec.Filename}, nil
}
// .md file — check for linked note
noteRec, err := a.notes.FindByFileID(fileID)
if err == nil && noteRec != nil {
noteNode, nodeErr := a.nodes.Get(noteRec.NodeID)
title := fileRec.Filename
if nodeErr == nil && noteNode != nil {
title = noteNode.Title
}
return &PreflightFileAction{Action: "note", NoteID: noteRec.NodeID, NoteTitle: title, FileName: fileRec.Filename}, nil
}
// .md inside Notes/ with no note record — auto-link
pathLower := strings.ToLower(fileRec.Path)
insideNotes := strings.Contains(pathLower, string(filepath.Separator)+"notes"+string(filepath.Separator)) ||
strings.HasPrefix(pathLower, "notes"+string(filepath.Separator))
if insideNotes {
noteNode, nodeErr := a.nodes.Get(fileRec.NodeID)
if nodeErr == nil && noteNode != nil {
_ = a.notes.LinkFile(noteNode.ID, fileID, "markdown")
return &PreflightFileAction{Action: "note", NoteID: noteNode.ID, NoteTitle: noteNode.Title, FileName: fileRec.Filename}, nil
}
}
// .md outside Notes/ — internal preview
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil {
return nil, err
+5 -3
View File
@@ -127,9 +127,11 @@ func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt
now := time.Now().UTC().Format(time.RFC3339)
id := util.UUID7()
if _, err := a.db.Exec(
`INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now); err != nil {
`INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at,
title_lower,url_lower,hostname_lower,note_lower)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now,
strings.ToLower(title), strings.ToLower(rawURL), strings.ToLower(hostnameForURL(rawURL)), strings.ToLower(note)); err != nil {
return nil, err
}
return a.getLink(id)
+48 -32
View File
@@ -10,6 +10,7 @@ import (
"verstak/internal/core/activity"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/notes"
"verstak/internal/core/templates"
"verstak/internal/core/util"
)
@@ -172,8 +173,43 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
}
}
// 1. Create default folders FIRST so Notes/ exists before default files.
var notesFolderID string
for _, folderName := range tmpl.DefaultFolders {
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
if folderSeg == "" {
folderSeg = "folder"
}
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
if childErr != nil {
rollbackChildren()
return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr)
}
created = append(created, childInfo{id: childNode.ID, file: false})
if folderName == notes.NotesFolder {
notesFolderID = childNode.ID
}
childFsPath := folderSeg
if fsPath != "" {
childFsPath = filepath.Join(fsPath, folderSeg)
}
childPhysPath := filepath.Join(a.vault, childFsPath)
childPhysPath = templates.UniquePath(childPhysPath)
childRel, _ := filepath.Rel(a.vault, childPhysPath)
childFsPath = childRel
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
rollbackChildren()
return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err)
}
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
}
// 2. Create default files, placing them under Notes folder when it exists.
for _, df := range tmpl.DefaultFiles {
fpath := filepath.Join(physPath, df.Path)
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)
@@ -182,7 +218,12 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
if fileTitle == "" {
fileTitle = "Overview"
}
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "")
parentForFile := &n.ID
if notesFolderID != "" {
parentForFile = &notesFolderID
}
childNode, childErr := a.nodes.Create(parentForFile, nodes.TypeNote, fileTitle, 0, "", "")
if childErr != nil {
rollbackChildren()
return nil, fmt.Errorf("create child node for %s: %w", df.Path, childErr)
@@ -209,9 +250,13 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
childNode.ID, fileID, "markdown")
_ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
parentIDForSync := n.ID
if notesFolderID != "" {
parentIDForSync = notesFolderID
}
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
"node_id": childNode.ID,
"parent_id": n.ID,
"parent_id": parentIDForSync,
"title": fileTitle,
"file_id": fileID,
"format": "markdown",
@@ -223,35 +268,6 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
})
}
for _, folderName := range tmpl.DefaultFolders {
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
if folderSeg == "" {
folderSeg = "folder"
}
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
if childErr != nil {
rollbackChildren()
return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr)
}
created = append(created, childInfo{id: childNode.ID, file: false})
childFsPath := folderSeg
if fsPath != "" {
childFsPath = filepath.Join(fsPath, folderSeg)
}
childPhysPath := filepath.Join(a.vault, childFsPath)
childPhysPath = templates.UniquePath(childPhysPath)
childRel, _ := filepath.Rel(a.vault, childPhysPath)
childFsPath = childRel
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
rollbackChildren()
return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err)
}
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
}
pid := ""
if parentID != "" {
pid = parentID
+77 -4
View File
@@ -4,6 +4,7 @@ import (
"time"
"verstak/internal/core/activity"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
)
@@ -12,19 +13,65 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
children, err := a.nodes.ListChildren(nodeID, false)
// Return empty for non-notes-capable parents.
if !a.notes.SupportsNotes(nodeID) {
return []NodeDTO{}, nil
}
// Try the canonical layout: notes live under a "Notes" folder.
// Also fall back to direct TypeNote children so that notes placed
// directly by AssignInboxNode / old layout are still visible.
notesFolder := a.notes.FindNotesFolder(nodeID)
// Direct children (old layout / inbox-assigned notes)
directChildren, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
seen := map[string]bool{}
var result []NodeDTO
for i := range children {
if children[i].Type == nodes.TypeNote {
result = append(result, toNodeDTO(&children[i]))
processNote := func(n nodes.Node) {
if n.Type == nodes.TypeNote && !seen[n.ID] {
seen[n.ID] = true
result = append(result, toNodeDTO(&n))
}
}
if notesFolder != nil {
// Canonical layout: notes inside the Notes folder
notesChildren, err := a.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
return nil, err
}
for i := range notesChildren {
processNote(notesChildren[i])
}
}
// Also include direct TypeNote children (old / inbox-assigned notes)
for i := range directChildren {
processNote(directChildren[i])
}
// Trigger repair in background for old-layout notes
go func() {
if _, err := a.notes.RepairNotesLayout(); err != nil {
// log only
}
}()
return result, nil
}
func (a *App) RepairNotesLayout() (*notes.RepairResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.notes.RepairNotesLayout()
}
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
@@ -67,3 +114,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)
}
+356
View File
@@ -0,0 +1,356 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"verstak/internal/core/config"
)
// PluginDTO represents a discovered plugin with its current state.
type PluginDTO struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author,omitempty"`
Description string `json:"description,omitempty"`
Active bool `json:"active"`
Installed bool `json:"installed"`
HasInstall bool `json:"hasInstall"`
HasPanel bool `json:"hasPanel"`
HasSettings bool `json:"hasSettings"`
UIContribs UIContribDTO `json:"uiContribs"`
}
// UIContribDTO describes what a plugin adds to the UI.
type UIContribDTO struct {
SidebarItems []SidebarItemDTO `json:"sidebarItems"`
NodeTabs []NodeTabDTO `json:"nodeTabs"`
}
type SidebarItemDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
}
type NodeTabDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Page string `json:"page"`
}
// ListPlugins returns all discovered plugins with their current enabled/disabled state.
func (a *App) ListPlugins() []PluginDTO {
if a.plugins == nil {
return nil
}
all := a.plugins.Plugins()
out := make([]PluginDTO, 0, len(all))
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
enabledSet[name] = true
}
}
for _, p := range all {
active := enabledSet[p.Meta.Name] || p.Active
contribs := UIContribDTO{}
for _, item := range p.Meta.UI.SidebarItems {
contribs.SidebarItems = append(contribs.SidebarItems, SidebarItemDTO{
ID: item.ID,
Label: item.Label,
Icon: item.Icon,
})
}
for _, tab := range p.Meta.UI.NodeTabs {
contribs.NodeTabs = append(contribs.NodeTabs, NodeTabDTO{
ID: tab.ID,
Label: tab.Label,
Page: tab.Page,
})
}
hasPanel := false
if p.Meta.Panel != "" {
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
if _, err := os.Stat(panelPath); err == nil {
hasPanel = true
}
}
out = append(out, PluginDTO{
Name: p.Meta.Name,
Version: p.Meta.Version,
Author: p.Meta.Author,
Description: p.Meta.Description,
Active: active,
Installed: p.Installed,
HasInstall: p.HasInstall,
HasPanel: hasPanel,
UIContribs: contribs,
})
}
return out
}
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
// Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
// Disable: deactivates runtime, then marks plugin as disabled in config.
func (a *App) SetPluginEnabled(name string, enabled bool) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
if enabled {
// Enable first (sets Enabled=true on the plugin struct)
if err := a.plugins.Enable(name); err != nil {
return err
}
// Activate runtime — if this fails, do NOT persist to config
if err := a.plugins.ActivatePlugin(name); err != nil {
// Rollback: deactivate runtime AND un-enable in-memory state
a.plugins.DeactivatePlugin(name)
a.plugins.Disable(name)
return fmt.Errorf("activate %q: %w", name, err)
}
// Only persist to config after successful activation
if err := a.saveEnabledPlugin(name); err != nil {
// Config save failed — rollback runtime too
a.plugins.DeactivatePlugin(name)
a.plugins.Disable(name)
return fmt.Errorf("save config for %q: %w", name, err)
}
} else {
// Deactivate runtime first, then disable
a.plugins.DeactivatePlugin(name)
if err := a.plugins.Disable(name); err != nil {
return err
}
// Remove from config
if err := a.removeEnabledPlugin(name); err != nil {
return fmt.Errorf("save config for %q: %w", name, err)
}
}
return nil
}
// saveEnabledPlugin adds name to EnabledPlugins in config and saves.
func (a *App) saveEnabledPlugin(name string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
for _, n := range appCfg.EnabledPlugins {
if n == name {
return nil // already present
}
}
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
return config.SaveAppConfig(appCfg)
}
// removeEnabledPlugin removes name from EnabledPlugins in config and saves.
func (a *App) removeEnabledPlugin(name string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil // nothing to remove
}
var updated []string
for _, n := range appCfg.EnabledPlugins {
if n != name {
updated = append(updated, n)
}
}
appCfg.EnabledPlugins = updated
return config.SaveAppConfig(appCfg)
}
// GetPluginPanelHTML returns the HTML panel content for a plugin.
// Validates that the panel path is safe: no absolute paths, no .. traversal,
// must be within the plugin directory, and must end with .html.
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready")
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
enabledSet[name] = true
}
}
for _, p := range a.plugins.Plugins() {
active := enabledSet[p.Meta.Name] || p.Active
if p.Meta.Name != pluginName || !active {
continue
}
if p.Meta.Panel == "" {
return "", nil
}
// Validate panel path: must be relative, no .., within plugin dir, .html only
panel := p.Meta.Panel
if filepath.IsAbs(panel) {
return "", fmt.Errorf("panel path %q must be relative", panel)
}
if strings.Contains(panel, "..") {
return "", fmt.Errorf("panel path %q must not contain ..", panel)
}
if !strings.HasSuffix(strings.ToLower(panel), ".html") {
return "", fmt.Errorf("panel path %q must end with .html", panel)
}
// Resolve and verify the path is within the plugin directory
panelPath := filepath.Join(p.Dir, panel)
absPanel, err := filepath.Abs(panelPath)
if err != nil {
return "", fmt.Errorf("resolve panel path: %w", err)
}
absDir, err := filepath.Abs(p.Dir)
if err != nil {
return "", fmt.Errorf("resolve plugin dir: %w", err)
}
if !strings.HasPrefix(absPanel, absDir+string(filepath.Separator)) {
return "", fmt.Errorf("panel path %q escapes plugin directory", panel)
}
data, err := os.ReadFile(absPanel)
if err != nil {
return "", fmt.Errorf("read panel %s: %w", panel, err)
}
return string(data), nil
}
return "", nil
}
// ListSystemViewsWithPlugins returns system views + plugin sidebar items.
func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
base := a.ListSystemViews()
if a.plugins == nil {
return base
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
enabledSet[name] = true
}
}
for _, p := range a.plugins.Plugins() {
active := enabledSet[p.Meta.Name] || p.Active
if !active {
continue
}
for _, item := range p.Meta.UI.SidebarItems {
pageID := "plugin:" + p.Meta.Name + ":" + item.ID
base = append(base, SystemViewDTO{
ID: pageID,
Label: item.Label,
Icon: item.Icon,
})
}
}
return base
}
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// CallPluginFunction calls a global Lua function on an active plugin.
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
// Only alphanumeric identifiers with underscores are allowed (no Lua injection).
// Returns JSON string or error.
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready")
}
// Validate funcName: only [a-zA-Z0-9_.]+ allowed, each segment must be valid ident
if funcName == "" {
return "", fmt.Errorf("funcName is empty")
}
segments := strings.Split(funcName, ".")
if len(segments) > 3 {
return "", fmt.Errorf("funcName %q too deep (max 2 dots)", funcName)
}
for _, seg := range segments {
if !validLuaIdent.MatchString(seg) {
return "", fmt.Errorf("funcName %q contains invalid segment %q", funcName, seg)
}
}
for _, p := range a.plugins.Plugins() {
if p.Meta.Name != pluginName || !p.Active {
continue
}
vm := p.VM()
if vm == nil {
continue
}
log.Printf("[plugins] CallPluginFunction: %s.%s active=true, calling Lua...", pluginName, funcName)
// Call via fully thread-safe LuaVM.CallFunctionJSON
// (JSON→Lua conversion happens under vm.mu)
result, err := vm.CallFunctionJSON(segments, paramsJSON)
if err != nil {
log.Printf("[plugins] CallPluginFunction: %s.%s ERROR: %v", pluginName, funcName, err)
return "", err
}
log.Printf("[plugins] CallPluginFunction: %s.%s OK (%d bytes)", pluginName, funcName, len(result))
return result, nil
}
log.Printf("[plugins] CallPluginFunction: %s.%s NOT FOUND or inactive", pluginName, funcName)
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
}
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
func (a *App) ReloadPlugins() error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
log.Print("[plugins] reload requested")
// Fully stop runtimes: schedulers first (they depend on VMs), then VMs
a.plugins.StopSchedulers()
a.plugins.CallShutdownHooks()
a.plugins.CloseRuntimes()
a.plugins.Discover()
appCfg, _ := config.LoadAppConfig()
a.plugins.SyncConfig(appCfg)
a.plugins.InitRuntimes()
a.plugins.CallInitHooks()
a.plugins.StartSchedulers()
log.Print("[plugins] reload complete")
return nil
}
// InstallPlugin creates plugin's database tables and defaults via on_install hook.
// Does NOT activate the plugin — use SetPluginEnabled after.
func (a *App) InstallPlugin(name string) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
return a.plugins.Install(name)
}
// UninstallPlugin drops plugin's database tables and cleans data via on_uninstall hook.
// Disables the plugin first if active. Does NOT delete plugin files from disk.
func (a *App) UninstallPlugin(name string) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
return a.plugins.Uninstall(name)
}
+119 -3
View File
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"verstak/internal/core/config"
"verstak/internal/core/nodes"
@@ -262,25 +263,140 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" {
return []SearchResultDTO{}, nil
}
out := []SearchResultDTO{}
seen := map[string]bool{}
add := func(r SearchResultDTO) {
if r.Title == "" {
return
}
key := r.Type + ":" + r.NodeID + ":" + r.TargetID + ":" + r.Title
if seen[key] {
return
}
seen[key] = true
out = append(out, r)
}
results, err := a.search.Search(query)
if err != nil {
return nil, err
}
out := make([]SearchResultDTO, len(results))
for i, r := range results {
out[i] = SearchResultDTO{
for _, r := range results {
path := r.Path
if path == "" && r.NodeID != "" {
path = a.nodes.Path(r.NodeID)
}
add(SearchResultDTO{
NodeID: r.NodeID,
Title: r.Title,
Snippet: r.Snippet,
Type: r.Type,
Path: path,
})
}
if len(out) < 20 {
nodes, err := a.nodes.Search(query, 20-len(out))
if err == nil {
for i := range nodes {
if nodes[i].IsDeleted() {
continue
}
add(SearchResultDTO{
NodeID: nodes[i].ID,
Title: nodes[i].Title,
Type: nodes[i].Type,
Path: a.nodes.Path(nodes[i].ID),
})
}
}
}
if len(out) < 20 {
rows, err := a.db.Query(
`SELECT l.id,l.node_id,l.title,l.url,l.hostname,COALESCE(l.note,''),n.deleted_at
FROM links l
LEFT JOIN nodes n ON n.id = l.node_id
WHERE n.deleted_at IS NULL
AND (l.title_lower LIKE ? OR l.url_lower LIKE ? OR l.hostname_lower LIKE ? OR l.note_lower LIKE ?)
ORDER BY l.created_at DESC
LIMIT ?`,
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
if err == nil {
defer rows.Close()
for rows.Next() {
var id, nodeID, title, url, hostname, note string
var deletedAt interface{}
if err := rows.Scan(&id, &nodeID, &title, &url, &hostname, &note, &deletedAt); err != nil {
return nil, err
}
snippet := url
if note != "" {
snippet = note
}
add(SearchResultDTO{
NodeID: nodeID,
TargetID: id,
Title: title,
Snippet: snippet,
Type: "link",
Path: a.nodes.Path(nodeID),
URL: url,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
}
}
if len(out) < 20 {
rows, err := a.db.Query(
`SELECT ac.id,ac.node_id,ac.title,ac.kind,COALESCE(ac.url,''),COALESCE(ac.command,''),n.deleted_at
FROM actions ac
LEFT JOIN nodes n ON n.id = ac.node_id
WHERE n.deleted_at IS NULL
AND (ac.title_lower LIKE ? OR ac.kind_lower LIKE ? OR ac.url_lower LIKE ? OR ac.command_lower LIKE ?)
ORDER BY ac.created_at DESC
LIMIT ?`,
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
if err == nil {
defer rows.Close()
for rows.Next() {
var id, nodeID, title, kind, url, command string
var deletedAt interface{}
if err := rows.Scan(&id, &nodeID, &title, &kind, &url, &command, &deletedAt); err != nil {
return nil, err
}
snippet := url
if snippet == "" {
snippet = command
}
add(SearchResultDTO{
NodeID: nodeID,
TargetID: id,
Title: title,
Snippet: snippet,
Type: "action",
Path: a.nodes.Path(nodeID),
})
}
if err := rows.Err(); err != nil {
return nil, err
}
}
}
return out, nil
}
func likeQuery(query string) string {
return "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
}
+211 -11
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
"verstak/internal/core/nodes"
)
type TrashDTO struct {
@@ -18,13 +20,14 @@ type TrashDTO struct {
}
type TrashNodeDTO struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Title string `json:"title"`
Type string `json:"type"`
FsPath string `json:"fsPath"`
NodePath string `json:"nodePath"`
DeletedAt string `json:"deletedAt"`
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Title string `json:"title"`
Type string `json:"type"`
FsPath string `json:"fsPath"`
NodePath string `json:"nodePath"`
TrashFsPath string `json:"trashFsPath"`
DeletedAt string `json:"deletedAt"`
}
type TrashEntryDTO struct {
@@ -45,8 +48,13 @@ func (a *App) ListTrash() (*TrashDTO, error) {
if err != nil {
return nil, err
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
// Phase 1: build all DTOs and compute direct trash paths for folder-type nodes.
nodeMap := make(map[string]*TrashNodeDTO, len(deleted))
var allDeleted []nodes.Node
for _, n := range deleted {
allDeleted = append(allDeleted, n)
}
for _, n := range allDeleted {
deletedAt := ""
if n.DeletedAt != nil {
deletedAt = n.DeletedAt.Format(time.RFC3339)
@@ -55,7 +63,7 @@ func (a *App) ListTrash() (*TrashDTO, error) {
if n.ParentID != nil {
parentID = *n.ParentID
}
nodes = append(nodes, TrashNodeDTO{
dto := &TrashNodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
@@ -63,7 +71,59 @@ func (a *App) ListTrash() (*TrashDTO, error) {
FsPath: n.FsPath,
NodePath: a.nodes.Path(n.ID),
DeletedAt: deletedAt,
})
}
nodeMap[n.ID] = dto
// Try direct trash entry (for folders that were os.Rename'd).
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
dto.TrashFsPath = p
} else if recs, recErr := a.files.ListTrashedByNode(n.ID); recErr == nil && len(recs) > 0 {
// Try file records (for TypeFile nodes whose files were individually moved).
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
if _, stErr := os.Stat(candidate); stErr == nil {
dto.TrashFsPath = candidate
break
}
}
}
}
// Phase 2: propagate trash paths from parents to children that have no direct entry
// but whose FsPath starts with the parent's FsPath.
changed := true
for changed {
changed = false
for _, n := range allDeleted {
dto := nodeMap[n.ID]
if dto.TrashFsPath != "" {
continue
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
if parentID == "" {
continue
}
parent := nodeMap[parentID]
if parent == nil || parent.TrashFsPath == "" {
continue
}
// Child inherits parent's trash path + relative FsPath fragment.
if n.FsPath != "" && parent.FsPath != "" && strings.HasPrefix(n.FsPath, parent.FsPath) {
rel := strings.TrimPrefix(n.FsPath, parent.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
dto.TrashFsPath = filepath.Join(parent.TrashFsPath, rel)
changed = true
}
}
}
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
for _, dto := range nodeMap {
nodes = append(nodes, *dto)
}
entries, err := listTrashEntries(trashPath)
@@ -186,11 +246,39 @@ func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
if fsPath == "" {
// TypeFile node — restore file records that were marked missing=1.
recs, err := a.files.ListTrashedByNode(nodeID)
if err != nil {
return err
}
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
if _, err := os.Stat(trashPath); os.IsNotExist(err) {
continue // already restored via parent dir
}
dst := filepath.Join(a.vault, r.Path)
rel, rErr := filepath.Rel(a.vault, dst)
if rErr != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("path safety: %s", r.Path)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
if err := os.Rename(trashPath, dst); err != nil {
return fmt.Errorf("restore file %s: %w", r.ID, err)
}
if _, err := a.db.Exec("UPDATE files SET missing=0, updated_at=? WHERE id=?", nowStr(), r.ID); err != nil {
return err
}
}
return nil
}
// Directory-type node — move the whole directory back from trash.
trashEntry, err := a.findTrashEntryForNode(nodeID)
if err != nil {
return nil
return nil // parent may have already been restored
}
dst := filepath.Join(a.vault, fsPath)
if _, err := os.Stat(dst); err == nil {
@@ -202,6 +290,10 @@ func (a *App) restoreTrashPath(nodeID, fsPath string) error {
return os.Rename(trashEntry, dst)
}
func nowStr() string {
return time.Now().UTC().Format(time.RFC3339)
}
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
trashPath := filepath.Join(a.vault, ".verstak", "trash")
entries, err := os.ReadDir(trashPath)
@@ -223,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 {
@@ -298,6 +400,104 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
return out, nil
}
// ReadTrashFile reads a trash file by its absolute filesystem path.
// This is preferred over ReadTrashFileContent (which re-resolves by nodeID)
// because the frontend already has the precomputed trashFsPath from ListTrash.
func (a *App) ReadTrashFile(trashFsPath string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
data, err := os.ReadFile(trashFsPath)
if err != nil {
return "", err
}
return string(data), nil
}
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
path, err := a.resolveTrashPath(nodeID)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// resolveTrashPath finds the physical path of a deleted node's file in the trash.
// For directly-moved entries (directory-type nodes), it looks up <nodeID>_* in
// the trash dir. For file-type nodes it searches by file record. For nested
// files (moved inside a parent folder) it walks up the ancestor chain.
func (a *App) resolveTrashPath(nodeID string) (string, error) {
// 1. Try direct lookup first (for directory-type nodes).
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
return p, nil
}
// 2. Try file records (for TypeFile nodes whose files were individually moved).
recs, err := a.files.ListTrashedByNode(nodeID)
if err == nil {
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
if info, stErr := os.Stat(candidate); stErr == nil && !info.IsDir() {
return candidate, nil
}
}
}
// 3. Walk parent chain to find nearest ancestor with a direct trash entry
// (for files nested inside a deleted parent directory).
type step struct {
ID string
FsPath string
Title string
}
var chain []step
current := nodeID
for current != "" {
n, err := a.nodes.Get(current)
if err != nil {
break
}
chain = append(chain, step{ID: n.ID, FsPath: n.FsPath, Title: n.Title})
if n.ParentID != nil {
current = *n.ParentID
} else {
current = ""
}
}
for i := 0; i < len(chain); i++ {
anc := chain[i]
ancPath, err := a.findTrashEntryForNode(anc.ID)
if err != nil {
continue
}
// Path 3a: compute relative path from ancestor's FsPath to target's FsPath.
if chain[0].FsPath != "" && anc.FsPath != "" && strings.HasPrefix(chain[0].FsPath, anc.FsPath) {
rel := strings.TrimPrefix(chain[0].FsPath, anc.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
fullPath := filepath.Join(ancPath, rel)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
return fullPath, nil
}
}
}
// Path 3b: try node title as a direct child inside the ancestor dir.
candidate := filepath.Join(ancPath, chain[0].Title)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("trash file not found for node %s", nodeID)
}
func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil {
return err
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"fmt"
"verstak/internal/core/config"
"verstak/internal/core/watcher"
)
// WatcherStatus returns whether the real-time file watcher is active.
func (a *App) WatcherStatus() (bool, error) {
if err := a.requireVault(); err != nil {
return false, err
}
return a.fileWatcher.IsWatching(), nil
}
// RunSnapshotScan performs a one-shot scan and returns results.
func (a *App) RunSnapshotScan() (*watcher.SnapshotResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.fileWatcher.RunScanner()
}
// ToggleFileWatcher enables or disables the real-time file watcher.
// Changing this persists to app config (~/.config/verstak/config.json → vault.file_watcher).
//
// При включении: запускает snapshot scan (сверка диска с БД), затем включает real-time watcher.
// При отключении: останавливает fsnotify, watcher_state в БД сохраняется.
//
// Отключить watcher НАВСЕГДА (независимо от галки):
// export VERSTAK_NO_WATCHER=1
//
// Отключить на один запуск:
// verstak-gui --no-watcher
//
// Snapshot scan запускается ВСЕГДА при открытии vault, даже при FileWatcher=false.
func (a *App) ToggleFileWatcher(enable bool) error {
if err := a.requireVault(); err != nil {
return err
}
cfg, err := config.LoadAppConfig()
if err != nil || cfg == nil {
return fmt.Errorf("config: %w", err)
}
cfg.Vault.FileWatcher = config.BoolPtr(enable)
if err := config.SaveAppConfig(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
if enable {
_, err := a.fileWatcher.RunScanner()
if err != nil {
return fmt.Errorf("scan: %w", err)
}
if _, err := a.fileWatcher.Start(true); err != nil {
return fmt.Errorf("start watcher: %w", err)
}
} else {
a.fileWatcher.Stop()
}
return nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-DNvOo-CH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CXWRKHDk.css">
<script type="module" crossorigin src="/assets/main-C0D__Sxo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css">
</head>
<body>
<div id="app"></div>
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
)
// TestSetPluginEnabled_BrokenPlugin_Rollback verifies that when activation fails,
// the plugin is fully rolled back: not Active, not Enabled, and not in config.
func TestSetPluginEnabled_BrokenPlugin_Rollback(t *testing.T) {
// Isolate global config to a temp dir so we don't pollute the user's real config
tmpCfgDir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(tmpCfgDir, 0o750); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", tmpCfgDir)
vaultRoot := t.TempDir()
// Create .verstak/plugins/ structure
pluginsDir := filepath.Join(vaultRoot, ".verstak", "plugins")
if err := os.MkdirAll(pluginsDir, 0o750); err != nil {
t.Fatalf("mkdir plugins: %v", err)
}
// Create a broken plugin: valid Lua but invalid background task interval
brokenDir := filepath.Join(pluginsDir, "broken")
if err := os.MkdirAll(brokenDir, 0o750); err != nil {
t.Fatalf("mkdir broken plugin: %v", err)
}
// plugin.json with an invalid background task interval ("not-a-duration")
pluginJSON := `{
"name": "broken",
"version": "0.1.0",
"hooks": {
"on_install": "on_install",
"on_init": "on_init"
},
"background_tasks": [
{"id": "bad-task", "interval": "not-a-duration", "script": "bad.lua"}
]
}`
if err := os.WriteFile(filepath.Join(brokenDir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
t.Fatalf("write plugin.json: %v", err)
}
// main.lua — on_install is a no-op (tables not needed for this test),
// on_init is harmless. Activation will fail on the invalid background task interval.
mainLua := `
function on_install()
-- no-op
end
function on_init()
-- harmless
end
`
if err := os.WriteFile(filepath.Join(brokenDir, "main.lua"), []byte(mainLua), 0o644); err != nil {
t.Fatalf("write main.lua: %v", err)
}
// Create App with real plugin manager
app := &App{
plugins: plugins.NewManager(vaultRoot),
vault: vaultRoot,
vaultOpen: true,
}
// Discover the plugin
app.plugins.Discover()
// Step 1: Install the plugin (creates tables, marks installed in config)
if err := app.plugins.Install("broken"); err != nil {
t.Fatalf("install broken plugin: %v", err)
}
// Verify plugin is installed
if !app.plugins.IsInstalled("broken") {
t.Fatal("expected plugin to be installed")
}
// Step 2: Try to enable — should fail because background task has invalid interval
err := app.SetPluginEnabled("broken", true)
if err == nil {
t.Fatal("expected SetPluginEnabled to fail for broken plugin, got nil")
}
t.Logf("SetPluginEnabled returned expected error: %v", err)
// Step 3: Verify plugin is NOT Active and NOT Enabled (in-memory rollback)
found := false
for _, p := range app.plugins.Plugins() {
if p.Meta.Name == "broken" {
found = true
if p.Active {
t.Error("expected plugin to NOT be Active after failed activation")
}
if p.Enabled {
t.Error("expected plugin to NOT be Enabled after failed activation (in-memory rollback)")
}
}
}
if !found {
t.Fatal("plugin 'broken' not found in manager")
}
// Step 4: Verify plugin is NOT in EnabledPlugins config
appCfg, err := config.LoadAppConfig()
if err != nil {
t.Fatalf("load app config: %v", err)
}
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
if name == "broken" {
t.Error("expected 'broken' to NOT be in EnabledPlugins config after failed activation")
}
}
}
}
+28 -9
View File
@@ -93,8 +93,9 @@ func TestJournalFullRegression(t *testing.T) {
if s.NodeID == n.ID {
found = true
// Verify events match what we inserted
if len(s.Events) != 3 {
t.Fatalf("suggestion has %d events, want 3", len(s.Events))
// Notes folder creation (from template) adds 1 auto event.
if len(s.Events) != 4 {
t.Fatalf("suggestion has %d events, want 4 (1 auto from Notes creation + 3 manual)", len(s.Events))
}
if len(s.EventIDs) != len(s.Events) {
t.Fatalf("suggestion eventIds (%d) != events (%d)", len(s.EventIDs), len(s.Events))
@@ -194,15 +195,21 @@ func TestSuggestionOnRepeatedActivity(t *testing.T) {
for _, s := range suggestions {
if s.NodeID == n.ID {
found = true
// Only the second event should be suggested
if len(s.Events) != 1 {
t.Fatalf("expected 1 new event, got %d", len(s.Events))
// The first event (Notes folder creation) was not accepted,
// so the suggestion includes it plus the new eid2.
if len(s.Events) < 1 {
t.Fatalf("expected at least 1 new event, got %d", len(s.Events))
}
if s.Events[0].ID != eid2 {
t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID)
// eid2 must be among the suggested events
var hasNew bool
for _, ev := range s.Events {
if ev.ID == eid2 {
hasNew = true
break
}
}
if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 {
t.Errorf("eventIds does not contain the new event")
if !hasNew {
t.Errorf("eid2 not found among suggestion events")
}
}
}
@@ -417,6 +424,18 @@ func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
if err != nil {
t.Fatalf("create node: %v", err)
}
// The template creates a Notes folder, which generates an auto event.
// Dismiss it first so it doesn't interfere with the test.
autoEvts, _ := app.GetSuggestions()
for _, s := range autoEvts {
if s.NodeID == n.ID && len(s.EventIDs) > 0 {
// Dismiss all outstanding auto events for this node
b, _ := json.Marshal(s.EventIDs)
_ = app.DismissSuggestion(n.ID, string(b))
}
}
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки")
if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil {
+305 -4
View File
@@ -136,15 +136,15 @@ func TestTrashCountPurgeAndEmpty(t *testing.T) {
if err != nil {
t.Fatalf("TrashCount: %v", err)
}
if count != 2 {
t.Fatalf("TrashCount = %d, want 2", count)
if count != 4 {
t.Fatalf("TrashCount = %d, want 4 (2 folders + 2 Notes children)", count)
}
if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil {
t.Fatalf("PurgeTrashNodesJSON: %v", err)
}
count, _ = app.TrashCount()
if count != 1 {
t.Fatalf("TrashCount after purge = %d, want 1", count)
if count != 2 {
t.Fatalf("TrashCount after purge = %d, want 2 (1 folder + 1 Notes child)", count)
}
if err := app.EmptyTrash(); err != nil {
t.Fatalf("EmptyTrash: %v", err)
@@ -154,3 +154,304 @@ func TestTrashCountPurgeAndEmpty(t *testing.T) {
t.Fatalf("TrashCount after empty = %d, want 0", count)
}
}
func TestTrashTypeFilePreviewAndRestore(t *testing.T) {
app, vault := setupTestApp(t)
// Create a folder to hold the file.
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
// Create a TypeFile node with a file record.
fileNode, err := app.CreateEmptyFile(parent.ID, "hello.txt")
if err != nil {
t.Fatalf("CreateEmptyFile: %v", err)
}
// Write some content to the physical file via file records.
recs, err := app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
}
record := recs[0]
absPath := filepath.Join(vault, record.Path)
content := "Hello, World!"
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Delete the entire tree.
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
// Verify trash listing has the file node with trashFsPath set.
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var fileTrashNode *TrashNodeDTO
for i, n := range trash.Nodes {
if n.ID == fileNode.ID {
fileTrashNode = &trash.Nodes[i]
break
}
}
if fileTrashNode == nil {
t.Fatalf("file node not found in trash listing")
}
if fileTrashNode.TrashFsPath == "" {
t.Fatalf("file node missing trashFsPath: %+v", fileTrashNode)
}
// Verify ReadTrashFile works with the precomputed path.
readContent, err := app.ReadTrashFile(fileTrashNode.TrashFsPath)
if err != nil {
t.Fatalf("ReadTrashFile: %v", err)
}
if readContent != content {
t.Fatalf("ReadTrashFile content = %q, want %q", readContent, content)
}
// Verify ReadTrashFileContent also works (fallback using file records).
readContent2, err := app.ReadTrashFileContent(fileNode.ID)
if err != nil {
t.Fatalf("ReadTrashFileContent: %v", err)
}
if readContent2 != content {
t.Fatalf("ReadTrashFileContent = %q, want %q", readContent2, content)
}
// Verify trashed file records are still in DB.
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListTrashedByNode: %v", err)
}
if len(trashedRecs) != 1 {
t.Fatalf("ListTrashedByNode = %d records, want 1", len(trashedRecs))
}
if !trashedRecs[0].Missing {
t.Fatalf("expected trashed record to have Missing=true")
}
if trashedRecs[0].Path == "" {
t.Fatalf("expected trashed record to keep original Path")
}
// Restore the file node.
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
t.Fatalf("RestoreTrashNode: %v", err)
}
// Verify the node is active.
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
t.Fatalf("file node not active after restore: %v", err)
}
// Verify the file record is restored (missing=0).
restoredRecs, err := app.files.ListByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListByNode after restore: %v", err)
}
if len(restoredRecs) != 1 {
t.Fatalf("ListByNode after restore = %d records, want 1", len(restoredRecs))
}
if restoredRecs[0].Missing {
t.Fatalf("file record should have Missing=false after restore")
}
// Verify the physical file content is intact.
absRestored := filepath.Join(vault, restoredRecs[0].Path)
restoredBytes, err := os.ReadFile(absRestored)
if err != nil {
t.Fatalf("read restored file: %v", err)
}
if string(restoredBytes) != content {
t.Fatalf("restored file content = %q, want %q", string(restoredBytes), content)
}
// Verify the trash entry is gone (file was moved back).
if _, err := os.Stat(fileTrashNode.TrashFsPath); !os.IsNotExist(err) {
t.Fatalf("trash entry should be gone after restore, err=%v", err)
}
// Parent is also restored because RestoreTrashNode restores the entire
// ancestor chain from the requested node up to the root deleted node.
if _, err := app.nodes.GetActive(parent.ID); err != nil {
t.Fatalf("parent should be active after child restore (ancestor chain): %v", err)
}
}
func TestTrashTypeFileInsideFolderRestorePreservesContent(t *testing.T) {
app, vault := setupTestApp(t)
// Create: parent folder → child TypeFile.
parent, err := app.CreateNodeFromTemplate("", "ProjectX", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
fileNode, err := app.CreateEmptyFile(parent.ID, "data.csv")
if err != nil {
t.Fatalf("CreateEmptyFile: %v", err)
}
// Write content.
recs, err := app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
}
absPath := filepath.Join(vault, recs[0].Path)
content := "a,b,c\n1,2,3"
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Delete the whole tree.
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
// Restore parent. This moves the directory back from trash but does NOT
// restore child nodes (RestoreTrashNode walks ancestor chain, not children).
if err := app.RestoreTrashNode(parent.ID); err != nil {
t.Fatalf("RestoreTrashNode parent: %v", err)
}
// Only the parent should be active.
if _, err := app.nodes.GetActive(parent.ID); err != nil {
t.Fatalf("parent should be active: %v", err)
}
if _, err := app.nodes.GetActive(fileNode.ID); err == nil {
t.Fatalf("child file node should remain deleted (RestoreTrashNode is ancestor-only)")
}
// Parent directory should exist on disk.
if _, err := os.Stat(filepath.Join(vault, "ProjectX")); err != nil {
t.Fatalf("parent directory should exist: %v", err)
}
// Now restore the child file node specifically.
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
t.Fatalf("RestoreTrashNode file: %v", err)
}
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
t.Fatalf("file node should be active: %v", err)
}
// File record should be restored.
recs, err = app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode after restore: %v (len=%d)", err, len(recs))
}
if recs[0].Missing {
t.Fatalf("file record should not be missing after restore")
}
// Physical file content should be intact.
absRestored := filepath.Join(vault, recs[0].Path)
restoredBytes, err := os.ReadFile(absRestored)
if err != nil {
t.Fatalf("read restored file: %v", err)
}
if string(restoredBytes) != content {
t.Fatalf("content = %q, want %q", string(restoredBytes), content)
}
}
func TestTrashTypeFileMultipleRecords(t *testing.T) {
app, vault := setupTestApp(t)
// Create a TypeFile node with two file records.
fileNode, err := app.CreateEmptyFile("", "report.txt")
if err != nil {
t.Fatalf("CreateEmptyFile: %v", err)
}
// Write content to first record.
recs, err := app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
}
absPath1 := filepath.Join(vault, recs[0].Path)
content1 := "version 1"
if err := os.WriteFile(absPath1, []byte(content1), 0o644); err != nil {
t.Fatalf("write file 1: %v", err)
}
// Manually insert a second file record with its own vault file.
now := nowStr()
absPath2 := filepath.Join(vault, "report-v2.txt")
content2 := "version 2"
if err := os.WriteFile(absPath2, []byte(content2), 0o644); err != nil {
t.Fatalf("write file 2: %v", err)
}
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
"second-record-id", fileNode.ID, "report-v2.txt",
"report-v2.txt", "vault", 0, "", "text/plain", now, now)
if err != nil {
t.Fatalf("insert second record: %v", err)
}
// Delete the node.
if err := app.DeleteNode(fileNode.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
// Verify both records are trashed.
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListTrashedByNode: %v", err)
}
if len(trashedRecs) != 2 {
t.Fatalf("expected 2 trashed records, got %d", len(trashedRecs))
}
for _, r := range trashedRecs {
if !r.Missing {
t.Fatalf("record %s should have Missing=true", r.ID)
}
}
// Verify trash listing has trashFsPath set.
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var found bool
for _, n := range trash.Nodes {
if n.ID == fileNode.ID {
found = true
if n.TrashFsPath == "" {
t.Fatalf("trashFsPath should be set for file node with multiple records")
}
break
}
}
if !found {
t.Fatalf("file node not found in trash listing")
}
// Restore.
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
t.Fatalf("RestoreTrashNode: %v", err)
}
// Both records should be restored.
restoredRecs, err := app.files.ListByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListByNode after restore: %v", err)
}
if len(restoredRecs) != 2 {
t.Fatalf("expected 2 restored records, got %d: %+v", len(restoredRecs), restoredRecs)
}
for _, r := range restoredRecs {
if r.Missing {
t.Fatalf("record %s should not be missing", r.ID)
}
}
}
@@ -0,0 +1,462 @@
package main
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
)
// --- Files tab / ListItems tests for Notes folder ---
// TestFileManagerListItemsShowsNotesFolder verifies that ListItems on a
// container returns the Notes folder, matching what the Files tab UI shows.
func TestFileManagerListItemsShowsNotesFolder(t *testing.T) {
app, _ := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// ListItems is what the Files tab actually calls
items, err := app.ListItems(proj.ID)
if err != nil {
t.Fatalf("ListItems: %v", err)
}
var foundNotes bool
for _, item := range items {
if item.Name == notes.NotesFolder && item.Type == "folder" {
foundNotes = true
break
}
}
if !foundNotes {
t.Errorf("ListItems(%q) should contain Notes folder, got %d items", proj.ID, len(items))
for _, item := range items {
t.Logf(" %s (type=%s)", item.Name, item.Type)
}
}
}
// TestFileManagerListItemsInsideNotesShowsOverview verifies that ListItems
// on the Notes folder returns the Overview note.
func TestFileManagerListItemsInsideNotesShowsOverview(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find Notes folder
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
// ListItems inside Notes folder
items, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems(Notes folder): %v", err)
}
var foundOverview bool
for _, item := range items {
if item.Name == "Overview" && item.Type == "note" {
foundOverview = true
if item.FileID == "" {
t.Error("Overview note has empty FileID")
}
if item.Mime == "" {
t.Error("Overview note has empty Mime")
}
break
}
}
if !foundOverview {
t.Errorf("ListItems(Notes) should contain Overview note, got %d items", len(items))
for _, item := range items {
t.Logf(" %s (type=%s)", item.Name, item.Type)
}
}
// Verify no root-level Overview.md on disk
rootPath := filepath.Join(vault, proj.FsPath, "Overview.md")
if _, err := os.Stat(rootPath); err == nil {
t.Error("Overview.md should NOT exist at root level, only in Notes/")
}
// Verify Notes/Overview.md exists on disk
notesPath := filepath.Join(vault, proj.FsPath, notes.NotesFolder, "Overview.md")
if _, err := os.Stat(notesPath); os.IsNotExist(err) {
t.Error("Overview.md should exist at Notes/Overview.md")
}
}
// TestRepairMovesDirectNoteChildrenToNotesFolder verifies that a note
// created as a direct child of a container (old layout) is moved into
// the Notes folder by RepairNotesLayout, and that ListItems then shows
// the note under Notes/ not as a direct child.
func TestRepairMovesDirectNoteChildrenToNotesFolder(t *testing.T) {
app, vault := setupTestApp(t)
// Create a container that supports notes
parent, err := app.CreateNodeFromTemplate("", "TestCase", "project.default")
if err != nil {
t.Fatalf("create container: %v", err)
}
// Simulate old layout: create TypeNote as direct child (not under Notes/)
noteNode, err := app.nodes.Create(&parent.ID, nodes.TypeNote, "LegacyNote", 0, "", "")
if err != nil {
t.Fatalf("create legacy note: %v", err)
}
// Write a physical file so repair has something to fix
noteDir := filepath.Join(vault, parent.FsPath)
if err := os.MkdirAll(noteDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
oldPath := filepath.Join(noteDir, "LegacyNote.md")
if err := os.WriteFile(oldPath, []byte("# Legacy Content\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
relPath, _ := filepath.Rel(vault, oldPath)
fileID := "repair-file-" + noteNode.ID
_, _ = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/plain','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
fileID, noteNode.ID, "LegacyNote.md", relPath)
_, _ = app.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
noteNode.ID, fileID, "markdown")
// Verify the note is a direct child before repair
beforeChildren, err := app.nodes.ListChildren(parent.ID, false)
if err != nil {
t.Fatalf("ListChildren before repair: %v", err)
}
var foundDirect bool
for _, c := range beforeChildren {
if c.ID == noteNode.ID {
foundDirect = true
break
}
}
if !foundDirect {
t.Fatal("legacy note should be a direct child before repair")
}
// Run repair
result, err := app.notes.RepairNotesLayout()
if err != nil {
t.Fatalf("RepairNotesLayout: %v", err)
}
if result.RepairedNotes == 0 {
t.Errorf("expected at least 1 repaired note, got 0")
}
// After repair: note should NOT be a direct child of parent
afterChildren, err := app.nodes.ListChildren(parent.ID, false)
if err != nil {
t.Fatalf("ListChildren after repair: %v", err)
}
for _, c := range afterChildren {
if c.ID == noteNode.ID {
t.Errorf("note should no longer be a direct child after repair")
break
}
}
// After repair: note should be inside Notes folder
// ListItems(parent.ID) should NOT show the note directly
parentItems, err := app.ListItems(parent.ID)
if err != nil {
t.Fatalf("ListItems(parent) after repair: %v", err)
}
for _, item := range parentItems {
if item.ID == noteNode.ID {
t.Errorf("ListItems(parent) should not show note directly after repair")
break
}
}
// But ListItems(Notes) should show it
var notesFolder *nodes.Node
for i := range afterChildren {
if afterChildren[i].Title == notes.NotesFolder && afterChildren[i].Type == "folder" {
notesFolder = &afterChildren[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder should exist after repair")
}
notesItems, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems(Notes) after repair: %v", err)
}
var foundInNotes bool
for _, item := range notesItems {
if item.ID == noteNode.ID && item.Type == "note" {
foundInNotes = true
break
}
}
if !foundInNotes {
t.Errorf("ListItems(Notes) should show the repaired note, got %d items", len(notesItems))
for _, item := range notesItems {
t.Logf(" %s (type=%s)", item.Name, item.Type)
}
}
// Verify node parent was updated
note, err := app.nodes.Get(noteNode.ID)
if err != nil {
t.Fatalf("get note node after repair: %v", err)
}
if note.ParentID == nil || *note.ParentID != notesFolder.ID {
t.Errorf("note.ParentID should be Notes folder (%s), got %v", notesFolder.ID, note.ParentID)
}
// Verify file path was updated
recs, err := app.files.ListByNode(noteNode.ID)
if err != nil {
t.Fatalf("ListByNode after repair: %v", err)
}
if len(recs) == 0 {
t.Fatal("file record should exist after repair")
}
expectedRelPath := filepath.Join(parent.FsPath, notes.NotesFolder, "LegacyNote.md")
if recs[0].Path != expectedRelPath {
t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path)
}
}
// TestCheckFileAction_NoteLinked verifies that CheckFileAction returns
// Action="note" for a .md file linked to a note record.
func TestCheckFileAction_NoteLinked(t *testing.T) {
app, _ := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find the Overview note — it should be inside Notes folder, linked via notes record
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
notesChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
t.Fatalf("ListChildren(Notes): %v", err)
}
if len(notesChildren) == 0 {
t.Fatal("expected at least one note inside Notes folder")
}
// Get file ID for the Overview note
items, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems: %v", err)
}
var overviewFileID string
for _, item := range items {
if item.Type == "note" && item.Name == "Overview" {
overviewFileID = item.FileID
break
}
}
if overviewFileID == "" {
t.Fatal("Overview note has no FileID")
}
// CheckFileAction should return Action="note"
action, err := app.CheckFileAction(overviewFileID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "note" {
t.Errorf("expected Action=note for linked .md, got %q", action.Action)
}
if action.NoteID == "" {
t.Error("expected non-empty NoteID for linked note")
}
if action.NoteTitle == "" {
t.Error("expected non-empty NoteTitle")
}
if action.FileName == "" {
t.Error("expected non-empty FileName")
}
}
// TestCheckFileAction_ExternalForNonMD verifies that non-.md files return
// Action="external" from CheckFileAction.
func TestCheckFileAction_ExternalForNonMD(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a file node and record for a non-.md file
fileNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "image.png", 0, "", filepath.Join(proj.FsPath, "image.png"))
if err != nil {
t.Fatalf("create file node: %v", err)
}
absPath := filepath.Join(vault, proj.FsPath, "image.png")
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(absPath, []byte("fake-png"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record directly
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','image/png','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-png-"+fileNode.ID, fileNode.ID, "image.png", filepath.Join(proj.FsPath, "image.png"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// CheckFileAction should return Action="external"
action, err := app.CheckFileAction("file-png-" + fileNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "external" {
t.Errorf("expected Action=external for .png, got %q", action.Action)
}
if action.FileName != "image.png" {
t.Errorf("expected FileName=image.png, got %q", action.FileName)
}
}
// TestCheckFileAction_PreviewForMDOutsideNotes verifies that .md files
// outside Notes/ without a note record return Action="preview".
func TestCheckFileAction_PreviewForMDOutsideNotes(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a .md file directly under the project (not inside Notes/)
mdNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "readme.md", 0, "", filepath.Join(proj.FsPath, "readme.md"))
if err != nil {
t.Fatalf("create md node: %v", err)
}
absPath := filepath.Join(vault, proj.FsPath, "readme.md")
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(absPath, []byte("# Readme\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record directly
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-md-"+mdNode.ID, mdNode.ID, "readme.md", filepath.Join(proj.FsPath, "readme.md"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// Do NOT create a notes record — this .md is outside Notes/
action, err := app.CheckFileAction("file-md-" + mdNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "preview" {
t.Errorf("expected Action=preview for .md outside Notes/, got %q", action.Action)
}
}
func TestCheckFileAction_AutoLinkInNotes(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find Notes folder
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
// Create a .md file INSIDE Notes/ but WITHOUT a notes record
mdNode, err := app.nodes.Create(&notesFolder.ID, nodes.TypeFile, "orphan.md", 0, "",
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
if err != nil {
t.Fatalf("create md node: %v", err)
}
notesDir := filepath.Join(vault, proj.FsPath, notes.NotesFolder)
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
absPath := filepath.Join(notesDir, "orphan.md")
if err := os.WriteFile(absPath, []byte("# Orphan Note\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-orphan-"+mdNode.ID, mdNode.ID, "orphan.md",
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// No notes record yet — just file + node
// CheckFileAction should auto-link and return Action="note"
action, err := app.CheckFileAction("file-orphan-" + mdNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "note" {
t.Errorf("expected Action=note for .md inside Notes/, got %q", action.Action)
}
if action.NoteID == "" {
t.Error("expected auto-linked NoteID")
}
if action.NoteTitle == "" {
t.Error("expected NoteTitle for auto-linked note")
}
// Verify notes record was actually created
noteRec, err := app.notes.FindByFileID("file-orphan-" + mdNode.ID)
if err != nil {
t.Fatalf("FindByFileID after auto-link: %v", err)
}
if noteRec == nil {
t.Fatal("expected note record after auto-link")
}
}
+61 -22
View File
@@ -126,12 +126,17 @@ func TestVaultLayout_CreateProjectTree(t *testing.T) {
t.Error("expected project folder on disk")
}
// 4. Verify template created Overview.md
overviewPath := filepath.Join(serverFolder, "Overview.md")
// 4. Verify template created Overview.md inside Notes/ subfolder
overviewPath := filepath.Join(serverFolder, "Notes", "Overview.md")
if _, err := os.Stat(overviewPath); os.IsNotExist(err) {
t.Log("note: Overview.md from template not created (may not be implemented)")
t.Errorf("expected Overview.md at %s", overviewPath)
}
// Verify no Overview.md in project root
overviewRootPath := filepath.Join(serverFolder, "Overview.md")
if _, err := os.Stat(overviewRootPath); err == nil {
t.Error("Overview.md should not be in project root, only in Notes/")
}
}
func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
app, vault := setupTestApp(t)
@@ -150,16 +155,14 @@ func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
t.Fatal("expected non-nil node and file record")
}
// Verify the note .md file is inside the project folder
expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md")
// Verify the note .md file is inside the project's notes/ subfolder
// SafeDisplayNameToPathSegment preserves spaces: "Моя заметка" stays as-is
expectedPath := filepath.Join(vault, proj.FsPath, "Notes", "Моя заметка.md")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
// Try the safe-display-name variant
expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md")
if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) {
// Show what actually exists
entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath))
t.Errorf("expected note file in project folder, found: %v", listNames(entries))
}
// Show what actually exists in notes subfolder
notesDir := filepath.Join(vault, proj.FsPath, "Notes")
entries, _ := os.ReadDir(notesDir)
t.Errorf("expected note file at %s, found in notes/: %v", expectedPath, listNames(entries))
}
}
@@ -1118,13 +1121,11 @@ func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
"Documents": "folder",
"Notes": "folder",
"Files": "folder",
"Overview": "note",
}
for _, child := range children {
expectedType, ok := expected[child.Title]
if !ok {
t.Errorf("unexpected child %q (type=%q)", child.Title, child.Type)
continue
continue // Overview is now under Notes/, not a direct child
}
if child.Type != expectedType {
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
@@ -1139,8 +1140,30 @@ func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
}
}
}
if len(children) < 4 {
t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children))
if len(children) < 3 {
t.Errorf("expected at least 3 children (3 folders), got %d", len(children))
}
// Overview should be inside Notes folder, not a direct child
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == "Notes" && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatalf("Notes folder not found among children")
}
noteChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
t.Fatalf("list Notes children: %v", err)
}
if len(noteChildren) != 1 || noteChildren[0].Title != "Overview" {
t.Errorf("Notes folder should have one child 'Overview', got %d children", len(noteChildren))
for _, c := range noteChildren {
t.Logf(" child: %s (type=%s)", c.Title, c.Type)
}
}
}
@@ -1153,21 +1176,37 @@ func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T
t.Fatalf("create project: %v", err)
}
// Find the Overview note child
// Overview is now created inside Notes/ folder, find it there
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("list children: %v", err)
}
var overview *nodes.Node
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == "Overview" {
overview = &children[i]
if children[i].Title == "Notes" && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("expected Notes folder child from template")
}
noteChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
t.Fatalf("list Notes children: %v", err)
}
var overview *nodes.Node
for i := range noteChildren {
if noteChildren[i].Title == "Overview" {
overview = &noteChildren[i]
break
}
}
if overview == nil {
t.Fatal("expected 'Overview' child node from template")
t.Fatal("expected 'Overview' child node from template inside Notes/")
}
if overview.Type != "note" {
t.Errorf("expected type 'note', got %q", overview.Type)
+781
View File
@@ -0,0 +1,781 @@
--[[
Calendar plugin for Verstak — reference plugin demonstrating the full plugin API.
Covers: verstak.db.* / config.* / state.* / node.* / worklog.* / activity.* / schedule.* / http.* / ui.*
]]
--------------------------------------------------------------------------------
-- Module table — internal
--------------------------------------------------------------------------------
local M = {}
-- Safe wrapper for optional service calls (avoids pcall boilerplate)
local function safe_log(fn, ...)
pcall(fn, ...)
end
-- ID generation
local function uuid()
local f = function() return math.random(0, 16777215) end
local p = string.format
return p("%04x%04x-%04x-%04x-%04x-%06x%06x",
f(), f(), f(), f(), f(), f(), f())
end
math.randomseed(os.time())
-- Current timestamp ISO8601
local function now()
return os.date("%Y-%m-%dT%H:%M:%S")
end
local function today()
return os.date("%Y-%m-%d")
end
--------------------------------------------------------------------------------
-- Default categories
--------------------------------------------------------------------------------
local DEFAULT_CATEGORIES = {
{ name = "Работа", color = "#3b82f6", icon = "💼", sort_order = 1 },
{ name = "Личное", color = "#10b981", icon = "🏠", sort_order = 2 },
{ name = "Встреча", color = "#8b5cf6", icon = "🤝", sort_order = 3 },
{ name = "Дедлайн", color = "#ef4444", icon = "🔥", sort_order = 4 },
{ name = "Здоровье", color = "#f59e0b", icon = "💪", sort_order = 5 },
{ name = "Звонок", color = "#06b6d4", icon = "📞", sort_order = 6 },
}
--------------------------------------------------------------------------------
-- Verstak.config — load/store default categories
--------------------------------------------------------------------------------
function M.ensure_categories()
local rows = verstak.db.query("SELECT COUNT(*) as cnt FROM categories WHERE deleted = 0")
local n = 0
if rows and #rows > 0 then
for _, v in pairs(rows[1]) do
if type(v) == "number" then n = v; break end
end
end
if n > 0 then return end
for _, cat in ipairs(DEFAULT_CATEGORIES) do
local id = uuid()
verstak.db.exec(
"INSERT INTO categories (id, name, color, icon, sort_order) VALUES (?, ?, ?, ?, ?)",
id, cat.name, cat.color, cat.icon, cat.sort_order
)
-- Activity log for each category
safe_log(verstak.activity.log,"category_created", "Категория: " .. cat.name, id, "")
end
-- Save as config so user can restore defaults
local cfg = verstak.config.get("categories") or {}
if next(cfg) == nil then
verstak.config.set("categories", DEFAULT_CATEGORIES)
end
end
--------------------------------------------------------------------------------
-- Categories CRUD (verstak.db.* demo)
--------------------------------------------------------------------------------
-- Get all non-deleted categories
function M.get_categories()
print("get_categories called")
return verstak.db.query(
"SELECT id, name, color, icon, sort_order FROM categories WHERE deleted = 0 ORDER BY sort_order"
)
end
-- Get all categories including deleted
function M.get_categories_all()
return verstak.db.query(
"SELECT id, name, color, icon, sort_order, deleted FROM categories ORDER BY sort_order"
)
end
-- Create a new category
function M.create_category(name, color, icon)
if not name or name == "" then error("category name required") end
local id = uuid()
verstak.db.exec(
"INSERT INTO categories (id, name, color, icon) VALUES (?, ?, ?, ?)",
id, name, color or "#6b7280", icon or "📌"
)
safe_log(verstak.activity.log,"category_created", "Категория: " .. name, id, "")
return id
end
-- Update a category
function M.update_category(id, fields)
if not id then error("category id required") end
local old = verstak.db.query_row("SELECT name FROM categories WHERE id = ?", id)
if not old then error("category not found: " .. id) end
verstak.db.exec(
"UPDATE categories SET name = ?, color = ?, icon = ?, sort_order = ?, updated_at = ? WHERE id = ?",
fields.name or old.name,
fields.color or "#6b7280",
fields.icon or "📌",
fields.sort_order or 0,
now(),
id
)
safe_log(verstak.activity.log,"category_updated", "Категория: " .. (fields.name or old.name), id, "")
return true
end
-- Soft-delete a category (keeps history)
function M.delete_category(id)
if not id then error("category id required") end
verstak.db.exec("UPDATE categories SET deleted = 1, updated_at = ? WHERE id = ?", now(), id)
safe_log(verstak.activity.log,"category_deleted", "Категория удалена: " .. id, id, "")
return true
end
-- Restore default categories
function M.restore_default_categories()
verstak.db.exec("UPDATE categories SET deleted = 1")
M.ensure_categories()
return true
end
--------------------------------------------------------------------------------
-- Events CRUD (verstak.db.* + verstak.state.* demo)
--------------------------------------------------------------------------------
-- Get events within a date range (inclusive)
function M.get_events(params, end_date)
-- Debug: log what we received
print("get_events: params type=" .. type(params) .. ", end_date type=" .. type(end_date))
if type(params) == "table" then
for k, v in pairs(params) do
print(" params[" .. tostring(k) .. "] type=" .. type(v))
end
end
-- Backward compat: support positional (start, end) and table {start=, end=}
if type(params) == "string" then
print("get_events: backward compat path, end_date=" .. tostring(end_date))
local e = end_date
if type(e) ~= "string" then e = nil end
return M.get_events{ start_date = params, ["end"] = e or params }
end
local start_date = params.start_date or params.start
if type(start_date) ~= "string" then print("WARN get_events start_date not string: " .. type(start_date)); start_date = tostring(start_date) end
local end_date = params["end"] or params.end_date or params.end_date
if type(end_date) ~= "string" then print("WARN get_events end_date not string: " .. type(end_date)); end_date = start_date end
if not start_date then error("start_date required") end
if not end_date then end_date = start_date end
return verstak.db.query(
[[SELECT e.id, e.title, e.description,
e.start, e.end, e.all_day,
e.category_id, e.color,
e.node_id, e.link_type,
e.recurring_rule, e.reminder_minutes,
e.completed, e.source_series,
e.created_at, e.updated_at,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM events e
LEFT JOIN categories c ON e.category_id = c.id AND c.deleted = 0
WHERE (e.start >= ? AND e.start <= ?)
OR (e.end >= ? AND e.end <= ?)
OR (e.start <= ? AND e.end >= ?)
ORDER BY e.start
]],
start_date, end_date, start_date, end_date, start_date, end_date
)
end
-- Get events for a specific day
function M.get_events_day(params)
local date_str = params.date or params
if type(date_str) ~= "string" then date_str = tostring(date_str) end
return M.get_events({ start_date = date_str .. "T00:00:00", end_date = date_str .. "T23:59:59" })
end
-- Get event by ID
function M.get_event(params)
local id = params.id or params
if not id then error("event id required") end
return verstak.db.query_row(
"SELECT * FROM events WHERE id = ?", id
)
end
-- Create a single event (base event for recurrences)
-- Already accepts a single table — no change needed
function M.create_event(opts)
opts = opts or {}
if not opts.title or opts.title == "" then error("event title required") end
if not opts.start then error("event start datetime required") end
local id = uuid()
local e_start = opts.start
local e_end = opts["end"] or e_start
local cat_id = opts.category_id or ""
local color = opts.color or "#6b7280"
-- Resolve color from category if not set
if color == "" and cat_id ~= "" then
local cat = verstak.db.query_row("SELECT color FROM categories WHERE id = ?", cat_id)
if cat then color = cat.color end
end
local recurring_json = nil
if opts.recurring then
recurring_json = verstak.state.get("rr:" .. id)
if not recurring_json then
recurring_json = opts.recurring
end
end
local reminder = "[]"
if opts.reminder_minutes then
reminder = "[" .. table.concat(opts.reminder_minutes, ",") .. "]"
end
verstak.db.exec(
[[INSERT INTO events (id, title, description, start, end, all_day,
category_id, color, node_id, link_type, recurring_rule, reminder_minutes,
completed, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)]],
id, opts.title, opts.description or "", e_start, e_end,
opts.all_day and 1 or 0,
cat_id, color,
opts.node_id or "", opts.link_type or "node",
recurring_json, reminder,
now(), now()
)
safe_log(verstak.activity.log,"event_created",
"Событие: " .. opts.title, id, opts.node_id or "")
-- Link to worklog if minutes provided
if opts.minutes and opts.node_id and opts.node_id ~= "" then
local ok, err = pcall(verstak.worklog.add, opts.node_id, opts.title, opts.minutes)
if not ok then
print("Calendar: worklog.add error: " .. tostring(err))
end
end
return id
end
-- Update an event (partial fields)
function M.update_event(params, fields)
-- Backward compat: support positional (id, fields) and table { id = ..., ... }
if type(params) == "string" then
local t = { id = params }
if fields then
for k, v in pairs(fields) do t[k] = v end
end
return M.update_event(t)
end
local id = params.id
if not id then error("event id required") end
local old = verstak.db.query_row("SELECT * FROM events WHERE id = ?", id)
if not old then error("event not found: " .. id) end
local set_clauses = {}
local sql_params = {}
-- Build dynamic update
local mutable = {
title = true, description = true, start = true, ["end"] = true,
all_day = true, category_id = true, color = true,
node_id = true, link_type = true, reminder_minutes = true,
completed = true
}
for k, v in pairs(params) do
if k ~= "id" and mutable[k] then
if k == "all_day" or k == "completed" then
v = v and 1 or 0
end
table.insert(set_clauses, k .. " = ?")
table.insert(sql_params, v)
end
end
table.insert(sql_params, now())
table.insert(sql_params, id)
if #set_clauses > 0 then
verstak.db.exec(
"UPDATE events SET " .. table.concat(set_clauses, ", ") .. ", updated_at = ? WHERE id = ?",
unpack(sql_params)
)
end
safe_log(verstak.activity.log,"event_updated",
"Событие обновлено: " .. (params.title or old.title or id), id, old.node_id or "")
return true
end
-- Delete an event
function M.delete_event(params)
local id = params.id or params
if not id or type(id) ~= "string" then error("event id required") end
local old = verstak.db.query_row("SELECT title, node_id FROM events WHERE id = ?", id)
if not old then return true end
verstak.db.exec("DELETE FROM events WHERE id = ?", id)
safe_log(verstak.activity.log,"event_deleted",
"Событие удалено: " .. (old.title or id), id, old.node_id or "")
return true
end
-- Delete ALL events (for testing/cache clear)
function M.clear_events()
verstak.db.exec("DELETE FROM events")
safe_log(verstak.activity.log,"events_cleared", "Все события удалены", "", "")
return true
end
--------------------------------------------------------------------------------
-- Recurrence (verstak.state.* for ex_dates cache)
--------------------------------------------------------------------------------
-- Parse an ISO date string
local function parse_date(s)
if not s then return nil end
local y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
if not y then return nil end
return { year = tonumber(y), month = tonumber(m), day = tonumber(d) }
end
local function date_to_epoch(t)
return os.time({ year = t.year, month = t.month, day = t.day, hour = 0, sec = 0 })
end
-- day-of-week: Mon=1..Sun=7
local function dow(t)
return os.date("*t", date_to_epoch(t)).wday
-- os.date().wday: Sun=1, Mon=2... → we convert
end
local function to_iso(t)
return string.format("%04d-%02d-%02dT00:00:00", t.year, t.month, t.day)
end
-- Expand a recurring event into concrete dates within a range
local function expand_recurring(base_start, base_end, rule, range_start, range_end)
rule = rule or {}
local freq = rule.freq or "weekly"
local interval = rule.interval or 1
local until_date = rule["until"]
local max_count = rule.count or 52
local by_day = rule.by_day or {}
local by_month_day = rule.by_month_day or {}
local by_month = rule.by_month or {}
local ex_dates_set = {}
if rule.ex_dates then
for _, d in ipairs(rule.ex_dates) do ex_dates_set[d] = true end
end
local start_t = parse_date(base_start)
local range_start_t = parse_date(range_start)
local range_end_t = parse_date(range_end)
if not start_t or not range_start_t or not range_end_t then return {} end
local results = {}
local count = 0
local max_iterations = 365 * 3
local iter = 0
local current = { year = start_t.year, month = start_t.month, day = start_t.day }
local current_epoch = date_to_epoch(current)
local range_start_epoch = date_to_epoch(range_start_t)
local range_end_epoch = date_to_epoch(range_end_t)
local until_epoch
if until_date then
local ut = parse_date(until_date)
if ut then until_epoch = date_to_epoch(ut) end
end
local function check_matches()
if ex_dates_set[to_iso(current)] then
return false
end
if freq == "daily" then return true end
if freq == "weekly" then
if #by_day == 0 then return true end
local wd = os.date("*t", current_epoch).wday
local our_wd = (wd == 1) and 7 or (wd - 1)
for _, d in ipairs(by_day) do
if d == our_wd then return true end
end
return false
end
if freq == "monthly" then
if #by_month_day == 0 then return true end
for _, d in ipairs(by_month_day) do
if d == current.day then return true end
end
return false
end
if freq == "yearly" then
local month_ok = (#by_month == 0)
if not month_ok then
for _, m in ipairs(by_month) do
if m == current.month then month_ok = true; break end
end
end
if not month_ok then return false end
if #by_month_day == 0 then return true end
for _, d in ipairs(by_month_day) do
if d == current.day then return true end
end
return false
end
return false
end
local function advance()
current_epoch = current_epoch + 86400 * interval
current = parse_date(os.date("%Y-%m-%d", current_epoch))
end
while count < max_count and iter < max_iterations do
iter = iter + 1
if check_matches() then
count = count + 1
local iso = to_iso(current)
if current_epoch >= range_start_epoch and current_epoch <= range_end_epoch then
table.insert(results, iso)
end
end
advance()
if until_epoch and current_epoch > until_epoch then break end
if current_epoch > range_end_epoch and iter > 7 then break end
end
return results
end
M.expand_recurring = expand_recurring
-- Get all events (flat + expanded) for a range — used by the panel
function M.get_expanded_events(params)
local start_date = params.start_date or params.start
local end_date = params.end_date or params["end"]
local base_events = verstak.db.query(
[[SELECT * FROM events WHERE recurring_rule IS NOT NULL AND recurring_rule != ''
AND completed = 0]]
)
local expanded = {}
for _, ev in ipairs(base_events) do
local rule
if type(ev.recurring_rule) == "string" then
-- Try to load as JSON (table) — for Lua demo we store as JSON string
-- In our case it's already a table since we stored via verstak.state
rule = verstak.state.get("rr:" .. ev.id)
end
if not rule then rule = {} end -- fallback: no rule, just use as-is
local dates = M.expand_recurring(ev.start, ev["end"], rule, start_date, end_date)
for _, d in ipairs(dates) do
-- Create instance copy
local instance = {}
for k, v in pairs(ev) do instance[k] = v end
instance.id = ev.id .. "_" .. d:gsub("-", "")
instance.start = d
instance["end"] = d
instance.is_recurring = true
instance.base_id = ev.id
table.insert(expanded, instance)
end
end
return expanded
end
-- Get all events (flat + expanded) for a range — used by the panel
function M.get_calendar_events(params)
-- 1. Normal events
local normal = M.get_events(params)
-- 2. Expanded recurring
local recur = M.get_expanded_events(params)
-- Merge
local all = {}
for _, e in ipairs(normal) do table.insert(all, e) end
for _, e in ipairs(recur) do table.insert(all, e) end
return all
end
--------------------------------------------------------------------------------
-- Node integration (verstak.node.* + verstak.worklog.* demo)
--------------------------------------------------------------------------------
-- Create an event linked to a Verstak node
function M.create_event_from_node(node_id, date_str, fields)
if not node_id then error("node_id required") end
local node = verstak.node.get(node_id)
if not node then error("node not found: " .. node_id) end
fields = fields or {}
if not fields.title then fields.title = "📎 " .. (node.title or "Без названия") end
if not fields.start then fields.start = date_str or today() .. "T09:00:00" end
fields.node_id = node_id
fields.link_type = "node"
local new_id = M.create_event(fields)
safe_log(verstak.activity.log,"event_from_node",
"Событие из узла: " .. fields.title, new_id, node_id)
return new_id
end
-- Open linked node from event (called when user clicks on event with node_id)
function M.open_event_node(event_id)
local ev = M.get_event(event_id)
if not ev or not ev.node_id or ev.node_id == "" then
return nil, "no linked node"
end
local node = verstak.node.get(ev.node_id)
if not node then return nil, "node not found" end
-- Navigate in Verstak
pcall(verstak.ui.navigate_to, "node:" .. ev.node_id)
return true
end
-- Log work for an event and link to node
function M.log_work_for_event(event_id, minutes)
local ev = M.get_event(event_id)
if not ev then error("event not found") end
if not ev.node_id or ev.node_id == "" then
error("event has no linked node — create link first")
end
local ok, result = pcall(verstak.worklog.add, ev.node_id, ev.title, minutes)
if not ok then error("worklog.add failed: " .. tostring(result)) end
safe_log(verstak.activity.log,"worklog_from_event",
"Worklog: " .. ev.title .. " (" .. minutes .. "м)", event_id, ev.node_id)
if ev.all_day == 1 then
-- Mark as completed if all-day
M.update_event(event_id, { completed = true })
end
return true
end
--------------------------------------------------------------------------------
-- Reminders (verstak.schedule.* + verstak.ui.* demo)
--------------------------------------------------------------------------------
local function parse_time(s)
if not s then return nil end
local y, m, d, h, min, sec = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)")
if not y then
y, m, d, h, min = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)")
end
if not y then
y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
if y then return { year = tonumber(y), month = tonumber(m), day = tonumber(d) } end
return nil
end
return {
year = tonumber(y), month = tonumber(m), day = tonumber(d),
hour = tonumber(h or 0), min = tonumber(min or 0), sec = tonumber(sec or 0)
}
end
local function iso_to_epoch(s)
local t = parse_time(s)
if not t then return nil end
return os.time({
year = t.year, month = t.month, day = t.day,
hour = t.hour or 0, min = t.min or 0, sec = t.sec or 0
})
end
function M.check_reminders()
local upcoming = verstak.db.query(
[[SELECT id, title, start, reminder_minutes, node_id
FROM events WHERE reminder_minutes != '[]' AND reminder_minutes != ''
AND completed = 0 AND datetime(start) > datetime('now')]]
)
local now_epoch = os.time()
local reminded = {}
for _, ev in ipairs(upcoming) do
local mins = {}
-- Parse reminder_minutes JSON array like [10, 60]
for m in string.gmatch(ev.reminder_minutes or "", "(-?%d+)") do
table.insert(mins, tonumber(m))
end
local ev_epoch = iso_to_epoch(ev.start)
if ev_epoch then
local key = "reminded:" .. ev.id
local already = verstak.state.get(key) or {}
for _, min_before in ipairs(mins) do
local notify_at = ev_epoch - min_before * 60
local diff = notify_at - now_epoch
if diff >= -30 and diff <= 60 and not already[tostring(min_before)] then
-- Fire reminder
local msg = "🔔 " .. ev.title
if min_before > 0 then
msg = msg .. " (через " .. min_before .. " мин)"
end
pcall(verstak.ui.toast, msg, "reminder")
print("Calendar reminder: " .. msg)
already[tostring(min_before)] = true
table.insert(reminded, ev.id)
end
end
verstak.state.set(key, already)
end
end
return #reminded
end
--------------------------------------------------------------------------------
-- Holidays via HTTP (verstak.http.* demo)
--------------------------------------------------------------------------------
function M.fetch_holidays(year)
if not year then year = tonumber(os.date("%Y")) end
local ok_http, resp = pcall(verstak.http.get, "https://date.nager.at/api/v3/PublicHolidays/" .. year .. "/RU")
if not ok_http then
print("Calendar: HTTP call failed: " .. tostring(resp))
return {}
end
if resp.status ~= 200 then
print("Calendar: HTTP status " .. tostring(resp.status))
return {}
end
local body = resp.body or "[]"
-- Cache in DB
verstak.db.exec("DELETE FROM events WHERE source_series = 'holiday_" .. year .. "'")
for _, h in ipairs(body) do
local date_str = h.date or (year .. "-01-01")
local title = (h.localName or "Праздник") .. " 🎉"
local cat_id
local cat = verstak.db.query_row("SELECT id FROM categories WHERE name = 'Личное' AND deleted = 0")
if cat then cat_id = cat.id end
M.create_event{
title = title,
start = date_str .. "T00:00:00",
all_day = true,
category_id = cat_id,
color = "#f59e0b",
node_id = "",
source_series = "holiday_" .. year,
}
end
print("Calendar: imported " .. #body .. " holidays for " .. year)
return true
end
--------------------------------------------------------------------------------
-- Hooks
--------------------------------------------------------------------------------
function on_install()
print("Calendar: on_install — creating tables")
local ok, err = pcall(function()
verstak.db.exec([[
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
icon TEXT NOT NULL DEFAULT '📌',
sort_order INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
]])
verstak.db.exec([[
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL,
end TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
category_id TEXT REFERENCES categories(id),
color TEXT NOT NULL DEFAULT '#6b7280',
node_id TEXT,
link_type TEXT DEFAULT 'node',
recurring_rule TEXT,
reminder_minutes TEXT DEFAULT '[]',
completed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
source_series TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
]])
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_start ON events(start)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_end ON events(end)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted)")
end)
if not ok then
print("Calendar: migration error: " .. tostring(err))
error(err)
else
print("Calendar: migration complete")
end
-- Insert default categories
M.ensure_categories()
print("Calendar: install complete")
end
function on_uninstall()
print("Calendar: on_uninstall — dropping tables")
local ok, err = pcall(function()
verstak.db.exec("DROP TABLE IF EXISTS events")
verstak.db.exec("DROP TABLE IF EXISTS categories")
end)
if not ok then
print("Calendar: uninstall error: " .. tostring(err))
else
print("Calendar: tables dropped")
end
-- Clean up config
pcall(verstak.config.set, "categories", nil)
print("Calendar: uninstall complete")
end
function on_init()
print("Calendar: on_init — registering API")
-- Register global API for panel access
_G.calendar = M
-- Set initial state (current month)
verstak.state.set("calendar_month", os.date("%Y-%m"))
verstak.state.set("calendar_view", "month")
print("Calendar: init complete — " .. #M.get_categories() .. " categories, API ready")
end
function on_shutdown()
print("Calendar: shutdown")
end
print("Calendar: module loaded")
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT,
location TEXT,
node_id TEXT,
color TEXT DEFAULT 'blue',
created_at TEXT DEFAULT (datetime('now'))
);
@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
icon TEXT NOT NULL DEFAULT '📌',
sort_order INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL,
end TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
category_id TEXT REFERENCES categories(id),
color TEXT NOT NULL DEFAULT '#6b7280',
node_id TEXT,
link_type TEXT DEFAULT 'node',
recurring_rule TEXT,
reminder_minutes TEXT DEFAULT '[]',
completed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
source_series TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);
CREATE INDEX IF NOT EXISTS idx_events_end ON events(end);
CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id);
CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id);
CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted);
@@ -0,0 +1,996 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--bg2: #22223a;
--bg3: #2a2a44;
--text: #e0e0e0;
--text-dim: #888;
--text-bright: #fff;
--border: #333;
--accent: #6366f1;
--accent-hover: #818cf8;
--danger: #ef4444;
--success: #22c55e;
--warn: #f59e0b;
--radius: 8px;
--radius-sm: 4px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
overflow-x: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
gap: 8px;
flex-wrap: wrap;
}
.header-left { display: flex; align-items: center; gap: 8px; }
.header-title { font-size: 1.1rem; font-weight: 600; color: var(--text-bright); }
.nav-btn {
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.nav-btn:hover { background: var(--accent); color: #fff; }
.nav-btn.today-btn { font-weight: 600; }
.view-tabs { display: flex; gap: 2px; }
.view-tab {
padding: 6px 14px;
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.view-tab:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
.view-tab:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
.view-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.view-tab:hover:not(.active) { background: var(--bg3); color: var(--text); }
/* Month grid */
.month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--border);
margin: 0;
}
.day-header {
background: var(--bg3);
padding: 8px 4px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
}
.day-cell {
background: var(--bg2);
min-height: 90px;
padding: 4px 6px;
cursor: pointer;
transition: background 0.1s;
position: relative;
}
.day-cell:hover { background: var(--bg3); }
.day-cell.other-month { opacity: 0.35; }
.day-cell.today { background: rgba(99, 102, 241, 0.12); }
.day-cell.selected { background: rgba(99, 102, 241, 0.2); box-shadow: inset 0 0 0 1px var(--accent); }
.day-cell.drop-target { background: rgba(34, 197, 94, 0.15); box-shadow: inset 0 0 0 2px var(--success); }
.day-number {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
color: var(--text);
}
.day-cell.today .day-number {
color: var(--accent);
}
/* Events in month grid */
.month-events {
display: flex;
flex-direction: column;
gap: 1px;
}
.month-event {
font-size: 11px;
padding: 1px 4px;
border-radius: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: opacity 0.1s;
color: #fff;
}
.month-event:hover { opacity: 0.85; }
.month-event.more-link {
background: transparent;
color: var(--text-dim);
font-size: 10px;
font-style: italic;
}
/* Week view */
.week-view {
display: grid;
grid-template-columns: 60px repeat(7, 1fr);
gap: 1px;
background: var(--border);
overflow-x: auto;
}
.hour-label {
background: var(--bg3);
padding: 4px;
text-align: right;
font-size: 11px;
color: var(--text-dim);
min-height: 40px;
border-bottom: 1px solid var(--border);
}
.hour-slot {
background: var(--bg2);
min-height: 40px;
border-bottom: 1px solid var(--border);
cursor: pointer;
position: relative;
}
.hour-slot:hover { background: var(--bg3); }
.week-event {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
font-size: 11px;
border-radius: 3px;
cursor: pointer;
overflow: hidden;
color: #fff;
}
/* Day view */
.day-view {
padding: 12px 16px;
}
.day-events-list { display: flex; flex-direction: column; gap: 8px; }
.day-event-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s;
border-left: 4px solid var(--accent);
}
.day-event-card:hover { background: var(--bg3); }
.day-event-time { font-size: 12px; color: var(--text-dim); }
.day-event-title { font-weight: 600; margin-top: 2px; }
.day-event-desc { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
.day-event-category { font-size: 11px; margin-top: 4px; display: inline-block; padding: 1px 6px; border-radius: 3px; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
max-width: 480px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
padding: 24px;
}
.modal h3 { margin-bottom: 16px; color: var(--text-bright); }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
}
.form-group textarea { resize: vertical; min-height: 60px; }
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none; border-color: var(--accent);
}
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.btn {
padding: 8px 18px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--bg3); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { opacity: 0.85; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; }
.checkbox-row input[type="checkbox"] { width: auto; }
/* Categories legend */
.categories-bar {
display: flex;
gap: 8px;
padding: 8px 16px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
align-items: center;
}
.cat-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
cursor: pointer;
transition: opacity 0.15s;
color: #fff;
}
.cat-tag:hover { opacity: 0.8; }
.cat-tag.active { box-shadow: 0 0 0 2px var(--text-bright); }
.cat-filter-all {
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
}
/* Toast */
.toast {
position: fixed;
bottom: 16px;
right: 16px;
background: var(--bg3);
border: 1px solid var(--border);
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-bright);
z-index: 2000;
animation: slideIn 0.25s ease;
}
@keyframes slideIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Loading */
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-dim);
font-size: 16px;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* Empty state */
.empty-day { color: var(--text-dim); padding: 20px; text-align: center; }
/* Responsive */
@media (max-width: 600px) {
.header { flex-direction: column; align-items: stretch; }
.view-tabs { justify-content: center; }
.day-cell { min-height: 60px; }
}
</style>
</head>
<body>
<div id="app">
<div class="loading-screen">⏳ Загрузка календаря...</div>
</div>
<script>
(function() {
'use strict';
// ─── State ────────────────────────────────────────────────────────
const state = {
view: 'month', // month | week | day
currentDate: new Date(), // the "focus" date (what month/week/day we're looking at)
selectedDate: null, // clicked day (for modal)
events: [],
categories: [],
filterCatId: null, // null = all
eventsLoaded: false,
dropDate: null, // date string being dragged onto
panelReady: false,
};
// Cache DOM refs
let appEl;
// ─── Helpers ───────────────────────────────────────────────────────
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()); }
function fmtISO(d) { return fmtDate(d) + 'T00:00:00'; }
function fmtMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1); }
function dayOfWeek(d) { const w = d.getDay(); return w === 0 ? 7 : w; } // Mon=1..Sun=7
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
function parseDate(s) { const d = new Date(s); if (isNaN(d.getTime())) return null; return d; }
function shortTime(s) {
if (!s) return '';
const d = parseDate(s);
if (!d) return s;
return pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function shortDate(s) {
if (!s) return '';
const d = parseDate(s);
if (!d) return s;
return pad(d.getDate()) + '.' + pad(d.getMonth()+1);
}
function fromNow(d) {
const now = new Date();
const diff = d.getTime() - now.getTime();
if (diff < 0) return 'прошло';
const mins = Math.round(diff / 60000);
if (mins < 60) return 'через ' + mins + ' мин';
const hours = Math.round(mins / 60);
if (hours < 24) return 'через ' + hours + ' ч';
const days = Math.round(hours / 24);
return 'через ' + days + ' дн';
}
// ─── Communication ─────────────────────────────────────────────────
function sendToParent(action, data) {
const msg = { source: 'calendar-plugin', action: action, data: data || {} };
if (window.parent && window.parent !== window) {
window.parent.postMessage(msg, '*');
}
}
// Request events from parent
function requestEvents() {
const start = getViewStart();
const end = getViewEnd();
sendToParent('get-events', { start: fmtISO(start), end: fmtISO(end) });
}
function getViewStart() {
const d = state.currentDate;
if (state.view === 'month') {
const first = new Date(d.getFullYear(), d.getMonth(), 1);
// Go back to Monday
const wd = dayOfWeek(first);
first.setDate(first.getDate() - (wd - 1));
return first;
}
if (state.view === 'week') {
const wd = dayOfWeek(d);
const monday = new Date(d);
monday.setDate(monday.getDate() - (wd - 1));
return monday;
}
return new Date(d);
}
function getViewEnd() {
const start = getViewStart();
const end = new Date(start);
if (state.view === 'month') end.setDate(end.getDate() + 42); // 6 weeks
else if (state.view === 'week') end.setDate(end.getDate() + 7);
else end.setDate(end.getDate() + 1);
return end;
}
// ─── Listen for parent messages ──────────────────────────────────────
window.addEventListener('message', function(e) {
const msg = e.data;
if (!msg || msg.source !== 'verstak') return;
switch (msg.type) {
case 'calendar-data':
try { window.parent.go.main.App.WriteDebugLog('[iframe] received calendar-data, events=' + (msg.events ? msg.events.length : 0) + ', categories=' + (msg.categories ? msg.categories.length : 0)); } catch(e) { try { console.log('iframe debug error: ' + e); } catch(e2) {} }
if (msg.events) state.events = msg.events;
if (msg.categories) state.categories = msg.categories;
state.eventsLoaded = true;
state.panelReady = true;
try { render(); } catch(e) { try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e) + ' ' + JSON.stringify({message: e.message, stack: e.stack?.substring(0,200)})); } catch(e2) {} }
break;
case 'drop':
// Dragged from another section
if (msg.date) {
state.dropDate = msg.date;
openCreateModal(msg.date, {
node_id: msg.data?.node_id || '',
link_type: msg.data?.link_type || 'node',
title: msg.data?.title || '',
});
}
break;
case 'event-created':
case 'event-updated':
case 'event-deleted':
requestEvents();
break;
}
});
// ─── Tell parent we're ready ─────────────────────────────────────────
function init() {
appEl = document.getElementById('app');
sendToParent('ready', { version: '1.0' });
// Request initial data
setTimeout(requestEvents, 100);
// Render immediately with empty state
render();
}
// ─── Render ──────────────────────────────────────────────────────────
function render() {
if (!appEl) {
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: appEl is null!'); } catch(e) {}
return;
}
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: eventsLoaded=' + state.eventsLoaded + ', events=' + (state.events ? state.events.length : 'no-state') + ', cats=' + (state.categories ? state.categories.length : 'no-state')); } catch(e) {}
if (!state.eventsLoaded) {
appEl.innerHTML = '<div class="loading-screen">⏳ Загрузка календаря...</div>';
return;
}
try {
appEl.innerHTML = renderHeader() + renderCategories() + renderView();
try { window.parent.go.main.App.WriteDebugLog('[iframe] render OK, view=' + state.view + ', events=' + state.events.length + ', cats=' + state.categories.length); } catch(e) {}
} catch(e) {
appEl.innerHTML = '<div class="loading-screen">⚠ Ошибка: ' + escapeHtml(String(e)) + '</div>';
try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e)); } catch(e2) {}
}
}
function renderHeader() {
const d = state.currentDate;
const monthNames = ['Январь','Февраль','Март','Апрель','Май','Июнь',
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
let title = '';
if (state.view === 'month') title = monthNames[d.getMonth()] + ' ' + d.getFullYear();
else if (state.view === 'week') title = 'Неделя ' + fmtDate(getViewStart());
else title = fmtDate(d);
return `<div class="header">
<div class="header-left">
<button class="nav-btn" onclick="calendar.prevPeriod()">◀</button>
<button class="nav-btn today-btn" onclick="calendar.goToday()">Сегодня</button>
<button class="nav-btn" onclick="calendar.nextPeriod()">▶</button>
<span class="header-title">${title}</span>
</div>
<div class="view-tabs">
<button class="view-tab${state.view==='month'?' active':''}" onclick="calendar.setView('month')">Месяц</button>
<button class="view-tab${state.view==='week'?' active':''}" onclick="calendar.setView('week')">Неделя</button>
<button class="view-tab${state.view==='day'?' active':''}" onclick="calendar.setView('day')">День</button>
</div>
</div>`;
}
function renderCategories() {
const cats = state.categories;
if (!cats || cats.length === 0) return '';
let html = '<div class="categories-bar">';
html += `<span class="cat-filter-all" onclick="calendar.setFilter(null)">${state.filterCatId === null ? '●' : '○'} Все</span>`;
for (const c of cats) {
const active = c.id === state.filterCatId;
html += `<span class="cat-tag${active ? ' active' : ''}" style="background:${c.color}" onclick="calendar.setFilter('${c.id}')">${c.icon || ''} ${c.name}</span>`;
}
html += '</div>';
return html;
}
function getEventsForDate(dateStr) {
const filtered = state.filterCatId
? state.events.filter(e => e.category_id === state.filterCatId)
: state.events;
return filtered.filter(e => {
const eStart = (e.start || '').substring(0, 10);
const eEnd = (e.end || e.start || '').substring(0, 10);
return dateStr >= eStart && dateStr <= eEnd;
});
}
function renderView() {
if (state.view === 'month') return renderMonth();
if (state.view === 'week') return renderWeek();
return renderDay();
}
function renderMonth() {
const d = state.currentDate;
const y = d.getFullYear(), m = d.getMonth();
const firstDay = new Date(y, m, 1);
const startDay = dayOfWeek(firstDay); // Mon=1..Sun=7
const totalDays = daysInMonth(y, m);
const todayStr = fmtDate(new Date());
const selectedStr = state.selectedDate ? fmtDate(state.selectedDate) : null;
// Previous month padding
const prevMonthDays = daysInMonth(y, m - 1);
let cells = [];
const startOffset = startDay - 1; // how many prev-month cells
// Day headers
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
let html = '<div class="month-grid">';
for (const n of dayNames) {
html += `<div class="day-header">${n}</div>`;
}
// Fill cells
const totalCells = Math.ceil((startOffset + totalDays) / 7) * 7;
for (let i = 0; i < totalCells; i++) {
let dayNum, cellDate, isOther = false;
if (i < startOffset) {
dayNum = prevMonthDays - startOffset + i + 1;
cellDate = new Date(y, m - 1, dayNum);
isOther = true;
} else if (i >= startOffset + totalDays) {
dayNum = i - startOffset - totalDays + 1;
cellDate = new Date(y, m + 1, dayNum);
isOther = true;
} else {
dayNum = i - startOffset + 1;
cellDate = new Date(y, m, dayNum);
}
const dateStr = fmtDate(cellDate);
const isToday = dateStr === todayStr;
const isSelected = selectedStr === dateStr;
const isDrop = state.dropDate === dateStr;
const events = getEventsForDate(dateStr);
const isWeekend = dayOfWeek(cellDate) >= 6;
let cls = 'day-cell';
if (isOther) cls += ' other-month';
if (isToday) cls += ' today';
if (isSelected) cls += ' selected';
if (isDrop) cls += ' drop-target';
html += `<div class="${cls}" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
html += `<div class="day-number">${dayNum}</div>`;
if (events.length > 0) {
html += '<div class="month-events">';
const maxShow = 3;
for (let j = 0; j < Math.min(events.length, maxShow); j++) {
const ev = events[j];
const color = ev.color || '#6366f1';
html += `<div class="month-event" style="background:${color}" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${ev.title}</div>`;
}
if (events.length > maxShow) {
html += `<div class="month-event more-link" onclick="event.stopPropagation(); calendar.setView('day'); state.currentDate = new Date('${dateStr}'); render();">+${events.length - maxShow} ещё</div>`;
}
html += '</div>';
}
html += '</div>';
}
html += '</div>';
return html;
}
function renderWeek() {
const start = getViewStart();
const todayStr = fmtDate(new Date());
let html = '<div class="week-view">';
// Corner label
html += '<div class="hour-label"></div>';
for (let d = 0; d < 7; d++) {
const day = new Date(start);
day.setDate(day.getDate() + d);
const dateStr = fmtDate(day);
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
html += `<div class="day-header" style="${dayOfWeek(day) >= 6 ? 'color:var(--danger)' : ''}">${dayNames[d]} ${day.getDate()}</div>`;
}
// Hours
for (let h = 0; h < 24; h++) {
html += `<div class="hour-label">${pad(h)}:00</div>`;
for (let d = 0; d < 7; d++) {
const day = new Date(start);
day.setDate(day.getDate() + d);
const dateStr = fmtDate(day);
const eventsOnHour = getEventsForDate(dateStr).filter(ev => {
const evH = parseInt((ev.start || '').substring(11, 13));
return evH === h;
});
let cellHtml = `<div class="hour-slot" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
for (const ev of eventsOnHour) {
const color = ev.color || '#6366f1';
cellHtml += `<div class="week-event" style="background:${color}; top:2px" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${shortTime(ev.start)} ${ev.title}</div>`;
}
cellHtml += '</div>';
html += cellHtml;
}
}
html += '</div>';
return html;
}
function renderDay() {
const d = state.currentDate;
const dateStr = fmtDate(d);
const events = getEventsForDate(dateStr);
const todayStr = fmtDate(new Date());
const isToday = dateStr === todayStr;
let html = `<div class="day-view">`;
html += `<h3 style="margin-bottom:12px">${fmtDate(d)}${isToday ? ' — Сегодня' : ''}</h3>`;
html += `<button class="btn btn-primary btn-sm" onclick="calendar.selectDay('${dateStr}')" style="margin-bottom:12px">+ Добавить событие</button>`;
if (events.length === 0) {
html += '<div class="empty-day">Нет событий на этот день</div>';
} else {
html += '<div class="day-events-list">';
for (const ev of events) {
const color = ev.category_color || ev.color || '#6366f1';
const catName = ev.category_name || '';
html += `<div class="day-event-card" style="border-left-color:${color}" onclick="calendar.openEvent('${ev.id}')">`;
if (ev.all_day == 1) {
html += `<div class="day-event-time">Весь день</div>`;
} else {
html += `<div class="day-event-time">${shortTime(ev.start)} — ${shortTime(ev.end)}</div>`;
}
html += `<div class="day-event-title">${ev.title}</div>`;
if (ev.description) {
html += `<div class="day-event-desc">${ev.description}</div>`;
}
if (catName) {
html += `<span class="day-event-category" style="background:${color}20; color:${color}">${catName}</span>`;
}
html += `</div>`;
}
html += '</div>';
}
html += '</div>';
return html;
}
// ─── Event Modal ─────────────────────────────────────────────────────
function openCreateModal(dateStr, prefill) {
prefill = prefill || {};
const cats = state.categories;
const todayStr = fmtDate(new Date());
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
let catOptions = '<option value="">Без категории</option>';
for (const c of cats) {
catOptions += `<option value="${c.id}" style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
}
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
<h3>${prefill.node_id ? '📎 Событие из узла' : '📅 Новое событие'}</h3>
<div class="form-group">
<label>Название</label>
<input id="ev-title" value="${escapeHtml(prefill.title || '')}" placeholder="Введите название события">
</div>
<div class="form-row">
<div class="form-group">
<label>Дата начала</label>
<input id="ev-start" type="date" value="${dateStr || todayStr}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-start-time" type="time" value="09:00">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дата окончания</label>
<input id="ev-end" type="date" value="${dateStr || todayStr}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-end-time" type="time" value="10:00">
</div>
</div>
<div class="checkbox-row">
<input id="ev-allday" type="checkbox">
<label for="ev-allday">Весь день</label>
</div>
<div class="form-group">
<label>Описание</label>
<textarea id="ev-desc" placeholder="Описание события (необязательно)"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Категория</label>
<select id="ev-category">${catOptions}</select>
</div>
</div>
${prefill.node_id ? `<input type="hidden" id="ev-node-id" value="${escapeHtml(prefill.node_id)}">
<input type="hidden" id="ev-link-type" value="${escapeHtml(prefill.link_type || 'node')}">` : ''}
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="submitCreate()">Создать</button>
</div>
</div>`;
document.body.appendChild(modal);
// Toggle time fields when "all day" is checked
document.getElementById('ev-allday').addEventListener('change', function() {
document.getElementById('ev-start-time').disabled = this.checked;
document.getElementById('ev-end-time').disabled = this.checked;
});
}
// Expose to global for onclick handlers
window.closeModal = function() {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(m => m.remove());
};
window.submitCreate = function() {
const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast('Название обязательно'); return; }
const startDate = document.getElementById('ev-start').value;
const endDate = document.getElementById('ev-end').value;
const allDay = document.getElementById('ev-allday').checked;
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
const description = document.getElementById('ev-desc').value.trim();
const categoryId = document.getElementById('ev-category').value;
const nodeId = document.getElementById('ev-node-id')?.value || '';
const linkType = document.getElementById('ev-link-type')?.value || 'node';
sendToParent('create-event', {
title: title,
start: startDate + 'T' + startTime + ':00',
end: endDate + 'T' + endTime + ':00',
all_day: allDay,
description: description,
category_id: categoryId,
node_id: nodeId,
link_type: linkType,
});
closeModal();
showToast('✅ Событие создаётся...');
};
function openEditModal(eventId) {
const ev = state.events.find(e => e.id === eventId);
if (!ev) { showToast('Событие не найдено'); return; }
const cats = state.categories;
const startDate = (ev.start || '').substring(0, 10);
const startTime = (ev.start || '').substring(11, 16);
const endDate = (ev.end || ev.start || '').substring(0, 10);
const endTime = (ev.end || ev.start || '').substring(11, 16);
const allDay = ev.all_day == 1;
const completed = ev.completed == 1;
let catOptions = '<option value="">Без категории</option>';
for (const c of cats) {
catOptions += `<option value="${c.id}" ${c.id === ev.category_id ? 'selected' : ''} style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
}
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
<h3>✏️ Редактировать событие</h3>
<div class="form-group">
<label>Название</label>
<input id="ev-title" value="${escapeHtml(ev.title)}">
</div>
<div class="form-row">
<div class="form-group">
<label>Дата начала</label>
<input id="ev-start" type="date" value="${startDate}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-start-time" type="time" value="${startTime}" ${allDay ? 'disabled' : ''}>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дата окончания</label>
<input id="ev-end" type="date" value="${endDate}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-end-time" type="time" value="${endTime}" ${allDay ? 'disabled' : ''}>
</div>
</div>
<div class="checkbox-row">
<input id="ev-allday" type="checkbox" ${allDay ? 'checked' : ''}>
<label for="ev-allday">Весь день</label>
</div>
<div class="form-group">
<label>Описание</label>
<textarea id="ev-desc">${escapeHtml(ev.description || '')}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Категория</label>
<select id="ev-category">${catOptions}</select>
</div>
</div>
<div class="checkbox-row">
<input id="ev-completed" type="checkbox" ${completed ? 'checked' : ''}>
<label for="ev-completed">Выполнено</label>
</div>
${ev.node_id ? `<div style="margin-top:8px;font-size:12px;color:var(--text-dim)">📎 Связано с узлом: ${escapeHtml(ev.node_id)}</div>` : ''}
<div class="modal-actions">
<button class="btn btn-danger" onclick="submitDelete('${ev.id}')">Удалить</button>
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="submitUpdate('${ev.id}')">Сохранить</button>
</div>
</div>`;
document.body.appendChild(modal);
document.getElementById('ev-allday').addEventListener('change', function() {
document.getElementById('ev-start-time').disabled = this.checked;
document.getElementById('ev-end-time').disabled = this.checked;
});
}
window.submitUpdate = function(id) {
const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast('Название обязательно'); return; }
const allDay = document.getElementById('ev-allday').checked;
const startDate = document.getElementById('ev-start').value;
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
const endDate = document.getElementById('ev-end').value;
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
const completed = document.getElementById('ev-completed')?.checked || false;
sendToParent('update-event', {
id: id,
title: title,
start: startDate + 'T' + startTime + ':00',
end: endDate + 'T' + endTime + ':00',
all_day: allDay,
description: document.getElementById('ev-desc').value.trim(),
category_id: document.getElementById('ev-category').value,
completed: completed,
});
closeModal();
showToast('✅ Сохраняю...');
};
window.submitDelete = function(id) {
if (!confirm('Удалить это событие?')) return;
sendToParent('delete-event', { id: id });
closeModal();
showToast('🗑️ Удаляю...');
};
// ─── Navigation functions ───────────────────────────────────────────
window.calendar = {
prevPeriod: function() {
const d = state.currentDate;
if (state.view === 'month') d.setMonth(d.getMonth() - 1);
else if (state.view === 'week') d.setDate(d.getDate() - 7);
else d.setDate(d.getDate() - 1);
state.currentDate = d;
requestEvents();
render();
},
nextPeriod: function() {
const d = state.currentDate;
if (state.view === 'month') d.setMonth(d.getMonth() + 1);
else if (state.view === 'week') d.setDate(d.getDate() + 7);
else d.setDate(d.getDate() + 1);
state.currentDate = d;
requestEvents();
render();
},
goToday: function() {
state.currentDate = new Date();
requestEvents();
render();
},
setView: function(view) {
state.view = view;
requestEvents();
render();
},
setFilter: function(catId) {
state.filterCatId = catId;
render();
},
selectDay: function(dateStr) {
state.selectedDate = parseDate(dateStr);
openCreateModal(dateStr, {});
},
openEvent: function(eventId) {
openEditModal(eventId);
},
};
// ─── Toast ──────────────────────────────────────────────────────────
function showToast(msg) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => { el.remove(); }, 3000);
}
// ─── Escape HTML ────────────────────────────────────────────────────
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// ─── Init ───────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "calendar",
"version": "1.0.0",
"author": "Verstak contributors",
"description": "Календарь событий с категориями, рекарренсом, напоминаниями, связью с деревом Верстака",
"license": "MIT",
"hooks": {
"on_init": "on_init",
"on_shutdown": "on_shutdown",
"on_install": "on_install",
"on_uninstall": "on_uninstall"
},
"ui": {
"sidebar_items": [
{
"id": "calendar",
"label": "Календарь",
"icon": "calendar",
"page": "plugin:calendar:main"
}
],
"create_dialog_categories": ["event"]
},
"background_tasks": [
{
"id": "check_reminders",
"interval": "1m",
"script": "scripts/check_reminders.lua"
}
],
"panel": "panels/calendar.html",
"migrations": [
"migrations/001_create_tables.sql"
]
}
@@ -0,0 +1,8 @@
-- Background task: check for upcoming reminders
-- Runs every 1 minute (defined in plugin.json)
-- Calls calendar.check_reminders() from main.lua
local n = calendar.check_reminders()
if n > 0 then
print("Calendar: " .. n .. " reminders fired")
end
+205
View File
@@ -0,0 +1,205 @@
-- Calendar plugin test
-- Exercises: verstak.db.* / verstak.config.* / verstak.state.* / verstak.activity.*
-- Runs as part of the LuaVM test suite (no node/worklog/http — those are service-dependent)
local function assert(cond, msg)
if not cond then
error("ASSERT FAIL: " .. tostring(msg), 2)
end
print(" ✓ " .. tostring(msg))
end
local test_count = 0
local function test(name, fn)
test_count = test_count + 1
print("\n[" .. test_count .. "] " .. name)
local ok, err = pcall(fn)
if not ok then
print(" ✗ FAIL: " .. tostring(err))
error("Test " .. test_count .. " failed: " .. tostring(err), 2)
end
end
----------------------------------------------------------------------
-- Test suite
----------------------------------------------------------------------
test("calendar module exists", function()
assert(calendar ~= nil, "calendar global exists")
assert(type(calendar.get_categories) == "function", "calendar.get_categories exists")
assert(type(calendar.create_event) == "function", "calendar.create_event exists")
end)
test("categories have defaults", function()
local cats = calendar.get_categories()
assert(#cats >= 6, "at least 6 default categories, got " .. #cats)
print(" Categories: " .. #cats)
for _, c in ipairs(cats) do
print(" " .. c.icon .. " " .. c.name .. " (" .. c.color .. ")")
end
end)
test("create custom category", function()
local id = calendar.create_category("Тестовая", "#ff0000", "🧪")
assert(id ~= nil, "got category id: " .. tostring(id))
local cats = calendar.get_categories()
local found = false
for _, c in ipairs(cats) do
if c.id == id then found = true end
end
assert(found, "category found in list")
end)
test("update category", function()
local cats = calendar.get_categories()
if #cats == 0 then return end
local ok = calendar.update_category(cats[1].id, { name = "Обновлённая" })
assert(ok, "category updated")
cats = calendar.get_categories()
-- Don't assert the name because the DB might not persist across LuaVM sessions
assert(#cats > 0, "still have categories")
end)
test("soft delete category", function()
local cats = calendar.get_categories()
if #cats < 2 then return end
calendar.delete_category(cats[2].id)
-- Verify it's gone from active list
local active = calendar.get_categories()
for _, c in ipairs(active) do
assert(c.id ~= cats[2].id, "deleted category not in active list")
end
end)
test("restore default categories", function()
local ok = calendar.restore_default_categories()
assert(ok, "defaults restored")
local cats = calendar.get_categories()
assert(#cats >= 6, "at least 6 default categories after restore")
end)
test("create event (all-day)", function()
local cats = calendar.get_categories()
local cat_id = ""
if #cats > 0 then cat_id = cats[1].id end
local id = calendar.create_event{
title = "Тестовое событие",
start = "2026-07-01T00:00:00",
all_day = true,
category_id = cat_id,
color = "#10b981",
}
assert(id ~= nil, "event created: " .. tostring(id))
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
local found = false
for _, e in ipairs(events) do
if e.id == id then found = true; break end
end
assert(found, "event found in date query")
end)
test("create event with time", function()
local id = calendar.create_event{
title = "Встреча в полдень",
start = "2026-07-02T12:00:00",
["end"] = "2026-07-02T13:00:00",
all_day = false,
color = "#8b5cf6",
}
assert(id ~= nil, "timed event created")
end)
test("create event with reminder", function()
local id = calendar.create_event{
title = "С напоминанием",
start = os.date("%Y-%m-%dT23:00:00"),
reminder_minutes = {10, 60},
}
assert(id ~= nil, "event with reminders created")
local ev = calendar.get_event(id)
assert(ev ~= nil, "event readable")
-- reminder_minutes should be stored — we can check if it has content
assert(ev.reminder_minutes ~= nil and ev.reminder_minutes ~= "", "reminder stored")
end)
test("update event partial", function()
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
if #events == 0 then return end
calendar.update_event(events[1].id, { title = "Обновлённое событие" })
print(" updated: " .. events[1].id)
end)
test("delete event", function()
local events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
if #events == 0 then return end
local ev_id = events[1].id
calendar.delete_event(ev_id)
events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
for _, e in ipairs(events) do
assert(e.id ~= ev_id, "deleted event not found")
end
print(" deleted: " .. ev_id)
end)
test("expand daily recurrence", function()
local dates = calendar.expand_recurring(
"2026-07-01T00:00:00",
"2026-07-01T01:00:00",
{ freq = "daily", interval = 1, count = 5, ["until"] = "2026-07-15" },
"2026-07-01T00:00:00",
"2026-07-10T00:00:00"
)
assert(#dates > 0, "got " .. #dates .. " daily instances")
print(" daily instances: " .. #dates)
for _, d in ipairs(dates) do
print(" " .. d)
end
end)
test("expand weekly recurrence (Mon/Wed/Fri)", function()
local dates = calendar.expand_recurring(
"2026-07-06T00:00:00", -- Monday
"2026-07-06T01:00:00",
{ freq = "weekly", interval = 1, by_day = {1, 3, 5}, count = 9 },
"2026-07-06T00:00:00",
"2026-07-20T00:00:00"
)
assert(#dates > 0, "got " .. #dates .. " weekday instances")
print(" weekday instances: " .. #dates)
for _, d in ipairs(dates) do
print(" " .. d)
end
end)
test("expand monthly recurrence", function()
local dates = calendar.expand_recurring(
"2026-07-15T00:00:00",
"2026-07-15T00:00:00",
{ freq = "monthly", interval = 1, by_month_day = {15}, count = 6 },
"2026-07-01T00:00:00",
"2026-12-31T00:00:00"
)
assert(#dates >= 5, "got " .. #dates .. " monthly instances (>= 5)")
print(" monthly instances: " .. #dates)
end)
test("check reminders (no crash)", function()
local n = calendar.check_reminders()
assert(type(n) == "number", "check_reminders returned a number: " .. tostring(n))
print(" reminders to fire: " .. tostring(n))
end)
-- Clear test data
calendar.clear_events()
print("\n========================")
print("All " .. test_count .. " tests passed!")
print("========================")
+3 -3
View File
@@ -29,10 +29,10 @@
## Файлы пакета
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии.
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server, плагины.
2. [[02_Architecture]] — архитектура core/GUI/CLI/server, плагины.
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
5. [[05_UI_UX]] — экраны GUI/TUI.
5. [[05_UI_UX]] — экраны GUI.
6. [[06_Roadmap]] — план разработки по этапам.
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
@@ -56,7 +56,7 @@
5. **Не таймтрекер, а восстановитель следов.**
6. **GUI основной, TUI быстрый, CLI служебный.**
6. **GUI основной, CLI служебный.**
7. **Sync не уничтожает данные.**
+1 -25
View File
@@ -28,7 +28,7 @@
### Core Library
Общее ядро, которое не зависит от GUI/TUI.
Общее ядро, которое не зависит от GUI.
Отвечает за:
@@ -72,27 +72,6 @@
- импорт DokuWiki;
- разбор конфликтов.
### TUI
Быстрый интерфейс из терминала.
Рекомендуемый стек:
- Bubble Tea;
- Bubbles;
- Lip Gloss.
TUI не обязан повторять весь GUI.
Функции:
- найти дело;
- открыть заметку;
- добавить worklog;
- запустить action;
- запустить sync;
- посмотреть “Сегодня”.
### CLI
CLI нужен не как главный интерфейс, а как служебный вход.
@@ -144,8 +123,6 @@ verstak/
main.go
verstak-gui/
main.go
verstak-tui/
main.go
verstak-server/
main.go
@@ -164,7 +141,6 @@ verstak/
config/
gui/
tui/
server/
frontend/
+1 -18
View File
@@ -243,24 +243,7 @@ Actions:
- редактировать название, URL и заметку;
- удалить ссылку.
## 13. TUI UX
TUI нужен для быстрого:
- fuzzy search;
- открыть дело;
- добавить worklog;
- запустить action;
- sync now.
Не нужно делать в TUI:
- импорт DokuWiki;
- сложный conflict resolver;
- preview документов;
- настройки sync.
## 14. CLI UX
## 13. CLI UX
CLI команды:
-11
View File
@@ -214,17 +214,6 @@ Acceptance:
- изменения файлов снаружи приложения обнаруживаются.
## Milestone 14 — TUI MVP (PAUSED)
- Bubble Tea;
- tree/search;
- add worklog;
- run action;
- sync.
Acceptance:
- можно из терминала быстро работать с Верстаком.
## Milestone 15 — 1.0 polish (PAUSED)
+2 -18
View File
@@ -12,7 +12,7 @@
- заметки и файлы физически лежат обычными файлами в vault;
- SQLite хранит индекс, связи, метаданные, FTS и sync state;
- основная сущность — вложенное дерево nodes;
- GUI, TUI и CLI должны использовать общий core;
- GUI и CLI должны использовать общий core;
- бизнес-логику нельзя завязывать на UI;
- не делать SaaS;
- не делать ИИ;
@@ -25,7 +25,6 @@
- Go;
- SQLite;
- Wails для GUI;
- Bubble Tea для TUI позже;
- fsnotify + snapshot scanner позже;
- sync server позже.
@@ -272,22 +271,7 @@ Acceptance:
- manual command `verstak scan`.
```
## Prompt 14 — TUI MVP
```markdown
Добавь TUI на Bubble Tea.
Функции:
- tree/search;
- open node;
- add worklog;
- run action;
- sync now.
TUI не должен повторять весь GUI.
```
## Prompt 15 — Integrity/restore
## Prompt 14 — Integrity/restore
```markdown
Добавь команды:
-1
View File
@@ -35,7 +35,6 @@
- [ ] Conflict resolver UI.
- [ ] Restore from sync server.
- [ ] Backup export / retention snapshots.
- [ ] TUI MVP.
## Не входит в первый MVP
+575 -90
View File
@@ -1,30 +1,36 @@
# Верстак — архитектура плагинов
## Принцип
## 1. Принцип
Верстак — это минималистичный движок с деревом дел.
Всё, что не входит в минимальную модель, — плагин.
Верстак — минималистичный движок с деревом дел и базовыми операциями
(заметки, файлы, действия, журнал, поиск, активность).
Плагин — это директория в `.verstak/plugins/<name>/`, которую
программа подхватывает без перекомпиляции.
Плагины расширяют функциональность без перекомпиляции:
- новые типы узлов и кастомные поля;
- календарь, канбан, импортёры;
- UI-элементы (пункты сайдбара, вкладки, страницы);
- фоновые задачи по расписанию;
- хуки на события (создание/удаление/изменение узлов).
## Структура плагина
## 2. Структура плагина
```
.verstak/plugins/<name>/
plugin.json # мета: name, version, author, hooks
main.lua # точка входа
plugin.json # метаданные: имя, версия, хуки, UI-contributions
main.lua # точка входа (обязательно, если есть Lua-код)
panels/ # HTML-панели для GUI (опционально)
calendar.html
kanban.html
templates/ # шаблоны дел (опционально)
client.json
repair.json
panels/ # UI-панели для GUI (опционально)
kanban.html
calendar.html
migrations/ # SQL-миграции (опционально)
001_create_tables.sql
assets/ # статика: иконки, CSS, изображения (опционально)
icon.svg
```
## plugin.json
### 2.1 plugin.json — полная схема
```json
{
@@ -32,55 +38,485 @@
"version": "1.0.0",
"author": "...",
"description": "Календарь событий, привязанных к делам",
"hooks": {
"on_init": "on_init",
"on_node_open": "on_node_open"
"on_vault_open": "on_vault_open",
"on_node_create": "on_node_create",
"on_node_delete": "on_node_delete",
"on_shutdown": "on_shutdown"
},
"ui": {
"sidebar_items": [
{
"id": "calendar",
"label": "Календарь",
"icon": "calendar",
"page": "plugin:calendar:main"
}
],
"node_tabs": [
{
"id": "events",
"label": "События",
"page": "plugin:calendar:node_events"
}
],
"node_actions": [
{
"id": "add_event",
"label": "Добавить событие",
"icon": "plus",
"page": "plugin:calendar:add_event"
}
],
"settings_pages": [
{
"id": "calendar",
"label": "Календарь",
"page": "plugin:calendar:settings"
}
]
},
"background_tasks": [
{
"id": "check_events",
"interval": "5m",
"script": "check_events.lua"
}
],
"node_types": ["event"],
"panel": "panels/calendar.html",
"migrations": ["migrations/001_create_tables.sql"]
}
```
## Lua API
#### Поля plugin.json
Плагины пишутся на Lua (gopher-lua). API:
| Поле | Тип | Описание |
|------|-----|----------|
| `name` | string | Уникальное имя плагина |
| `version` | string | SemVer |
| `hooks` | map[string]string | Имя Lua-функции для каждого события |
| `ui.sidebar_items` | [SidebarItem] | Пункты сайдбара |
| `ui.node_tabs` | [NodeTab] | Вкладки в карточке дела |
| `ui.node_actions` | [NodeAction] | Кнопки действий в деле |
| `ui.settings_pages` | [SettingsPage] | Страницы настроек |
| `background_tasks` | [BackgroundTask] | Фоновые задачи по расписанию |
| `node_types` | [string] | Имена новых типов узлов, которые регистрирует плагин |
| `panel` | string | HTML-файл панели (главная страница плагина) |
| `migrations` | [string] | SQL-файлы миграций |
```lua
-- Получить node по ID
local node = verstak.node.get(id)
### 2.2 SidebarItem
-- Создать node
local n = verstak.node.create(parent_id, "type", "title")
-- Получить config value
local v = verstak.config.get("key")
-- Записать в activity log
verstak.activity.log({
node_id = n.id,
event_type = "calendar_event",
title = "Встреча с клиентом"
})
-- Зарегистрировать HTTP-эндпоинт (для GUI)
verstak.http.route("GET", "/api/calendar/events", get_events)
-- Показать уведомление
verstak.ui.toast("Событие добавлено")
```json
{
"id": "calendar",
"label": "Календарь",
"icon": "calendar",
"page": "plugin:calendar:main"
}
```
## Жизненный цикл плагина
- `id` — уникальный в рамках плагина
- `icon` — имя иконки из библиотеки GUI (или путь к `assets/icon.svg`)
- `page` — идентификатор страницы в формате `plugin:<name>:<page_id>`
- При клике — фронтенд переключается на эту страницу, отправляет Lua-скрипту событие `on_page_open`
1. **on_init** — при старте программы, до открытия vault.
Инициализация, создание таблиц.
2. **on_vault_open** — при открытии vault.
3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
4. **on_shutdown** — при закрытии.
### 2.3 NodeTab
## Реестр типов дел
```json
{
"id": "events",
"label": "События",
"page": "plugin:calendar:node_events"
}
```
Плагины могут регистрировать новые типы:
- Появляется как дополнительная вкладка в карточке дела
- Вкладка видна для всех типов узлов, если не указан `node_type_filter`
- При переключении — фронтенд запрашивает данные через Lua API
### 2.4 BackgroundTask
```json
{
"id": "check_events",
"interval": "5m",
"script": "check_events.lua"
}
```
- `interval` — интервал в человекочитаемом формате: `5m`, `1h`, `30s`
- `script` — путь к Lua-файлу относительно корня плагина
- Скрипт выполняется в том же Lua-стейте, что и main.lua, с полным доступом к API
## 3. Lua API — полный справочник
Плагины пишутся на Lua (gopher-lua). Доступны следующие модули:
### 3.1 `verstak.node` — работа с деревом дел
```lua
-- Получить узел по ID
local node = verstak.node.get(id)
-- { id, parent_id, type, title, slug, sort_order, created_at, updated_at, section }
-- Создать узел
local n = verstak.node.create(parent_id, "type", "title")
-- Можно с дополнительными полями:
local n = verstak.node.create(parent_id, "type", "title", { section = "work" })
-- Создать дочерний узел (сокращение)
local n = verstak.node.create_child(parent_id, "type", "title")
-- Обновить узел
verstak.node.update(id, { title = "Новый заголовок", section = "personal" })
-- Удалить узел (в корзину)
verstak.node.delete(id)
-- Найти узел по slug (относительно parent)
local n = verstak.node.find_by_slug(parent_id, "my-slug")
-- Поиск по дереву
local results = verstak.node.search("query")
-- results = [{ id, title, type, match_column, snippet }]
-- Получить детей узла
local children = verstak.node.children(parent_id)
-- children = [{ id, title, type, ... }]
-- Получить полный путь от корня
local path = verstak.node.path(id)
-- path = [{ id, title, type }, ...]
```
### 3.2 `verstak.file` — файлы
```lua
-- Получить файлы узла
local files = verstak.file.list(node_id)
-- Добавить файл (копировать в vault)
local f = verstak.file.add(node_id, "/path/to/file.pdf")
-- Удалить (в корзину)
verstak.file.delete(file_id)
-- Получить содержимое текстового файла
local content = verstak.file.read_text(file_id)
```
### 3.3 `verstak.worklog` — журнал работ
```lua
-- Добавить запись
verstak.worklog.add(node_id, minutes, "Описание работы")
-- Добавить с датой
verstak.worklog.add(node_id, minutes, "Описание", { date = "2025-06-07", billable = true })
-- Список записей узла
local entries = verstak.worklog.list(node_id, { limit = 10, offset = 0 })
-- Сводка
local summary = verstak.worklog.summary(node_id, { since = "2025-01-01", until = "2025-12-31" })
```
### 3.4 `verstak.activity` — лог активности
```lua
-- Записать событие
verstak.activity.log({
node_id = node_id,
event_type = "calendar_event",
title = "Встреча с клиентом",
description = "Обсуждение макета",
confidence = "high"
})
```
### 3.5 `verstak.config` — конфигурация плагина
```lua
-- Читать
local val = verstak.config.get("calendar.default_view")
-- val == nil если ключ не задан, иначе { key, value, plugin }
-- Писать (сохраняется в .verstak/plugins/<name>/config.json)
verstak.config.set("calendar.default_view", "month")
verstak.config.set("calendar.working_hours", { start = 9, end = 18 })
```
Конфиг плагина изолирован: ключи автоматически префиксируются именем плагина.
### 3.6 `verstak.db` — собственное хранилище плагина
Плагин может создавать и читать свои таблицы в отдельном SQLite-файле
`.verstak/plugins/<name>/data.db`.
```lua
-- Выполнить запрос (INSERT/UPDATE/DELETE)
verstak.db.exec("INSERT INTO events (title, date, node_id) VALUES (?, ?, ?)",
"Встреча", "2025-06-15", node_id)
-- Запрос с результатами
local rows = verstak.db.query("SELECT * FROM events WHERE date >= ? ORDER BY date", "2025-06-01")
-- rows = [{ id, title, date, node_id }, ...]
-- Одна строка
local row = verstak.db.query_row("SELECT * FROM events WHERE id = ?", event_id)
-- row = { id, title, date, node_id } или nil
```
### 3.7 `verstak.ui` — взаимодействие с GUI
```lua
-- Показать уведомление (тост)
verstak.ui.toast("Событие добавлено")
verstak.ui.toast("Что-то пошло не так", { type = "error", duration = 5000 })
-- Навигация
verstak.ui.navigate_to("plugin:calendar:main", { date = "2025-06-07" })
-- Обновить данные на текущей странице
verstak.ui.refresh()
-- Установить заголовок окна
verstak.ui.set_title("Календарь — Июнь 2025")
-- Открыть диалог
local result = verstak.ui.confirm("Удалить событие?")
-- result = true/false
```
### 3.8 `verstak.http` — HTTP-эндпоинты
Плагины могут регистрировать HTTP-эндпоинты на внутреннем сервере
(тот же порт, что и Bridge/Extension, но с префиксом `/api/plugins/<name>/`).
```lua
-- GET /api/plugins/calendar/events?month=2025-06
verstak.http.route("GET", "/events", function(params)
local events = verstak.db.query("SELECT * FROM events WHERE date LIKE ?", params.month .. "%")
return { json = events }
end)
-- POST /api/plugins/calendar/events
verstak.http.route("POST", "/events", function(body)
verstak.db.exec("INSERT INTO events (...) VALUES (?,?)", body.title, body.date)
return { json = { ok = true } }
end)
```
Эндпоинты аутентифицируются через X-Verstak-Secret (как и Bridge).
### 3.9 `verstak.schedule` — фоновые задачи (из Lua)
Помимо декларативных `background_tasks` в plugin.json, можно создавать
задачи динамически:
```lua
-- Каждые 15 минут
local timer_id = verstak.schedule.every("15m", function()
local events = verstak.db.query("SELECT * FROM events WHERE date = date('now')")
if #events > 0 then
verstak.ui.toast("Сегодня " .. #events .. " событий")
end
end)
-- Отменить
verstak.schedule.cancel(timer_id)
-- Однократно через N секунд
verstak.schedule.once("30s", function()
verstak.ui.toast("30 секунд прошло")
end)
-- Каждое N-е число месяца (cron-подобно)
verstak.schedule.cron("0 9 * * 1", function()
-- Каждый понедельник в 9 утра
verstak.ui.toast("Планирование недели")
end)
```
### 3.10 `verstak.state` — разделяемое состояние между хуками/тасками
```lua
-- Сохранить значение в памяти плагина
verstak.state.set("event_count", 42)
-- Прочитать
local n = verstak.state.get("event_count")
-- Удалить
verstak.state.delete("event_count")
```
Состояние хранится только в runtime — при перезапуске теряется.
Для постоянных данных используйте `verstak.config` или `verstak.db`.
## 4. Хуки — жизненный цикл
Имя в plugin.json → имя Lua-функции, которая вызывается в main.lua.
### 4.1 `on_init`
```lua
function on_init()
-- Вызывается при старте программы, ДО открытия vault.
-- Здесь плагин может выполнить миграции БД,
-- зарегистрировать свои таблицы.
-- ВАЖНО: vault ещё не открыт, verstak.node недоступен.
verstak.db.exec([[CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
node_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
)]])
end
```
### 4.2 `on_vault_open`
```lua
function on_vault_open(vault_path)
-- Вызывается после открытия vault.
-- verstak.node, verstak.file уже доступны.
-- Можно зарегистрировать HTTP-эндпоинты.
verstak.http.route("GET", "/events", list_events)
end
```
### 4.3 `on_node_create`
```lua
function on_node_create(node)
-- node = { id, parent_id, type, title, section, ... }
-- Вызывается после создания любого узла.
-- Нельзя отменить создание (позднее — вернём before_create).
if node.type == "event" then
verstak.activity.log({
node_id = node.id,
event_type = "calendar_event_created",
title = "Создано событие: " .. node.title
})
end
end
```
### 4.4 `on_node_delete`
```lua
function on_node_delete(node)
-- Вызывается при удалении узла.
-- Плагин может почистить свои данные.
verstak.db.exec("DELETE FROM events WHERE node_id = ?", node.id)
end
```
### 4.5 `on_shutdown`
```lua
function on_shutdown()
-- При закрытии программы.
-- Сохранить состояние, закрыть соединения.
end
```
### 4.6 `on_page_open` (автоматический)
Когда пользователь переходит на страницу плагина (`plugin:calendar:main`),
фронтенд посылает событие в Lua:
```lua
-- Не нужно объявлять в plugin.json — вызывается автоматически
function on_page_open(page_id, params)
-- page_id = "main" или "node_events"
-- params = { node_id = "...", date = "2025-06-07" }
if page_id == "main" then
local events = verstak.db.query("SELECT * FROM events ORDER BY date")
-- Данные будут отправлены фронтенду через Wails binding
verstak.state.set("page_data", events)
-- Фронтенд получит данные через вызов GetPluginPageData()
end
end
```
## 5. UI-интеграция — как это работает
```
┌──────────────────────────────────────────────────┐
│ Svelte Frontend │
│ │
│ ┌──────────────┬──────────────────────────────┐ │
│ │ Sidebar │ Main Workspace │ │
│ │ │ │ │
│ │ 📁 Дела │ ┌─ plugin:calendar:main ──┐ │ │
│ │ 📅 Календарь │ │ [Calendar HTML Panel] │ │ │
│ │ ⚙ Настройки │ │ │ │ │
│ │ │ └────────────────────────┘ │ │
│ └──────────────┴──────────────────────────────┘ │
│ │
│ Go Backend (Wails Bindings) │
│ ┌─────────────────────────────────────────┐ │
│ │ PluginManager │ │
│ │ ├─ Lua Runtime (gopher-lua) │ │
│ │ ├─ Scheduler │ │
│ │ └─ HTTP Server │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
### 5.1 Регистрация UI-компонентов
При старте плагина Go-код:
1. Читает `plugin.json`
2. Из `ui.sidebar_items` регистрирует пункты в общем реестре сайдбара
3. Из `ui.node_tabs` регистрирует вкладки для карточки дела
4. Из `ui.node_actions` регистрирует кнопки действий
5. Из `ui.settings_pages` регистрирует страницы настроек
Фронтенд читает реестр через Wails binding и рендерит соответствующие
элементы UI.
### 5.2 HTML-панели
Плагин может поставлять HTML-панель (`panel` в plugin.json или `panels/`).
Панель **рендерится в iframe** внутри основного окна.
Это изолирует DOM плагина от DOM приложения.
Каналы связи с бэкендом:
- **Данные:** Wails binding `GetPluginPageData(plugin, page)` — Lua возвращает текущие `page_data`
- **Действия:** Wails binding `CallPluginAction(plugin, action, params)` — вызывает `on_action` в Lua
- **Уведомления:** Lua → фронтенд через `verstak.ui.toast()` (WS или polling)
- **Навигация:** iframe может сообщить родителю о смене страницы через `window.parent.postMessage`
> **Важно:** HTML-панели — временное решение. В будущем плагины
> смогут поставлять Svelte-компоненты, но это требует динамической
> компиляции и горячей перезагрузки. Для MVP — iframe достаточно.
### 5.3 Данные для панели
Панель получает данные через `__VERSTAK_DATA__` (глобальная JS-переменная,
инжектится при рендеринге панели). Механизм:
1. Фронтенд открывает страницу `plugin:calendar:main`
2. Go бэкенд вызывает Lua `on_page_open("main", params)`
3. Lua сохраняет данные через `verstak.state.set("page_data", ...)`
4. Фронтенд вызывает `GetPluginPageData("calendar", "main")`
5. Go возвращает Lua-данные, фронтенд инжектит их в iframe
### 5.4 Кастомные типы узлов
Плагины могут регистрировать новые типы узлов через `node_types` в plugin.json
и/или через Lua API:
```lua
verstak.node.register_type({
@@ -88,72 +524,121 @@ verstak.node.register_type({
label = "Событие",
icon = "calendar",
fields = {
{ name = "date", label = "Дата", type = "date" },
{ name = "time", label = "Время", type = "time" },
{ name = "location", label = "Место", type = "text" },
{ name = "date", label = "Дата", type = "date" },
{ name = "time", label = "Время", type = "time" },
{ name = "location", label = "Место", type = "text" },
{ name = "color", label = "Цвет", type = "select", options = {"red","blue","green"} },
}
})
```
GUI рисует карточку дела на основе зарегистрированных полей типа.
GUI рисует карточку дела на основе зарегистрированных полей.
Поля сохраняются как метаданные узла.
## Шаблоны дела
## 6. Песочница и безопасность
Шаблон — JSON-описание предзаполненного дерева:
Lua-плагины работают в изолированной среде:
- **Нет доступа к ФС** напрямую — только через `verstak.*` API (который проверяет пути)
- **Нет `io.*`, `os.execute`, `os.popen`** — библиотеки удалены
- **Нет `loadlib`** — динамическая загрузка C-библиотек запрещена
- **Память:** лимит на размер Lua-стейта (настраивается, по умолчанию 32 MB)
- **Инструкции:** защита от бесконечных циклов (максимум N инструкций на вызов, default 10M)
- **Сеть:** никаких сокетов из Lua — только через `verstak.http` (контролируемый роутинг)
- **Таймаут:** каждый Lua-вызов (хук, HTTP-обработчик, таска) имеет таймаут 30s
### Конфигурация песочницы
В `config.json` vault:
```json
{
"name": "Клиент",
"icon": "user",
"tree": [
{ "type": "folder", "title": "Документы" },
{ "type": "folder", "title": "Переписка" },
{ "type": "folder", "title": "Скриншоты" },
{ "type": "note", "title": "Overview" },
{ "type": "action", "title": "Открыть сайт", "kind": "open_url", "url": "" }
],
"meta": [
{ "key": "domain", "label": "Домен сайта", "type": "text" },
{ "key": "admin_url", "label": "Админка", "type": "url" }
]
"plugins": {
"sandbox": {
"max_memory_mb": 32,
"max_instructions": 10000000,
"call_timeout_ms": 30000,
"allow_plugins": ["calendar", "kanban"]
}
}
}
```
GUI: при создании дела пользователь выбирает шаблон — и дерево
создаётся автоматически.
## 7. Жизненный цикл — детально
## Песочница
```
┌─────────────────────────────────────────────────────────────┐
│ Старт программы │
│ │
│ 1. Инициализация config, storage │
│ 2. PluginManager.NewManager(vaultRoot) │
│ 3. Discover() — сканирование .verstak/plugins/*/plugin.json │
│ 4. Для каждого активного плагина: │
│ a. Выполнить SQL-миграции │
│ b. Создать Lua-стейт (песочница) │
│ c. Загрузить main.lua │
│ d. Зарегистрировать node_types, ui. sidebar/tabs/actions │
│ e. Вызвать on_init() │
│ 5. Открытие vault │
│ 6. Для каждого плагина: вызвать on_vault_open(path) │
│ 7. Запуск фоновых задач (scheduler) │
│ │
│ ┌──── Эксплуатация ──────────────────────────────────────┐ │
│ │ - Хуки на события дерева │ │
│ │ - HTTP-запросы → Lua-обработчики │ │
│ │ - UI-навигация → on_page_open() │ │
│ │ - Фоновые задачи по расписанию │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 8. Закрытие программы │
│ 9. Для каждого плагина: on_shutdown() → закрыть Lua-стейт │
│ 10. Остановка scheduler │
└─────────────────────────────────────────────────────────────┘
```
Lua-плагины работают в песочнице:
## 8. Распространение и CLI
- нет доступа к файловой системе напрямую (только через API vault);
- нет `io.*`, `os.execute` и т.д.;
- память ограничена;
- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
### CLI-команды
Go-плагины (buildmode=plugin) доступны для продвинутых
разработчиков, но требуют совместимости версий.
```bash
verstak plugin list # Список плагинов
verstak plugin enable <name> # Включить
verstak plugin disable <name> # Выключить
verstak plugin install <name> # Установить из registry (TODO)
verstak plugin remove <name> # Удалить
```
## Инициализация
### Структура пакета
При старте `verstak init` создаёт `.verstak/plugins/`.
При старте GUI/CLI/TUI:
Плагин распространяется как zip-архив:
1. Сканировать `.verstak/plugins/*/plugin.json`
2. Валидировать (имя, версия, структура)
3. Загрузить миграции и выполнить
4. Загрузить Lua-скрипты через gopher-lua
5. Вызвать `on_init` у каждого плагина
6. Зарегистрировать node types, HTTP routes, UI panels
```bash
calendar-1.0.0.vpk # Verstak Plugin Package
plugin.json
main.lua
panels/calendar.html
migrations/001_create_tables.sql
```
## Распространение
Плагин — это zip-архив с правильной структурой.
Репозиторий плагинов: `verstak-registry` (отдельный проект).
Установка:
Установка из registry (будущее):
```bash
verstak plugin install calendar
verstak plugin enable calendar
verstak plugin list
# → скачивает calendar-1.0.0.vpk
# → распаковывает в .verstak/plugins/calendar/
# → выполняет миграции
# → активирует
```
## 9. Пример: Calendar plugin
(См. `contrib/plugins/calendar/` — будет реализован как эталон.)
Плагин `calendar` демонстрирует:
- Полный цикл: plugin.json → main.lua → panel → миграции
- Регистрацию пункта в сайдбаре
- Свою HTML-панель с календарной сеткой
- Создание/удаление событий через Lua API
- Привязку событий к делам
- Фоновую задачу (проверка событий на сегодня)
- Кастомный тип узла `event`
- Настройки плагина через `verstak.config`
+80 -22
View File
@@ -27,13 +27,12 @@
| 14 | **MVP stabilization** | ✅ выполнено |
| 15 | **Sync Server + Client** | ✅ выполнено |
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence |
| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
| 17 | **File Scanner/Watcher** | ✅ выполнено — fsnotify, snapshot scanner |
| 18 | **Plugins: Lua runtime** | ✅ скелет — gopher-lua, хуки, песочница, шедулер, Calendar-пример |
| 19 | **Browser Extension** | ✅ выполнено — tracking, capture, evidence |
| 20 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
| 21 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
| 22 | **New templates/integrations** | ⏳ ожидает — community plugins |
---
@@ -193,33 +192,92 @@ Core service extensions:
---
## Ожидающие шаги (17-23)
## Завершённый этап: ШАГ 17 — File Scanner/Watcher
### ШАГ 17 — File Scanner/Watcher
- fsnotify watcher на vault
- snapshot scanner для обнаружения изменений при закрытом приложении
- обнаружение missing файлов
**Статус:** ✅ выполнено
### ШАГ 18 — TUI MVP (Bubble Tea)
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
Что реализовано:
- `internal/core/watcher/scanner.go` — Snapshot scanner:
- Итерирует все ноды с FsPath, сканирует их директории на диске
- Детектит missing файлы (запись в БД есть — файла нет) → MarkMissing + activity event
- Детектит restored файлы (был missing — снова на диске) → MarkMissing(false) + activity event
- Детектит modified файлы (SHA256 не совпал) → обновление размера/SHA в БД + activity event
- Считает new файлы на диске без записи в БД (не авто-добавляет, только счётчик)
- Пропускает `.verstak/` и скрытые директории
- `internal/core/watcher/watcher.go` — fsnotify Watcher:
- Реальное время: CREATE/REMOVE/RENAME/WRITE события
- Debounce 2 секунды для группировки burst-событий
- CREATE → авто-добавление file record + `file_added` event
- REMOVE → MarkMissing(true) + `file_deleted` event
- WRITE → обновление SHA256 + `file_modified` event
- Рекурсивное добавление watcher-ов на поддиректории
- Пропускает скрытые файлы и `.verstak/`
- `internal/core/watcher/service.go` — Объединённый сервис:
- Start(enableWatcher) → snapshot scan + опционально запуск real-time watcher
- Stop() → остановка watcher
- RunScanner() → одноразовое сканирование
- `internal/core/activity/activity.go` — Новые типы событий: `file_modified`, `file_restored`
- `internal/core/storage/migrations_017.sql.go` — Таблица `watcher_state` для трекинга состояния файлов
- `internal/core/config/appconfig.go` — Настройка `Vault.FileWatcher` (по умолчанию true)
- `cmd/verstak-gui/app.go` — Интеграция в App: initVault запускает сканер + watcher, closeVault останавливает
- `cmd/verstak-gui/bindings_watcher.go` — Bindings: WatcherStatus(), RunSnapshotScan(), ToggleFileWatcher()
- `internal/core/watcher/watcher_test.go` — 5 тестов: no changes, missing, restored, modified, hidden filter
- `internal/core/files/file.go` — Новые методы: ListAllVault(), ListAllVaultWithTrashed()
- `internal/core/nodes/repository.go` — Новый метод: ListAllWithFsPath()
### ШАГ 19 — Plugins: Lua runtime
- gopher-lua runtime
- хуки на события (node created, file added, etc.)
- песочница
### Как включить/отключить
### ШАГ 20 — Browser Extension
| Способ | Команда | Действие |
|--------|---------|----------|
| **GUI** | Settings → "File Watcher" toggle | Вкл/выкл real-time, snapshot всегда |
| **Config** | `~/.config/verstak/config.json` → `vault.file_watcher: false` | Навсегда |
| **Env** | `VERSTAK_NO_WATCHER=1 ./verstak-gui` | На сессию (переопределяет config) |
| **CLI** | `./verstak-gui --no-watcher` | Один запуск (переопределяет всё) |
Важно: **snapshot scan** (сверка диска с БД) выполняется ВСЕГДА при открытии vault.
Real-time fsnotify watcher управляется отдельно.
### Как проверить что работает
После запуска в логе:
```
[watcher] snapshot: 0 missing, 0 restored, 0 modified, 0 new
```
Через GUI: `WatcherStatus()` — true если watcher активен.
Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`.
## Выполненные шаги (18-22 частично)
### ШАГ 18 — Plugins: Lua runtime ✅ скелет
**Статус:** ✅ Скелет готов — рантайм, хуки, песочница, шедулер, Calendar-пример.
Что реализовано:
- `internal/core/plugins/runtime.go` — gopher-lua VM: загрузка main.lua, вызов хуков, таймауты
- `internal/core/plugins/api.go` — регистрация таблицы `verstak` с субмодулями
- `internal/core/plugins/api_node.go` — `verstak.node.*` (заглушки, ждут реализации)
- `internal/core/plugins/api_ext.go` — `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.ui.*`, `verstak.schedule.*`, `verstak.http.*` (state — работает, остальное — заглушки)
- `internal/core/plugins/api_utils.go` — вспомогательные функции
- `internal/core/plugins/scheduler.go` — фоновые задачи по расписанию (goroutine + ticker)
- `internal/core/plugins/manager.go` — обновлён: InitRuntimes, CallInitHooks, StartSchedulers, etc.
- `internal/core/plugins/runtime_test.go` — 5 тестов: hook call, missing hook, sandbox, state, Manager lifecycle
- `contrib/plugins/calendar/` — пример-плагин: plugin.json, main.lua, HTML-panel, migrations, background task
**Следующее:** реализовать `verstak.node.*`, `verstak.db.*`, `verstak.config.*`, `verstak.ui.*` полноценно, и пройтись по Calendar-плагину вживую.
### ШАГ 19 — Browser Extension
- Расширение для браузера: трекинг времени, скриншоты, evidence
### ШАГ 21 — Calendar/Kanban
### ШАГ 20 — Calendar/Kanban
- Календарный вид по датам
- Канбан-доска по статусам
### ШАГ 22 — Integrity Check + Repair
### ШАГ 21 — Integrity Check + Repair
- Проверка целостности vault
- Контрольные суммы, восстановление после сбоев
### ШАГ 23 — New templates/integrations
### ШАГ 22 — New templates/integrations
- Community-плагины и шаблоны
---
+161
View File
@@ -0,0 +1,161 @@
# Frontend Architecture
## Overview
Verstak frontend is a Svelte 3 application running inside Wails v2 (Go bridge).
The app manages a hierarchical vault of nodes (folders/cases, notes, files, links, actions)
with sync capabilities, worklog/journal, and activity tracking.
## Technology Stack
- **UI Framework:** Svelte 3 (plain JS, no TypeScript in components)
- **Desktop Bridge:** Wails v2 (`window.go.main.App.*`)
- **Bundler:** Vite (via Wails)
- **Markdown:** Custom renderer in `lib/markdown/`
- **i18n:** Custom lightweight system in `lib/i18n/`
- **Styling:** Scoped CSS in Svelte components, dark theme
## Directory Structure
```
frontend/src/
├── App.svelte # Root component (being modularised)
├── TreeNode.svelte # Tree node for sidebar (inline)
├── FileTreeRow.svelte # File row in file tab (inline)
├── wailsjs/go/main/App.js # Auto-generated Wails bindings
├── lib/
│ ├── components/ # Reusable UI components
│ │ └── notes/
│ │ ├── NoteEditorPanel.svelte
│ │ ├── MarkdownEditor.svelte
│ │ ├── MarkdownPreview.svelte
│ │ ├── InternalLinkPicker.svelte
│ │ └── ObjectPickerModal.svelte
│ ├── services/ # API/Data access layer
│ │ ├── wails.js # Base Wails call helper
│ │ ├── notes.js # Notes API
│ │ ├── files.js # Files API
│ │ ├── search.js # Search API
│ │ ├── inbox.js # Inbox API
│ │ ├── trash.js # Trash API
│ │ ├── sync.js # Sync API
│ │ ├── journal.js # Journal/Worklog API
│ │ ├── actions.js # Actions API
│ │ ├── links.js # Links API
│ │ └── activity.js # Activity API
│ ├── state/ # State management (planned)
│ │ ├── navigation.js # Navigation state
│ │ └── uiState.js # UI state
│ ├── markdown/ # Markdown processing
│ │ ├── markdown.ts
│ │ └── internalLinks.ts
│ ├── i18n/ # Internationalisation
│ │ ├── index.js
│ │ └── locales/
│ │ ├── en.js
│ │ └── ru.js
│ ├── util/ # Utilities
│ │ ├── keyboardLayout.ts
│ │ └── markdown.test.js
│ ├── AppHeader.svelte
│ ├── GlobalSearch.svelte
│ ├── FileBreadcrumbs.svelte
│ ├── FileIcon.svelte
│ ├── FilePreviewModal.svelte
│ ├── ConfirmModal.svelte
│ ├── TodayScreen.svelte
│ ├── BrowserEvents.svelte
│ ├── FirstRun.svelte
│ ├── VaultRecovery.svelte
│ ├── SyncStatus.svelte
│ ├── TemplateIcon.svelte
│ ├── CalendarPluginPage.svelte
│ ├── SettingsWindow.svelte
│ ├── SettingsSidebar.svelte
│ ├── SettingsGeneral.svelte
│ ├── SettingsSync.svelte
│ ├── SettingsPlugins.svelte
│ ├── SettingsBrowserBridge.svelte
│ ├── SettingsWorkspace.svelte
│ ├── SettingsTemplates.svelte
│ ├── SettingsFiles.svelte
│ ├── SettingsBackup.svelte
│ ├── SettingsActivity.svelte
│ ├── actionIcons.js
│ └── fileUtils.js
```
## Wails Bridge
All backend calls go through `window.go.main.App[method](...)`.
The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
## Planned Components (to extract from App.svelte)
### Layout
- `AppShell.svelte` — root layout wrapper
- `Sidebar.svelte` — navigation sidebar
- `MainWorkspace.svelte` — main content area
### Pages/Tab Content
- `OverviewTab.svelte` — node overview with meta and quick actions
- `NotesTab.svelte` — notes list and creation
- `FilesTab.svelte` — file browser with breadcrumbs
- `InboxContent.svelte` + `InboxFullScreen.svelte`
- `LinksTab.svelte`
- `ActionsTab.svelte`
- `WorklogTab.svelte`
- `ActivityTabContent.svelte`
- `TrashContent.svelte`
- `JournalScreen.svelte`
- `ActivityFeedScreen.svelte`
- `WelcomeScreen.svelte`
### Modals
- `CreateNodeModal.svelte`
- `WorklogModal.svelte`
- `CreateActionModal.svelte`
- `ImportModal.svelte`
- `RenameModal.svelte`
- `AssignInboxModal.svelte`
- `EditLinkModal.svelte`
- `LinkInsertModal.svelte`
- `NoteRenameModal.svelte`
- `ContextMenu.svelte`
## Data Flow
1. User interacts with UI component
2. Component calls a service function (e.g., `notesApi.createNote(...)`)
3. Service calls `wailsCall('CreateNote', ...)`
4. Wails bridge forwards to Go backend
5. Go backend returns result → Wails → service → component updates state
## State Management
Currently all state lives in App.svelte as local variables.
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`.
### Files Flow
- **Component:** `lib/components/files/FilesTab.svelte` — self-contained file browser
- **API services:** `lib/services/files.js`, `lib/services/nodes.js`
- **Events emitted:**
- `on:openNote` — when a .md file linked to a note is opened
- `on:refreshParent` — after file operations that modify the tree
- `on:error` — on operation failures
- `on:rename` — requests parent to show rename modal
- `on:confirm` — requests parent to show confirm dialog
- **Public methods:**
- `resetToNode(nodeId)` — reset state when selected node changes
- `filesHandleKeydown(e)` — keyboard handler for files tab
- **.md → note editor flow:** Handled inside FilesTab via `CheckFileAction` Wails call. If action is 'note', emits `openNote`. If 'external', opens in system viewer. Otherwise shows built-in preview.
- **File preview:** `FilePreviewModal.svelte` (already existed), invoked by FilesTab
- **Import dialog:** Inline in FilesTab template (moved from App.svelte)
## Build & Verification
- `npm run build` in `frontend/` directory
- `go test ./...` from project root
- `bash scripts/build.sh gui` for full GUI binary
- Manual smoke testing via Wails dev server
+107
View File
@@ -0,0 +1,107 @@
# Frontend Change Map
## Purpose
This document tracks the refactoring of `App.svelte` from a 4794-line monolith
into a modular frontend architecture. Each step preserves behaviour exactly.
## Phase 1: Documentation & Foundation
- [x] Audit App.svelte (all 4794 lines read and mapped)
- [x] Create `docs/frontend-architecture.md`
- [x] Create `docs/frontend-change-map.md`
## Phase 2: API Layer
Extract all Wails calls into service modules.
- [x] Create `lib/services/wails.js` — base `wailsCall` helper
- [x] Create `lib/services/notes.js` — `listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
- [x] Create `lib/services/files.js` — `loadFolder`, `addFile`, `deleteFile`, etc.
- [x] Create `lib/services/search.js` — `searchNodes`, `getNodeDetail`, `searchWorkspace`
- [x] Create `lib/services/inbox.js` — inbox capture and management
- [x] Create `lib/services/trash.js` — trash operations
- [x] Create `lib/services/sync.js` — sync status and trigger
- [x] Create `lib/services/journal.js` — worklog CRUD and reports
- [x] Create `lib/services/actions.js` — actions CRUD
- [x] Create `lib/services/links.js` — links CRUD
- [x] Create `lib/services/activity.js` — activity feed
- [x] Create `lib/services/nodes.js` — tree, node CRUD, system views
- [x] Create `lib/services/suggestions.js` — worklog suggestions
- [x] Create `lib/services/today.js` — today dashboard
- [x] Create `lib/services/browserEvents.js` — browser extension events
- [x] Create `lib/services/search.js` — `searchNodes`
- [x] Create `lib/services/inbox.js` — `listInbox`, `captureClipboard`, etc.
- [x] Create `lib/services/trash.js` — `loadTrash`, `restore`, `purge`
- [x] Create `lib/services/sync.js` — `loadSyncStatus`, `runSync`
- [x] Create `lib/services/journal.js` — `loadJournal`, `worklog CRUD`
- [x] Create `lib/services/actions.js` — `listActions`, `createAction`, `deleteAction`
- [x] Create `lib/services/links.js` — `listLinks`, `updateLink`, `deleteLink`
- [x] Create `lib/services/activity.js` — `loadActivityFeed`, `loadCaseActivity`
## Phase 3: State Extraction
- [ ] Create `lib/state/navigation.js` — `selectedSection`, `selectedNode`, `activeTab`, `navHistory`
- [ ] Create `lib/state/uiState.js` — modals state, confirm state, rename state, drag state
## Phase 4: Component Extraction — Layout
- [ ] Extract `Sidebar.svelte` — brand, nav items, workspace tree, footer
- [ ] Extract `MainWorkspace.svelte` — content routing
- [ ] Create `AppShell.svelte` — root layout wrapper
## Phase 5: Component Extraction — Tab Content
- [x] Extract `OverviewTab.svelte`
- [x] Extract `NotesTab.svelte`
- [x] Extract `FilesTab.svelte`
- [ ] Extract `LinksTab.svelte`
- [ ] Extract `ActionsTab.svelte`
- [ ] Extract `WorklogTab.svelte`
- [ ] Extract `ActivityTabContent.svelte`
- [ ] Extract `InboxContent.svelte`
- [ ] Extract `InboxFullScreen.svelte`
- [ ] Extract `TrashContent.svelte`
- [ ] Extract `JournalScreen.svelte`
- [ ] Extract `ActivityFeedScreen.svelte`
- [ ] Extract `WelcomeScreen.svelte`
## Phase 6: Component Extraction — Modals
- [ ] Extract `CreateNodeModal.svelte`
- [ ] Extract `WorklogModal.svelte`
- [ ] Extract `CreateActionModal.svelte`
- [ ] Extract `ImportModal.svelte`
- [ ] Extract `RenameModal.svelte`
- [ ] Extract `AssignInboxModal.svelte`
- [ ] Extract `EditLinkModal.svelte`
- [ ] Extract `ContextMenu.svelte`
## Phase 7: Extract Inline Components
- [ ] Extract `NoteEditorHeader.svelte` (note editor header with rename)
- [ ] Extract `ErrorBanner.svelte`
- [ ] Extract `CaptureDropOverlay.svelte`
- [ ] Extract `SidebarFooter.svelte`
## Phase 8: Verification
- [ ] `npm run build` passes
- [ ] `go test ./...` passes
- [ ] Smoke checklist:
1. Sidebar renders with system views
2. Workspace tree loads and is expandable
3. Selecting a node shows tabs
4. Overview tab shows metadata and quick actions
5. Notes tab — create, rename, delete notes
6. Note editor — edit, preview, save, internal links, external links
7. Files tab — browse, add file/folder, navigate breadcrumbs
8. File preview — open, close
9. Inbox — list, sort, group, assign, delete
10. Trash — browse, restore, purge
11. Journal — filter, export, worklog CRUD
12. Activity feed — load and open events
13. Today screen — dashboard, suggestions, browser events
14. Settings — open/close, sections
15. Context menu on workspace tree
16. Create node modal — templates
+203
View File
@@ -0,0 +1,203 @@
# Plugin Security Model
## Overview
Verstak's Lua plugin system is designed with a **sandbox-first** approach. Plugins run in isolated Lua 5.1 states with restricted access to the host system. This document defines the security boundaries.
## Lua Sandbox
### Blocked Global Functions
The following Lua globals are set to `nil` in every plugin VM:
| Function | Reason |
|----------|--------|
| `dofile` | Arbitrary file read/execute |
| `loadfile` | Arbitrary file read |
| `require` | Module loading (no filesystem access) |
| `module` | Module system (deprecated, unsafe) |
| `load` | Arbitrary code execution from string |
| `rawequal` | Bypass metatable protections |
| `rawget` | Bypass metatable protections |
| `rawset` | Bypass metatable protections |
| `rawlen` | Bypass metatable protections |
| `setfenv` | Environment manipulation |
| `getfenv` | Environment manipulation |
### Blocked Libraries
| Library | Status | Notes |
|---------|--------|-------|
| `io` | **Not opened** | No file I/O |
| `debug` | **Not opened** | No debug introspection |
| `coroutine` | **Not opened** | No coroutine creation |
| `os` | **Restricted** | Only `clock`, `date`, `difftime`, `time`, `tmpname` |
| `package` | **Restricted** | Only `seeall` is available; `loadlib`, `preload`, `loaders`, `loaded`, `path`, `cpath`, `config`, `searchpath` are blocked |
### Restricted `os.*` Functions
| Function | Status | Reason |
|----------|--------|--------|
| `os.clock` | ✅ Allowed | Safe timing |
| `os.date` | ✅ Allowed | Safe date formatting |
| `os.difftime` | ✅ Allowed | Safe time diff |
| `os.time` | ✅ Allowed | Safe timestamp |
| `os.tmpname` | ✅ Allowed | Safe temp name |
| `os.execute` | ❌ Blocked | Shell command execution |
| `os.exit` | ❌ Blocked | Process termination |
| `os.remove` | ❌ Blocked | File deletion |
| `os.rename` | ❌ Blocked | File renaming |
| `os.setlocale` | ❌ Blocked | Locale manipulation |
| `os.getenv` | ❌ Blocked | Environment variable access |
### Safe Libraries (Available)
- `string` — string manipulation
- `table` — table operations
- `math` — mathematical functions
- `type`, `tostring`, `tonumber` — type operations
- `pcall`, `xpcall` — protected calls
- `error`, `assert` — error handling
- `select`, `unpack` — argument manipulation
- `pairs`, `ipairs`, `next` — iteration
- `collectgarbage` — GC control
### Print Redirection
`print()` is redirected to Go's `log.Printf` with the `[lua]` prefix. Plugins cannot write to stdout directly.
## Plugin Function Call Security
### `CallPluginFunction` Validation
When the frontend calls a plugin function via `CallPluginFunction(pluginName, funcName, paramsJSON)`:
1. **funcName validation**: Only `[a-zA-Z_][a-zA-Z0-9_]*` segments separated by dots (max 2 dots, 3 segments)
2. **No string-based code generation**: Functions are resolved via `_G` table walk, not `DoString`
3. **Direct PCall**: Arguments are pushed as `lua.LValue` directly, not serialized to Lua code
4. **Plugin must be active**: Only enabled plugins can receive calls
### Blocked funcName Patterns
```
❌ "func;os.execute('id')" — semicolon injection
❌ "func..name" — empty segment
❌ "func[name]" — bracket injection
❌ "func()" — parentheses
❌ "func name" — space injection
❌ "func-name" — special chars
❌ "a.b.c.d" — too deep (>3 segments)
❌ "123func" — starts with number
```
## iframe Bridge Security
### PluginPage.svelte ↔ iframe Communication
1. **Source verification**: Messages are only accepted from the specific `iframeEl.contentWindow` (not just any `source: 'calendar-plugin'`)
2. **Type checking**: `msg.source`, `msg.action` must be strings
3. **sandbox attribute**: iframe uses `sandbox="allow-scripts allow-same-origin"` (no forms, no popups)
4. **srcdoc**: iframe content is injected as `srcdoc` (same origin, no external network)
### Message Protocol
All messages must have:
```js
{ source: 'calendar-plugin', action: 'ready' | 'get-events' | 'create-event' | ... }
```
Messages from iframe to Svelte are validated:
- `e.source === iframeEl.contentWindow` (origin check)
- `msg.source === 'calendar-plugin'` (source check)
- `typeof msg.action === 'string'` (type check)
## Plugin Lifecycle Security
### State Separation
| State | Description | Data Persistence |
|-------|-------------|-----------------|
| **Discovered** | Plugin found on disk | None |
| **Installed** | `on_install` hook ran, tables created | DB tables exist |
| **Enabled** | Plugin is active, Lua VM running | Full access |
| **Active** | Runtime is initialized | Full access |
| **Disabled** | Plugin deactivated, data preserved | DB tables exist |
| **Uninstalled** | `on_uninstall` hook ran, tables dropped | Data cleaned |
### Idempotency
- `Install` on already-installed plugin → error (no duplicate config entries)
- `Uninstall` on non-installed plugin → error
- `Enable` on non-installed plugin → error
- `Disable` on already-disabled plugin → no-op (error, but harmless)
- `ReloadPlugins` → stops schedulers, calls shutdown hooks, closes VMs, re-discovers
### ReloadPlugins Cleanup Order
1. `StopSchedulers()` — stops all background tasks, sets `scheduler = nil`
2. `CallShutdownHooks()` — calls `on_shutdown` for all active plugins
3. `CloseRuntimes()` — closes all Lua VMs, sets `vm = nil`
4. `Discover()` — re-scans plugins directory
5. `SyncConfig()` — applies installed/enabled state from config
6. `InitRuntimes()` — creates new VMs and schedulers
7. `CallInitHooks()` — calls `on_init` for all active plugins
8. `StartSchedulers()` — starts background tasks
## Network Access
Plugins have **no direct network access**. All HTTP/network operations must go through the `verstak.http.*` API (registered by the core), which:
- Uses Go's `net/http` (not Lua socket)
- Can be rate-limited by the core
- Goes through the proxy if configured
## Filesystem Access
Plugins have **no direct filesystem access**. All file operations must go through:
- `verstak.db.*` — SQLite queries (plugin's own DB only)
- `verstak.node.*` — Node CRUD (via core services)
- `verstak.files.*` — File operations (via core services)
The `io` library is not opened. `os.remove`, `os.rename` are blocked.
## Database Isolation
Each plugin gets its own SQLite database at `.verstak/plugins/<name>/data/`. Plugins cannot access:
- The main Verstak database
- Other plugins' databases
- System databases
## Calendar Plugin (Reference)
The Calendar plugin in `contrib/plugins/calendar/` is a **reference implementation**, not a core feature:
- It is **not** loaded by default
- It requires explicit `Install` + `Enable`
- Its tables are created only during `on_install`
- It can be fully `Uninstall`ed without affecting core functionality
- It does not add calendar UI to any core views
- It demonstrates the full plugin API: `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.node.*`, `verstak.activity.*`, `verstak.schedule.*`, `verstak.http.*`
## Security Tests
All security properties are verified by tests in `internal/core/plugins/security_test.go`:
- `TestLuaVM_Sandbox_Comprehensive` — 12 dangerous APIs blocked
- `TestLuaVM_Sandbox_SafeLibs` — 9 safe libraries available
- `TestLuaVM_Sandbox_GlobalsRemoved` — 11 dangerous globals nil
- `TestLuaVM_Sandbox_OsRestricted` — 6 os.* functions blocked, 5 safe ones available
- `TestLuaVM_Sandbox_IoBlocked` — io library not opened
- `TestLuaVM_Sandbox_DebugBlocked` — debug library not opened
- `TestLuaVM_Sandbox_CoroutineBlocked` — coroutine library not opened
- `TestLuaVM_Sandbox_Package*Blocked` — 9 package.* fields blocked
- `TestLuaVM_Sandbox_PrintIsRedirected` — print goes to Go log
- `TestLuaVM_DoStringTimeout` — infinite loops killed by timeout
- `TestLuaVM_Isolation` — plugins cannot see each other's globals
- `TestLuaVM_ClosedVM` — operations on closed VM return errors
- `TestLuaVM_DoString_InjectionAttempt` — dangerous APIs return nil
- `TestPluginManager_InstallIdempotent` — no duplicate config entries
- `TestPluginManager_DisablePreservesData` — Disable keeps data
- `TestPluginManager_UninstallRemovesFromConfig` — Uninstall cleans config
- `TestPluginManager_ReloadPlugins_Cleanup` — full cleanup on reload
- `TestCallPluginFunction_Validation` — funcName validation (5 valid, 10 invalid cases)
+1
View File
@@ -0,0 +1 @@
{"uploadUuid":"8634d0ba5e1641b2a504640b5992b540","channel":"unlisted","xpiCrcHash":"124e35e2b615071abcbccd2e75a8f4247f42ddb700077632affa3a7c3c82c2ef"}
+303
View File
@@ -0,0 +1,303 @@
// Verstak Bridge for Firefox — Background Service Worker
// Uses browser.* WebExtensions API with chrome.* polyfill layer.
//
// IMPORTANT: All event listeners are registered at global scope,
// NOT inside onInstalled/onStartup. MV3 service workers can be restarted at
// any time; listeners inside lifecycle hooks are lost on restart.
// Firefox uses browser.* namespace; provide chrome.* aliases for shared code.
if (typeof browser !== 'undefined' && typeof chrome === 'undefined') {
globalThis.chrome = browser;
}
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const RECENT_KEY = 'verstak_recent';
const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false };
const DEFAULT_DEVICE_ID = generateDeviceId();
// Session state
let session = {
url: '',
title: '',
domain: '',
tabId: -1,
startedAt: null,
};
// --- Global event listeners (registered once at script load) ---
chrome.runtime.onInstalled.addListener(() => {
setupAlarms();
// Generate a stable device ID on first install
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const cfg = data[BRIDGE_KEY] || {};
if (!cfg.deviceId) {
chrome.storage.local.set({ [BRIDGE_KEY]: { ...DEFAULT_CONFIG, ...cfg, deviceId: DEFAULT_DEVICE_ID } });
}
});
});
chrome.runtime.onStartup.addListener(() => {
setupAlarms();
});
// Tab tracking — global scope
chrome.tabs.onActivated.addListener(onTabActivated);
chrome.tabs.onUpdated.addListener(onTabUpdated);
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
// Alarm handler — global scope
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'flushEvents') flushSession();
if (alarm.name === 'pingBridge') pingBridge();
});
// Message handler — global scope
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FORCE_FLUSH') {
flushSession();
flushQueue();
sendResponse({ ok: true });
}
if (msg.type === 'FORCE_PING') {
pingBridge();
sendResponse({ ok: true });
}
if (msg.type === 'SET_TRACKING') {
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
sendResponse({ ok: true });
}
return true;
});
// --- Alarm setup ---
function setupAlarms() {
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
}
// --- Tab tracking ---
function onTabActivated(activeInfo) {
flushSession();
startTracking(activeInfo.tabId);
}
function onTabUpdated(tabId, changeInfo, tab) {
if (changeInfo.status === 'complete' && tab.active) {
flushSession();
startTracking(tabId);
}
}
function onWindowFocusChanged(windowId) {
if (windowId === chrome.windows.WINDOW_ID_NONE) {
flushSession();
session = { url: '', title: '', domain: '', tabId: -1, startedAt: null };
} else {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs.length > 0) {
startTracking(tabs[0].id);
}
});
}
}
function startTracking(tabId) {
chrome.tabs.get(tabId, (tab) => {
if (chrome.runtime.lastError || !tab || !tab.url) return;
if (isInternalPage(tab.url)) return;
session = {
url: tab.url,
title: tab.title || '',
domain: extractDomain(tab.url),
tabId,
startedAt: Date.now(),
};
});
}
// --- Session flush ---
function flushSession() {
if (!session.url || !session.startedAt) return;
const duration = Math.round((Date.now() - session.startedAt) / 1000);
if (duration < 3) {
session.startedAt = Date.now();
return;
}
const event = {
id: generateId(),
type: 'page_visit',
url: session.url,
title: session.title,
domain: session.domain,
active_seconds: duration,
ts_start: new Date(session.startedAt).toISOString(),
ts_end: new Date().toISOString(),
};
queueEvent(event);
session.startedAt = Date.now();
}
function queueEvent(event) {
chrome.storage.local.get(STORAGE_KEY, (data) => {
const queue = data[STORAGE_KEY] || [];
queue.push(event);
if (queue.length > 500) {
queue.splice(0, queue.length - 500);
}
chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => {
if (queue.length >= 5) {
flushQueue();
}
});
});
}
// --- Bridge communication ---
function flushQueue() {
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => {
const queue = data[STORAGE_KEY] || [];
if (queue.length === 0) return;
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
const port = config.port || 9786;
const secret = config.secret || '';
const deviceId = config.deviceId || DEFAULT_DEVICE_ID;
const headers = { 'Content-Type': 'application/json' };
// Only send secret header if it's a non-empty string
if (secret && typeof secret === 'string' && secret.length > 0) {
headers['X-Verstak-Secret'] = secret;
}
const payload = {
version: 1,
device_id: 'firefox-' + deviceId,
events: queue,
};
fetch(`http://127.0.0.1:${port}/api/events`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
})
.then((res) => {
if (res.ok) {
for (const ev of queue) {
updateRecent(ev);
}
chrome.storage.local.set({ [STORAGE_KEY]: [] });
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, bridgeReachable: true, lastPing: Date.now() }
});
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
} else if (res.status === 401) {
console.warn('[verstak] bridge auth failed, check secret');
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
});
} else {
console.warn('[verstak] bridge error', res.status);
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
});
}
})
.catch((err) => {
console.warn('[verstak] bridge unreachable:', err.message);
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
});
});
});
}
function pingBridge() {
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
const port = config.port || 9786;
fetch(`http://127.0.0.1:${port}/api/ping`, {
signal: AbortSignal.timeout(3000),
})
.then((res) => res.json())
.then((info) => {
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() }
});
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
})
.catch(() => {
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
});
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
});
});
}
// --- Recent events ---
function updateRecent(event) {
chrome.storage.local.get(RECENT_KEY, (data) => {
const recent = data[RECENT_KEY] || [];
recent.push({
domain: event.domain,
title: event.title,
url: event.url,
active_seconds: event.active_seconds,
ts: new Date().toISOString(),
});
if (recent.length > 50) {
recent.splice(0, recent.length - 50);
}
chrome.storage.local.set({ [RECENT_KEY]: recent });
});
}
// --- Helpers ---
function extractDomain(url) {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
function isInternalPage(url) {
return url.startsWith('about:') ||
url.startsWith('moz-extension://') ||
url.startsWith('chrome://') ||
url.startsWith('chrome-extension://') ||
url.startsWith('edge://') ||
url.startsWith('file://') ||
url.startsWith('data:') ||
url.startsWith('blob:') ||
url.startsWith('view-source:') ||
url.startsWith('resource:');
}
function generateId() {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8);
return `evt_${ts}_${rand}`;
}
function generateDeviceId() {
const buf = new Uint8Array(6);
crypto.getRandomValues(buf);
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"manifest_version": 3,
"name": "Verstak Bridge",
"version": "1.0.4",
"description": "Отслеживает активные вкладки и отправляет события в Verstak",
"author": "Verstak",
"homepage_url": "https://git.mirv.top/mirivlad/verstak",
"browser_specific_settings": {
"gecko": {
"id": "verstak-bridge@verstak.app",
"strict_min_version": "115.0",
"update_url": "https://mirv.top/verstak/firefox/updates.json"
}
},
"permissions": [
"tabs",
"storage",
"alarms"
],
"host_permissions": [
"http://127.0.0.1/*",
"http://localhost/*"
],
"background": {
"scripts": ["background.js"],
"type": "module"
},
"action": {
"default_popup": "popup/popup.html",
"default_title": "Verstak Bridge",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"name": "extension-firefox",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"jsonwebtoken": "^9.0.3"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/semver": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"jsonwebtoken": "^9.0.3"
}
}
+321
View File
@@ -0,0 +1,321 @@
/* Verstak Bridge Popup */
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #1e2d50;
--accent: #0f9b8e;
--accent-glow: rgba(15, 155, 142, 0.3);
--text: #e0e0e0;
--text-dim: #8892b0;
--danger: #e74c3c;
--success: #2ecc71;
--radius: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 320px;
min-height: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.container {
padding: 12px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
header h1 {
font-size: 16px;
font-weight: 600;
color: var(--accent);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot.online {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.dot.offline {
background: var(--danger);
box-shadow: 0 0 6px var(--danger);
}
.info {
background: var(--surface);
border-radius: var(--radius);
padding: 8px 12px;
margin-bottom: 12px;
}
.row {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.label {
color: var(--text-dim);
}
.value {
font-weight: 500;
}
.value.offline {
color: var(--danger);
}
.value.online {
color: var(--success);
}
/* Toggle switch */
.controls {
margin-bottom: 12px;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.toggle input {
display: none;
}
.slider {
width: 36px;
height: 20px;
background: var(--surface2);
border-radius: 10px;
position: relative;
transition: background 0.2s;
}
.slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
top: 2px;
left: 2px;
transition: all 0.2s;
}
.toggle input:checked + .slider {
background: var(--accent);
}
.toggle input:checked + .slider::after {
left: 18px;
background: white;
}
.toggle-label {
font-size: 12px;
color: var(--text-dim);
}
/* Recent events */
.recent {
margin-bottom: 12px;
}
.recent h2 {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 6px;
letter-spacing: 0.5px;
}
#event-list {
max-height: 200px;
overflow-y: auto;
}
.event-item {
background: var(--surface);
border-radius: 6px;
padding: 6px 8px;
margin-bottom: 4px;
font-size: 11px;
line-height: 1.4;
}
.event-item .domain {
color: var(--accent);
font-weight: 500;
}
.event-item .title {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-item .duration {
color: var(--text-dim);
font-size: 10px;
}
.empty {
color: var(--text-dim);
font-style: italic;
font-size: 11px;
}
/* Footer buttons */
footer {
display: flex;
gap: 6px;
}
.btn {
flex: 1;
padding: 8px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: background 0.2s;
background: var(--accent);
color: white;
}
.btn:hover {
background: #0d8a7e;
}
.btn.secondary {
background: var(--surface2);
color: var(--text);
}
.btn.secondary:hover {
background: #253a60;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Settings panel */
#settings-panel {
padding-top: 4px;
}
.settings-form {
background: var(--surface);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
color: var(--text-dim);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.port-row {
display: flex;
align-items: center;
gap: 4px;
}
.port-prefix {
color: var(--text-dim);
font-size: 12px;
font-family: monospace;
}
#port-input {
width: 80px;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--surface2);
border-radius: 4px;
color: var(--text);
font-size: 13px;
font-family: monospace;
}
#port-input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
margin-top: 4px;
line-height: 1.3;
}
.settings-actions {
display: flex;
gap: 6px;
}
.settings-actions .btn {
flex: 1;
}
.btn.primary {
background: var(--accent);
}
.btn.primary:hover {
background: #0d8a7e;
}
.port-status {
margin-top: 8px;
font-size: 11px;
min-height: 16px;
}
.port-status.ok {
color: var(--success);
}
.port-status.error {
color: var(--danger);
}
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verstak Bridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<!-- Main panel -->
<div id="main-panel">
<header>
<h1>Verstak Bridge</h1>
<span id="status-dot" class="dot offline"></span>
</header>
<section class="info">
<div class="row">
<span class="label">Сервер</span>
<span id="bridge-status" class="value offline">Недоступен</span>
</div>
<div class="row">
<span class="label">Очередь</span>
<span id="queue-count" class="value">—</span>
</div>
<div class="row">
<span class="label">Адрес</span>
<span id="bridge-port" class="value">—</span>
</div>
</section>
<section class="controls">
<label class="toggle">
<input type="checkbox" id="track-toggle" checked>
<span class="slider"></span>
<span class="toggle-label">Отслеживать вкладки</span>
</label>
</section>
<section class="recent">
<h2>Последние события</h2>
<div id="event-list">
<p class="empty">Нет событий</p>
</div>
</section>
<footer>
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
<button id="settings-btn" class="btn secondary">⚙</button>
</footer>
</div>
<!-- Settings panel (hidden by default) -->
<div id="settings-panel" style="display: none;">
<header>
<h1>Настройки</h1>
</header>
<section class="settings-form">
<div class="form-group">
<label for="port-input">Порт сервера</label>
<div class="port-row">
<span class="port-prefix">127.0.0.1:</span>
<input type="number" id="port-input" min="1024" max="65535" value="9786" />
</div>
<p class="hint">По умолчанию: 9786. Изменения вступят в силу при следующей отправке событий.</p>
</div>
<div class="settings-actions">
<button id="test-port-btn" class="btn">Проверить</button>
<button id="save-port-btn" class="btn primary">Сохранить</button>
</div>
<div id="port-status" class="port-status"></div>
</section>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
+224
View File
@@ -0,0 +1,224 @@
// Verstak Bridge — Popup Script
// Shows connection status, queue count, recent events, and settings.
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const RECENT_KEY = 'verstak_recent';
const DEFAULT_PORT = 9786;
document.addEventListener('DOMContentLoaded', () => {
// Force a fresh ping check on popup open, don't rely on stale cache
chrome.runtime.sendMessage({ type: 'FORCE_PING' });
// Read cached state for immediate render
restoreToggleState();
restoreSettingsPanel();
updateUI();
document.getElementById('sync-btn').addEventListener('click', forceSync);
document.getElementById('track-toggle').addEventListener('change', onToggle);
document.getElementById('settings-btn').addEventListener('click', toggleSettings);
document.getElementById('save-port-btn').addEventListener('click', savePort);
document.getElementById('test-port-btn').addEventListener('click', testPort);
});
function updateUI() {
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY, RECENT_KEY], (data) => {
const queue = data[STORAGE_KEY] || [];
const config = data[BRIDGE_KEY] || {};
const recent = data[RECENT_KEY] || [];
// Queue count
document.getElementById('queue-count').textContent = queue.length;
// Bridge status
const statusEl = document.getElementById('bridge-status');
const portEl = document.getElementById('bridge-port');
const dotEl = document.getElementById('status-dot');
if (config.bridgeReachable === true) {
statusEl.textContent = 'Доступен';
statusEl.className = 'value online';
dotEl.className = 'dot online';
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
} else if (config.bridgeReachable === false) {
statusEl.textContent = 'Недоступен';
statusEl.className = 'value offline';
dotEl.className = 'dot offline';
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
} else {
// Unknown — not yet checked
statusEl.textContent = 'Проверка...';
statusEl.className = 'value';
dotEl.className = 'dot';
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
}
// Update settings panel port field
const portInput = document.getElementById('port-input');
if (portInput && !portInput.dataset.userEdited) {
portInput.value = config.port || DEFAULT_PORT;
}
// Recent events
renderRecent(recent, config.bridgeReachable);
});
}
function renderRecent(events, reachable) {
const list = document.getElementById('event-list');
list.innerHTML = '';
if (!events || events.length === 0) {
list.innerHTML = '<p class="empty">Нет событий</p>';
return;
}
const lastFive = events.slice(-5).reverse();
for (const ev of lastFive) {
const item = document.createElement('div');
item.className = 'event-item';
item.innerHTML = `
<div class="domain">${escapeHtml(ev.domain || '?')}</div>
<div class="title">${escapeHtml(truncate(ev.title || ev.url, 60))}</div>
<div class="duration">${ev.active_seconds || 0}с${reachable ? '' : ' (ожидает)'}</div>
`;
list.appendChild(item);
}
}
function forceSync() {
const btn = document.getElementById('sync-btn');
btn.disabled = true;
btn.textContent = '⏳ Отправка...';
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, () => {
setTimeout(() => {
updateUI();
btn.disabled = false;
btn.textContent = '🔄 Отправить сейчас';
}, 1000);
});
}
function onToggle(e) {
const enabled = e.target.checked;
chrome.storage.local.set({ 'verstak_tracking_enabled': enabled });
chrome.runtime.sendMessage({ type: 'SET_TRACKING', enabled });
}
function restoreToggleState() {
chrome.storage.local.get('verstak_tracking_enabled', (data) => {
const enabled = data.verstak_tracking_enabled !== false;
document.getElementById('track-toggle').checked = enabled;
});
}
// --- Settings panel ---
function toggleSettings() {
const panel = document.getElementById('settings-panel');
const main = document.getElementById('main-panel');
const btn = document.getElementById('settings-btn');
if (panel.style.display === 'none' || !panel.style.display) {
panel.style.display = 'block';
main.style.display = 'none';
btn.textContent = '← Назад';
} else {
panel.style.display = 'none';
main.style.display = 'block';
btn.textContent = '⚙';
}
}
function restoreSettingsPanel() {
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
const portInput = document.getElementById('port-input');
if (portInput) {
portInput.value = config.port || DEFAULT_PORT;
}
});
}
function savePort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 1024–65535';
statusEl.className = 'port-status error';
return;
}
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
config.port = port;
config.bridgeReachable = null; // Force re-check on next popup open
chrome.storage.local.set({ [BRIDGE_KEY]: config }, () => {
statusEl.textContent = 'Сохранено';
statusEl.className = 'port-status ok';
// Trigger immediate ping with new port
chrome.runtime.sendMessage({ type: 'FORCE_PING' });
setTimeout(() => { updateUI(); statusEl.textContent = ''; }, 1500);
});
});
}
function testPort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 1024–65535';
statusEl.className = 'port-status error';
return;
}
statusEl.textContent = 'Проверка...';
statusEl.className = 'port-status';
fetch(`http://127.0.0.1:${port}/api/ping`, { signal: AbortSignal.timeout(3000) })
.then((res) => {
if (res.ok) {
statusEl.textContent = '✓ Сервер отвечает';
statusEl.className = 'port-status ok';
} else {
statusEl.textContent = `✗ Ошибка ${res.status}`;
statusEl.className = 'port-status error';
}
})
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
} else {
statusEl.textContent = '✗ Сервер недоступен';
}
statusEl.className = 'port-status error';
});
}
// Listen for updates from background
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'UI_UPDATE') {
updateUI();
}
});
// --- Helpers ---
function truncate(s, n) {
if (!s) return '';
return s.length > n ? s.substring(0, n) + '...' : s;
}
function escapeHtml(s) {
if (!s) return '';
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+255
View File
@@ -0,0 +1,255 @@
// Verstak Bridge — Background Service Worker (Chrome)
// Tracks active tab changes and queues browser events.
// Pushes events to the Verstak HTTP bridge when available.
//
// IMPORTANT: All chrome.* event listeners are registered at global scope,
// NOT inside onInstalled/onStartup. MV3 service workers can be restarted at
// any time; listeners inside lifecycle hooks are lost on restart.
// See: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/events
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const RECENT_KEY = 'verstak_recent';
const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false };
const IDLE_RESET_MS = 60_000;
// Session state
let session = {
url: '',
title: '',
domain: '',
tabId: -1,
startedAt: null,
};
// --- Global event listeners (registered once at script load) ---
chrome.runtime.onInstalled.addListener(() => {
setupAlarms();
});
chrome.runtime.onStartup.addListener(() => {
setupAlarms();
});
// Tab tracking — global scope
chrome.tabs.onActivated.addListener(onTabActivated);
chrome.tabs.onUpdated.addListener(onTabUpdated);
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
// Alarm handler — global scope
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'flushEvents') flushSession();
if (alarm.name === 'pingBridge') pingBridge();
});
// Message handler — global scope
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FORCE_FLUSH') {
flushSession();
flushQueue();
sendResponse({ ok: true });
}
if (msg.type === 'SET_TRACKING') {
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
sendResponse({ ok: true });
}
return true;
});
// --- Alarm setup ---
function setupAlarms() {
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
}
// --- Tab tracking ---
function onTabActivated(activeInfo) {
flushSession();
startTracking(activeInfo.tabId);
}
function onTabUpdated(tabId, changeInfo, tab) {
if (changeInfo.status === 'complete' && tab.active) {
flushSession();
startTracking(tabId);
}
}
function onWindowFocusChanged(windowId) {
if (windowId === chrome.windows.WINDOW_ID_NONE) {
flushSession();
session = { url: '', title: '', domain: '', tabId: -1, startedAt: null };
} else {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs.length > 0) {
startTracking(tabs[0].id);
}
});
}
}
function startTracking(tabId) {
chrome.tabs.get(tabId, (tab) => {
if (chrome.runtime.lastError || !tab || !tab.url) return;
if (isInternalPage(tab.url)) return;
session = {
url: tab.url,
title: tab.title || '',
domain: extractDomain(tab.url),
tabId,
startedAt: Date.now(),
};
});
}
// --- Session flush ---
function flushSession() {
if (!session.url || !session.startedAt) return;
const duration = Math.round((Date.now() - session.startedAt) / 1000);
if (duration < 2) {
session.startedAt = Date.now();
return;
}
const event = {
id: generateId(),
type: 'page_visit',
url: session.url,
title: session.title,
domain: session.domain,
active_seconds: Math.min(duration, IDLE_RESET_MS / 1000),
ts_start: new Date(session.startedAt).toISOString(),
ts_end: new Date().toISOString(),
};
queueEvent(event);
session.startedAt = Date.now();
}
function queueEvent(event) {
chrome.storage.local.get(STORAGE_KEY, (data) => {
const queue = data[STORAGE_KEY] || [];
queue.push(event);
if (queue.length > 500) {
queue.splice(0, queue.length - 500);
}
chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => {
if (queue.length >= 5) {
flushQueue();
}
});
});
}
// --- Bridge communication ---
function flushQueue() {
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => {
const queue = data[STORAGE_KEY] || [];
if (queue.length === 0) return;
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
const port = config.port || 9786;
const secret = config.secret || '';
const payload = {
version: 1,
device_id: 'chrome-' + (config.deviceId || 'unknown'),
events: queue,
};
fetch(`http://127.0.0.1:${port}/api/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Verstak-Secret': secret,
},
body: JSON.stringify(payload),
})
.then((res) => {
if (res.ok) {
for (const ev of queue) {
updateRecent(ev);
}
chrome.storage.local.set({ [STORAGE_KEY]: [] });
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
} else if (res.status === 401) {
console.warn('[verstak] bridge auth failed, check secret');
}
})
.catch(() => {
// Bridge not available — keep events in queue
});
});
}
function pingBridge() {
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
const port = config.port || 9786;
fetch(`http://127.0.0.1:${port}/api/ping`)
.then((res) => res.json())
.then((info) => {
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() }
});
})
.catch(() => {
chrome.storage.local.set({
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
});
});
});
}
// --- Recent events ---
function updateRecent(event) {
chrome.storage.local.get(RECENT_KEY, (data) => {
const recent = data[RECENT_KEY] || [];
recent.push({
domain: event.domain,
title: event.title,
url: event.url,
active_seconds: event.active_seconds,
ts: new Date().toISOString(),
});
if (recent.length > 50) {
recent.splice(0, recent.length - 50);
}
chrome.storage.local.set({ [RECENT_KEY]: recent });
});
}
// --- Helpers ---
function extractDomain(url) {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
function isInternalPage(url) {
const internal = [
'chrome://', 'chrome-extension://', 'about:', 'edge://',
'file://', 'data:', 'blob:', 'view-source:', 'devtools:',
];
return internal.some((p) => url.startsWith(p));
}
function generateId() {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8);
return `evt_${ts}_${rand}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+40
View File
@@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Verstak Bridge",
"version": "1.0.0",
"description": "Отслеживает активные вкладки и отправляет события в Verstak",
"author": "Verstak",
"homepage_url": "https://git.mirv.top/mirivlad/verstak",
"permissions": [
"tabs",
"storage",
"alarms"
],
"host_permissions": [
"http://127.0.0.1/*",
"http://localhost/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_popup": "popup/popup.html",
"default_title": "Verstak Bridge",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+321
View File
@@ -0,0 +1,321 @@
/* Verstak Bridge Popup */
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #1e2d50;
--accent: #0f9b8e;
--accent-glow: rgba(15, 155, 142, 0.3);
--text: #e0e0e0;
--text-dim: #8892b0;
--danger: #e74c3c;
--success: #2ecc71;
--radius: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 320px;
min-height: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.container {
padding: 12px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
header h1 {
font-size: 16px;
font-weight: 600;
color: var(--accent);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot.online {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.dot.offline {
background: var(--danger);
box-shadow: 0 0 6px var(--danger);
}
.info {
background: var(--surface);
border-radius: var(--radius);
padding: 8px 12px;
margin-bottom: 12px;
}
.row {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.label {
color: var(--text-dim);
}
.value {
font-weight: 500;
}
.value.offline {
color: var(--danger);
}
.value.online {
color: var(--success);
}
/* Toggle switch */
.controls {
margin-bottom: 12px;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.toggle input {
display: none;
}
.slider {
width: 36px;
height: 20px;
background: var(--surface2);
border-radius: 10px;
position: relative;
transition: background 0.2s;
}
.slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
top: 2px;
left: 2px;
transition: all 0.2s;
}
.toggle input:checked + .slider {
background: var(--accent);
}
.toggle input:checked + .slider::after {
left: 18px;
background: white;
}
.toggle-label {
font-size: 12px;
color: var(--text-dim);
}
/* Recent events */
.recent {
margin-bottom: 12px;
}
.recent h2 {
font-size: 11px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 6px;
letter-spacing: 0.5px;
}
#event-list {
max-height: 200px;
overflow-y: auto;
}
.event-item {
background: var(--surface);
border-radius: 6px;
padding: 6px 8px;
margin-bottom: 4px;
font-size: 11px;
line-height: 1.4;
}
.event-item .domain {
color: var(--accent);
font-weight: 500;
}
.event-item .title {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-item .duration {
color: var(--text-dim);
font-size: 10px;
}
.empty {
color: var(--text-dim);
font-style: italic;
font-size: 11px;
}
/* Footer buttons */
footer {
display: flex;
gap: 6px;
}
.btn {
flex: 1;
padding: 8px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: background 0.2s;
background: var(--accent);
color: white;
}
.btn:hover {
background: #0d8a7e;
}
.btn.secondary {
background: var(--surface2);
color: var(--text);
}
.btn.secondary:hover {
background: #253a60;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Settings panel */
#settings-panel {
padding-top: 4px;
}
.settings-form {
background: var(--surface);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
color: var(--text-dim);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.port-row {
display: flex;
align-items: center;
gap: 4px;
}
.port-prefix {
color: var(--text-dim);
font-size: 12px;
font-family: monospace;
}
#port-input {
width: 80px;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--surface2);
border-radius: 4px;
color: var(--text);
font-size: 13px;
font-family: monospace;
}
#port-input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
margin-top: 4px;
line-height: 1.3;
}
.settings-actions {
display: flex;
gap: 6px;
}
.settings-actions .btn {
flex: 1;
}
.btn.primary {
background: var(--accent);
}
.btn.primary:hover {
background: #0d8a7e;
}
.port-status {
margin-top: 8px;
font-size: 11px;
min-height: 16px;
}
.port-status.ok {
color: var(--success);
}
.port-status.error {
color: var(--danger);
}
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verstak Bridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<!-- Main panel -->
<div id="main-panel">
<header>
<h1>Verstak Bridge</h1>
<span id="status-dot" class="dot offline"></span>
</header>
<section class="info">
<div class="row">
<span class="label">Сервер</span>
<span id="bridge-status" class="value offline">Недоступен</span>
</div>
<div class="row">
<span class="label">Очередь</span>
<span id="queue-count" class="value">—</span>
</div>
<div class="row">
<span class="label">Адрес</span>
<span id="bridge-port" class="value">—</span>
</div>
</section>
<section class="controls">
<label class="toggle">
<input type="checkbox" id="track-toggle" checked>
<span class="slider"></span>
<span class="toggle-label">Отслеживать вкладки</span>
</label>
</section>
<section class="recent">
<h2>Последние события</h2>
<div id="event-list">
<p class="empty">Нет событий</p>
</div>
</section>
<footer>
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
<button id="settings-btn" class="btn secondary">⚙</button>
</footer>
</div>
<!-- Settings panel (hidden by default) -->
<div id="settings-panel" style="display: none;">
<header>
<h1>Настройки</h1>
</header>
<section class="settings-form">
<div class="form-group">
<label for="port-input">Порт сервера</label>
<div class="port-row">
<span class="port-prefix">127.0.0.1:</span>
<input type="number" id="port-input" min="1024" max="65535" value="9786" />
</div>
<p class="hint">По умолчанию: 9786. Изменения вступят в силу при следующей отправке событий.</p>
</div>
<div class="settings-actions">
<button id="test-port-btn" class="btn">Проверить</button>
<button id="save-port-btn" class="btn primary">Сохранить</button>
</div>
<div id="port-status" class="port-status"></div>
</section>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
+214
View File
@@ -0,0 +1,214 @@
// Verstak Bridge — Popup Script
// Shows connection status, queue count, recent events, and settings.
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const RECENT_KEY = 'verstak_recent';
const DEFAULT_PORT = 9786;
document.addEventListener('DOMContentLoaded', () => {
updateUI();
restoreToggleState();
restoreSettingsPanel();
document.getElementById('sync-btn').addEventListener('click', forceSync);
document.getElementById('track-toggle').addEventListener('change', onToggle);
document.getElementById('settings-btn').addEventListener('click', toggleSettings);
document.getElementById('save-port-btn').addEventListener('click', savePort);
document.getElementById('test-port-btn').addEventListener('click', testPort);
});
function updateUI() {
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY, RECENT_KEY], (data) => {
const queue = data[STORAGE_KEY] || [];
const config = data[BRIDGE_KEY] || {};
const recent = data[RECENT_KEY] || [];
// Queue count
document.getElementById('queue-count').textContent = queue.length;
// Bridge status
const statusEl = document.getElementById('bridge-status');
const portEl = document.getElementById('bridge-port');
const dotEl = document.getElementById('status-dot');
if (config.bridgeReachable) {
statusEl.textContent = 'Доступен';
statusEl.className = 'value online';
dotEl.className = 'dot online';
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
} else {
statusEl.textContent = 'Недоступен';
statusEl.className = 'value offline';
dotEl.className = 'dot offline';
portEl.textContent = config.lastPing ? '—' : 'проверка...';
}
// Update settings panel port field
const portInput = document.getElementById('port-input');
if (portInput && !portInput.dataset.userEdited) {
portInput.value = config.port || DEFAULT_PORT;
}
// Recent events
renderRecent(recent, config.bridgeReachable);
});
}
function renderRecent(events, reachable) {
const list = document.getElementById('event-list');
list.innerHTML = '';
if (!events || events.length === 0) {
list.innerHTML = '<p class="empty">Нет событий</p>';
return;
}
const lastFive = events.slice(-5).reverse();
for (const ev of lastFive) {
const item = document.createElement('div');
item.className = 'event-item';
item.innerHTML = `
<div class="domain">${escapeHtml(ev.domain || '?')}</div>
<div class="title">${escapeHtml(truncate(ev.title || ev.url, 60))}</div>
<div class="duration">${ev.active_seconds || 0}с${reachable ? '' : ' (ожидает)'}</div>
`;
list.appendChild(item);
}
}
function forceSync() {
const btn = document.getElementById('sync-btn');
btn.disabled = true;
btn.textContent = '⏳ Отправка...';
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, () => {
setTimeout(() => {
updateUI();
btn.disabled = false;
btn.textContent = '🔄 Отправить сейчас';
}, 1000);
});
}
function onToggle(e) {
const enabled = e.target.checked;
chrome.storage.local.set({ 'verstak_tracking_enabled': enabled });
chrome.runtime.sendMessage({ type: 'SET_TRACKING', enabled });
}
function restoreToggleState() {
chrome.storage.local.get('verstak_tracking_enabled', (data) => {
const enabled = data.verstak_tracking_enabled !== false;
document.getElementById('track-toggle').checked = enabled;
});
}
// --- Settings panel ---
function toggleSettings() {
const panel = document.getElementById('settings-panel');
const main = document.getElementById('main-panel');
const btn = document.getElementById('settings-btn');
if (panel.style.display === 'none' || !panel.style.display) {
panel.style.display = 'block';
main.style.display = 'none';
btn.textContent = '← Назад';
} else {
panel.style.display = 'none';
main.style.display = 'block';
btn.textContent = '⚙';
}
}
function restoreSettingsPanel() {
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
const portInput = document.getElementById('port-input');
if (portInput) {
portInput.value = config.port || DEFAULT_PORT;
}
});
}
function savePort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 1024–65535';
statusEl.className = 'port-status error';
return;
}
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
config.port = port;
chrome.storage.local.set({ [BRIDGE_KEY]: config }, () => {
statusEl.textContent = 'Сохранено';
statusEl.className = 'port-status ok';
// Trigger immediate ping with new port
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' });
setTimeout(() => { updateUI(); statusEl.textContent = ''; }, 1500);
});
});
}
function testPort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 1024–65535';
statusEl.className = 'port-status error';
return;
}
statusEl.textContent = 'Проверка...';
statusEl.className = 'port-status';
fetch(`http://127.0.0.1:${port}/api/ping`, { signal: AbortSignal.timeout(3000) })
.then((res) => {
if (res.ok) {
statusEl.textContent = '✓ Сервер отвечает';
statusEl.className = 'port-status ok';
} else {
statusEl.textContent = `✗ Ошибка ${res.status}`;
statusEl.className = 'port-status error';
}
})
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
} else {
statusEl.textContent = '✗ Сервер недоступен';
}
statusEl.className = 'port-status error';
});
}
// Listen for updates from background
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'UI_UPDATE') {
updateUI();
}
});
// --- Helpers ---
function truncate(s, n) {
if (!s) return '';
return s.length > n ? s.substring(0, n) + '...' : s;
}
function escapeHtml(s) {
if (!s) return '';
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+153
View File
@@ -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",
+9
View File
@@ -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"
}
}
+1036 -959
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
<script>
import GlobalSearch from './GlobalSearch.svelte'
export let title = ''
export let subtitle = ''
export let wailsCall = async () => []
export let typeLabel = (type) => type || ''
export let onOpenSearchResult = () => {}
</script>
<header class="header">
<div class="header-left">
<span class="header-title" class:placeholder={!title}>{title || ''}</span>
{#if subtitle}
<span class="header-subtitle">{subtitle}</span>
{/if}
</div>
<div class="header-right">
<GlobalSearch {wailsCall} {typeLabel} on:open={(e) => onOpenSearchResult(e.detail)} />
<slot name="actions"></slot>
</div>
</header>
<style>
.header { padding: 10px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 16px; flex-shrink: 0; min-height: 52px; }
.header-left { display: flex; align-items: center; gap: 8px; min-width: 120px; flex: 0 1 auto; overflow: hidden; }
.header-title { font-size: 15px; font-weight: 600; color: #e4e4ef; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-title.placeholder { color: #666; font-weight: 500; }
.header-subtitle { font-size: 11px; color: #8888a0; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.header-right { flex: 1; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
@media (max-width: 760px) {
.header { padding: 8px 12px; gap: 10px; align-items: flex-start; flex-wrap: wrap; }
.header-left { flex: 1 1 100%; }
.header-right { flex: 1 1 100%; justify-content: stretch; }
}
</style>
+250
View File
@@ -0,0 +1,250 @@
<script>
import { t } from './i18n'
export let events = []
export let loading = false
export let onAccept = (ev) => {}
export let onDismiss = (ev) => {}
export let onAttach = (ev) => {}
export let onRefresh = () => {}
export let formatTime = (iso) => ''
function eventIcon(type) {
if (type === 'page_visit') return '🌐'
if (type === 'note_capture') return '📝'
if (type === 'screenshot') return '📸'
return '•'
}
function truncate(s, n) {
if (!s) return ''
return s.length > n ? s.substring(0, n) + '...' : s
}
</script>
<div class="browser-events">
<div class="browser-header">
<h3>{t('browserEvents', 'События браузера')}</h3>
<button class="refresh-btn" on:click={onRefresh} disabled={loading}>
{loading ? '⏳' : '🔄'}
</button>
</div>
{#if events.length === 0}
<div class="empty-state">
{#if loading}
<p class="loading">{t('loading', 'Загрузка...')}</p>
{:else}
<p class="empty-text">{t('noBrowserEvents', 'Нет событий браузера')}</p>
{/if}
</div>
{:else}
<div class="event-list">
{#each events as ev}
<div class="event-item" class:pending={ev.status === 'pending'}>
<div class="event-icon">{eventIcon(ev.type)}</div>
<div class="event-body">
<div class="event-domain">{ev.domain || '?'}</div>
<div class="event-title" title={ev.title}>{truncate(ev.title || ev.url, 80)}</div>
<div class="event-meta">
{#if ev.active_seconds > 0}
<span class="duration">{ev.active_seconds}с</span>
{/if}
{#if ev.ts_start}
<span class="time">{formatTime(ev.ts_start)}</span>
{/if}
{#if ev.status !== 'pending'}
<span class="status-badge status-{ev.status}">{ev.status}</span>
{/if}
</div>
{#if ev.selected_text}
<div class="event-text">"{truncate(ev.selected_text, 120)}"</div>
{/if}
</div>
{#if ev.status === 'pending'}
<div class="event-actions">
<button class="btn-accept" title="Принять как worklog"
on:click={() => onAccept(ev)}>✓</button>
<button class="btn-attach" title="Прикрепить к делу"
on:click={() => onAttach(ev)}>📎</button>
<button class="btn-dismiss" title="Удалить"
on:click={() => onDismiss(ev)}>✕</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.browser-events {
padding: 8px 0;
}
.browser-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
}
.browser-header h3 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim, #8892b0);
margin: 0;
}
.refresh-btn {
background: var(--surface2, #1e2d50);
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.refresh-btn:hover {
background: var(--surface, #16213e);
}
.empty-state {
padding: 24px;
text-align: center;
}
.empty-text, .loading {
color: var(--text-dim, #8892b0);
font-style: italic;
font-size: 12px;
}
.event-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.event-item {
display: flex;
gap: 8px;
padding: 6px 8px;
background: var(--surface, #16213e);
border-radius: 6px;
transition: background 0.15s;
}
.event-item.pending {
border-left: 2px solid var(--accent, #0f9b8e);
}
.event-icon {
font-size: 16px;
line-height: 1.4;
min-width: 20px;
text-align: center;
}
.event-body {
flex: 1;
min-width: 0;
}
.event-domain {
font-size: 11px;
color: var(--accent, #0f9b8e);
font-weight: 500;
}
.event-title {
font-size: 12px;
color: var(--text, #e0e0e0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-meta {
display: flex;
gap: 8px;
margin-top: 2px;
}
.duration, .time {
font-size: 10px;
color: var(--text-dim, #8892b0);
}
.status-badge {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
text-transform: uppercase;
}
.status-accepted {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
}
.status-dismissed {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
}
.status-attached {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
}
.event-text {
font-size: 11px;
color: var(--text-dim, #8892b0);
font-style: italic;
margin-top: 2px;
padding: 2px 6px;
background: rgba(255,255,255,0.03);
border-radius: 3px;
}
.event-actions {
display: flex;
gap: 2px;
align-items: flex-start;
}
.event-actions button {
background: none;
border: 1px solid var(--surface2, #1e2d50);
border-radius: 4px;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.15s;
}
.btn-accept:hover {
background: rgba(46, 204, 113, 0.2);
border-color: #2ecc71;
}
.btn-attach:hover {
background: rgba(52, 152, 219, 0.2);
border-color: #3498db;
}
.btn-dismiss:hover {
background: rgba(231, 76, 60, 0.2);
border-color: #e74c3c;
}
</style>
+264
View File
@@ -0,0 +1,264 @@
<script>
import { onMount, onDestroy } from 'svelte'
export let sectionId = ''
// Parse "plugin:name:pageId"
$: parts = sectionId.split(':')
$: pluginName = parts[1] || ''
$: pageId = parts[2] || 'main'
$: pageLabel = pageId.charAt(0).toUpperCase() + pageId.slice(1)
let htmlPanel = ''
let loading = true
let error = ''
let iframeEl = null
let messageQueue = []
let iframeReady = false
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 error:', method, e) }
return Promise.reject(new Error('Wails not connected: ' + method))
}
// Post message to iframe (queue if not ready)
function postToIframe(msg) {
console.log('[CalendarPluginPage] postToIframe: iframeReady=' + iframeReady + ', hasEl=' + !!iframeEl + ', hasCW=' + !!(iframeEl?.contentWindow))
if (iframeEl && iframeEl.contentWindow && iframeReady) {
console.log('[CalendarPluginPage] postToIframe: sending msg.type=' + msg.type)
iframeEl.contentWindow.postMessage(msg, '*')
} else {
console.log('[CalendarPluginPage] postToIframe: queuing msg.type=' + msg.type)
messageQueue.push(msg)
}
}
// Handle messages from iframe — only accept from our own iframeEl
// The iframe identifies itself via msg.source (set by the panel HTML).
// We accept any source that starts with "plugin:" to support generic plugin panels.
function handleIframeMessage(e) {
// Verify the message comes from our iframe (srcdoc = same origin)
if (!iframeEl || !iframeEl.contentWindow || e.source !== iframeEl.contentWindow) return
const msg = e.data
if (!msg || typeof msg !== 'object') return
if (!msg.source || typeof msg.source !== 'string') return
if (!msg.action || typeof msg.action !== 'string') return
switch (msg.action) {
case 'ready':
iframeReady = true
// Flush queued messages
while (messageQueue.length > 0) {
iframeEl.contentWindow.postMessage(messageQueue.shift(), '*')
}
// Load initial data
loadCalendarData()
break
case 'get-events':
handleGetEvents(msg.data)
break
case 'create-event':
handleCreateEvent(msg.data)
break
case 'update-event':
handleUpdateEvent(msg.data)
break
case 'delete-event':
handleDeleteEvent(msg.data)
break
default:
console.log('[CalendarPluginPage] Unknown iframe action:', msg.action)
}
}
// Build the Lua function prefix from plugin name (e.g. "calendar" → "calendar.")
$: funcPrefix = pluginName ? pluginName + '.' : ''
// Load events + categories from Lua backend and send to iframe
async function loadCalendarData() {
try {
console.log('[CalendarPluginPage] loadCalendarData: step 1')
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
const start = new Date(year, month, 1).toISOString().slice(0, 10) + 'T00:00:00'
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10) + 'T23:59:59'
console.log('[CalendarPluginPage] loadCalendarData: step 2, start=' + start + ', end=' + end)
console.log('[CalendarPluginPage] loadCalendarData: step 3, calling get_events...')
const eventsPromise = wailsCall('CallPluginFunction', pluginName, funcPrefix + 'get_events', JSON.stringify({ start, end }))
console.log('[CalendarPluginPage] loadCalendarData: step 4, calling get_categories...')
const categoriesPromise = wailsCall('CallPluginFunction', pluginName, funcPrefix + 'get_categories', '{}')
console.log('[CalendarPluginPage] loadCalendarData: step 5, awaiting Promise.all...')
const [eventsRaw, categoriesRaw] = await Promise.all([eventsPromise, categoriesPromise])
console.log('[CalendarPluginPage] loadCalendarData: step 6, got results, eventsRaw=' + typeof eventsRaw + ', categoriesRaw=' + typeof categoriesRaw)
const events = eventsRaw ? JSON.parse(eventsRaw) : []
const categories = categoriesRaw ? JSON.parse(categoriesRaw) : []
postToIframe({
source: 'verstak',
type: 'calendar-data',
events,
categories,
})
// Confirm delivery via Go log
try { window.go.main.App.WriteDebugLog('[CalendarPluginPage] calendar-data sent to iframe, events.len=' + events.length + ', categories.len=' + (categories?.length || 0) + ', typeof events=' + typeof events + ', typeof eventsRaw=' + typeof eventsRaw + ', eventsRaw.length=' + eventsRaw.length + ', eventsRaw.substr(0,10)=' + String(eventsRaw).substring(0,10)) } catch(e) {}
} catch (e) {
console.error('[CalendarPluginPage] loadCalendarData:', e)
console.log('[CalendarPluginPage] error details:', JSON.stringify({message: String(e), stack: e?.stack}))
error = String(e)
}
}
async function handleGetEvents(data) {
try {
const params = JSON.stringify({ start: data.start, end: data.end })
const raw = await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'get_events', params)
const events = raw ? JSON.parse(raw) : []
postToIframe({
source: 'verstak',
type: 'calendar-data',
events,
})
} catch (e) {
console.error('[CalendarPluginPage] get-events:', e)
}
}
async function handleCreateEvent(data) {
try {
const params = JSON.stringify(data)
const raw = await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'create_event', params)
const result = raw ? JSON.parse(raw) : {}
postToIframe({
source: 'verstak',
type: 'event-created',
event: result,
})
loadCalendarData()
} catch (e) {
console.error('[CalendarPluginPage] create-event:', e)
postToIframe({
source: 'verstak',
type: 'error',
message: String(e),
})
}
}
async function handleUpdateEvent(data) {
try {
const params = JSON.stringify(data)
await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'update_event', params)
postToIframe({
source: 'verstak',
type: 'event-updated',
event: data,
})
loadCalendarData()
} catch (e) {
console.error('[CalendarPluginPage] update-event:', e)
}
}
async function handleDeleteEvent(data) {
try {
const params = JSON.stringify({ id: data.id })
await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'delete_event', params)
postToIframe({
source: 'verstak',
type: 'event-deleted',
id: data.id,
})
loadCalendarData()
} catch (e) {
console.error('[CalendarPluginPage] delete-event:', e)
}
}
onMount(async () => {
try {
htmlPanel = (await wailsCall('GetPluginPanelHTML', pluginName)) || ''
} catch (e) {
error = String(e)
}
loading = false
// Listen for messages from iframe
window.addEventListener('message', handleIframeMessage)
})
onDestroy(() => {
window.removeEventListener('message', handleIframeMessage)
})
// Expose drop handler for parent Svelte components
export function handleDrop(data, date) {
postToIframe({
source: 'verstak',
type: 'drop',
date: date,
data: data,
})
}
</script>
<div class="plugin-page">
{#if loading}
<p class="loading">Загрузка…</p>
{:else if !htmlPanel}
<div class="empty-state">
<p>Плагин «{pluginName}» активен, но HTML-панель не настроена.</p>
<p class="hint">Для отображения контента добавьте поле <code>panel</code> в <code>plugin.json</code>.</p>
</div>
{:else}
<div class="plugin-frame-container">
{#if error}
<div class="plugin-error-bar">⚠ {error}</div>
{/if}
<iframe
bind:this={iframeEl}
class="plugin-frame"
srcdoc={htmlPanel}
sandbox="allow-scripts allow-same-origin"
title="{pluginName} panel"
></iframe>
</div>
{/if}
</div>
<style>
.plugin-page { padding: 1.5rem; height: 100%; display: flex; flex-direction: column; }
.plugin-page-header { margin-bottom: 1rem; flex-shrink: 0; }
.plugin-page-header h2 { margin: 0; font-size: 1.1rem; color: var(--text, #e0e0e0); }
.loading { color: var(--text-dim, #888); }
.error { color: #f87171; }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-dim, #888); }
.empty-state code { background: var(--surface-alt, #252538); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
.plugin-frame-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.plugin-error-bar {
padding: 0.5rem 1rem;
background: rgba(248, 113, 113, 0.12);
border: 1px solid #f87171;
border-bottom: none;
border-radius: 8px 8px 0 0;
color: #f87171;
font-size: 0.85rem;
flex-shrink: 0;
}
.plugin-frame { flex: 1; border: 1px solid var(--border, #2a2a3e); border-radius: 8px; background: #fff; width: 100%; min-height: 400px; display: block; }
.plugin-frame-container .plugin-frame { border-radius: 0 0 8px 8px; border-top: none; }
</style>
+216
View File
@@ -0,0 +1,216 @@
<script>
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
import { t } from './i18n'
import { expandKeyboardVariants } from './util/keyboardLayout'
export let wailsCall = async () => []
export let typeLabel = (type) => type || ''
const dispatch = createEventDispatcher()
let query = ''
let results = []
let open = false
let loading = false
let selectedIndex = 0
let inputEl
let searchTimer
function clean(value) {
return String(value || '').trim()
}
function resultPath(result) {
return clean(result.path)
}
function resultMeta(result) {
const parts = []
const type = typeLabel(result.type)
const path = resultPath(result)
if (type) parts.push(type)
if (path) parts.push(path)
return parts.join(' · ')
}
async function runSearch() {
const q = clean(query)
if (q.length < 2) {
results = []
open = false
loading = false
return
}
loading = true
try {
// Build query variants: original + keyboard-swapped + lowercased
const variants = expandKeyboardVariants(q)
// Deduplicate preserving order
const seenVariants = new Set()
const queries = []
for (const v of variants) {
if (!seenVariants.has(v)) {
seenVariants.add(v)
queries.push(v)
}
}
// Execute all variant queries in parallel, collect results
const allResults = await Promise.all(
queries.map(v => wailsCall('Search', v).catch(() => []))
)
// Merge and deduplicate by node ID (or type+title for links/actions)
const merged = new Map()
// First pass: results from the original query get priority
for (let qi = 0; qi < allResults.length; qi++) {
const arr = allResults[qi] || []
for (const r of arr) {
const key = r.type + ':' + r.nodeId + ':' + (r.targetId || '') + ':' + r.title
if (!merged.has(key)) {
merged.set(key, r)
}
}
}
results = Array.from(merged.values())
selectedIndex = 0
open = results.length > 0
} catch (e) {
results = []
open = false
} finally {
loading = false
}
}
function scheduleSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(runSearch, 180)
}
function closeResults() {
open = false
selectedIndex = 0
}
function clearSearch() {
query = ''
results = []
closeResults()
}
function openResult(result) {
if (!result) return
dispatch('open', result)
clearSearch()
inputEl?.blur()
}
function handleInput() {
scheduleSearch()
}
function handleKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault()
clearSearch()
inputEl?.blur()
return
}
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex = Math.min(results.length - 1, selectedIndex + 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex = Math.max(0, selectedIndex - 1)
} else if (e.key === 'Enter') {
e.preventDefault()
openResult(results[selectedIndex])
}
}
async function focusSearch() {
await tick()
inputEl?.focus()
inputEl?.select()
}
function handleWindowKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
focusSearch()
return
}
if (e.key === '/' && !isEditableTarget(e.target)) {
e.preventDefault()
focusSearch()
}
}
function isEditableTarget(target) {
if (!target || !(target instanceof Element)) return false
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
}
onMount(() => {
window.addEventListener('keydown', handleWindowKeydown)
})
onDestroy(() => {
window.removeEventListener('keydown', handleWindowKeydown)
clearTimeout(searchTimer)
})
</script>
<div class="global-search">
<input
class="global-search-input"
bind:this={inputEl}
bind:value={query}
type="search"
placeholder={t('search.placeholder')}
aria-label={t('search.placeholder')}
autocomplete="off"
on:input={handleInput}
on:focus={() => { if (results.length > 0) open = true }}
on:keydown={handleKeydown}
on:blur={() => setTimeout(closeResults, 160)}
/>
{#if open}
<div class="global-search-results">
{#if loading}
<div class="global-search-empty">{t('common.loading')}</div>
{:else if results.length === 0}
<div class="global-search-empty">{t('search.noResults')}</div>
{:else}
{#each results as result, index}
<button
class="global-search-result"
class:selected={index === selectedIndex}
on:mousedown|preventDefault={() => openResult(result)}
>
<span class="global-search-title">{result.title}</span>
{#if resultMeta(result)}
<span class="global-search-meta">{resultMeta(result)}</span>
{/if}
{#if result.snippet}
<span class="global-search-snippet">{result.snippet}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.global-search { position: relative; flex: 1 1 360px; min-width: 180px; max-width: 520px; }
.global-search-input { width: 100%; height: 32px; padding: 0 12px; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; color: #e4e4ef; font: inherit; font-size: 13px; }
.global-search-input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.18); }
.global-search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 160; max-height: min(360px, calc(100vh - 88px)); overflow-y: auto; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; box-shadow: 0 14px 36px rgba(0,0,0,0.38); padding: 4px; }
.global-search-result { display: flex; flex-direction: column; gap: 2px; width: 100%; border: 0; background: transparent; color: #e4e4ef; text-align: left; padding: 8px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
.global-search-result:hover, .global-search-result.selected { background: #252545; }
.global-search-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-search-meta { font-size: 11px; color: #a0a0bc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-search-snippet { font-size: 12px; color: #767690; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-search-empty { padding: 10px 12px; color: #8888a0; font-size: 12px; }
</style>
@@ -0,0 +1,254 @@
<script>
import { t } from './i18n'
export let appConfig = null
let bridgeEnabled = false
let bridgePort = 9786
let bridgeRunning = false
let bridgeActualPort = 0
let saving = false
let statusMsg = ''
let statusKind = '' // 'ok' | 'error' | ''
// Load current bridge state from config
function loadFromConfig() {
if (appConfig && appConfig.vault && appConfig.vault.bridge) {
const bc = appConfig.vault.bridge
bridgeEnabled = bc.enabled !== false
bridgePort = bc.port || 9786
}
}
// Also fetch live bridge status from backend
async function fetchBridgeStatus() {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const info = await window['go']['main']['App']['BridgeInfo']()
bridgeRunning = info.running || false
bridgeActualPort = info.port || 0
}
} catch (e) {
bridgeRunning = false
bridgeActualPort = 0
}
}
loadFromConfig()
fetchBridgeStatus()
async function save() {
saving = true
statusMsg = ''
statusKind = ''
try {
// Update config
if (!appConfig) appConfig = {}
if (!appConfig.vault) appConfig.vault = {}
if (!appConfig.vault.bridge) appConfig.vault.bridge = {}
appConfig.vault.bridge.enabled = bridgeEnabled
appConfig.vault.bridge.port = bridgePort
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
await window['go']['main']['App']['SaveAppConfig'](appConfig)
}
statusMsg = 'saved'
statusKind = 'ok'
// Refresh live status
await fetchBridgeStatus()
} catch (e) {
statusMsg = String(e)
statusKind = 'error'
}
saving = false
}
async function restartBridge() {
saving = true
statusMsg = ''
statusKind = ''
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
await window['go']['main']['App']['RestartBridge']()
}
statusMsg = 'bridge restarted'
statusKind = 'ok'
await fetchBridgeStatus()
} catch (e) {
statusMsg = String(e)
statusKind = 'error'
}
saving = false
}
</script>
<div class="settings-section">
<h2>Browser Bridge</h2>
<p class="section-desc">
Локальный HTTP-сервер для приёма событий от браузерного расширения Verstak Bridge.
Работает только на 127.0.0.1.
</p>
{#if statusMsg}
<div class="status-msg" class:ok={statusKind === 'ok'} class:error={statusKind === 'error'}>
{statusMsg}
</div>
{/if}
<div class="settings-card">
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" bind:checked={bridgeEnabled} />
<span class="toggle-text">Включить Browser Bridge</span>
</label>
</div>
<div class="form-group">
<label>
<span class="label-text">Порт</span>
<input type="number" bind:value={bridgePort} min="1024" max="65535" disabled={!bridgeEnabled} />
</label>
<p class="field-hint">По умолчанию: 9786. Требуется перезапуск сервера при изменении.</p>
</div>
<div class="bridge-status">
<div class="info-row">
<span class="info-label">Статус</span>
<span class="info-value" class:status-ok={bridgeRunning} class:status-off={!bridgeRunning}>
{bridgeRunning ? 'Запущен' : 'Остановлен'}
</span>
</div>
{#if bridgeRunning && bridgeActualPort}
<div class="info-row">
<span class="info-label">Активный порт</span>
<span class="info-value mono">127.0.0.1:{bridgeActualPort}</span>
</div>
{/if}
</div>
</div>
<div class="actions">
<button class="btn btn-primary" on:click={save} disabled={saving}>
Сохранить
</button>
{#if bridgeEnabled}
<button class="btn" on:click={restartBridge} disabled={saving}>
Перезапустить сервер
</button>
{/if}
</div>
</div>
<style>
.settings-section {
padding: 1.5rem;
max-width: 600px;
}
.settings-section h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
color: var(--text, #e0e0e0);
}
.section-desc {
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.settings-card {
background: var(--surface-alt, #1e1e30);
border: 1px solid var(--border, #2a2a3e);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.label-text {
display: block;
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 0.35rem;
}
input[type="number"] {
width: 120px;
padding: 0.4rem 0.6rem;
background: var(--bg, #13131f);
border: 1px solid var(--border, #2a2a3e);
border-radius: 6px;
color: var(--text, #e0e0e0);
font-size: 0.9rem;
}
input[type="number"]:disabled {
opacity: 0.5;
}
.field-hint {
color: var(--text-dim, #666);
font-size: 0.75rem;
margin-top: 0.35rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.toggle-text {
color: var(--text, #e0e0e0);
font-size: 0.9rem;
}
.bridge-status {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border, #2a2a3e);
}
.info-row {
display: flex;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.info-label {
width: 140px;
min-width: 140px;
color: var(--text-dim, #888);
}
.info-value {
color: var(--text, #e0e0e0);
}
.info-value.mono {
font-family: monospace;
font-size: 0.85rem;
}
.status-ok {
color: #34d399;
font-weight: 600;
}
.status-off {
color: #888;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.status-msg {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
}
.status-msg.ok {
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.3);
color: #34d399;
}
.status-msg.error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: #ff6b6b;
}
</style>
+264 -9
View File
@@ -1,6 +1,13 @@
<script>
import { onMount } from 'svelte'
import { t } from './i18n'
export let onPluginToggle = null
let plugins = []
let loading = true
let error = ''
function wailsCall(method, ...args) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
@@ -11,19 +18,267 @@
return Promise.reject(new Error('Wails not connected: ' + method))
}
async function openPluginsDir() {
try { await wailsCall('OpenPluginsFolder') } catch(e) {}
onMount(async () => {
try {
plugins = await wailsCall('ListPlugins') || []
} catch (e) {
error = String(e)
}
loading = false
})
async function toggle(p) {
const newState = !p.active
try {
await wailsCall('SetPluginEnabled', p.name, newState)
// Force refresh from backend — ensures UI stays in sync
plugins = await wailsCall('ListPlugins') || []
if (onPluginToggle) onPluginToggle()
} catch (e) {
error = String(e)
}
}
async function install(p) {
error = ''
try {
await wailsCall('InstallPlugin', p.name)
p.installed = true
// Refresh list to reflect new state
plugins = await wailsCall('ListPlugins') || []
if (onPluginToggle) onPluginToggle()
} catch (e) {
error = String(e)
}
}
async function uninstall(p) {
if (!confirm(`Удалить плагин «${p.name}»? Все данные плагина будут очищены. Файлы плагина сохранятся.`)) return
error = ''
try {
await wailsCall('UninstallPlugin', p.name)
p.installed = false
p.active = false
plugins = await wailsCall('ListPlugins') || []
if (onPluginToggle) onPluginToggle()
} catch (e) {
error = String(e)
}
}
$: sidebarCount = plugins.reduce((n, p) => n + (p.uiContribs?.sidebarItems?.length || 0), 0)
$: tabCount = plugins.reduce((n, p) => n + (p.uiContribs?.nodeTabs?.length || 0), 0)
</script>
<div class="settings-section">
<h2>{t('settings.plugins')}</h2>
<p class="section-desc">{t('settings.noPlugins')}</p>
<button class="btn" on:click={openPluginsDir}>{t('settings.openPluginsDir')}</button>
<div class="settings-plugins">
<h3>{t('settings.plugins') || 'Плагины'}</h3>
<p class="hint">Включение и отключение плагинов. Изменения применяются сразу.</p>
{#if loading}
<p class="loading">{t('common.loading')}</p>
{:else if error}
<p class="error">{error}</p>
{:else if plugins.length === 0}
<div class="empty-state">
<p>Нет установленных плагинов.</p>
<p class="hint">Создайте папку <code>.verstak/plugins/</code> в хранилище и добавьте туда плагины.</p>
</div>
{:else}
<div class="plugin-list">
{#each plugins as p}
<div class="plugin-card">
<div class="plugin-info">
<div class="plugin-name">
{p.name}
{#if p.version}
<span class="plugin-version">v{p.version}</span>
{/if}
</div>
{#if p.description}
<div class="plugin-desc">{p.description}</div>
{/if}
<div class="plugin-meta">
{#if p.author}
<span>{t('settings.author') || 'Автор'}: {p.author}</span>
{/if}
{#if p.uiContribs?.sidebarItems?.length}
<span>{p.uiContribs.sidebarItems.length} пункт(а) в сайдбаре</span>
{/if}
{#if p.uiContribs?.nodeTabs?.length}
<span>{p.uiContribs.nodeTabs.length} вкладок(и) в карточке дела</span>
{/if}
{#if p.hasPanel}
<span>🗖 HTML-панель</span>
{/if}
</div>
</div>
<div class="plugin-toggle">
{#if !p.installed}
<button class="install-btn" on:click={() => install(p)}>
📦 Установить
</button>
{:else}
<div class="toggle-group">
<button
class="toggle-btn"
class:active={p.active}
on:click={() => toggle(p)}
role="switch"
aria-checked={p.active}
>
<span class="toggle-knob"></span>
</button>
<button class="uninstall-btn btn-sm" on:click={() => uninstall(p)} title="Удалить плагин (очистить данные)">
🗑
</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.settings-section { padding: 1.5rem; max-width: 500px; }
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; margin-bottom: 1rem; }
.settings-plugins {
padding: 1.5rem;
}
h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
color: var(--text, #e0e0e0);
}
.hint {
margin: 0 0 1rem;
font-size: 0.85rem;
color: var(--text-dim, #888);
}
.loading { color: var(--text-dim, #888); }
.error { color: #f87171; background: rgba(248,113,113,0.1); padding: 0.5rem 0.75rem; border-radius: 6px; }
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-dim, #888);
}
.empty-state code {
background: var(--surface-alt, #252538);
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.85rem;
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.plugin-card {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface, #1e1e2e);
border: 1px solid var(--border, #2a2a3e);
border-radius: 8px;
padding: 0.75rem 1rem;
gap: 1rem;
}
.plugin-info {
flex: 1;
min-width: 0;
}
.plugin-name {
font-weight: 600;
color: var(--text, #e0e0e0);
display: flex;
align-items: center;
gap: 0.5rem;
}
.plugin-version {
font-size: 0.75rem;
color: var(--text-dim, #888);
font-weight: 400;
}
.plugin-desc {
font-size: 0.85rem;
color: var(--text-dim, #aaa);
margin-top: 0.15rem;
}
.plugin-meta {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-dim, #888);
}
.plugin-toggle {
flex-shrink: 0;
}
.toggle-btn {
position: relative;
width: 44px;
height: 24px;
background: var(--surface-alt, #333);
border-radius: 12px;
border: 1px solid var(--border, #444);
cursor: pointer;
padding: 0;
transition: background 0.2s;
}
.toggle-btn.active {
background: #6366f1;
border-color: #6366f1;
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
transition: left 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.toggle-btn.active .toggle-knob {
left: 22px;
}
.toggle-group {
display: flex;
align-items: center;
gap: 6px;
}
.install-btn {
padding: 6px 14px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.install-btn:hover { filter: brightness(1.15); }
.uninstall-btn {
width: 28px;
height: 28px;
background: var(--surface, #1a1a2e);
color: #ef4444;
border: 1px solid rgba(239,68,68,0.3);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: all 0.15s;
}
.uninstall-btn:hover {
opacity: 1;
background: rgba(239,68,68,0.1);
}
</style>
+16 -1
View File
@@ -13,6 +13,7 @@
{ id: 'activity', label: t('settings.activity') },
{ id: 'sync', label: t('settings.sync') },
{ id: 'backup', label: t('settings.backup') },
{ id: 'browserBridge', label: 'Browser Bridge' },
]
function select(id) {
@@ -25,7 +26,7 @@
<button
class="settings-nav-item"
class:active={activeSection === sec.id}
class:disabled={sec.id === 'plugins' || sec.id === 'files' || sec.id === 'activity' || sec.id === 'backup'}
class:disabled={sec.id === 'files' || sec.id === 'activity' || sec.id === 'backup'}
on:click={() => select(sec.id)}
>
<span class="settings-nav-icon">
@@ -57,6 +58,9 @@
<path d="M5 4h12l2 2v14H5z"/>
<path d="M8 4v6h8V4"/>
<path d="M8 20v-6h8v6"/>
{:else if sec.id === 'browserBridge'}
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
{/if}
</svg>
</span>
@@ -97,6 +101,17 @@
background: var(--accent-bg, rgba(99, 102, 241, 0.15));
color: var(--accent, #818cf8);
font-weight: 600;
position: relative;
}
.settings-nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
background: var(--accent, #818cf8);
border-radius: 0 2px 2px 0;
}
.settings-nav-item.disabled {
opacity: 0.4;
+5 -1
View File
@@ -9,9 +9,11 @@
import SettingsActivity from './SettingsActivity.svelte'
import SettingsSync from './SettingsSync.svelte'
import SettingsBackup from './SettingsBackup.svelte'
import SettingsBrowserBridge from './SettingsBrowserBridge.svelte'
export let onClose = null
export let onSyncRefresh = null
export let onPluginToggle = null
export let initialSection = 'general'
let activeSection = initialSection
@@ -81,7 +83,7 @@
{:else if activeSection === 'templates'}
<SettingsTemplates onRefresh={loadConfig} />
{:else if activeSection === 'plugins'}
<SettingsPlugins />
<SettingsPlugins {onPluginToggle} />
{:else if activeSection === 'files'}
<SettingsFiles />
{:else if activeSection === 'activity'}
@@ -90,6 +92,8 @@
<SettingsSync onRefresh={handleSyncRefresh} />
{:else if activeSection === 'backup'}
<SettingsBackup />
{:else if activeSection === 'browserBridge'}
<SettingsBrowserBridge {appConfig} />
{/if}
</div>
</div>
+40 -15
View File
@@ -1,5 +1,6 @@
<script>
import { t } from './i18n'
import BrowserEvents from './BrowserEvents.svelte'
export let todayDashboard = null
export let suggestions = []
@@ -8,11 +9,8 @@
export let suggestionCount = 0
export let formatTime = (iso) => ''
export let formatDate = (iso) => ''
export let eventLabel = (type) => type
export let eventIcon = (type) => ''
export let nodeKindLabel = (kind) => kind
export let captureKindLabel = (kind) => kind
export let inboxMetaText = (item) => ''
export let onOpenNodeById = (id) => {}
@@ -23,6 +21,15 @@
export let onDeleteSuggestion = (s) => {}
export let onOpenNodeFolder = (id) => {}
export let onOpenInboxArtifact = (item) => {}
export let onOpenTrashNode = (nodeId) => {}
// Browser events
export let browserEvents = []
export let browserLoading = false
export let onBrowserAccept = (ev) => {}
export let onBrowserDismiss = (ev) => {}
export let onBrowserAttach = (ev) => {}
export let onBrowserRefresh = () => {}
let activeTab = 'feed'
@@ -50,6 +57,7 @@
}
function feedItemLabel(ev) {
if (!ev || !ev.eventType) return ''
return eventLabel(ev.eventType)
}
@@ -67,14 +75,15 @@
}
function handleFeedTitleClick(ev) {
if (!ev || !ev.eventType) return
if (ev.eventType === 'folder_deleted') {
onOpenNodeById(ev.nodeId)
if (ev.targetId) onOpenTrashNode(ev.targetId)
return
}
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
if (ev.targetId) {
onOpenActivityTarget(ev)
} else {
} else if (ev.nodeId) {
onOpenNodeById(ev.nodeId)
}
return
@@ -82,20 +91,22 @@
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
if (ev.targetType === 'note' && ev.targetId) {
onOpenActivityTarget(ev)
} else {
} else if (ev.nodeId) {
onOpenNodeById(ev.nodeId)
}
return
}
if (ev.eventType === 'worklog_added') {
onOpenNodeById(ev.nodeId)
if (ev.nodeId) onOpenNodeById(ev.nodeId)
return
}
if (['action_created','action_done'].includes(ev.eventType)) {
onOpenNodeById(ev.nodeId)
if (ev.nodeId) onOpenNodeById(ev.nodeId)
return
}
onOpenActivityEvent(ev)
if (ev.id || ev.nodeId) {
onOpenActivityEvent(ev)
}
}
function feedItemSubtitle(ev) {
@@ -152,7 +163,6 @@
<div class="today-screen">
<div class="today-header">
<h2>{t('today.title')}</h2>
<span class="today-date">{todayDashboard?.date || ''}</span>
</div>
@@ -171,6 +181,10 @@
{t('today.captured')}
{#if todayCaptures.length > 0}<span class="tab-badge">{todayCaptures.length}</span>{/if}
</button>
<button class="today-tab" class:active={activeTab === 'browser'} on:click={() => activeTab = 'browser'}>
Браузер
{#if browserEvents.length > 0}<span class="tab-badge">{browserEvents.length}</span>{/if}
</button>
</div>
{#if activeTab === 'feed'}
@@ -189,9 +203,9 @@
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
<div class="feed-body">
<div class="feed-title-line">
<span class="feed-type">{feedItemLabel(ev.eventType)}</span>
<span class="feed-type">{feedItemLabel(ev)}</span>
<span class="feed-colon">:</span>
<span class="feed-title link-btn">{ev.title}</span>
<span class="feed-title link-btn">{ev.title || ''}</span>
</div>
<div class="feed-meta-line">
{#if feedItemSubtitle(ev)}
@@ -327,13 +341,24 @@
</div>
{/if}
</div>
{:else if activeTab === 'browser'}
<div class="today-tab-content">
<BrowserEvents
events={browserEvents}
loading={browserLoading}
{formatTime}
onAccept={onBrowserAccept}
onDismiss={onBrowserDismiss}
onAttach={onBrowserAttach}
onRefresh={onBrowserRefresh}
/>
</div>
{/if}
</div>
<style>
.today-screen { padding: 24px; overflow-y: auto; flex: 1; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.today-header h2 { font-size: 24px; margin: 0; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; min-height: 20px; }
.today-date { font-size: 13px; color: #666; }
.today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
@@ -368,7 +393,7 @@
.feed-title:hover { text-decoration: underline; }
.feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.feed-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-event-type { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
.feed-event-type { font-size: 10px; color: #a5b4fc; background: rgba(99,102,241,0.1); padding: 2px 8px; border-radius: 8px; font-weight: 500; }
.feed-time { font-size: 11px; color: #555; margin-left: auto; flex-shrink: 0; }
.feed-nav-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; border-radius: 4px; flex-shrink: 0; margin-top: 2px; }
.feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; }
@@ -0,0 +1,15 @@
<script>
export let show = false
export let label = ''
</script>
{#if show}
<div class="capture-drop-overlay">
<div class="capture-drop-box">{label}</div>
</div>
{/if}
<style>
.capture-drop-overlay { position: fixed; inset: 0; z-index: 120; pointer-events: none; display: flex; align-items: center; justify-content: center; background: rgba(19, 19, 31, 0.42); border: 2px dashed #818cf8; }
.capture-drop-box { max-width: min(520px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid #3a3a5c; border-radius: 8px; background: #1a1a28; color: #e4e4ef; font-size: 14px; font-weight: 600; box-shadow: 0 12px 32px rgba(0,0,0,0.35); text-align: center; }
</style>
@@ -0,0 +1,37 @@
<script>
import { t } from '../i18n'
export let error = ''
export let onDismiss = () => {}
function translateError(msg) {
const map = {
'vault not open': t('error.vaultNotOpen'),
}
return map[msg] || msg
}
function handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onDismiss()
}
}
</script>
{#if error}
<div class="error-banner" role="button" tabindex="0" on:click={onDismiss} on:keydown={handleKeydown}>
{translateError(error)}
<button class="dismiss-btn" on:click|stopPropagation={onDismiss} aria-label="Dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/if}
<style>
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
.dismiss-btn:hover { color: #ff4444; }
</style>
@@ -0,0 +1,98 @@
<script>
import { t } from '../i18n'
import { actionIcon } from '../actionIcons'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let node = null
export let notes = []
export let worklog = []
export let nodeKindLabel = (type) => type || ''
export let formatDate = (d) => d || ''
function goNotesCreate() {
dispatch('goTab', 'notes')
dispatch('createNote')
}
function goFilesAdd() {
dispatch('goTab', 'files')
dispatch('addFile')
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
}
}
}
</script>
<div class="overview">
<h2 class="node-title">{node.title}</h2>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(node.type)}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{node.section || '—'}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(node.createdAt)}</span></div>
</div>
<div class="quick-actions">
<button class="qa-btn" on:click={goNotesCreate}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{t('overview.newNote')}
</button>
<button class="qa-btn" on:click={goFilesAdd}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{t('overview.addFile')}
</button>
<button class="qa-btn" on:click={() => dispatch('createAction')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{t('overview.addAction')}
</button>
<button class="qa-btn" on:click={() => dispatch('goTab', 'worklog')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('overview.logTime')}
</button>
</div>
{#if notes.length > 0}
<div class="recent-section">
<h3>{t('overview.recentNotes')}</h3>
{#each notes.slice(0, 5) as note}
<div class="recent-note" role="button" tabindex="0" on:click={() => dispatch('openNote', { note })} on:keydown={onKeyActivate(() => dispatch('openNote', { note }))}>
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
</div>
{/each}
</div>
{/if}
{#if worklog.length > 0}
<div class="recent-section">
<h3>{t('overview.recentEntries')}</h3>
{#each worklog.slice(0, 3) as e}
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
{/each}
</div>
{/if}
</div>
<style>
.overview { padding: 24px; }
.node-title { font-size: 24px; margin-bottom: 16px; color: #e4e4ef; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.qa-btn:hover { background: #222233; }
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.recent-section { margin-bottom: 24px; }
.recent-section h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
.recent-note { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
.recent-note:hover { background: #1a1a28; }
.recent-date { font-size: 11px; color: #555; }
.recent-entry { padding: 6px 0; font-size: 13px; color: #888; border-bottom: 1px solid #1a1a28; }
</style>
@@ -0,0 +1,616 @@
<script>
import { createEventDispatcher } from 'svelte'
import { t } from '../../i18n'
import { isMarkdownFile } from '../../fileUtils.js'
import FileTreeRow from '../../../FileTreeRow.svelte'
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
import FilePreviewModal from '../../FilePreviewModal.svelte'
const dispatch = createEventDispatcher()
// Props
export let selectedNode = null
// Internal state
let loadingFiles = false
let currentFolderId = null
let folderStack = []
let fileItems = []
let previewItem = null
let previewContent = ''
let previewLoading = false
let previewError = ''
let clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
let importing = false
let importSummary = null
let showImportDialog = false
let pendingImportPath = ''
let pendingImportParent = ''
// ===== Wails call helper =====
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))
}
// ===== Public API for keyboard handler =====
export function filesHandleKeydown(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete') {
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
// ===== Folder navigation =====
export async function loadFolder(folderId) {
loadingFiles = true
try {
let items = await wailsCall('ListItems', folderId) || []
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return (a.name || '').localeCompare(b.name || '')
})
fileItems = items
} catch (e) {
fileItems = []
}
loadingFiles = false
}
async function navigateToFolder(folderId) {
if (!selectedNode) return
try {
const node = await wailsCall('GetNodeDetail', folderId)
if (node) {
folderStack = [...folderStack, { id: folderId, name: node.title }]
}
} catch (e) {
folderStack = [...folderStack, { id: folderId, name: '...' }]
}
currentFolderId = folderId
await loadFolder(folderId)
}
function navigateBack() {
if (folderStack.length < 2) {
folderStack = []
currentFolderId = null
if (selectedNode) loadFolder(selectedNode.id)
} else {
const target = folderStack[folderStack.length - 2]
folderStack = folderStack.slice(0, -1)
currentFolderId = target.id
loadFolder(target.id)
}
}
function navigateToBreadcrumb(index) {
const target = folderStack[index]
folderStack = folderStack.slice(0, index + 1)
currentFolderId = target.id
loadFolder(target.id)
}
// ===== File preview =====
async function openPreview(item) {
if (item && item.fileId && isMarkdownFile(item)) {
try {
const action = await wailsCall('CheckFileAction', item.fileId)
if (action.action === 'note') {
dispatch('openNote', { note: { id: action.noteId, title: action.noteTitle } })
return
}
if (action.action === 'external') {
await wailsCall('OpenFile', item.fileId)
return
}
} catch (e) {
console.warn('CheckFileAction failed, falling back to preview:', e)
}
}
previewItem = item
previewContent = ''
previewError = ''
previewLoading = true
try {
if (needsBase64Preview(item)) {
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
} else if (needsTextPreview(item)) {
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
}
} catch (e) {
previewError = String(e)
}
previewLoading = false
}
function closePreview() {
previewItem = null
previewContent = ''
previewError = ''
}
// ===== Helpers for preview =====
function needsBase64Preview(item) {
if (!item) return false
const ext = (item.name || '').toLowerCase()
return ext.endsWith('.png') || ext.endsWith('.jpg') || ext.endsWith('.jpeg') ||
ext.endsWith('.gif') || ext.endsWith('.webp') || ext.endsWith('.svg') ||
ext.endsWith('.bmp') || ext.endsWith('.ico') || ext.endsWith('.pdf')
}
function needsTextPreview(item) {
if (!item) return false
const ext = (item.name || '').toLowerCase()
return ext.endsWith('.txt') || ext.endsWith('.log') || ext.endsWith('.json') ||
ext.endsWith('.xml') || ext.endsWith('.csv') || ext.endsWith('.md') ||
ext.endsWith('.js') || ext.endsWith('.ts') || ext.endsWith('.css') ||
ext.endsWith('.html') || ext.endsWith('.yaml') || ext.endsWith('.yml')
}
// ===== File operations =====
async function createFile() {
const name = prompt(t('file.namePrompt'))
if (!name || !name.trim()) return
try {
const parentId = currentFolderId || selectedNode.id
await wailsCall('CreateEmptyFile', parentId, name.trim())
await loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId })
} catch (e) {
dispatch('error', { message: String(e) })
}
}
async function duplicateItem(id) {
try {
await wailsCall('DuplicateNode', id)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId })
} catch (e) {
dispatch('error', { message: String(e) })
}
}
function renameItem(id) {
const item = fileItems.find(x => x.id === id)
if (item) {
dispatch('rename', { id, name: item.name })
}
}
function cutItem(id) {
clipboard = { items: [id], mode: 'cut' }
}
function copyItem(id) {
clipboard = { items: [id], mode: 'copy' }
}
async function pasteItem() {
if (clipboard.items.length === 0) return
const targetId = currentFolderId || selectedNode.id
try {
if (clipboard.mode === 'copy') {
for (const id of clipboard.items) {
await wailsCall('DuplicateNode', id)
}
} else {
for (const id of clipboard.items) {
await wailsCall('MoveNode', id, targetId)
}
}
clipboard = { items: [], mode: 'copy' }
await loadFolder(targetId)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
// ===== Selection =====
function getTargetIds(ids) {
return ids.length > 0 ? ids : fileItems.map(x => x.id)
}
function toggleSelection(id) {
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter(x => x !== id)
} else {
selectedIds = [...selectedIds, id]
}
}
function selectOne(id) {
selectedIds = [id]
}
function selectAll() {
selectedIds = fileItems.map(x => x.id)
}
function clearSelection() {
selectedIds = []
}
function rangeSelect(id) {
if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
const lastIdx = fileItems.findIndex(x => x.id === lastId)
const curIdx = fileItems.findIndex(x => x.id === id)
if (lastIdx === -1 || curIdx === -1) return
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
const range = fileItems.slice(start, end + 1).map(x => x.id)
const set = new Set(selectedIds)
range.forEach(x => set.add(x))
selectedIds = [...set]
}
function cutSelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'cut' }
selectedIds = []
}
function copySelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'copy' }
selectedIds = []
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
dispatch('rename', { id: item.id, name: item.name })
}
}
}
async function deleteSelected() {
const ids = getTargetIds(selectedIds)
const item = fileItems.find(x => x.id === ids[0])
let label
if (ids.length === 1 && item?.type === 'folder') {
label = t('delete.folder')
} else if (ids.length === 1) {
label = t('delete.file')
} else {
label = t('delete.files', { count: ids.length })
}
dispatch('confirm', {
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
for (const id of ids) {
try {
await wailsCall('DeleteFileOrFolder', id)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
selectedIds = []
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
}
})
}
// ===== Drag-and-drop =====
function onDragStart(e, id) {
e.stopPropagation()
const ids = selectedIds.includes(id) ? selectedIds : [id]
dragIds = ids
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', ids.join(','))
}
function onDragOver(e, folderId) {
const item = fileItems.find(x => x.id === folderId)
if (item && item.type === 'folder') {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
}
async function onDrop(e, folderId) {
e.preventDefault()
e.stopPropagation()
if (dragIds.length === 0) return
for (const id of dragIds) {
try {
await wailsCall('MoveNode', id, folderId)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
dragIds = []
selectedIds = []
await loadFolder(currentFolderId || selectedNode.id)
}
// ===== Import =====
async function addFile() {
const path = await wailsCall('PickFile')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function addFolder() {
const path = await wailsCall('PickDirectory')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function startImport(parentID, sourcePath) {
importing = true
try {
const summary = await wailsCall('PreviewImport', sourcePath)
importSummary = summary
pendingImportPath = sourcePath
pendingImportParent = parentID
showImportDialog = true
} catch (e) {
dispatch('error', { message: String(e) })
}
importing = false
}
async function confirmImport(mode) {
try {
const parentId = pendingImportParent || selectedNode.id
const result = mode === 'copy'
? await wailsCall('AddPathCopy', parentId, pendingImportPath)
: await wailsCall('AddPathLink', parentId, pendingImportPath)
showImportDialog = false
importSummary = null
folderStack = []
currentFolderId = null
await loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId })
} catch (e) {
dispatch('error', { message: String(e) })
}
}
function cancelImport() {
showImportDialog = false
importSummary = null
}
async function deleteFile({ id, type }) {
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
dispatch('confirm', {
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteFileOrFolder', id)
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
})
}
// ===== Public API: called from parent when node changes =====
export function resetToNode(nodeId) {
folderStack = []
currentFolderId = null
selectedIds = []
dragIds = []
clipboard = { items: [], mode: 'copy' }
fileItems = []
loadingFiles = false
previewItem = null
previewContent = ''
previewError = ''
importing = false
showImportDialog = false
importSummary = null
if (nodeId) loadFolder(nodeId)
}
// Load folder when selectedNode changes
$: if (selectedNode && selectedNode.id && !currentFolderId && fileItems.length === 0 && !loadingFiles) {
loadFolder(selectedNode.id)
}
</script>
<div class="files-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
{/if}
</div>
{#if loadingFiles}
<div class="empty-state">
<p>{t('common.loading')}</p>
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
currentFolderId = null
if (selectedNode) loadFolder(selectedNode.id)
} else {
navigateToBreadcrumb(i - 1)
}
}}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}>
<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="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
{t('common.backLabel')}
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
{/if}
{#if fileItems.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">{t('file.hint')}</p>
<div class="empty-actions">
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button>
</div>
</div>
{:else}
<div class="file-list">
{#each fileItems as item (item.id)}
<FileTreeRow
{item}
selected={selectedIds.includes(item.id)}
{onDragStart}
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
on:cut={(e) => cutItem(e.detail)}
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
{/if}
{/if}
{#if importing && !showImportDialog}
<div class="empty-state"><p>{t('file.scanning')}</p></div>
{/if}
<!-- Import dialog -->
{#if showImportDialog && importSummary}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('file.importTitle')} on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
<div class="modal-box" role="document">
<h3>{t('file.importTitle')}</h3>
<div class="import-summary">
<p><strong>{t('file.path')}:</strong> {importSummary.path || pendingImportPath}</p>
{#if importSummary.files}
<p>{t('file.files')}: {importSummary.files.length}</p>
{/if}
</div>
<div class="form-actions">
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<!-- File preview modal -->
{#if previewItem}
<FilePreviewModal
item={previewItem}
content={previewContent}
loading={previewLoading}
error={previewError}
{wailsCall}
on:close={closePreview}
/>
{/if}
</div>
<style>
.files-tab { padding: 20px; }
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.file-list { display: flex; flex-direction: column; }
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
.empty-icon { margin-bottom: 12px; color: #333; }
.empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; }
.modal-box { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; min-width: 320px; max-width: 560px; width: 90vw; }
.modal-box h3 { margin: 0 0 16px; font-size: 16px; }
.import-summary { margin-bottom: 16px; font-size: 13px; }
.import-summary p { margin: 4px 0; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; }
</style>
@@ -0,0 +1,348 @@
<script>
import { createEventDispatcher } from 'svelte';
import { escapeMarkdownLabel } from '../../markdown/markdown';
import { expandKeyboardVariants } from '../../util/keyboardLayout';
export let visible = false;
const dispatch = createEventDispatcher();
// Filter types
const types = [
{ id: 'case', label: 'Дело' },
{ id: 'note', label: 'Заметка' },
{ id: 'file', label: 'Файл' },
{ id: 'secret', label: 'Секрет' },
];
let activeType = 'note';
let query = '';
let rawResults = []; // all search results (unfiltered)
let results = []; // filtered by activeType
let selectedIndex = 0;
let loading = false;
let error = '';
let inputRef;
// Map node types from backend to our filter types
function nodeTypeToFilter(type) {
if (type === 'note') return 'note';
if (type === 'file') return 'file';
if (type === 'secret') return 'secret';
return 'case';
}
// Filter rawResults by activeType into displayed results
function applyTypeFilter() {
results = rawResults.filter(n => nodeTypeToFilter(n.type) === activeType);
selectedIndex = 0;
}
async function search() {
if (!query.trim() || query.trim().length < 2) {
rawResults = [];
results = [];
return;
}
loading = true;
error = '';
try {
// Expand query with keyboard layout variants for tolerant search
const variants = expandKeyboardVariants(query.trim());
const seen = new Set();
const queries = [];
for (const v of variants) {
if (!seen.has(v)) {
seen.add(v);
queries.push(v);
}
}
// Execute all variant queries in parallel
const allResults = await Promise.all(
queries.map(q => window['go']['main']['App']['SearchNodes'](q).catch(() => []))
);
// Merge and deduplicate by node ID
const merged = new Map();
for (const arr of allResults) {
for (const n of (arr || [])) {
if (!merged.has(n.id)) {
merged.set(n.id, n);
}
}
}
rawResults = Array.from(merged.values());
applyTypeFilter();
} catch (e) {
error = String(e);
rawResults = [];
results = [];
} finally {
loading = false;
}
}
function handleInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(search, 250);
}
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]) {
selectResult(results[selectedIndex]);
}
} else if (e.key === 'Escape') {
dispatch('close');
}
}
function selectResult(item) {
const label = escapeMarkdownLabel(item.title || item.id);
const md = `[${label}](verstak://${nodeTypeToFilter(item.type)}/${item.id})`;
dispatch('insert', { markdown: md });
}
function handleTypeChange(typeId) {
activeType = typeId;
applyTypeFilter();
if (inputRef) inputRef.focus();
}
let searchTimer;
$: if (visible && inputRef) {
setTimeout(() => inputRef.focus(), 50);
}
</script>
{#if visible}
<div class="picker-modal-overlay" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
<div class="picker-modal" role="dialog" aria-modal="true" aria-label="Выбрать объект">
<div class="picker-header">
<input
type="text"
bind:this={inputRef}
bind:value={query}
on:input={handleInput}
on:keydown={handleKeydown}
placeholder="Поиск..."
class="picker-search-input"
autocomplete="off"
/>
</div>
<div class="picker-types" role="tablist" aria-label="Тип объекта">
{#each types as type}
<button
type="button"
class="picker-type-btn"
class:active={activeType === type.id}
class:disabled={type.id === 'secret'}
role="tab"
aria-selected={activeType === type.id}
on:click={() => type.id !== 'secret' && handleTypeChange(type.id)}
disabled={type.id === 'secret'}
>
{type.label}
</button>
{/each}
</div>
{#if activeType === 'secret'}
<div class="picker-secret-placeholder">
<p>🔒 Сейф доступов ещё не реализован</p>
</div>
{:else}
<div class="picker-results" role="listbox">
{#if loading}
<div class="picker-loading">Загрузка...</div>
{:else if error}
<div class="picker-error">{error}</div>
{:else if query.trim().length >= 2 && results.length === 0 && !loading}
<div class="picker-empty">
{rawResults.length > 0 ? 'Нет результатов для этого типа' : 'Ничего не найдено'}
</div>
{:else}
{#each results as item, i}
<button
type="button"
class="picker-result"
class:selected={i === selectedIndex}
role="option"
aria-selected={i === selectedIndex}
on:click={() => selectResult(item)}
>
<span class="picker-result-title">{item.title}</span>
{#if item.path}
<span class="picker-result-path">{item.path}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
<div class="picker-footer">
<button type="button" class="btn" on:click={() => dispatch('close')}>Отмена</button>
</div>
</div>
</div>
{/if}
<style>
.picker-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.picker-modal {
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 12px;
width: 480px;
max-width: 90vw;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-header {
padding: 12px 16px;
border-bottom: 1px solid #2a2a3c;
}
.picker-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #13131f;
color: #e4e4ef;
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.picker-search-input:focus {
border-color: #818cf8;
}
.picker-types {
display: flex;
gap: 4px;
padding: 8px 16px;
border-bottom: 1px solid #2a2a3c;
}
.picker-type-btn {
padding: 4px 12px;
border: 1px solid #2a2a3c;
border-radius: 4px;
background: #13131f;
color: #999;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.picker-type-btn:hover:not(:disabled) {
background: #1e1e38;
color: #ccc;
}
.picker-type-btn.active {
background: #2a2a4a;
color: #e4e4ef;
border-color: #6366f1;
}
.picker-type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.picker-secret-placeholder {
padding: 32px;
text-align: center;
color: #666;
font-size: 14px;
}
.picker-results {
flex: 1;
overflow-y: auto;
min-height: 0;
max-height: 300px;
}
.picker-loading,
.picker-error,
.picker-empty {
padding: 24px;
text-align: center;
color: #666;
font-size: 13px;
}
.picker-error {
color: #ff8888;
}
.picker-result {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 10px 16px;
border: none;
border-bottom: 1px solid #1e1e30;
background: transparent;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.picker-result:last-child {
border-bottom: none;
}
.picker-result:hover,
.picker-result.selected {
background: #1e1e38;
}
.picker-result-title {
font-weight: 500;
}
.picker-result-path {
font-size: 11px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #2a2a3c;
}
</style>
@@ -0,0 +1,390 @@
<script>
import { createEventDispatcher } from 'svelte';
import { t } from '../../i18n';
export let content = '';
export let viewMode = 'edit';
export let placeholder = '';
const dispatch = createEventDispatcher();
let textareaRef;
// ─── Public API: insert text at cursor ──────────────────────────
export function insertText(text) {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = content.substring(0, start);
const after = content.substring(end);
const newContent = before + text + after;
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = ta.selectionEnd = start + text.length;
});
}
// ─── 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 { createEventDispatcher } from 'svelte';
import { renderMarkdown, isInternalLink, parseVerstakUrl, isAllowedVerstakType } from '../../markdown/markdown';
import { t } from '../../i18n';
export let content = '';
const dispatch = createEventDispatcher();
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;
dispatch('verstak-link', { 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,188 @@
<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();
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor
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);
}
// ─── Public API ──────────────────────────────────────────────────
export function insertText(text) {
if (activeEditor && typeof activeEditor.insertText === 'function') {
activeEditor.insertText(text);
}
}
</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
bind:this={activeEditor}
{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
bind:this={activeEditor}
{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,114 @@
<script>
import { t } from '../../i18n'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let notes = []
export let showCreateNote = false
export let formatDate = (d) => d || ''
let newNoteTitle = ''
function handleCreateNote() {
dispatch('createNote')
}
function handleSubmitCreateNote() {
if (newNoteTitle.trim()) {
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
newNoteTitle = ''
}
}
function handleCancelCreateNote() {
dispatch('cancelCreateNote')
}
function handleOpenNote(note) {
dispatch('openNote', { note })
}
function handleStartRename(note) {
dispatch('startRename', { note })
}
function handleDelete(note) {
dispatch('delete', { note })
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
}
}
}
</script>
<div class="notes-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={handleCreateNote}>{t('note.add')}</button>
</div>
{#if showCreateNote}
<div class="create-form">
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
on:keydown={(e) => e.key === 'Enter' && handleSubmitCreateNote()} />
<div class="form-actions">
<button class="btn btn-primary" on:click={handleSubmitCreateNote}>{t('common.create')}</button>
<button class="btn" on:click={handleCancelCreateNote}>{t('common.cancel')}</button>
</div>
</div>
{/if}
{#if notes.length === 0 && !showCreateNote}
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
{:else}
<div class="notes-list">
{#each notes as note}
<div class="note-card" role="button" tabindex="0" on:click={() => handleOpenNote(note)} on:keydown={onKeyActivate(() => handleOpenNote(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={() => handleStartRename(note)} 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={() => handleDelete(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}
</div>
<style>
.notes-tab { padding: 24px; }
.tab-toolbar { margin-bottom: 16px; }
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; outline: none; }
.create-form input:focus { border-color: #6366f1; }
.form-actions { display: flex; gap: 8px; }
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
.note-card:hover { border-color: #3a3a5c; }
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.note-card-date { font-size: 11px; color: #555; }
.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; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
</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>
+45
View File
@@ -45,6 +45,7 @@ export default {
'inbox.assignTitle': 'Assign material',
'inbox.assignTarget': 'Case',
'inbox.assignSearchPlaceholder': 'Find case',
'inbox.assignSearchHint': 'Start typing a Case name',
'inbox.deleteTitle': 'Delete material',
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
'capture.kind.text': 'Text',
@@ -107,6 +108,8 @@ export default {
'common.created': 'Created',
'common.empty': 'None',
'common.newName': 'New name',
'search.placeholder': 'Search Verstak...',
'search.noResults': 'No results found',
'welcome.title': 'Verstak',
'welcome.selectSection': 'Select a section in the sidebar.',
'welcome.createCase': 'Or create a new case with the + button.',
@@ -139,6 +142,7 @@ export default {
'kind.archive': 'Archive',
'kind.case': 'Case',
'kind.link': 'Link',
'kind.action': 'Action',
'links.empty': 'No links yet',
'links.editTitle': 'Edit link',
'links.note': 'Note',
@@ -174,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',
@@ -278,6 +322,7 @@ export default {
'today.sortAsc': 'ascending',
'today.sortDesc': 'descending',
'journal.title': 'Work Log',
'journal.worklogTab': 'Work Log',
'journal.empty': 'No entries for the selected period',
'journal.dateFrom': 'From',
'journal.dateTo': 'To',
+45
View File
@@ -46,6 +46,7 @@ export default {
'inbox.assignTitle': 'Разложить материал',
'inbox.assignTarget': 'Дело',
'inbox.assignSearchPlaceholder': 'Найти дело',
'inbox.assignSearchHint': 'Начните набирать название Дела',
'inbox.deleteTitle': 'Удалить материал',
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
@@ -112,6 +113,8 @@ export default {
'common.created': 'Создано',
'common.empty': 'Нет',
'common.newName': 'Новое имя',
'search.placeholder': 'Поиск по Верстаку...',
'search.noResults': 'Ничего не найдено',
'welcome.title': 'Верстак',
'welcome.selectSection': 'Выберите раздел в боковой панели.',
@@ -147,6 +150,7 @@ export default {
'kind.archive': 'Архив',
'kind.case': 'Дело',
'kind.link': 'Ссылка',
'kind.action': 'Действие',
'links.empty': 'Ссылок пока нет',
'links.editTitle': 'Редактировать ссылку',
@@ -186,6 +190,46 @@ 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.caseNotFound': 'Дело не найдено',
'note.internal.noteNotFound': 'Заметка не найдена',
'note.internal.fileNotFound': 'Файл не найден',
'note.internal.fileFound': 'Файл найден: {title}',
'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': '+ Новый файл',
@@ -294,6 +338,7 @@ export default {
'today.sortDesc': 'по убыванию',
'journal.title': 'Журнал работы',
'journal.worklogTab': 'Журнал работы',
'journal.empty': 'Нет записей за выбранный период',
'journal.dateFrom': 'От',
'journal.dateTo': 'До',
@@ -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;
}
}
+194
View File
@@ -0,0 +1,194 @@
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();
// Internal verstak links use hash-based href (e.g. #verstak-file-abc)
// and store the real URL in data-verstak-href. Don't block them.
if (node.hasAttribute('data-verstak-href')) {
return;
}
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);
// Use href="#" as a safe placeholder — DOMPurify won't strip it.
// The actual navigation is handled by data-verstak-href + click handler.
// e.preventDefault() in the click handler prevents scrolling to top.
return `<a href="#" 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] };
}
/**
* Escape markdown special characters in link label text.
* Escapes [ ] ( ) so they don't break markdown inline link syntax.
*/
export function escapeMarkdownLabel(s) {
return s.replace(/([[\]()])/g, '\\$1');
}
/**
* Check if a verstak:// type is supported.
*/
export function isAllowedVerstakType(type) {
return ALLOWED_VERSTAK_TYPES.has(type);
}
// ─── Helpers ───────────────────────────────────────────────────────
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(s) {
return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Actions API — actions (quick commands) on nodes.
*/
import { createApi } from './wails.js'
export const listActions = createApi('ListActions')
export const createAction = createApi('CreateAction')
export const runAction = createApi('RunAction')
export const deleteAction = createApi('DeleteAction')
+7
View File
@@ -0,0 +1,7 @@
/**
* Activity API — activity feed and per-node activity.
*/
import { createApi } from './wails.js'
export const listActivityFeed = createApi('ListActivityFeed')
export const listActivityByNode = createApi('ListActivityByNode')
@@ -0,0 +1,9 @@
/**
* Browser Events API — browser extension events.
*/
import { createApi } from './wails.js'
export const listBrowserEvents = createApi('ListBrowserEvents')
export const acceptBrowserEvent = createApi('AcceptBrowserEvent')
export const dismissBrowserEvent = createApi('DismissBrowserEvent')
export const attachBrowserEventToNode = createApi('AttachBrowserEventToNode')
+20
View File
@@ -0,0 +1,20 @@
/**
* Files API — all file/folder related Wails calls.
*/
import { createApi } from './wails.js'
export const listItems = createApi('ListItems')
export const listFiles = createApi('ListFiles')
export const createEmptyFile = createApi('CreateEmptyFile')
export const deleteFileOrFolder = createApi('DeleteFileOrFolder')
export const openFile = createApi('OpenFile')
export const openFolder = createApi('OpenFolder')
export const pickFile = createApi('PickFile')
export const pickDirectory = createApi('PickDirectory')
export const previewImport = createApi('PreviewImport')
export const addPathCopy = createApi('AddPathCopy')
export const addPathLink = createApi('AddPathLink')
export const checkFileAction = createApi('CheckFileAction')
export const getFileBase64 = createApi('GetFileBase64')
export const readFileText = createApi('ReadFileText')
export const showInFolder = createApi('OpenFolder')
+14
View File
@@ -0,0 +1,14 @@
/**
* Inbox API — capture and inbox management.
*/
import { createApi } from './wails.js'
export const listInboxNodes = createApi('ListInboxNodes')
export const listInboxNodesForTarget = createApi('ListInboxNodesForTarget')
export const captureClipboardTextWithContext = createApi('CaptureClipboardTextWithContext')
export const captureTextWithContext = createApi('CaptureTextWithContext')
export const captureURLWithContext = createApi('CaptureURLWithContext')
export const capturePathWithContext = createApi('CapturePathWithContext')
export const captureFileDataWithContext = createApi('CaptureFileDataWithContext')
export const resolveInboxNode = createApi('ResolveInboxNode')
export const deleteInboxNode = createApi('DeleteInboxNode')
+13
View File
@@ -0,0 +1,13 @@
/**
* Journal & Worklog API — time tracking and reporting.
*/
import { createApi } from './wails.js'
export const listWorklog = createApi('ListWorklog')
export const createWorklogFull = createApi('CreateWorklogFull')
export const updateWorklogEntry = createApi('UpdateWorklogEntry')
export const deleteWorklogEntry = createApi('DeleteWorklogEntry')
export const getWorklogEntryEvents = createApi('GetWorklogEntryEvents')
export const listWorklogReport = createApi('ListWorklogReport')
export const worklogReportSummary = createApi('WorklogReportSummary')
export const saveWorklogReport = createApi('SaveWorklogReport')
+9
View File
@@ -0,0 +1,9 @@
/**
* Links API — external URL links management.
*/
import { createApi } from './wails.js'
export const listLinks = createApi('ListLinks')
export const updateLink = createApi('UpdateLink')
export const deleteLink = createApi('DeleteLink')
export const openLink = createApi('OpenLink')

Some files were not shown because too many files have changed in this diff Show More