Compare commits

..

208 Commits

Author SHA1 Message Date
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
mirivlad a37afd3b67 fix: trash integrity for TypeFile nodes — file record soft-delete, correct preview/restore 2026-06-05 17:31:18 +08:00
mirivlad 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
mirivlad 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
mirivlad c512ada386 cleanup: remove stale frontend-dist assets, fix warnings 2026-06-05 16:21:21 +08:00
mirivlad ceee03959b cleanup: remove stale frontend-dist assets 2026-06-05 16:17:31 +08:00
mirivlad 2ed2ecf77a Today screen: tabs (feed, suggestions, in-progress, captured) + inbox sort/group
- Новый экран 'Сегодня' разбит на 4 вкладки: Лента, Предложения,
  В работе, Захвачено
- Лента отображает события за сегодня с кликабельными сущностями
- Предложения вынесены в отдельную вкладку (только предложения)
- В работе: изменённые файлы/заметки/действия за сегодня с сортировками
- Захвачено: захваченные элементы за сегодня с сортировками
- Неразобранное: сортировка по дате/имени/типу с направлением
- Неразобранное: переключатель 'Группировать по месту захвата'
- TodayScreen.svelte: новый компонент с 4 вкладками
- Новые i18n ключи для вкладок и сортировок
- Backend: ListTodayInProgress, ListTodayCaptures bindings
- Все переходы из вкладок ведут в соответствующее место программы
2026-06-05 16:17:22 +08:00
mirivlad c8aaf36533 fix: stabilize trash navigation and action icons 2026-06-05 14:41:40 +08:00
mirivlad 1fa009b1e2 feat: complete trash restore and batch actions 2026-06-05 12:43:30 +08:00
mirivlad 10b287de7b feat: aggregate journals across node subtrees 2026-06-05 12:37:25 +08:00
mirivlad 23f517dee3 feat: simplify inbox actions and group task tabs 2026-06-05 12:32:36 +08:00
mirivlad 6d15639b41 fix: normalize bare URLs in capture flow 2026-06-05 12:29:19 +08:00
mirivlad 56ef211418 chore: move app icons into frontend assets 2026-06-05 12:25:47 +08:00
mirivlad 22b05f57b4 fix: show inbox capture target context 2026-06-05 08:10:33 +08:00
mirivlad a8df9d118c fix: hide explorer action for inbox links 2026-06-05 08:07:39 +08:00
mirivlad 91b5629e01 fix: open inbox artifacts by type 2026-06-05 08:06:06 +08:00
mirivlad db47d31183 docs: describe unified capture inbox flow 2026-06-05 07:51:08 +08:00
mirivlad 4755d3199d chore: update embedded gui assets 2026-06-05 07:51:00 +08:00
mirivlad 0e5d13ff01 fix: ignore global hotkeys in editable fields 2026-06-05 07:47:07 +08:00
mirivlad f112e9a2d0 feat: add local inbox and links tabs 2026-06-05 07:44:38 +08:00
mirivlad c1dfc456ec feat: unify frontend capture pipeline 2026-06-05 07:41:15 +08:00
mirivlad 9e70e36f7f feat: add native clipboard capture bridge 2026-06-05 07:34:45 +08:00
mirivlad bcb093d453 feat: resolve inbox links separately 2026-06-05 07:33:10 +08:00
mirivlad 336037d887 feat: track capture context in inbox 2026-06-05 07:30:00 +08:00
mirivlad 6eaa4cda49 feat: assign and delete inbox artifacts 2026-06-05 02:15:27 +08:00
mirivlad a96a316883 feat: capture files and images in inbox 2026-06-05 02:06:21 +08:00
mirivlad 326f6f283d feat: capture clipboard links in inbox gui 2026-06-05 01:55:38 +08:00
mirivlad 44d0be2649 feat: add text and url inbox capture 2026-06-05 01:46:22 +08:00
mirivlad d6ef3a973a feat: model inbox capture artifacts 2026-06-05 01:40:08 +08:00
mirivlad 2e86229350 fix: restrict inbox to captured artifacts 2026-06-05 01:35:27 +08:00
mirivlad 58a74acbf6 test: capture inbox and trash gui smoke 2026-06-05 01:07:51 +08:00
mirivlad cc83cd3476 feat: expose trash in gui 2026-06-05 01:05:57 +08:00
mirivlad 035f877280 feat: add interactive inbox view 2026-06-05 00:59:57 +08:00
mirivlad 02d68ca3f4 feat: edit suggestions before accepting worklog 2026-06-05 00:53:13 +08:00
mirivlad eb6a861310 feat: edit and delete worklog entries 2026-06-05 00:48:12 +08:00
mirivlad 272a7f870b docs: update gui verification workflow 2026-06-04 19:50:44 +08:00
mirivlad 644ec0ed00 test: cover sync conflict warnings 2026-06-04 19:48:02 +08:00
mirivlad cc59f928a8 test: harden file manager regressions 2026-06-04 19:44:28 +08:00
mirivlad 2284f893f8 test: exercise gui smoke actions 2026-06-04 19:39:27 +08:00
mirivlad cb6c06fdc5 fix: style workspace settings actions 2026-06-04 19:34:03 +08:00
mirivlad 767c03ba8c test: add rendered gui smoke harness 2026-06-04 18:59:07 +08:00
mirivlad 20e605bab7 chore: add gui smoke check 2026-06-04 07:49:34 +08:00
mirivlad cc157a2d36 fix: stabilize settings icons and back label 2026-06-04 07:47:40 +08:00
mirivlad c40d8c9dd3 fix: show sync warnings in settings 2026-06-04 03:53:17 +08:00
mirivlad 7641a311cc build: clean embedded assets before copy 2026-06-04 03:50:38 +08:00
mirivlad 7e709e140d build: refresh embedded gui assets 2026-06-04 03:49:12 +08:00
mirivlad 3a20e1b093 docs: update roadmap status from current code 2026-06-04 03:47:31 +08:00
mirivlad bb0bb608e3 chore: sync english locale keys 2026-06-04 03:44:25 +08:00
mirivlad 3c6bc097e1 fix: show manual sync conflicts in gui 2026-06-04 03:39:52 +08:00
mirivlad e2aad19cc4 fix: avoid outgoing ops during remote template apply 2026-06-04 03:36:44 +08:00
mirivlad 0c0b0d98c7 fix: keep plugin templates separate from system templates 2026-06-04 03:34:18 +08:00
mirivlad a526661825 docs: align vault storage documentation with code 2026-06-04 03:32:02 +08:00
mirivlad 58795b66b2 fix: keep default templates and plugins folder working 2026-06-04 03:28:32 +08:00
mirivlad 9d14ba50af test: restore vault and worklog test baseline 2026-06-04 03:25:04 +08:00
mirivlad a69dc845e6 fix: vault init on startup; add nil guards to all bindings; fix SA_ONSTACK signal crash; deduplicate settings button; add i18n for vault error 2026-06-04 00:37:14 +08:00
mirivlad f92394e3d7 feat: settings window polish, sync widget fix, dark form controls
- Fix: settings overlay uses on:click|self so sidebar clicks don't close it
- Fix: openSettings(section) supports opening at specific tab
- Fix: 'Настроить' opens Settings → Синхронизация instead of Общие
- Style: dark theme select with custom arrow, global :global() CSS
- Style: settings cards, section descriptions, button/layout polish
- Style: settings gear buttons (icon-button pattern, 32px, soft hover)
- Style: settings sidebar with disabled stubs, consistent icons
- i18n: add generalDesc, workspaceDesc, appearance, localization keys
2026-06-03 23:09:40 +08:00
mirivlad e30a75c5a0 fix: openActivityTarget navigates to parent folder and previews files
- resolveActivityTarget uses 'targetId'/'targetPath' for all types
  (note, file, folder) instead of type-specific property names
- openActivityTarget for files: resolves the file node via
  GetNodeDetail, navigates to its parent folder in the Files tab,
  then auto-previews the file if it's a previewable type
- For root-level files (no parent_id): loads root items
- Removed spurious OpenFolder(targetPath) call that silently failed
  because OpenFolder expects a node ID, not a filesystem path
2026-06-03 17:32:18 +08:00
mirivlad 4ec03c849f fix: openActivityTarget now loads fileItems before showing Files tab
selectNode() resets fileItems=[] and activeTab='overview'. Setting
activeTab='files' programmatically does not trigger the tab click
handler that calls loadFolder(), so the file tree stays empty.

Fix: explicitly call await loadFolder(selectedNode.id) in the files
branch of openActivityTarget.

Also: unified resolveActivityTarget return shape to always use
targetId/targetPath regardless of targetType.
2026-06-03 17:23:42 +08:00
mirivlad 0bebcdce8c docs: update AGENTS.md with WriteDebugLog, regression tests, resolveActivityTarget 2026-06-03 17:07:00 +08:00
mirivlad 3e55b08e6f feat: WriteDebugLog binding writes frontend logs to <vault>/.verstak/debug.log
- New cmd/verstak-gui/bindings_debug.go: WriteDebugLog(msg) appends a
  timestamped line to the vault's .verstak/debug.log
- Frontend: console.log replaced with writeDebugLog() which calls
  wailsCall('WriteDebugLog', ...) — works in production Wails builds
- Both acceptTodaySuggestion and acceptJournalSuggestion log eventIds,
  events, and errors to the file
2026-06-03 17:06:17 +08:00
mirivlad 9338b0a851 test: add journal regression, repeated activity, manual entry tests + resolveActivityTarget helper
Tests added to suggest_test.go:
- TestJournalFullRegression: GetSuggestions -> verify eventIds match
  events -> AcceptSuggestionWith -> verify all 3 linked via
  worklog_entry_events + JOIN + GetWorklogEntryEvents
- TestSuggestionOnRepeatedActivity: first event accepted, new event
  created -> GetSuggestions still returns suggestion for the new event
- TestManualWorklogEntry: CreateWorklogFull -> source=manual,
  billable/approximate/details preserved, GetWorklogEntryEvents empty

Frontend:
- resolveActivityTarget(ev) pure function returning { nodeId, tab,
  noteId/fileId/targetPath } based on targetType
- openActivityTarget(ev) uses resolveActivityTarget for navigation
2026-06-03 16:49:30 +08:00
mirivlad db869a7c97 docs: update AGENTS.md with Wails []string fix, event link validation, openActivityTarget 2026-06-03 16:00:54 +08:00
mirivlad b42aa35ee8 fix: bypass Wails v2 []string marshalling via JSON.stringify + end-to-end test
Root cause: Wails v2.12.0 cannot reliably marshal []string arguments
from JavaScript to Go when called through positional binding.
The event IDs array arrived empty on the Go side, causing no
worklog_entry_events INSERTs.

Fix:
- AcceptSuggestionWith now accepts eventIDsJSON (string) instead of
  eventIDs ([]string). Frontend passes JSON.stringify(eventIds).
- Backend json.Unmarshal into []string before validation.
- Pre-insert validation: each eventID checked in activity_events.
- Atomic tx: entry create + linking in single Begin/Commit.
- INSERT (not INSERT OR IGNORE) — failure is a hard error.
- Post-commit verification: JOIN COUNT(*) must match len(eventIDs).
- End-to-end test: TestAcceptSuggestionWithEndToEnd creates a node,
  3 activity events, accepts suggestion, verifies all 3 linked.

Other changes:
- GetWorklogEntryEvents: fixed column name (details_json -> metadata).
- openActivityTarget(ev): new function for 'Посмотреть' button that
  navigates to specific note/file/folder instead of just opening node.
- All 'openNodeById(ev.nodeId)' in event contexts replaced with
  'openActivityTarget(ev)'.
2026-06-03 16:00:17 +08:00
mirivlad 21a595c3ce fix: transaction-safe AcceptSuggestionWith + safe eventIds fallback + debug logging
Root cause: s.eventIds may be undefined in JavaScript even when s.events
has data (Wails v2 marshalling of []string in nested struct response).
On calling AcceptSuggestionWith(eventIDs []string), empty array reached Go,
no INSERTs executed, events silently lost.

Changes:
- Frontend: extractEventIds() fallback — s.eventIds || s.events[].id || []
- Frontend: console.log debug for eventIds/events in accept handler
- Backend: AcceptSuggestionWith wrapped in tx (Begin/Commit/Rollback) so
  entry creation + event linking is atomic
- Backend: AddWithSourceTx method for transaction-aware insert
- Backend: buildEntry helper extracted
- Backend: fmt.Printf debug logging for received eventIDs + link count
- Backend: verification query after commit
- Cleanup: removed stale frontend-dist assets, .gitignore build.log
2026-06-03 15:10:25 +08:00
mirivlad 7076980954 fix: AcceptSuggestionWith uses flat fields to avoid Wails marshalling issues; human-readable event labels
- AcceptSuggestionWith now accepts nodeID, summary, minutes, date, eventIDs
  as separate args instead of the entire Suggestion struct (Wails v2 skips
  nested struct fields during JS→Go marshalling)
- Error handling: event link failures now return an error instead of silent ignore
- Event type labels in suggestion detail and journal row detail now use
  eventLabel() which maps snake_case types to human-readable i18n labels
  (e.g. note_updated → 'Заметка изменена')
- Added missing event labels: note_deleted, node_deleted, folder_moved,
  action_created, action_done, worklog_added
2026-06-03 12:35:13 +08:00
mirivlad fd99dd4f5c feat: worklog source field, suggestion logic fix, modal form, activity navigation
- Add source column to worklog_entries (migration 014): manual/suggestion/unknown
- GetSuggestions now excludes only events linked in worklog_entry_events,
  not entire nodes — repeated activity same day now produces suggestions
- Manual entry form replaced with '+' button + modal dialog
- Source display shows correct origin (manual/suggestion/unknown/no-events)
- Include-children checkbox hidden when no node selected
- Activity events navigate to specific notes/files instead of just case
- Expandable row reactivity fixed (journalRows/worklog reassignment)
2026-06-03 12:27:50 +08:00
mirivlad 1472bb3e6f feat: journal UX overhaul — picker, export dialog, events, readability
- Sidebar i18n: added missing nav.journal to backend ru.json
- Export: SaveWorklogReport binding with native SaveFileDialog + os.WriteFile
- Filter: better IncludeChildren label with disabled tooltip
- Filter: renamed billable→К оплате, approximate→Тип времени with hints
- worklog_entry_events table (migration 013) linking entries to activity events
- Suggestion: EventIDs + Events details, expandable cards with timestamps
- Journal rows: expandable with details, source, linked events
- Contrast: improved readability for dates, timestamps, hover states
- i18n: added worklog.*, journal.*, suggest.* keys to ru.js/en.js
2026-06-03 11:24:59 +08:00
mirivlad d34100e2ed feat: node search picker, ByNode grouping fix, PDF export
- node picker: Search/Path on Repository, SearchNodes binding,
  debounced search dropdown showing title + full path
- ByNode summary groups by nodeID with NodePath as label (not NodeTitle)
- PDF export for worklog reports with embedded DejaVuSans fonts
- ExportWorklogPDF binding + button on Journal screen
- Removed unused Section field from ReportFilter
- ListReport now calls BuildReportPaths so nodePath is available
- go.sum: +github.com/signintech/gopdf dependency
2026-06-03 10:56:13 +08:00
mirivlad 5732264fc5 fix(step16.1): review fixes — acceptance, filters, sorting, export
- Remove dead acceptSuggestion, unify into refreshAfterSuggestion()
- Journal: nodeID picker, includeChildren only with selected node
- Journal: billable/approximate filters (all/yes/no selects)
- Summary: ByDay sorted by date desc, ByNode by minutes desc
- CSV: proper encoding/csv writer (was manual fmt.Sprintf)
- Markdown: escape pipes and newlines via escMD()
- After suggestion: refresh suggestions + count + worklog + journal
- Add GetNodeTitle binding
- i18n: common.all/no/date/search
2026-06-03 10:30:48 +08:00
mirivlad c25e75f839 Step 16.1: global worklog dashboard + conservative suggestions
- Fix date timezone: worklog.Add uses local date (was UTC)
- Conservative suggestion estimator:
  - burst detection (10min window), time spread analysis
  - 5-30 min range, 60+ only with strong evidence
  - confidence levels: low/medium/high with reason
- worklog/report.go: ReportFilter, ListReport, Summary, ExportCSV, ExportMarkdown
- Expanded WorklogDTO: date, details, approximate, billable, nodeTitle
- New bindings: CreateWorklogFull, ListWorklogReport, WorklogSummary, Export*
- New system section 'Журнал' in sidebar with badge (suggestion count)
- Global journal screen: filters (date range, includeChildren), table, summary
- Suggestions shown on Today dashboard + Journal screen + per-node worklog tab
- Suggestion cards: editable minutes, confidence display, apply/open buttons
- i18n: all new keys in ru + en
2026-06-03 09:56:17 +08:00
mirivlad 57d13c9506 feat: activity-based worklog suggestions (Step 16)
- Suggestion struct with nodeId, nodeTitle, summary, suggestedMin
- GetSuggestions binding: analyzes today's activity events, groups by
  node, skips nodes with existing today's worklog, generates summary
- AcceptSuggestion binding: creates worklog entry from suggestion
- HasTodayEntries helper on worklog.Service
- Suggestions panel in Worklog tab with Apply button
- i18n: worklog.suggestions / worklog.apply (ru + en)
2026-06-03 09:31:40 +08:00
mirivlad ca280a59c0 test: comprehensive sync package unit tests (37 new tests)
- safe_path_test.go: path traversal protection (11 table-driven cases)
- blob_test.go: SHA-256 hashing, store/deduplicate/read blobs
- sync_test.go: Service CRUD ops, state, push/mark lifecycle
- client_test.go: Push/Pull/Blobs/Auth via httptest.Server
- sync_e2e_test.go: auto-build server binary on demand
2026-06-03 09:16:38 +08:00
mirivlad 7d81250ebd fix: rename node not found, A11y warnings cleanup
- fix renameId cleared before RenameNode API call (submitRename)
- add role, tabindex, keydown handlers to all interactive divs
- associate labels with inputs (wrap in label + .label-text)
- remove autofocus, unused CSS selectors, old root build.sh
- update frontend-dist assets
2026-06-03 08:55:38 +08:00
mirivlad 23b3d07071 fix: tree DnD — correct cycle detection, reactive indicators, canonical reload
Backend:
- Fix MoveNode validation: wouldCreateCycle walks from newParentID up
  toward root, rejects if nodeID is encountered (parent into descendant)
- Allow moving descendant to ancestor (C into A) and child to root
- Add isContainerType validation for new parent
- Add 8 tests covering all scenarios + duplicate ID invariant

Frontend TreeNode.svelte:
- Build parent map from full tree (not just loaded children)
- canDrop uses parent map for cycle detection
- Reactive drop-valid/drop-invalid CSS via pre-computed dropAllowed map
- Keyed {#each nodes as node (node.id)} for correct identity tracking
- Auto-expand container on 600ms drag-over hover
- Proper dragleave detection (ignore transitions to child elements)
- Clean up state on dragend

Frontend App.svelte:
- reloadTreePreservingExpanded: fresh roots + children (no patching)
- Root area visual drop indicator (dashed outline)
- dragleave handler for root area

Clean up stale GUI dist assets
2026-06-03 05:27:20 +08:00
mirivlad 8cbf23a74d docs: update PLAN.md — mark steps 1-14 done, unpause 15-23, sync with current state 2026-06-03 05:15:46 +08:00
mirivlad b6a3a2238d fix: tab highlight reactivity, cleanup docs and build scripts
- Fix tab highlight not updating visually — switch from class={tabClass()}
  to Svelte's class:active directive for proper reactive class binding
- Rewrite README.md with full project structure, architecture, build guide
- Rewrite build.sh to build both GUI and server, output to build/
- Add scripts/build.sh for granular builds (gui/server/all)
- Add build/, frontend-dist/, and test vault dirs to .gitignore
- Remove stale binaries from project root
- Update AGENTS.md session summary
2026-06-03 05:08:58 +08:00
mirivlad 105657400b fix: context menu close, OpenFolder for TypeFile nodes, tab highlight visibility
- FileTreeRow handleShowInFolder now closes the menu (menuOpen = false)
- OpenFolder: TypeFile nodes with empty FsPath fall back to file record path
- Active tab background increased to rgba(99,102,241,0.12), weight 600, border #818cf8
2026-06-03 04:56:11 +08:00
mirivlad cc3500c14f fix: sidebar refresh, context menu position, show-in-explorer for all items
Sidebar refresh:
- addFile/addFolder now use currentFolderId || selectedNode.id as parent
- startImport stores pendingImportParent; confirmImport uses it
- setNodeChildren also updates node.has_children for toggle arrow reactivity
- navigateToFolder expands the folder node in sidebar tree

Context menu position:
- FileTreeRow stores menuX/menuY from contextmenu event coords
- Menu uses position:fixed with cursor-relative left/top and window clamp

Show in explorer:
- Sidebar context menu uses file.showInExplorer label
- en.js: add file.showInExplorer key
2026-06-03 04:46:42 +08:00
mirivlad 3c9b9edf8c fix: dynamic sidebar tree refresh after import
- reloadTreePreservingExpanded no longer replaces the whole tree,
  only patches children of expanded nodes in-place
- New refreshParentNode(nodeId) function updates a single parent's children
- createFile, duplicateItem, confirmImport use refreshParentNode instead of reloadTreePreservingExpanded
- No intermediate render where children are lost
2026-06-03 04:34:27 +08:00
mirivlad 81405ed61b fix: refresh sidebar tree after import/create/duplicate in files tab
- Call reloadTreePreservingExpanded after createFile, confirmImport, duplicateItem
- New folders created inside a case now appear in sidebar without restart
- Add AGENTS.md with build instructions
2026-06-03 04:28:41 +08:00
mirivlad baf57e993d feat: move-to-root, active tab highlight, show-in-explorer for all file items
- Add 'Move to root' context menu item for non-root sidebar tree nodes
- Improve active tab visual contrast (background + font-weight)
- FileTreeRow: show-in-explorer inline button & context menu for all items
- OpenFolder now resolves file-type nodes to parent directory
- handleShowInFolder uses item.nodeId || item.id for FileDTO compatibility
- Add i18n key nav.moveToRoot (ru/en)
2026-06-03 04:01:55 +08:00
mirivlad c941f05dab gui: sidebar tree UX fixes — has_children, preserve expanded, double-click, DnD visual
- Backend: add HasChildren field to NodeDTO; ListWorkspaceTree/ListChildren
  populate it by querying CountChildren for container types
- Node repository: add CountChildren(parentID, types...) method
- TreeNode: toggle shown only when has_children (not isContainer); double-click
  on row = expand/collapse; icon click = expand/collapse; drop-valid class via
  DOM classList for DnD highlight; auto-expand collapsed container on 500ms
  hover during drag; auto-scroll near edge during drag
- App.svelte: selectNode no longer resets workspaceTree/expanded; new
  reloadTreePreservingExpanded() helper re-fetches children for all expanded
  nodes after DnD move / delete; deleteWorkspaceNode preserves expanded state
2026-06-03 03:48:53 +08:00
mirivlad 9260582072 gui: sidebar tree model fix — only container nodes, improved DnD + context menu
- Backend: ListWorkspaceTree/ListWorkspaceChildren filter to container types
  only (case, client, project, folder, document, recipe)
- TreeNode: full-row context menu (removed label stopPropagation),
  double-click toggles expand, icon-click toggles expand, DnD auto-expand
  on 500ms hover, auto-scroll near edges, drag-over highlight via classList
- App.svelte: toggleExpand uses ListWorkspaceChildren, submitCreateNode uses
  ListWorkspaceChildren for child tree population
- Note/file nodes no longer appear in the sidebar workspace tree
2026-06-03 03:33:13 +08:00
mirivlad b2dcb116c9 gui: drag-and-drop sidebar, tree expand, localization fixes
TreeNode.svelte:
- Native HTML5 drag-and-drop with move effect
- Lazy tree expand/collapse (arrow for container types only)
- Drop validation: no self-drop, container-only, descendant check
- 'case' icon kind added

App.svelte:
- toggleExpand loads children via ListChildren into tree
- handleNodeDrop calls MoveNode(draggedId, targetId), refreshes tree
- Root workspace area is a drop target (handleDropRoot)
- Overview section shows nodeKindLabel instead of raw type enum
- Context menu shows Create only for container types
- Create modal title uses 'Создать элемент'
- submitCreateNode expands parent after child creation

TemplateIcon.svelte: added 'case' icon (folder-like with dividers)
i18n: added nav.createNode key (ru+en)
2026-06-03 03:18:04 +08:00
mirivlad f022f46909 gui: fix sidebar icons, create modal, and type display
- TreeNode.svelte: no white spacer for leaf nodes, iconKind maps node.type
  directly, 32px rows with hover/selected states
- App.svelte: header shows localized nodeKindLabel, auto-select created
  node, create modal with Пустое дело card + template descriptions,
  disable button until name+type chosen
- i18n: add kind.folder/note/file, template descriptions,
  template.optionNone → Пустое дело / Empty case
2026-06-03 02:58:27 +08:00
mirivlad a6b0f9d7e6 Rebuild GUI binary with updated frontend assets 2026-06-03 02:40:43 +08:00
mirivlad b26b757d80 GUI: fix sidebar icons and create modal with template selection
- Add TemplateIcon.svelte with SVG icons for folder/project/client/document/recipe
- TreeNode.svelte: render type-based icon before each node label
- Create modal: show template selection cards (translate titles via t())
- Context menu: use TemplateIcon + translated template names
- i18n (ru/en): add template.* translation keys
- Fix white capsule issue: tree-toggle arrow at 10px rendered oddly
  without icon presence; replaced with proper SVG icons
2026-06-03 02:29:11 +08:00
mirivlad d285f9ad8b sync_apply FS-first rewrite; CreateNodeFromTemplate rollback; DeleteNodeAndChildren fail on trash errors; PLAN.md update
- applyRemoteNodeUpdate: FS-first with SafeVaultPath validation, must-fail os.Rename
- applyRemoteNodeMove: FS-first for folders and notes/files
- moveNodeFiles: rewritten FS-first with atomic DB transaction
- applyRemoteNoteMove: delegates to moveNodeFiles
- CreateNodeFromTemplate: rollbackChildren on any child creation failure
- DeleteToTrash: skip rename if source file already missing
- DeleteNodeAndChildren: fail on deleteFileRecords errors and trash move failures
- docs/PLAN.md: update step 14 status with known gaps
2026-06-03 02:22:49 +08:00
mirivlad 7e38ffed7b bindings_nodes: fix parent variable redeclaration (rename to parentVal) 2026-06-03 02:18:10 +08:00
mirivlad a31f5fd702 fix: third stabilization pass — template children as nodes, atomicity, fs_path validation, sync_apply compat, smoke test 2026-06-03 02:05:53 +08:00
mirivlad 49c0fda61c chore: add wails.json, remove wails3 artifacts, rebuild binaries
- Add wails.json for Wails v2 build
- Remove wails3 boilerplate (build/Taskfile.yml, build/config.yml, etc.)
- Add server-data/ to .gitignore
- Rebuild frontend-dist and GUI binary
2026-06-03 01:48:12 +08:00
mirivlad 7b2a1da529 fix: note/file move ops, rename/move atomicity, importDir folder creation
- importDir: create physical folder for imported directories
- RenameNode/MoveNode: os.Rename before DB updates (atomicity)
- RenameNode note/file: fail if physical file missing
- MoveNode: file renames before DB updates
- applyRemoteNoteOp: handle OpMove for notes/files
- applyRemoteNodeMove: handle notes/files with empty FsPath
- MoveNode sync payload: no fs_path for notes/files
- Add 7 tests covering all fixes
2026-06-03 01:32:47 +08:00
mirivlad 20a05569ac fix: второй стабилизационный pass vault layout — sync payload, bindings, vaultPath, tests
sync_apply.go:
- applyRemoteNodeCreate: полный payload (template_id/fs_path/sort_order/archived),
  INSERT сохраняет все поля, для folder-like создаётся физическая папка.
- applyRemoteNodeUpdate: принимает fs_path/template_id/archived,
  физическое переименование папки при изменении title/fs_path.
- applyRemoteNodeMove: принимает fs_path, обновляет parent_id+fs_path,
  физически перемещает папку (folder-like) или file record (note/file).

bindings_nodes.go:
- MoveNode: node.FsPath = newFsPath после UpdateFsPath;
  sync.RecordOp отправляет новый fs_path; note/file move to root — файл в vault root.
- RenameNode: EntityFile для file, EntityNote для note;
  коллизия → генерация уникального имени; файл переименовывается только после os.Rename.
- DeleteNode: единый вызов a.files.DeleteNodeAndChildren(), дублирование удалено.
- Исправлен deadlock с SetMaxOpenConns(1) — Query/Exec больше не конфликтуют.

files.Service.vaultPath: filepath.Rel-based проверка,
  sibling-prefix escape (/tmp/vault vs /tmp/vault_evil) отклоняется.

VaultCheck: SQL JOIN с n.deleted_at IS NULL, чтобы удалённые узлы
  не показывались как missing files.

Добавлены тесты: RenameFileNodeUsesEntityFile, MoveNoteToRoot,
  DeleteFolderLeavesVaultCheckHealthy, SyncNodeCreatePreservesFields,
  VaultPathSiblingPrefixEscape.
2026-06-02 17:03:05 +08:00
mirivlad 66c5c81f39 fix: стабилизация vault layout — rename/move/delete note/file vs folder разведены, sync apply без spaces/, VaultCheck усилен
- RenameNode/DeleteNode/MoveNode: note/file и folder-like nodes
  обрабатываются по-разному (file record vs физическая папка)
- DeleteNode: рекурсивный soft-delete всех descendants
- SafeVaultPath возвращает clean relative, filepath.Join в sync_apply.go
- Fallback spaces/ → .verstak/remote-inbox в applyRemoteNoteCreate
- VaultCheck: проверка parent_id != nil, orphan descendants,
  fs_path folder на диске
2026-06-02 16:36:43 +08:00
mirivlad 4f01f2de2e fix: complete vault layout transition — fs_path everywhere, no more spaces/
- notes.Create(): .md files stored in parent node's fs_path folder
- files.CopyIntoVault/CreateEmptyFile/Duplicate: use parent fs_path
- files.AddPathCopy/AddPathLink: use parent fs_path, set folder fs_path
- files.DeleteNodeAndChildren: move physical folder to .verstak/trash
- UpdateFsPathRecursive: use SafeDisplayNameToPathSegment(child.Title)
- sync_apply.go note ops: use fs_path instead of spaces/
- internal/gui/server.go file upload: use n.FsPath instead of nodeSlug
- VaultCheck diagnostic: walk nodes/files, verify paths on disk
- Tests: create/rename/move/delete/name-conflict/vault-check all pass
2026-06-02 15:43:40 +08:00
mirivlad 0b26f7e5b3 refactor: implement template-driven node tree and human-readable vault layout
Unified Node model: added template_id, fs_path, archived, sort_order fields.
Template registry: system templates embedded as JSON (folder/project/client/
document/recipe), with Registry for enabled/disabled/filtered access.
SafeDisplayNameToPathSegment: human-readable path segments with Cyrillic
support, illegal char replacement, uniqueness via numeric suffixes.
Sidebar refactored: system views (Today/Inbox/Activity) separate from
workspace tree. Creation menu built dynamically from enabled templates.
Create/Rename/Move: physical folder operations with fs_path update,
recursive descendant path updates.
DB migration 012: adds template_id, fs_path, archived columns.
Vault migration command: rebuilds fs_path for existing nodes.
Tests: safename, registry, node model, repository integration.
Docs: VAULT_LAYOUT.md, TEMPLATES.md, PLAN.md updated.
i18n: nav.system, nav.workspace, template.*, common.rename/archive,
migrate.* keys added to ru.json and en.json.
2026-06-02 12:47:06 +08:00
mirivlad 12f2916a24 followup: SafeVaultPath in note update, email i18n, strict check-i18n.sh
- applyRemoteNoteUpdate: use SafeVaultPath for vault mode, skip non-vault with log
- Email subjects/bodies moved to Go i18n (confirm + reset) in ru.json and en.json
- check-i18n.sh: ru/en key mismatch now FAIL (not WARNING)
- All builds, tests, frontend pass
2026-06-02 11:40:27 +08:00
mirivlad 7091397649 server i18n: move inline HTML to templates.go, localize all handler strings
- Admin & user dashboard HTML moved from handlers to templates.go with i18n.T()
- SafeVaultPath applied in sync_apply.go (note/file create/update, blob restore)
- DeleteNode/RenameNode/MoveNode fixed: correct activity type / entity variant
- Added TypeNoteDeleted, TypeNodeDeleted, TypeFolderMoved activity constants
- Added locale() helper on Server struct, removed hardcoded 'ru' in handlers
- Password policy loosened: 8-256 chars, any characters, machine-readable error codes
- check-i18n.sh: Go Cyrillic = FAIL with explicit exception list, Go locale key consistency check
2026-06-02 11:26:54 +08:00
mirivlad 2fa583d157 stabilization: server.go split + i18n templates + frontend localization
cmd/verstak-server/server.go (2838→127 строк): разделён на 12 файлов
- config.go, tokens.go, schema.go
- server.go (только struct + NewServer + ListenAndServe)
- routes.go, middleware.go, smtp.go
- handlers_api.go, handlers_user.go, handlers_web_user.go, handlers_admin.go
- templates.go (конвертирован в функции с i18n.T())

frontend: все русские строки заменены на t() вызовы
- App.svelte, FileTreeRow.svelte, ConfirmModal.svelte
- FilePreviewModal.svelte, fileUtils.js

core: gofmt по всему проекту

Все сборки (CLI, server, gui, frontend), go vet, go test проходят.
check-i18n.sh: frontend чист, Go-файлы с кириллицей — только тесты/легаси.
2026-06-02 11:08:29 +08:00
mirivlad 3089d777a8 refactor(gui): разделить app.go на binding-файлы по доменам, вынести sync apply
- app.go (1810→280 строк): только App struct, startup, DTOs, helpers
- bindings_{nodes,files,notes,actions,worklog,activity,sync,settings}.go
- sync_apply.go: все applyRemote* методы
- i18n: internal/i18n (Go, embed JSON) + frontend/src/lib/i18n (JS)
- core/sync/safe_path.go: SafeVaultPath
- scripts/check-i18n.sh: проверка хардкода кириллицы и bidi-символов
- build.sh: NVM loading, set -e

Все сборки (CLI, server, gui, frontend), go vet, go test проходят.
2026-06-02 10:47:38 +08:00
mirivlad 390d451977 frontend: удалён мёртвый код (verstak.js, FileActions.svelte, style.css) 2026-06-02 09:48:58 +08:00
mirivlad 50e7e95844 test(sync): add end-to-end two-client sync smoke test
TestE2ESync starts a real server process, registers user, pairs two devices,
pushes a node op from client A, pulls on client B, verifies payload content,
and confirms /api/auth/test does not create devices.
2026-06-02 08:02:19 +08:00
mirivlad 4a96aa3468 fix(sync): expand payloads, implement ApplyRemoteOp, fix SyncTestConnection and auto sync
- Expand all RecordOp payloads with full entity data needed for reconstruction
- Add applyRemoteOp with handlers for node/note/file/action/worklog CRUD
- SyncNow() now applies pulled ops to real DB tables + sets LastSeenServerSeq
- SyncTestConnection uses /api/auth/test instead of PairDevice
- autoSyncLoop respects configured interval with lastSync timing
- Add helper functions: nodePayload, notePayload, filePayload, actionPayload, worklogPayload
2026-06-02 08:02:15 +08:00
mirivlad f8f9510e2a fix(sync): add /api/auth/test endpoint, fix CSS %&amp; vet warnings
- Add handleAuthTest endpoint that validates credentials without creating a device
- Fix resetPasswordHTML to use {TOKEN} placeholder instead of %s
- Remove fmt.Sprintf from admin dashboard (no format args needed)
2026-06-02 08:02:11 +08:00
mirivlad 852d6d373c fix(sync): send LastSeenServerSeq from CLI push, report conflicts 2026-06-02 08:02:07 +08:00
mirivlad 3c7e9d1d56 fix(sync): add ClientSequence and LastSeenServerSeq to Op struct and Push
- Add ClientSequence and LastSeenServerSeq fields to sync.Op struct
- Fix Client.Push() to copy these fields to PushOp
- Add GetDeviceID() method to sync.Service
2026-06-02 08:02:03 +08:00
mirivlad 87c8dfcbea sync: overhaul sync system — device pairing, server_sequence, auto-sync, dashboards
BREAKING: replace legacy API keys with device tokens via pairing flow.
- Server: /api/client/pair, revoke, me endpoints; server_sequence + tombstones + idempotency
- Desktop client: PairDevice, GetMe, RevokeCurrent; auto-sync loop every 60s
- Config: device_token stored in separate file (0600), not config.yml
- Client DB: last_pull_seq migration for incremental pull
- Frontend (Svelte): settings modal with connect/disconnect/interval
- User dashboard (/dashboard): device list with status, revoke with password
- Admin dashboard (/admin/dashboard): devices table from /admin/api/devices
- CLI (cmd/verstak): updated for ServerSequence/GetState changes
- Fix: autoSyncLoop falls back to SQLite sync_state for server URL
- Fix: SyncSetInterval preserves server_url/device_id from SQLite
2026-06-02 02:26:05 +08:00
mirivlad 7fe02fc8df feat: forgot/reset password pages, login link, consistent error page helper, fix reset URL bug 2026-06-02 00:43:28 +08:00
mirivlad b0d992b0d6 fix: rebuild GUI with login/password sync fields, make sync buttons more visible 2026-06-02 00:37:31 +08:00
mirivlad e5860ca076 feat: styled registration/confirm pages with login link, consistent theme 2026-06-02 00:31:53 +08:00
mirivlad daed8e0aba feat: SMTP security selector (none/STARTTLS/TLS) instead of port-based detection 2026-06-02 00:18:04 +08:00
mirivlad fa6f988368 fix: SMTP test send JSON instead of multipart FormData (ParseForm can't read multipart) 2026-06-02 00:14:52 +08:00
mirivlad c8cdb089a6 feat: SMTP test button in admin modal — sends real test email, shows result 2026-06-02 00:12:41 +08:00
mirivlad 4afcc0e135 feat: add SMTP/logging — log.Printf for smtpSend errors, fix confirm URL logic 2026-06-02 00:10:04 +08:00
mirivlad 61928cf28e fix: restore side-by-side layout for stat counters 2026-06-02 00:04:56 +08:00
mirivlad 04af88940b refactor: SMTP form and health check into modals with toolbar buttons 2026-06-02 00:03:35 +08:00
mirivlad 015c8fdec7 docs: update sync server guide with user registration flow and full API 2026-06-02 00:00:53 +08:00
mirivlad 0f5c584c50 fix: admin dashboard format errors — use JS for stats, string concat for SMTP values, fix layout overlap 2026-06-01 23:59:15 +08:00
mirivlad 99e47fcb17 feat: add user registration web form at /register 2026-06-01 23:46:25 +08:00
mirivlad 0ef54c31f8 feat: user web GUI — login, dashboard with devices/keys, logout 2026-06-01 23:40:48 +08:00
mirivlad b3662d4876 test: update smoke test for user auth flow 2026-06-01 23:36:38 +08:00
mirivlad f8dc436709 feat: client auth — login/password flow, auto device reg, sync interval + improved sync UI 2026-06-01 23:36:19 +08:00
mirivlad 241a9d8c06 feat: user registration, email confirmation, login, device management + SMTP config UI in admin panel 2026-06-01 23:33:58 +08:00
mirivlad 5db3da3618 fix: protect device register with admin auth; improve admin UI (full API key, copy button, styling) 2026-06-01 23:22:19 +08:00
mirivlad e828ebd44e docs: add sync server installation and usage guide 2026-06-01 23:13:59 +08:00
mirivlad 84c0bcbcab test: add E2E smoke test for sync 2026-06-01 23:07:24 +08:00
mirivlad a1a50863c5 gui: add sync settings panel in Svelte 2026-06-01 22:58:12 +08:00
mirivlad 1abe8c4fa0 cli: add sync push/pull/status commands 2026-06-01 22:56:05 +08:00
mirivlad 5b2cec5bcc sync: fix SyncStatus binding — remove invalid type assertion, use config for device ID 2026-06-01 22:55:50 +08:00
mirivlad 1a20edac44 feat: sync — client ops recording in core services
- internal/core/sync/: Service, Client, Blob packages

- RecordOp creates sync_ops entries for all mutations

- Client for push/pull/blob HTTP to server

- Blob SHA-256 hashing and local storage

- Wired into app.go alongside activity recording

- Device ID from config or fallback
2026-06-01 22:54:23 +08:00
mirivlad ad684eb118 feat: sync — push/pull API endpoints
- POST /api/v1/sync/push — accepts ops, assigns revisions, returns accepted list

- POST /api/v1/sync/pull — returns ops since given revision with server_revision
2026-06-01 22:51:30 +08:00
mirivlad 10c6d06e38 feat: sync — blob upload/download with SHA-256 storage
- POST /api/v1/blobs/ — multipart upload, stored as blobs/ab/cd/sha256

- GET /api/v1/blobs/{sha256} — download by hash

- server_blobs table for tracking stored blobs
2026-06-01 22:50:38 +08:00
mirivlad ec928e3be6 feat: sync — systemd unit and install.sh for server deployment
- verstak-server.service — systemd unit with sandboxing, configurable port via env

- install.sh — creates user, installs binary, sets up admin, enables service

  Usage: sudo ./install.sh --admin-user admin --admin-pass secret [--port 47732]
2026-06-01 22:49:40 +08:00
mirivlad c5e0060fee chore: add verstak-server to gitignore 2026-06-01 22:49:10 +08:00
mirivlad 834b5ef0d4 feat: sync — server skeleton with health, admin login/dashboard, device registration
- cmd/verstak-server/main.go — flags: --port, --data, --admin-user, --admin-pass

- Server DB schema: server_devices, server_revisions, server_ops

- Health endpoint GET /api/v1/health

- Admin login page + session cookie auth

- Admin dashboard with device stats and API key management

- Device registration POST /api/v1/device/register

- Stub push/pull/blob endpoints
2026-06-01 22:49:02 +08:00
mirivlad 4145b4d74a feat: sync — migration 010 for sync_ops and sync_state tables 2026-06-01 22:45:12 +08:00
mirivlad edc708a106 chore: add spaces/ to gitignore (vault data) 2026-06-01 22:17:39 +08:00
mirivlad ee708d30bb docs: sync documentation with current codebase state
- Architecture: Wails v3→v2, removed TUI/sync/security from diagram

- UI/UX: layout updated to sidebar+header, sync marked future

- Roadmap: Wails v3→v2 migration note, milestones 10+ PAUSED

- MVP Checklist: mark implemented features as done

- PLAN.md: bindings list synced, repo structure fixed, progress updated

- Medium-term steps documented (sync, scanner, TUI, Lua, etc.)
2026-06-01 22:17:29 +08:00
mirivlad 305158ecc6 test: MVP smoke test for core workflow
Covers: vault init, node tree, notes CRUD, file import, actions CRUD, worklog, search (FTS5 optional), reopen persistence, soft delete, worklog report
2026-06-01 22:17:25 +08:00
mirivlad 996322f3a9 gui: actions CRUD + FromTemplate bindings + UI
- CreateAction / DeleteAction Wails bindings

- FromTemplate / ListTemplates bindings with recursive tree creation

- Plugin manager stored in App struct for template access

- Action creation modal (title, kind, data) in Overview and Actions tabs

- Delete action button on action cards

- Template selector in new-node dialog
2026-06-01 22:17:18 +08:00
mirivlad a098cf721c add missing Wails bindings for ListActivityFeed, ListActivityByNode, CountActivityByNode 2026-06-01 22:00:58 +08:00
mirivlad 3672e3133b activity: global feed, per-case log, sidebar section, today UX
- migration 009: target_type, target_id, target_path columns
- new Event fields: TargetType, TargetID, TargetPath
- ListActivityFeed (paginated global), ListActivityByNode (per-case)
- all Record() callsites pass target info
- frontend: Активность sidebar section with chronological feed
- per-case Активность tab with real data (was placeholder)
- today events: clickable, target-type badges, event counts
2026-06-01 02:53:56 +08:00
mirivlad 5a1c4c6d7f simplify ListTodayView: remove fallback table queries, pure activity_events + ListTodayNodes
TodayView now uses only activity_events (source of truth) + ListTodayNodes (ensure changed cases appear). Removed direct queries to nodes (notes) and files tables — those will come from activity_events going forward.
2026-06-01 02:45:55 +08:00
mirivlad 08c9d5dbea gitignore: fix verstak-gui pattern to root-only, add .verstak/ 2026-06-01 02:16:25 +08:00
mirivlad c74fa3ad43 today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar 2026-06-01 02:16:13 +08:00
mirivlad 69891e395c Today view and Inbox section: dynamic query, not stored sections
- ListTodayView() on backend: queries nodes created or updated today
  using local timezone day boundaries
- todayBoundaries() helper returns start/end of current day in RFC3339
- Section validation: Create() rejects today and inbox as node sections
- validSections moved from repository.go to types.go with IsValidSection
  and IsServiceSection helpers
- Frontend selectSection('today') calls ListTodayView instead of
  ListNodesBySection('today')
- FAB (create node) hidden in today and inbox sections
- CreateNode in app.go guarded against today/inbox sections
- types.go: today/inbox defined as service sections (sidebar only)
2026-06-01 01:39:02 +08:00
mirivlad a4ae22c445 File manager: selection, keyboard shortcuts, rename modal, security, localization
- Selection model: click=select, dblclick=open, Ctrl+click toggle,
  Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
  Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
  Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
  validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
2026-06-01 01:35:45 +08:00
317 changed files with 52898 additions and 3551 deletions

21
.codex/config.toml Normal file
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
.env.example Normal file
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=

32
.gitignore vendored
View File

@ -20,10 +20,17 @@ go.work
# Wails # Wails
frontend/dist/ frontend/dist/
frontend/frontend-dist/
frontend/node_modules/ frontend/node_modules/
frontend/bindings/ frontend/bindings/
verstak-gui /verstak-gui
verstak-cli /verstak-cli
/verstak-server
/verstak
# Vault data
.verstak/
spaces/
# VS Code # VS Code
.vscode/ .vscode/
@ -35,3 +42,24 @@ Thumbs.db
# Vault test data # Vault test data
test-vault/ test-vault/
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/

Binary file not shown.

187
AGENTS.md Normal file
View File

@ -0,0 +1,187 @@
# 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)
1. **webkit2_41 build tag** — binary wouldn't start without it. Added to build instructions.
2. **Sidebar refresh**`reloadTreePreservingExpanded` patches children in-place so expand/collapse state stays intact.
3. **Context menu off-screen** — changed to `position: fixed` with cursor coordinates.
4. **"Show in explorer" only for folder types** — `OpenFolder` in backend falls back to file record path for `TypeFile` nodes.
5. **Context menu not closing on action**`handleShowInFolder` calls `closeMenu()`.
6. **Wrong folder when opening file's parent folder**`OpenFolder` checks `n.FsPath == ""` for TypeFile and uses first file record path.
7. **Tab highlight not updating visually** — was using `class={tabClass(tab.id)}` which didn't trigger reactive class updates in Svelte. Switched to `class="tab" class:active={activeTab === tab.id}`.
8. **Journal table expand/collapse** — added explicit ▸/▾ toggle column so it's clear rows are expandable.
9. **Per-node worklog entries** — made entries expandable with ▸/▾, showing details + billable/approximate tags.
10. **Manual worklog entry form** — converted inline form to modal dialog ("+ Добавить запись") with all fields: date, summary, minutes, details, billable, approximate.
11. **"С подзадачами" → "Учитывать вложенные дела"** — renamed, now hidden when no node selected.
12. **Filter/export layout** — split into separate "Фильтры" and "Экспорт отчёта" sections with headings.
13. **Suggestion events** — added "Показать в проводнике" button for file-type events in suggestion detail.
14. **Removed duplicate i18n keys** in `ru.js` (worklog.suggestions, worklog.apply).
15. **Removed unused CSS** (`.journal-filters`, `.wl-meta`, `.worklog-form`).
16. **Added `openNodeFolder(nodeOrId)`** — accepts both string ID and node object.
17. **Added `resetJournalFilters()`** — resets all filters and reloads.
18. **Source field** — added `worklog_entries.source` column (migration 014). Values: manual, suggestion. Old entries default to 'unknown'.
19. **Suggestions now use worklog_entry_events** instead of `HasTodayEntries` — only events already linked to worklog entries are excluded. Repeated activity on the same node today now produces new suggestions.
20. **Activity target navigation** — clicking activity events for notes opens the note tab and loads the specific note. File events open the files tab.
21. **Source display** — detail sections now show accurate source: "Ручная запись", "Из предложения", "Из предложения, но связанные события отсутствуют", or "Источник неизвестен".
22. **Wails `[]string` marshalling bug** — Wails v2.12.0 silently drops `[]string` positional args from JS→Go. **Fix**: pass all string arrays as `JSON.stringify()``string``json.Unmarshal` on Go side.
23. **Event link validation**`AcceptSuggestionWith` pre-checks each eventID against `activity_events`, uses plain `INSERT` (not `INSERT OR IGNORE`), and verifies with JOIN `COUNT(*)` after commit.
24. **GetWorklogEntryEvents column fix** — query used `e.details_json` but the column is `e.metadata`. Fixed to `COALESCE(e.metadata,'')`.
25. **"Посмотреть" button** — `openActivityTarget(ev)` navigates to the specific target: note tab + open note for `targetType=note`, files tab + `OpenFolder(targetPath)` for `file/folder`.
26. **End-to-end test**`TestAcceptSuggestionWithEndToEnd` creates node, 3 activity events, accepts suggestion, verifies all 3 linked via `worklog_entry_events` + JOIN.
27. **WriteDebugLog binding**`bindings_debug.go` writes frontend logs to `<vault>/.verstak/debug.log` for production GUI debugging.
28. **Journal regression tests**`TestJournalFullRegression`, `TestSuggestionOnRepeatedActivity`, `TestManualWorklogEntry`.
29. **resolveActivityTarget helper** — pure function returning `{ nodeId, tab, noteId/fileId/targetPath }`, used by `openActivityTarget`.
30. **First-run flow** — no auto-vault creation. New `GetStartupStatus` binding returns `first_run`/`recovery`/`ready`. Frontend shows FirstRun.svelte or VaultRecovery.svelte accordingly.
31. **Global config.json** — moved vault path, sync settings, templates, theme, language from implicit CLI args to `~/.config/verstak/config.json` (`AppConfig` struct).
32. **Sync settings in Settings** — extracted sync modal into Settings → Sync section. Removed all inline sync form fields from `App.svelte`. Added `SyncStatus.svelte` widget replacing navbar sync button.
33. **Settings window** — modal with sidebar (8 sections: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup). ESC to close. Lazy-loaded content panels.
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.
- `openNodeFolder(id)` accepts a string ID or a node object.
- `GetSuggestions` filters out only events already in `worklog_entry_events`, not entire nodes.
- New worklog entries get `source=manual` via `Add`/`AddWithDate`; suggestion entries get `source=suggestion` via `AcceptSuggestionWith`.
- **NEVER pass `[]string` through Wails v2 bindings** — always JSON-serialize to `string` first. Wails v2.12.0 silently drops slice arguments.
- **Always wrap create-entry + link-events in a transaction** with pre-validation and post-commit verification to prevent orphan entries.
- Frontend debug logs in production: use `wailsCall('WriteDebugLog', msg)` → writes to `<vault>/.verstak/debug.log`.
- `AppConfig` stores all global settings in `~/.config/verstak/config.json`. Vault-specific config stays in `.verstak/config.yml`.
- Use `GetStartupStatus` to determine first-run vs recovery vs normal startup.
- Settings window uses a sidebar with 8 sections; each section is a separate Svelte component imported lazily.
- Template enable/disable state is stored in `appCfg.EnabledTemplates` and applied to the registry during `initVault`.
# Build instructions
## GUI binary (Wails v2)
```bash
# From project root:
cp -r frontend/dist/* cmd/verstak-gui/frontend-dist/
go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak-gui-linux-amd64 ./cmd/verstak-gui/
```
## Server binary
```bash
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
```

257
README.md
View File

@ -1,46 +1,229 @@
# Верстак # Верстак
**Верстак** — локальная программа, где по каждому клиенту или проекту **Верстак** — local-first рабочий vault. Всё организовано вокруг **дел**, а не задач.
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди, Дело может быть: клиентом, проектом, набором документов, рецептом, архивом, разовой работой.
у которых работа организована через дела, а не через задачи: Внутри дела: вложенные папки, Markdown-заметки, файлы, действия (URL/файл/папка/команда), журнал работ, история активности.
```
дело → файлы → заметки → документы → действия → история → вернуться через месяц
```
## Для кого
Один продукт — разные входные двери:
| Кто | Как видит Верстак |
|-----|-------------------|
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
## Универсальные сущности
Базовая модель предельно проста — плагины добавляют функционал:
- **Дело** — контекст для всего остального
- **Заметка** — Markdown внутри vault
- **Файл / Документ** — любой файл, привязанный к делу
- **Действие** — кнопка запуска: URL, файл, папка, команда
- **Журнал** — записи о затраченном времени
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
эти сущности без перекомпиляции программы.
## Стек ## Стек
Go + SQLite + Lua (плагины) + Wails + Bubble Tea. | Слой | Технология |
|------|------------|
| GUI | Wails v2 + Svelte 4 |
| CLI | Go |
| Backend | Go |
| Хранилище | SQLite (индекс) + файловая система (vault) |
| Плагины | Lua |
| Синхронизация | HTTP API (опциональный сервер) |
## Архитектура
```
┌────────────────────┐
│ GUI (Wails v2) │
└─────────┬──────────┘
┌─────────▼────────┐ ┌─────────────┐
│ Core Library │◄──│ CLI Commands │
└─────────┬────────┘ └─────────────┘
┌─────────▼──────────┐
│ Local Vault+SQLite │
└─────────┬──────────┘
┌─────────▼──────────┐
│ Sync Client │
└─────────┬──────────┘
┌─────────▼──────────┐
│ Sync Server │
└────────────────────┘
```
## Быстрый старт
### Требования
- Go 1.25+
- Node.js 20+
- libwebkit2gtk-4.1-dev, libgtk-3-dev и другие Wails-зависимости (см. [wails.io/docs/desktop/linux](https://wails.io/docs/desktop/linux))
- npm
### Сборка
```bash
# Всё сразу (GUI + сервер)
./scripts/build.sh
# Или по отдельности
./scripts/build.sh gui # только GUI
./scripts/build.sh server # только сервер
```
Проверка GUI перед коммитом:
```bash
./scripts/check-gui.sh
```
Она проверяет локали, production-сборку фронтенда, актуальность embedded Wails assets и компиляцию GUI-бинаря.
Дополнительно запускается 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-приложение
- `verstak-server` — опциональный сервер синхронизации
### Запуск
```bash
# GUI (после сборки)
./build/verstak
# Сервер (после сборки)
./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
│ ├── verstak-gui/ # Wails GUI
│ └── verstak-server/ # Sync server
├── internal/
│ ├── core/ # Бизнес-логика
│ │ ├── actions/ # Действия (URL, папка, команда)
│ │ ├── config/ # Конфигурация
│ │ ├── files/ # Файлы и импорт
│ │ ├── i18n/ # Интернационализация (Go)
│ │ ├── nodes/ # Дела/узлы дерева
│ │ ├── plugins/ # Lua-плагины
│ │ ├── search/ # Поиск
│ │ ├── storage/ # SQLite + миграции
│ │ ├── sync/ # Синхронизация
│ │ ├── templates/ # Шаблоны дел
│ │ ├── vault/ # Vault layout
│ │ └── worklog/ # Журнал работ
│ └── gui/ # Wails bridge (embedded HTML)
├── frontend/ # Svelte-приложение
│ └── src/
│ ├── lib/
│ │ └── i18n/ # Локали (JS)
│ └── ...svelte # Компоненты
├── migrations/ # SQL-миграции
├── docs/ # Документация
└── scripts/ # Вспомогательные скрипты и сборка
```
## CLI команды
```bash
go run ./cmd/verstak/ sync # Синхронизация с сервером
go run ./cmd/verstak/ sync configure # Настройка сервера
go run ./cmd/verstak/ sync status # Статус синхронизации
```
## Vault layout
Данные хранятся в локальной папке (vault). Структура на диске:
```
vault/
.verstak/ # Служебные данные: index.db, config.yml, trash, blobs
Проекты/ # Пользовательские папки-дела
Клиенты/
Рабочие/
Archive/
```
Внутри папок-дел: `Notes/`, `Files/`, `Documents/`, `Overview.md`.
Vault открывается в любом файловом менеджере без специальных инструментов.
## Документация ## Документация
- Описание продукта: [docs/01_Product_Spec.md](docs/01_Product_Spec.md) | Раздел | Описание |
- Архитектура: [docs/02_Architecture.md](docs/02_Architecture.md) |--------|----------|
- Плагины: [docs/09_Extensibility.md](docs/09_Extensibility.md) | [Описание продукта](docs/01_Product_Spec.md) | Аудитория, сценарии, фичи |
- План разработки: [docs/PLAN.md](docs/PLAN.md) | [Архитектура](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 |
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
| [План](docs/PLAN.md) | Дорожная карта |
| [Шаблоны](docs/TEMPLATES.md) | Шаблоны дел |
## Лицензия
MIT

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

View File

@ -1,4 +0,0 @@
#!/bin/bash
cd frontend && npm run build && cd ..
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui

View File

@ -1,355 +0,0 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
cmds:
- task: install:frontend:deps:{{.PACKAGE_MANAGER}}
install:frontend:deps:npm:
dir: frontend
sources:
- package.json
- package-lock.json
generates:
- node_modules
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
install:frontend:deps:bun:
dir: frontend
sources:
- package.json
- bun.lock
- bun.lockb
generates:
- node_modules
preconditions:
- sh: bun --version
msg: "bun not found"
cmds:
- bun install
install:frontend:deps:pnpm:
dir: frontend
sources:
- package.json
- pnpm-lock.yaml
generates:
- node_modules
preconditions:
- sh: pnpm --version
msg: "pnpm not found"
cmds:
- pnpm install
install:frontend:deps:yarn:
dir: frontend
sources:
- package.json
- yarn.lock
status:
- test -d node_modules || test -f .pnp.cjs
preconditions:
- sh: yarn --version
msg: "yarn not found"
cmds:
- yarn install
build:frontend:
label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
summary: Build the frontend project
dir: frontend
sources:
- "**/*"
- exclude: node_modules/**/*
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
cmds:
- task: frontend:run
vars:
SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
frontend:run:
summary: Run a frontend script with selected runner
cmds:
- task: frontend:run:{{.PACKAGE_MANAGER}}
vars:
SCRIPT: "{{.SCRIPT}}"
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:npm:
dir: frontend
cmds:
- npm run {{.SCRIPT}} -q
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:yarn:
dir: frontend
cmds:
- yarn {{.SCRIPT}}
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:pnpm:
dir: frontend
cmds:
- pnpm run {{.SCRIPT}}
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:run:bun:
dir: frontend
cmds:
- bun run {{.SCRIPT}}
vars:
SCRIPT: "{{.SCRIPT}}"
frontend:vendor:puppertino:
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
sources:
- frontend/public/puppertino/puppertino.css
generates:
- frontend/public/puppertino/puppertino.css
cmds:
- |
set -euo pipefail
mkdir -p frontend/public/puppertino
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
else
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
fi
else
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
fi
# Ensure index.html includes Puppertino CSS and button classes
INDEX_HTML=frontend/index.html
if [ -f "$INDEX_HTML" ]; then
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
# Insert Puppertino link tag after style.css link
awk '
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
fi
# Replace default .btn with Puppertino primary button classes if present
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
fi
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: Generates bindings for the frontend
deps:
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true{{if eq .OBFUSCATED "true"}} -obfuscated{{end}}
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
dir: build
sources:
- "appicon.png"
- "appicon.icon"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
dev:frontend:
summary: Runs the frontend in development mode
deps:
- task: install:frontend:deps
cmds:
- task: frontend:dev:{{.PACKAGE_MANAGER}}
frontend:dev:npm:
dir: frontend
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
frontend:dev:yarn:
dir: frontend
cmds:
- yarn dev --port {{.VITE_PORT}} --strictPort
frontend:dev:pnpm:
dir: frontend
cmds:
- pnpm dev --port {{.VITE_PORT}} --strictPort
frontend:dev:bun:
dir: frontend
cmds:
- bun run dev --port {{.VITE_PORT}} --strictPort
update:build-assets:
summary: Updates the build assets
dir: build
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
build:server:
summary: Builds the application in server mode (no GUI, HTTP server only)
desc: |
Builds the application with the server build tag enabled.
Server mode runs as a pure HTTP server without native GUI dependencies.
Usage: task build:server
deps:
- task: build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
vars:
BUILD_FLAGS: "{{.BUILD_FLAGS}}"
run:server:
summary: Builds and runs the application in server mode
deps:
- task: build:server
cmds:
- ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
build:docker:
summary: Builds a Docker image for server mode deployment
desc: |
Creates a minimal Docker image containing the server mode binary.
The image is based on distroless for security and small size.
Usage: task build:docker [TAG=myapp:latest]
cmds:
- docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
vars:
TAG: "{{.TAG}}"
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required. Please install Docker first."
- sh: test -f build/docker/Dockerfile.server
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
run:docker:
summary: Builds and runs the Docker image
desc: |
Builds the Docker image and runs it, exposing port 8080.
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
Note: The internal container port is always 8080. The PORT variable
only changes the host port mapping. Ensure your app uses port 8080
or modify the Dockerfile to match your ServerOptions.Port setting.
deps:
- task: build:docker
vars:
TAG:
ref: .TAG
cmds:
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
vars:
TAG: "{{.TAG}}"
PORT: "{{.PORT}}"
setup:docker:
summary: Builds Docker image for cross-compilation (~800MB download)
desc: |
Builds the Docker image needed for cross-compiling to any platform.
Run this once to enable cross-platform builds from any OS.
cmds:
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required. Please install Docker first."
ios:device:list:
summary: Lists connected iOS devices (UDIDs)
cmds:
- xcrun xcdevice list
ios:run:device:
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
vars:
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
CONFIG: '{{.CONFIG | default "Debug"}}'
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
UDID: '{{.UDID}}' # from `task ios:device:list`
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
preconditions:
- sh: xcrun -f xcodebuild
msg: "xcodebuild not found. Please install Xcode."
- sh: xcrun -f devicectl
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
- sh: test -n '{{.PROJECT}}'
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
- sh: test -n '{{.SCHEME}}'
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
- sh: test -n '{{.UDID}}'
msg: "Set UDID to your device UDID (see: task ios:device:list)."
- sh: test -n '{{.BUNDLE_ID}}'
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
cmds:
- |
set -euo pipefail
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
XCB_ARGS=(
-project "{{.PROJECT}}"
-scheme "{{.SCHEME}}"
-configuration "{{.CONFIG}}"
-destination "id={{.UDID}}"
-derivedDataPath "{{.DERIVED}}"
-allowProvisioningUpdates
-allowProvisioningDeviceRegistration
)
# Optionally inject signing identifiers if provided
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
# If xcpretty isn't installed, run without it
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
xcodebuild "${XCB_ARGS[@]}" build
fi
# Find built .app
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
exit 1
fi
echo "Installing: $APP_PATH"
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
echo "Launching: {{.BUNDLE_ID}}"
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-246,-251)">
<g id="Ebene1">
<path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 698 B

View File

@ -1,51 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"appearance" : "dark",
"value" : {
"solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"image-name" : "wails_icon_vector.svg",
"name" : "wails_icon_vector",
"position" : {
"scale" : 1.25,
"translation-in-points" : [
36.890625,
4.96875
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@ -1,79 +0,0 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
# This information is used to generate the build assets.
info:
companyName: "My Company" # The name of the company
productName: "My Product" # The name of the application
productIdentifier: "com.mycompany.myproduct" # The unique product identifier
description: "A program that does X" # The application description
copyright: "(c) 2025, My Company" # Copyright text
comments: "Some Product Comments" # Comments
version: "0.0.1" # The application version
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
# # Should match the name of your .icon file without the extension
# # If not set and Assets.car exists, defaults to "appicon"
# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.
# ios:
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
# bundleID: "com.mycompany.myproduct"
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
# displayName: "My Product"
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
# version: "0.0.1"
# # The company/organisation name for templates and project settings
# company: "My Company"
# # Additional comments to embed in Info.plist metadata
# comments: "Some Product Comments"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
- "*_test.go"
watched_extension:
- "*.go"
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# mimeType: image/jpeg # (optional)
# Other data
other:
- name: My Other Data

View File

@ -1,212 +0,0 @@
# Cross-compile Wails v3 apps to any platform
#
# Darwin: Zig + macOS SDK
# Linux: Native GCC when host matches target, Zig for cross-arch
# Windows: Zig + bundled mingw
#
# Usage:
# docker build -t wails-cross -f Dockerfile.cross .
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
# docker run --rm -v $(pwd):/app wails-cross linux amd64
# docker run --rm -v $(pwd):/app wails-cross linux arm64
# docker run --rm -v $(pwd):/app wails-cross windows amd64
# docker run --rm -v $(pwd):/app wails-cross windows arm64
FROM golang:1.26-bookworm
ARG TARGETARCH
ARG GARBLE_VERSION=v0.16.0
# Install base tools, GCC, and GTK/WebKit dev packages
RUN apt-get update && apt-get install -y --no-install-recommends \
curl xz-utils nodejs npm pkg-config gcc libc6-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev \
libgtk-4-dev libwebkitgtk-6.0-dev \
&& rm -rf /var/lib/apt/lists/*
RUN go install mvdan.cc/garble@${GARBLE_VERSION}
# Install Zig - automatically selects correct binary for host architecture
ARG ZIG_VERSION=0.14.0
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \
| tar -xJ -C /opt \
&& ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
# Download macOS SDK (required for darwin targets)
ARG MACOS_SDK_VERSION=14.5
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
| tar -xJ -C /opt \
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
ENV MACOS_SDK_PATH=/opt/macos-sdk
# Create Zig CC wrappers for cross-compilation targets
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
# Darwin arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
# Darwin amd64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
# Windows amd64 - uses Zig's bundled mingw
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target x86_64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-amd64
# Windows arm64 - uses Zig's bundled mingw
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target aarch64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-arm64
# Build script
COPY <<'SCRIPT' /usr/local/bin/build.sh
#!/bin/sh
set -e
OS=${1:-darwin}
ARCH=${2:-arm64}
case "${OS}-${ARCH}" in
darwin-arm64|darwin-aarch64)
export CC=zcc-darwin-arm64
export GOARCH=arm64
export GOOS=darwin
;;
darwin-amd64|darwin-x86_64)
export CC=zcc-darwin-amd64
export GOARCH=amd64
export GOOS=darwin
;;
linux-arm64|linux-aarch64)
export CC=gcc
export GOARCH=arm64
export GOOS=linux
;;
linux-amd64|linux-x86_64)
export CC=gcc
export GOARCH=amd64
export GOOS=linux
;;
windows-arm64|windows-aarch64)
export CC=zcc-windows-arm64
export GOARCH=arm64
export GOOS=windows
;;
windows-amd64|windows-x86_64)
export CC=zcc-windows-amd64
export GOARCH=amd64
export GOOS=windows
;;
*)
echo "Usage: <os> <arch>"
echo " os: darwin, linux, windows"
echo " arch: amd64, arm64"
exit 1
;;
esac
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
# Build frontend if exists and not already built (host may have built it)
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
(cd frontend && npm install --silent && npm run build --silent)
fi
# Build
APP=${APP_NAME:-$(basename $(pwd))}
mkdir -p bin
EXT=""
LDFLAGS="-s -w"
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
LDFLAGS="-s -w -H windowsgui"
fi
TAGS="production"
if [ -n "$EXTRA_TAGS" ]; then
TAGS="${TAGS},${EXTRA_TAGS}"
fi
COMPILER="go build"
if [ "$OBFUSCATED" = "true" ]; then
COMPILER="garble ${GARBLE_ARGS} build"
TAGS="${TAGS},wails_obfuscated"
fi
${COMPILER} -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
SCRIPT
RUN chmod +x /usr/local/bin/build.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build.sh"]
CMD ["darwin", "arm64"]

View File

@ -1,41 +0,0 @@
# Wails Server Mode Dockerfile
# Multi-stage build for minimal image size
# Build stage
FROM golang:alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy source code
COPY . .
# Remove local replace directive if present (for production builds)
RUN sed -i '/^replace/d' go.mod || true
# Download dependencies
RUN go mod tidy
# Build the server binary
RUN go build -tags server -ldflags="-s -w" -o server .
# Runtime stage - minimal image
FROM gcr.io/distroless/static-debian12
# Copy the binary
COPY --from=builder /app/server /server
# Copy frontend assets
COPY --from=builder /app/frontend/dist /frontend/dist
# Expose the default port
EXPOSE 8080
# Bind to all interfaces (required for Docker)
# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
ENV WAILS_SERVER_HOST=0.0.0.0
# Run the server
ENTRYPOINT ["/server"]

View File

@ -1,224 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# PGP_KEY: "path/to/signing-key.asc"
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Linux
cmds:
# Linux requires CGO - use Docker when:
# 1. Cross-compiling from non-Linux, OR
# 2. No C compiler is available, OR
# 3. Target architecture differs from host architecture (cross-arch compilation)
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Determine target architecture (defaults to host ARCH if not specified)
TARGET_ARCH: '{{.ARCH | default ARCH}}'
# Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
HAS_CC:
sh: 'wails3 tool has-cc'
build:native:
summary: Builds the application natively on Linux
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
- task: generate:dotdesktop
preconditions:
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
cmds:
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
- task: generate:dotdesktop
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
package:
summary: Packages the application for Linux
deps:
- task: build
cmds:
- task: create:appimage
- task: create:deb
- task: create:rpm
- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/linux/appimage
deps:
- task: build
- task: generate:dotdesktop
cmds:
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
- cp ../../appicon.png "{{.APP_NAME}}.png"
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../../bin/{{.APP_NAME}}'
ICON: '{{.APP_NAME}}.png'
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: '{{.APP_NAME}}'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'
sign:deb:
summary: Signs the DEB package
desc: |
Signs the .deb package with a PGP key.
Configure PGP_KEY in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:deb
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
preconditions:
- sh: '[ -n "{{.PGP_KEY}}" ]'
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
sign:rpm:
summary: Signs the RPM package
desc: |
Signs the .rpm package with a PGP key.
Configure PGP_KEY in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:rpm
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
preconditions:
- sh: '[ -n "{{.PGP_KEY}}" ]'
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
sign:packages:
summary: Signs all Linux packages (DEB and RPM)
desc: |
Signs both .deb and .rpm packages with a PGP key.
Configure PGP_KEY in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
cmds:
- task: sign:deb
- task: sign:rpm
preconditions:
- sh: '[ -n "{{.PGP_KEY}}" ]'
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"

View File

@ -2,36 +2,62 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "log"
"os/exec"
"path/filepath" "path/filepath"
"strings" "sync"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions" "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/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/search" "verstak/internal/core/search"
"verstak/internal/core/storage" "verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog" "verstak/internal/core/worklog"
) )
// App is the Wails v2 application adapter. It wraps core services. // App is the Wails v2 application adapter. It wraps core services.
type App struct { type App struct {
ctx context.Context ctx context.Context
db *storage.DB mu sync.RWMutex
nodes *nodes.Repository vaultOpen bool
files *files.Service
notes *notes.Service db *storage.DB
actions *actions.Service nodes *nodes.Repository
worklog *worklog.Service templates *templates.Registry
search *search.Service files *files.Service
vault string 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.
// All binding methods that access vault services MUST call this first.
func (a *App) requireVault() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
return nil
} }
// startup is called when the app starts. Store context and wire drag-and-drop. // startup is called when the app starts. Store context and wire drag-and-drop.
@ -44,18 +70,91 @@ func (a *App) startup(ctx context.Context) {
}) })
} }
func (a *App) autoSyncLoop() {
// Wait for vault to be ready
time.Sleep(5 * time.Second)
if !a.IsReady() {
return
}
const checkInterval = 60 * time.Second
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
log.Printf("[autosync] started")
var lastSync time.Time
for {
select {
case <-ticker.C:
if !a.IsReady() {
return
}
a.mu.RLock()
vaultPath := a.vault
a.mu.RUnlock()
serverURL, _, _, _, _ := a.sync.GetState()
if serverURL == "" {
appCfg, _ := config.LoadAppConfig()
if appCfg != nil && appCfg.Vault.Sync.ServerURL != "" {
serverURL = appCfg.Vault.Sync.ServerURL
}
}
if serverURL == "" {
continue
}
interval := 0
appCfg, _ := config.LoadAppConfig()
if appCfg != nil {
interval = appCfg.Vault.Sync.SyncInterval
}
if interval <= 0 {
continue
}
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
continue
}
deviceToken := config.LoadDeviceToken(vaultPath)
if deviceToken == "" {
continue
}
log.Printf("[autosync] running SyncNow...")
if _, err := a.SyncNow(); err != nil {
log.Printf("[autosync] SyncNow error: %v", err)
} else {
lastSync = time.Now()
}
case <-a.ctx.Done():
log.Printf("[autosync] stopped")
return
}
}
}
// ============================================================ // ============================================================
// DTOs // DTOs
// ============================================================ // ============================================================
type NodeDTO struct { type NodeDTO struct {
ID string `json:"id"` ID string `json:"id"`
ParentID string `json:"parentId"` ParentID *string `json:"parent_id,omitempty"`
Title string `json:"title"` Type string `json:"type"`
Type string `json:"type"` Title string `json:"title"`
Section string `json:"section"` TemplateID string `json:"template_id"`
Path string `json:"path"` FsPath string `json:"fs_path"`
CreatedAt string `json:"createdAt"` SortOrder int `json:"sort_order"`
Archived bool `json:"archived"`
HasChildren bool `json:"has_children"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type TemplateDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Icon string `json:"icon,omitempty"`
} }
type SectionDTO struct { type SectionDTO struct {
@ -72,24 +171,32 @@ type NoteDTO struct {
} }
type FileDTO struct { type FileDTO struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Size int64 `json:"size"` Size int64 `json:"size"`
Mime string `json:"mime"` Mime string `json:"mime"`
IsDir bool `json:"isDir"` IsDir bool `json:"isDir"`
Missing bool `json:"missing"` Missing bool `json:"missing"`
} }
type FileTreeItemDTO struct { type FileTreeItemDTO struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` // "folder" | "file" Type string `json:"type"`
FileID string `json:"fileId,omitempty"` FileID string `json:"fileId,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"` Mime string `json:"mime,omitempty"`
HasKids bool `json:"hasKids"` HasKids bool `json:"hasKids"`
}
// PreflightFileAction describes what should happen when opening a file from the Files tab.
type PreflightFileAction struct {
Action string `json:"action"` // "note" | "preview" | "external"
NoteID string `json:"noteId,omitempty"`
NoteTitle string `json:"noteTitle,omitempty"`
FileName string `json:"fileName"`
} }
type ActionDTO struct { type ActionDTO struct {
@ -101,381 +208,70 @@ type ActionDTO struct {
} }
type WorklogDTO struct { type WorklogDTO struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
Summary string `json:"summary"` NodeTitle string `json:"nodeTitle,omitempty"`
Minutes int `json:"minutes"` NodePath string `json:"nodePath,omitempty"`
CreatedAt string `json:"createdAt"` Summary string `json:"summary"`
Minutes int `json:"minutes"`
Date string `json:"date,omitempty"`
Details string `json:"details,omitempty"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
Source string `json:"source"`
CreatedAt string `json:"createdAt"`
} }
type SearchResultDTO struct { type SearchResultDTO struct {
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
Title string `json:"title"` TargetID string `json:"targetId,omitempty"`
Snippet string `json:"snippet"` Title string `json:"title"`
Type string `json:"type"` Snippet string `json:"snippet"`
Type string `json:"type"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
} }
// ============================================================ type EventDTO struct {
// Sections ID string `json:"id"`
// ============================================================ NodeID string `json:"nodeId"`
NodePath string `json:"nodePath,omitempty"`
func (a *App) ListSections() []SectionDTO { EventType string `json:"eventType"`
return []SectionDTO{ TargetType string `json:"targetType"`
{ID: "today", Label: "Сегодня"}, TargetID string `json:"targetId"`
{ID: "inbox", Label: "Неразобранное"}, TargetPath string `json:"targetPath"`
{ID: "clients", Label: "Клиенты"}, Title string `json:"title"`
{ID: "projects", Label: "Проекты"}, DetailsJSON string `json:"detailsJson"`
{ID: "recipes", Label: "Рецепты"}, CreatedAt string `json:"createdAt"`
{ID: "documents", Label: "Документы"},
{ID: "archive", Label: "Архив"},
}
} }
// ============================================================ type CaseActivityDTO struct {
// Nodes Node NodeDTO `json:"node"`
// ============================================================ Events []EventDTO `json:"events"`
func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
list, err := a.nodes.ListRoots(false, section)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
} }
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) { type SummaryDTO struct {
list, err := a.nodes.ListChildren(parentID, false) ChangedCases int `json:"changedCases"`
if err != nil { Notes int `json:"notes"`
return nil, err Files int `json:"files"`
} Actions int `json:"actions"`
return toNodeDTOs(list), nil TimeEntries int `json:"timeEntries"`
} }
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) { type TodayGroupDTO struct {
n, err := a.nodes.GetActive(nodeID) NodeID string `json:"nodeId"`
if err != nil { NodeTitle string `json:"nodeTitle"`
return nil, err NodeKind string `json:"nodeKind"`
} Section string `json:"section"`
dto := toNodeDTO(n) LastActivityAt string `json:"lastActivityAt"`
return &dto, nil Events []EventDTO `json:"events"`
} }
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) { type TodayDashboardDTO struct {
n, err := a.nodes.Create(parentID, nodeType, title, section) Date string `json:"date"`
if err != nil { Summary SummaryDTO `json:"summary"`
return nil, err Groups []TodayGroupDTO `json:"groups"`
} Events []EventDTO `json:"events"`
dto := toNodeDTO(n)
return &dto, nil
}
func (a *App) DeleteNode(id string) error {
return a.nodes.SoftDelete(id)
}
// ============================================================
// Notes
// ============================================================
// ListNotes returns note-type children of a node.
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
var result []NodeDTO
for i := range children {
if children[i].Type == nodes.TypeNote {
result = append(result, toNodeDTO(&children[i]))
}
}
return result, nil
}
// CreateNote creates a note under a parent node.
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
node, _, err := a.notes.Create(parentID, title, "")
if err != nil {
return nil, err
}
dto := toNodeDTO(node)
return &dto, nil
}
// ReadNote reads note content.
func (a *App) ReadNote(noteID string) (string, error) {
return a.notes.Read(noteID)
}
// SaveNote saves note content.
func (a *App) SaveNote(noteID, content string) error {
return a.notes.Save(noteID, content)
}
// ============================================================
// Files
// ============================================================
// ListFiles returns file records directly linked to a node (non-recursive).
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
records, err := a.files.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]FileDTO, len(records))
for i := range records {
rec := &records[i]
result[i] = FileDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Name: rec.Filename,
Path: rec.Path,
Size: rec.Size,
Mime: rec.MIME,
IsDir: rec.MIME == "inode/directory",
Missing: rec.Missing,
}
}
return result, nil
}
// ListItems returns children of a node for the file tree view.
// Folders can be expanded; files include their file record info.
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
result := make([]FileTreeItemDTO, 0, len(children))
for i := range children {
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
continue
}
item := FileTreeItemDTO{
ID: children[i].ID,
Name: children[i].Title,
Type: children[i].Type,
}
if children[i].Type == nodes.TypeFolder {
// Check if this folder has children
kids, _ := a.nodes.ListChildren(children[i].ID, false)
item.HasKids = len(kids) > 0
} else if children[i].Type == 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
}
}
result = append(result, item)
}
return result, nil
}
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
if err != nil {
return nil, err
}
return toNodeDTOs(nodes), nil
}
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
if err != nil {
return nil, err
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
return a.files.DeleteNodeAndChildren(nodeID)
}
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
node, err := a.files.CreateEmptyFile(parentID, filename)
if err != nil {
return nil, err
}
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
node, err := a.files.Duplicate(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) RenameNode(nodeID, newTitle string) error {
return a.nodes.UpdateTitle(nodeID, newTitle)
}
func (a *App) MoveNode(nodeID, newParentID string) error {
return a.nodes.Move(nodeID, newParentID, 0)
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
return a.files.PreviewImport(sourcePath)
}
// ============================================================
// Actions
// ============================================================
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
list, err := a.actions.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]ActionDTO, len(list))
for i := range list {
data := list[i].Command
if list[i].URL != "" {
data = list[i].URL
}
result[i] = ActionDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Title: list[i].Title,
Type: list[i].Kind,
Data: data,
}
}
return result, nil
}
func (a *App) RunAction(id string) error {
_, err := a.actions.Run(id)
return err
}
// ============================================================
// Worklog
// ============================================================
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
list, err := a.worklog.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]WorklogDTO, len(list))
for i := range list {
mins := 0
if list[i].Minutes != nil {
mins = *list[i].Minutes
}
result[i] = WorklogDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Summary: list[i].Summary,
Minutes: mins,
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return result, nil
}
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
if err != nil {
return nil, err
}
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
dto := &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
return dto, nil
}
// ============================================================
// Search
// ============================================================
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if strings.TrimSpace(query) == "" {
return []SearchResultDTO{}, nil
}
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{
NodeID: r.NodeID,
Title: r.Title,
Snippet: r.Snippet,
Type: r.Type,
}
}
return out, nil
}
// ============================================================
// File Dialogs (Wails v2 Runtime)
// ============================================================
func (a *App) PickFile() (string, error) {
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите файл",
})
}
func (a *App) PickFiles() ([]string, error) {
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите файлы",
})
}
func (a *App) PickDirectory() (string, error) {
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите папку",
})
}
// ============================================================
// System helpers
// ============================================================
func (a *App) OpenFile(fileID string) error {
return a.files.Open(fileID)
}
func (a *App) ReadFileText(fileID string) (string, error) {
return a.files.ReadText(fileID)
}
func (a *App) GetFileBase64(fileID string) (string, error) {
return a.files.ReadBase64(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return fmt.Errorf("get node: %w", err)
}
dir := filepath.Join(a.vault, "spaces", n.Slug)
if _, err := os.Stat(dir); os.IsNotExist(err) {
dir = a.vault
}
cmd := exec.Command("xdg-open", dir)
return cmd.Run()
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
} }
// ============================================================ // ============================================================
@ -483,22 +279,17 @@ func (a *App) VerstakVersion() string {
// ============================================================ // ============================================================
func toNodeDTO(n *nodes.Node) NodeDTO { func toNodeDTO(n *nodes.Node) NodeDTO {
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
path := ""
if n.Path != nil {
path = *n.Path
}
return NodeDTO{ return NodeDTO{
ID: n.ID, ID: n.ID,
ParentID: parentID, ParentID: n.ParentID,
Title: n.Title, Type: n.Type,
Type: n.Type, Title: n.Title,
Section: n.Section, TemplateID: n.TemplateID,
Path: path, FsPath: n.FsPath,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), SortOrder: n.SortOrder,
Archived: n.Archived,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
} }
} }
@ -510,4 +301,158 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
return result return result
} }
func toEventDTO(e activity.Event) EventDTO {
return EventDTO{
ID: e.ID,
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
DetailsJSON: e.DetailsJSON,
CreatedAt: e.CreatedAt,
}
}
func nodePayload(n *nodes.Node) map[string]interface{} {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
return map[string]interface{}{
"id": n.ID,
"parent_id": pid,
"type": n.Type,
"title": n.Title,
"slug": n.Slug,
"template_id": n.TemplateID,
"fs_path": n.FsPath,
"section": n.Section,
"sort_order": n.SortOrder,
"archived": n.Archived,
"created_at": n.CreatedAt.Format(time.RFC3339),
"updated_at": n.UpdatedAt.Format(time.RFC3339),
}
}
func (a *App) filePayload(n *nodes.Node) map[string]interface{} {
p := map[string]interface{}{
"node_id": n.ID,
"type": n.Type,
"title": n.Title,
"slug": n.Slug,
"created_at": n.CreatedAt.Format(time.RFC3339),
"updated_at": n.UpdatedAt.Format(time.RFC3339),
}
if n.ParentID != nil {
p["parent_id"] = *n.ParentID
}
if recs, err := a.files.ListByNode(n.ID); err == nil && len(recs) > 0 {
rec := recs[0]
p["filename"] = rec.Filename
p["path"] = rec.Path
p["storage_mode"] = rec.StorageMode
p["size"] = rec.Size
p["sha256"] = rec.SHA256
p["mime"] = rec.MIME
p["file_id"] = rec.ID
if rec.StorageMode == "vault" {
if rec.SHA256 != "" {
p["blob_sha256"] = rec.SHA256
} else {
absPath := filepath.Join(a.vault, rec.Path)
if hash, err := syncsvc.HashFile(absPath); err == nil {
p["blob_sha256"] = hash
}
}
}
} else {
p["filename"] = n.Title
}
return p
}
func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} {
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
return map[string]interface{}{
"node_id": node.ID,
"parent_id": pid,
"title": node.Title,
"file_id": fileRec.ID,
"format": "markdown",
"content": content,
"filename": fileRec.Filename,
"path": fileRec.Path,
"created_at": node.CreatedAt.Format(time.RFC3339),
"updated_at": node.UpdatedAt.Format(time.RFC3339),
}
}
func actionPayload(rec *actions.Record) map[string]interface{} {
return map[string]interface{}{
"id": rec.ID,
"node_id": rec.NodeID,
"title": rec.Title,
"kind": rec.Kind,
"command": rec.Command,
"args": rec.Args,
"working_dir": rec.WorkingDir,
"url": rec.URL,
"confirm_required": rec.ConfirmRequired,
"capture_output": rec.CaptureOutput,
"created_at": rec.CreatedAt.Format(time.RFC3339),
"updated_at": rec.UpdatedAt.Format(time.RFC3339),
}
}
func worklogPayload(entry *worklog.Entry) map[string]interface{} {
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
p := map[string]interface{}{
"id": entry.ID,
"node_id": entry.NodeID,
"summary": entry.Summary,
"details": entry.Details,
"minutes": mins,
"date": entry.Date,
"approximate": entry.Approximate,
"billable": entry.Billable,
"created_at": entry.CreatedAt.Format(time.RFC3339),
"updated_at": entry.UpdatedAt.Format(time.RFC3339),
}
if entry.StartedAt != nil {
p["started_at"] = entry.StartedAt.Format(time.RFC3339)
}
if entry.EndedAt != nil {
p["ended_at"] = entry.EndedAt.Format(time.RFC3339)
}
return p
}
func jsonArgs(args []string) string {
if len(args) == 0 {
return ""
}
b, _ := json.Marshal(args)
return string(b)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@ -0,0 +1,64 @@
package main
import (
syncsvc "verstak/internal/core/sync"
)
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.actions.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]ActionDTO, len(list))
for i := range list {
data := list[i].Command
if list[i].URL != "" {
data = list[i].URL
}
result[i] = ActionDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Title: list[i].Title,
Type: list[i].Kind,
Data: data,
}
}
return result, nil
}
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec))
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Title: rec.Title,
Type: rec.Kind,
Data: data,
}, nil
}
func (a *App) DeleteAction(id string) error {
if err := a.requireVault(); err != nil {
return err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
return a.actions.Delete(id)
}
func (a *App) RunAction(id string) error {
if err := a.requireVault(); err != nil {
return err
}
_, err := a.actions.Run(id)
return err
}

View File

@ -0,0 +1,263 @@
package main
import (
"sort"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
"verstak/internal/i18n"
)
type SystemViewDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
}
func (a *App) ListSystemViews() []SystemViewDTO {
return []SystemViewDTO{
{ID: "today", Label: i18n.TF("ru", "nav.today")},
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
{ID: "trash", Label: i18n.TF("ru", "nav.trash")},
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
}
}
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
aeByParent, err := a.activity.ListTodayEventsByParent()
if err != nil {
aeByParent = nil
}
todayNodes, _ := a.nodes.ListTodayNodes()
type rawEvent struct {
NodeID string
EventType string
TargetType string
TargetID string
TargetPath string
Title string
CreatedAt string
}
type caseInfo struct {
Node nodes.Node
Events []rawEvent
}
caseMap := make(map[string]*caseInfo)
ensureCase := func(caseID string) *caseInfo {
if ci, ok := caseMap[caseID]; ok {
return ci
}
ci := &caseInfo{Events: nil}
if n, err := a.nodes.GetActive(caseID); err == nil {
ci.Node = *n
}
caseMap[caseID] = ci
return ci
}
for pid, events := range aeByParent {
ci := ensureCase(pid)
for _, e := range events {
ci.Events = append(ci.Events, rawEvent{
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
CreatedAt: e.CreatedAt,
})
}
}
for _, n := range todayNodes {
_ = ensureCase(n.ID)
if ci := caseMap[n.ID]; ci.Node.ID == "" {
ci.Node = n
}
}
var groups []TodayGroupDTO
var flatEvents []EventDTO
summary := SummaryDTO{}
for _, ci := range caseMap {
if ci.Node.ID == "" {
continue
}
summary.ChangedCases++
dtoEvents := make([]EventDTO, 0, len(ci.Events))
for _, re := range ci.Events {
dtoEvents = append(dtoEvents, EventDTO{
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
NodeID: re.NodeID,
EventType: re.EventType,
TargetType: re.TargetType,
TargetID: re.TargetID,
TargetPath: re.TargetPath,
Title: re.Title,
CreatedAt: re.CreatedAt,
})
switch re.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
summary.Notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
summary.Files++
}
}
last := ci.Node.UpdatedAt.Format(time.RFC3339)
for _, e := range dtoEvents {
if e.CreatedAt > last {
last = e.CreatedAt
}
}
groups = append(groups, TodayGroupDTO{
NodeID: ci.Node.ID,
NodeTitle: ci.Node.Title,
NodeKind: ci.Node.Type,
Section: ci.Node.Section,
LastActivityAt: last,
Events: dtoEvents,
})
flatEvents = append(flatEvents, dtoEvents...)
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].LastActivityAt > groups[j].LastActivityAt
})
sort.Slice(flatEvents, func(i, j int) bool {
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
})
return &TodayDashboardDTO{
Date: time.Now().Format("2006-01-02"),
Summary: summary,
Groups: groups,
Events: flatEvents,
}, nil
}
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListRecent(limit, offset)
if err != nil {
return nil, err
}
result := make([]EventDTO, len(events))
for i, e := range events {
result[i] = a.eventDTOWithPath(e)
}
return result, nil
}
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
if err != nil {
return nil, err
}
result := make([]EventDTO, len(events))
for i, e := range events {
result[i] = a.eventDTOWithPath(e)
}
return result, nil
}
func (a *App) CountActivityByNode(nodeID string) (int, error) {
if err := a.requireVault(); err != nil {
return 0, err
}
return a.activity.CountByNode(nodeID)
}
var _ = syncsvc.EntityNode
func (a *App) listActivityByNodeSubtree(nodeID string, limit, offset int) ([]activity.Event, error) {
rows, err := a.db.Query(
`WITH RECURSIVE subtree(id) AS (
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
WHERE n.deleted_at IS NULL
)
SELECT e.id, e.node_id, e.event_type, COALESCE(e.target_type,''), COALESCE(e.target_id,''), COALESCE(e.target_path,''),
e.title, COALESCE(e.metadata,'{}'), e.created_at
FROM activity_events e
JOIN subtree s ON s.id = e.node_id
ORDER BY e.created_at DESC
LIMIT ? OFFSET ?`, nodeID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []activity.Event
for rows.Next() {
var e activity.Event
if err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, &e.TargetType, &e.TargetID, &e.TargetPath, &e.Title, &e.DetailsJSON, &e.CreatedAt); err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
dto := toEventDTO(e)
dto.NodePath = a.nodes.Path(e.NodeID)
return dto
}
// ListTodayInProgress returns today's modification events — items the user worked on.
func (a *App) ListTodayInProgress() ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListTodayEvents()
if err != nil {
return nil, err
}
modTypes := map[string]bool{
activity.TypeNoteCreated: true,
activity.TypeNoteUpdated: true,
activity.TypeNoteDeleted: true,
activity.TypeFileAdded: true,
activity.TypeFileDeleted: true,
activity.TypeFileRenamed: true,
activity.TypeFileCopied: true,
activity.TypeFileMoved: true,
activity.TypeFolderAdded: true,
activity.TypeFolderDeleted: true,
activity.TypeFolderRenamed: true,
activity.TypeFolderMoved: true,
activity.TypeNodeCreated: true,
activity.TypeNodeUpdated: true,
activity.TypeNodeDeleted: true,
activity.TypeActionCreated: true,
activity.TypeActionDone: true,
}
result := make([]EventDTO, 0, len(events))
for _, e := range events {
if modTypes[e.EventType] {
result = append(result, a.eventDTOWithPath(e))
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
return result, nil
}

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
}

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)
}

View File

@ -0,0 +1,475 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"mime"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/util"
)
type CaptureContextDTO struct {
ContextType string `json:"contextType"`
NodeID string `json:"nodeId,omitempty"`
Section string `json:"section,omitempty"`
SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"`
}
func (a *App) CaptureText(text string) (*InboxNodeDTO, error) {
return a.CaptureTextWithContext(text, "clipboard", "")
}
func (a *App) CaptureTextWithContext(text, source, contextJSON string) (*InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
text = strings.TrimSpace(text)
if text == "" {
return nil, fmt.Errorf("text required")
}
title := firstLineTitle(text, "Captured text")
content := "# " + title + "\n\n" + text + "\n"
ctx := parseCaptureContext(contextJSON)
return a.createCaptureNote(title, content, "text", source, ctx)
}
func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) {
return a.CaptureURLWithContext(rawURL, title, "clipboard", "")
}
func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (*InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("url required")
}
normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return nil, fmt.Errorf("invalid url")
}
rawURL = normalizedURL
title = strings.TrimSpace(title)
if title == "" {
title = linkTitle(rawURL, "")
}
ctx := parseCaptureContext(contextJSON)
node, err := a.nodes.Create(nil, nodes.TypeLink, firstLineTitle(title, rawURL), 0, "", "")
if err != nil {
return nil, fmt.Errorf("create capture link: %w", err)
}
if err := a.setCaptureMeta(node.ID, "url", source, ctx); err != nil {
return nil, err
}
_ = a.nodes.MetaSet(node.ID, "capture.url", rawURL)
_ = a.nodes.MetaSet(node.ID, "capture.title", title)
_ = a.nodes.MetaSet(node.ID, "capture.hostname", hostnameForURL(rawURL))
_ = a.activity.Record("", activity.TargetNode, node.ID, "", activity.TypeNodeCreated, title, `{"capture":true,"kind":"url"}`)
_ = a.sync.RecordOp(syncsvc.EntityNode, node.ID, syncsvc.OpCreate, nodePayload(node))
return a.inboxNodeDTO(node)
}
func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) {
return a.CapturePathWithContext(sourcePath, "drop", "")
}
func (a *App) CapturePathWithContext(sourcePath, source, contextJSON string) (*InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
sourcePath = strings.TrimSpace(sourcePath)
if sourcePath == "" {
return nil, fmt.Errorf("path required")
}
absPath, err := filepath.Abs(sourcePath)
if err != nil {
return nil, fmt.Errorf("abs path: %w", err)
}
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
nodeType := nodes.TypeFile
kind := captureKindForFilename(absPath)
if info.IsDir() {
nodeType = nodes.TypeFolder
kind = "folder"
}
node, err := a.nodes.Create(nil, nodeType, filepath.Base(absPath), 0, "", "")
if err != nil {
return nil, fmt.Errorf("create capture node: %w", err)
}
stagingRel, _, err := a.captureStagingDir(node)
if err != nil {
return nil, err
}
if info.IsDir() {
if err := a.nodes.UpdateFsPath(node.ID, stagingRel); err != nil {
return nil, fmt.Errorf("set capture folder path: %w", err)
}
entries, err := os.ReadDir(absPath)
if err != nil {
return nil, fmt.Errorf("read source dir: %w", err)
}
for _, entry := range entries {
if _, err := a.files.AddPathCopy(node.ID, filepath.Join(absPath, entry.Name())); err != nil {
return nil, fmt.Errorf("copy capture child %s: %w", entry.Name(), err)
}
}
} else {
if _, err := a.files.CopyIntoVault(node.ID, absPath, stagingRel); err != nil {
return nil, fmt.Errorf("copy capture file: %w", err)
}
}
ctx := parseCaptureContext(contextJSON)
if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil {
return nil, err
}
target := activity.TargetFile
evType := activity.TypeFileAdded
entity := syncsvc.EntityFile
if info.IsDir() {
target = activity.TargetFolder
evType = activity.TypeFolderAdded
entity = syncsvc.EntityFolder
}
_ = a.activity.Record("", target, node.ID, "", evType, node.Title, `{"capture":true}`)
_ = a.sync.RecordOp(entity, node.ID, syncsvc.OpCreate, a.filePayload(node))
return a.inboxNodeDTO(node)
}
func (a *App) CaptureFileData(filename, dataBase64 string) (*InboxNodeDTO, error) {
return a.CaptureFileDataWithContext(filename, dataBase64, "clipboard", "")
}
func (a *App) CaptureFileDataWithContext(filename, dataBase64, source, contextJSON string) (*InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
filename = filepath.Base(strings.TrimSpace(filename))
if filename == "." || filename == "" {
filename = "clipboard.bin"
}
if err := files.ValidateName(filename); err != nil {
return nil, err
}
if comma := strings.Index(dataBase64, ","); comma >= 0 {
dataBase64 = dataBase64[comma+1:]
}
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataBase64))
if err != nil {
return nil, fmt.Errorf("decode file data: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("file data required")
}
node, err := a.nodes.Create(nil, nodes.TypeFile, filename, 0, "", "")
if err != nil {
return nil, fmt.Errorf("create capture file node: %w", err)
}
stagingRel, stagingAbs, err := a.captureStagingDir(node)
if err != nil {
return nil, err
}
absPath := filepath.Join(stagingAbs, filename)
if err := os.WriteFile(absPath, data, 0o640); err != nil {
return nil, fmt.Errorf("write capture data: %w", err)
}
relPath := filepath.Join(stagingRel, filename)
fileRec := &files.Record{
ID: util.UUID7(),
NodeID: node.ID,
Filename: filename,
Path: relPath,
StorageMode: "vault",
Size: int64(len(data)),
MIME: mimeForFilename(filename),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if _, err := a.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,0)`,
fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode,
fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("insert capture file data: %w", err)
}
ctx := parseCaptureContext(contextJSON)
if err := a.setCaptureMeta(node.ID, captureKindForFilename(filename), source, ctx); err != nil {
return nil, err
}
_ = a.activity.Record("", activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, `{"capture":true}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
return a.inboxNodeDTO(node)
}
func (a *App) createCaptureNote(title, content, kind, source string, ctx CaptureContextDTO) (*InboxNodeDTO, error) {
node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
if err != nil {
return nil, fmt.Errorf("create node: %w", err)
}
inboxDir := filepath.Join(a.vault, ".verstak", "inbox")
if err := os.MkdirAll(inboxDir, 0o750); err != nil {
return nil, fmt.Errorf("create inbox dir: %w", err)
}
filename := node.ID + ".md"
absPath := filepath.Join(inboxDir, filename)
if err := os.WriteFile(absPath, []byte(content), 0o640); err != nil {
return nil, fmt.Errorf("write capture note: %w", err)
}
relPath, _ := filepath.Rel(a.vault, absPath)
now := time.Now().UTC()
fileRec := &files.Record{
ID: util.UUID7(),
NodeID: node.ID,
Filename: filename,
Path: relPath,
StorageMode: "vault",
Size: int64(len(content)),
MIME: "text/markdown",
CreatedAt: now,
UpdatedAt: now,
}
if _, err := a.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,0)`,
fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode,
fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("insert capture file: %w", err)
}
if _, err := a.db.Exec(`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown"); err != nil {
return nil, fmt.Errorf("insert capture note: %w", err)
}
if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil {
return nil, err
}
_ = a.activity.Record("", activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, `{"capture":true}`)
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content))
return a.inboxNodeDTO(node)
}
func (a *App) captureStagingDir(node *nodes.Node) (string, string, error) {
segment := node.ID + "_" + templates.SafeDisplayNameToPathSegment(node.Title)
rel := filepath.Join(".verstak", "inbox", segment)
abs := filepath.Join(a.vault, rel)
if err := os.MkdirAll(abs, 0o750); err != nil {
return "", "", fmt.Errorf("create inbox staging dir: %w", err)
}
return rel, abs, nil
}
func (a *App) setCaptureMeta(nodeID, kind, source string, ctx CaptureContextDTO) error {
if source == "" {
source = "clipboard"
}
ctx = normalizeCaptureContext(ctx)
if err := a.nodes.MetaSet(nodeID, "capture.inbox", "true"); err != nil {
return err
}
if err := a.nodes.MetaSet(nodeID, "capture.status", "unresolved"); err != nil {
return err
}
if err := a.nodes.MetaSet(nodeID, "capture.kind", kind); err != nil {
return err
}
if err := a.nodes.MetaSet(nodeID, "capture.source_kind", kind); err != nil {
return err
}
if err := a.nodes.MetaSet(nodeID, "capture.source", source); err != nil {
return err
}
if err := a.nodes.MetaSet(nodeID, "capture.context_type", ctx.ContextType); err != nil {
return err
}
if ctx.NodeID != "" {
if err := a.nodes.MetaSet(nodeID, "capture.context_node_id", ctx.NodeID); err != nil {
return err
}
}
if ctx.Section != "" {
if err := a.nodes.MetaSet(nodeID, "capture.context_section", ctx.Section); err != nil {
return err
}
}
if ctx.SuggestedTargetNodeID != "" {
if err := a.nodes.MetaSet(nodeID, "capture.suggested_target_node_id", ctx.SuggestedTargetNodeID); err != nil {
return err
}
}
return a.nodes.MetaSet(nodeID, "capture.created_at", time.Now().UTC().Format(time.RFC3339))
}
func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) {
dto := &InboxNodeDTO{NodeDTO: toNodeDTO(n)}
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.kind"); err == nil && ok {
dto.CaptureKind = kind
}
if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok {
dto.CaptureSource = source
}
if status, ok, err := a.nodes.MetaGet(n.ID, "capture.status"); err == nil && ok {
dto.CaptureStatus = status
}
if dto.CaptureStatus == "" {
dto.CaptureStatus = "unresolved"
}
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.source_kind"); err == nil && ok {
dto.SourceKind = kind
}
if dto.SourceKind == "" {
dto.SourceKind = dto.CaptureKind
}
if contextType, ok, err := a.nodes.MetaGet(n.ID, "capture.context_type"); err == nil && ok {
dto.CaptureContextType = contextType
}
if dto.CaptureContextType == "" {
dto.CaptureContextType = "global"
}
if nodeID, ok, err := a.nodes.MetaGet(n.ID, "capture.context_node_id"); err == nil && ok {
dto.CaptureContextNodeID = nodeID
dto.CaptureContextLabel = a.captureNodeLabel(nodeID)
}
if section, ok, err := a.nodes.MetaGet(n.ID, "capture.context_section"); err == nil && ok {
dto.CaptureContextSection = section
if dto.CaptureContextLabel == "" {
dto.CaptureContextLabel = section
}
}
if targetID, ok, err := a.nodes.MetaGet(n.ID, "capture.suggested_target_node_id"); err == nil && ok {
dto.SuggestedTargetNodeID = targetID
dto.SuggestedTargetLabel = a.captureNodeLabel(targetID)
}
if capturedAt, ok, err := a.nodes.MetaGet(n.ID, "capture.created_at"); err == nil && ok {
dto.CapturedAt = capturedAt
}
if rawURL, ok, err := a.nodes.MetaGet(n.ID, "capture.url"); err == nil && ok {
dto.URL = rawURL
}
if hostname, ok, err := a.nodes.MetaGet(n.ID, "capture.hostname"); err == nil && ok {
dto.Hostname = hostname
}
return dto, nil
}
func parseCaptureContext(contextJSON string) CaptureContextDTO {
var ctx CaptureContextDTO
if strings.TrimSpace(contextJSON) != "" {
_ = json.Unmarshal([]byte(contextJSON), &ctx)
}
return normalizeCaptureContext(ctx)
}
func normalizeCaptureContext(ctx CaptureContextDTO) CaptureContextDTO {
ctx.ContextType = strings.TrimSpace(ctx.ContextType)
ctx.NodeID = strings.TrimSpace(ctx.NodeID)
ctx.Section = strings.TrimSpace(ctx.Section)
ctx.SuggestedTargetNodeID = strings.TrimSpace(ctx.SuggestedTargetNodeID)
switch ctx.ContextType {
case "node":
if ctx.NodeID == "" {
ctx.ContextType = "global"
break
}
if ctx.SuggestedTargetNodeID == "" {
ctx.SuggestedTargetNodeID = ctx.NodeID
}
case "section":
if ctx.Section == "" {
ctx.Section = "root"
}
case "global":
default:
if ctx.NodeID != "" {
ctx.ContextType = "node"
if ctx.SuggestedTargetNodeID == "" {
ctx.SuggestedTargetNodeID = ctx.NodeID
}
} else if ctx.Section != "" {
ctx.ContextType = "section"
} else {
ctx.ContextType = "global"
}
}
return ctx
}
func (a *App) captureNodeLabel(nodeID string) string {
if nodeID == "" {
return ""
}
if p := a.nodes.Path(nodeID); p != "" {
return p
}
if n, err := a.nodes.GetActive(nodeID); err == nil {
return n.Title
}
return ""
}
func hostnameForURL(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
return u.Hostname()
}
func captureKindForFilename(filename string) string {
if strings.HasPrefix(mimeForFilename(filename), "image/") {
return "image"
}
return "file"
}
func mimeForFilename(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
}
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
if semi := strings.Index(mimeType, ";"); semi >= 0 {
return mimeType[:semi]
}
return mimeType
}
return "application/octet-stream"
}
func firstLineTitle(text, fallback string) string {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
if len(line) > 80 {
return line[:80]
}
return line
}
}
return fallback
}

View File

@ -0,0 +1,42 @@
package main
import (
"fmt"
"strings"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
func (a *App) ReadClipboardText() (string, error) {
text, err := wailsruntime.ClipboardGetText(a.ctx)
if err != nil {
return "", fmt.Errorf("clipboard text is unavailable")
}
return text, nil
}
func (a *App) CaptureClipboardTextWithContext(contextJSON string) (*InboxNodeDTO, error) {
text, err := a.ReadClipboardText()
if err != nil {
return nil, err
}
kind, value := classifyClipboardText(text)
if value == "" {
return nil, fmt.Errorf("clipboard is empty")
}
if kind == "url" {
return a.CaptureURLWithContext(value, "", "clipboard_button", contextJSON)
}
return a.CaptureTextWithContext(value, "clipboard_button", contextJSON)
}
func classifyClipboardText(text string) (string, string) {
value := strings.TrimSpace(text)
if value == "" {
return "text", ""
}
if normalized, ok := normalizeHTTPURL(value); ok {
return "url", normalized
}
return "text", value
}

View File

@ -0,0 +1,479 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/browser"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/vault"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
// StartupStatus describes the application startup state.
type StartupStatus struct {
Status string `json:"status"` // "first_run", "recovery", "ready"
VaultPath string `json:"vaultPath"` // configured or default vault path
VaultExists bool `json:"vaultExists"` // whether index.db exists at the path
DefaultPath string `json:"defaultPath"` // default vault path suggestion
Error string `json:"error,omitempty"`
AppConfig *config.AppConfig `json:"appConfig,omitempty"`
}
// GetStartupStatus checks the global config and vault state.
func (a *App) GetStartupStatus() (*StartupStatus, error) {
defaultPath, _ := config.DefaultVaultPath()
appCfg, err := config.LoadAppConfig()
if err != nil {
return &StartupStatus{
Status: "first_run",
DefaultPath: defaultPath,
Error: fmt.Sprintf("config load error: %v", err),
}, nil
}
// No config at all → first run
if appCfg == nil {
return &StartupStatus{
Status: "first_run",
DefaultPath: defaultPath,
}, nil
}
// Config says not completed → first run
if !appCfg.FirstRunCompleted {
return &StartupStatus{
Status: "first_run",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
}, nil
}
// Config has no vault path → first run
if appCfg.VaultPath == "" {
appCfg.VaultPath = defaultPath
_ = config.SaveAppConfig(appCfg)
}
// Check if vault exists
vaultExists := vaultExistsAt(appCfg.VaultPath)
if !vaultExists {
return &StartupStatus{
Status: "recovery",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
AppConfig: appCfg,
}, nil
}
// Initialize services so that the vault is ready for use
if err := a.initVault(appCfg.VaultPath); err != nil {
return &StartupStatus{
Status: "recovery",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
Error: fmt.Sprintf("init vault: %v", err),
}, nil
}
return &StartupStatus{
Status: "ready",
VaultPath: appCfg.VaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
func vaultExistsAt(vaultPath string) bool {
dbPath := filepath.Join(vaultPath, ".verstak", "index.db")
if _, err := os.Stat(dbPath); err != nil {
return false
}
return true
}
// CreateVault creates a new vault at the given path and initializes all services.
func (a *App) CreateVault(vaultPath string) (*StartupStatus, error) {
if vaultPath == "" {
return nil, fmt.Errorf("vault path is empty")
}
// Create vault directories and database
if err := vault.Init(vaultPath); err != nil {
return nil, fmt.Errorf("create vault: %w", err)
}
// Initialize services for this vault
if err := a.initVault(vaultPath); err != nil {
return nil, fmt.Errorf("init vault services: %w", err)
}
// Save global config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.VaultPath = vaultPath
appCfg.FirstRunCompleted = true
if err := config.SaveAppConfig(appCfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
log.Printf("[startup] vault created at %s", vaultPath)
return &StartupStatus{
Status: "ready",
VaultPath: vaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
// OpenVault opens an existing vault and initializes services.
func (a *App) OpenVault(vaultPath string) (*StartupStatus, error) {
if vaultPath == "" {
return nil, fmt.Errorf("vault path is empty")
}
if !vaultExistsAt(vaultPath) {
return nil, fmt.Errorf("vault not found at %s", vaultPath)
}
if err := a.initVault(vaultPath); err != nil {
return nil, fmt.Errorf("init vault: %w", err)
}
// Update config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.VaultPath = vaultPath
appCfg.FirstRunCompleted = true
if err := config.SaveAppConfig(appCfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
log.Printf("[startup] vault opened at %s", vaultPath)
return &StartupStatus{
Status: "ready",
VaultPath: vaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
// initVault opens the vault DB and initializes all core services.
func (a *App) initVault(vaultPath string) error {
// Close previous vault if any
a.closeVault()
abs, err := filepath.Abs(vaultPath)
if err != nil {
return err
}
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
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()
if err := templatesReg.LoadSystem(); err != nil {
log.Printf("warning: failed to load system templates: %v", err)
}
// Apply enabled templates from config
appCfg, _ := config.LoadAppConfig()
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
enabledSet := make(map[string]bool)
for _, id := range appCfg.EnabledTemplates {
enabledSet[id] = true
}
for _, t := range templatesReg.All() {
if !enabledSet[t.ID] {
_ = templatesReg.Disable(t.ID)
}
}
}
// Apply installed/enabled state from config to discovered plugins
pm.SyncConfig(appCfg)
// Sync service
deviceID := ""
_ = appCfg // will store sync settings
if appCfg != nil && appCfg.Vault.Sync.DeviceID != "" {
deviceID = appCfg.Vault.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
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
a.files = fileSvc
a.notes = noteSvc
a.activity = activitySvc
a.actions = actionSvc
a.worklog = worklogSvc
a.search = searchSvc
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
}
// closeVault shuts down current vault services if any.
func (a *App) closeVault() {
a.mu.Lock()
defer a.mu.Unlock()
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()
}
a.db = nil
a.nodes = nil
a.files = nil
a.notes = nil
a.activity = nil
a.actions = nil
a.worklog = nil
a.search = nil
a.plugins = nil
a.templates = nil
a.sync = nil
a.fileWatcher = nil
a.bridge = nil
a.browser = nil
a.vault = ""
a.vaultOpen = false
}
// IsReady returns true if a vault is open and services are initialized.
func (a *App) IsReady() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.vaultOpen
}
// GetAppConfig returns the current global app config.
func (a *App) GetAppConfig() (*config.AppConfig, error) {
cfg, err := config.LoadAppConfig()
if err != nil {
return config.DefaultAppConfig(), nil
}
if cfg == nil {
return config.DefaultAppConfig(), nil
}
return cfg, nil
}
// SaveAppConfig saves the global app config.
func (a *App) SaveAppConfig(cfg *config.AppConfig) error {
return config.SaveAppConfig(cfg)
}
// GetDefaultVaultPath returns the default vault path.
func (a *App) GetDefaultVaultPath() (string, error) {
return config.DefaultVaultPath()
}
// CheckVaultPath checks whether a given path is usable as a vault.
type CheckVaultPathResult struct {
Exists bool `json:"exists"`
HasVault bool `json:"hasVault"`
Writable bool `json:"writable"`
Description string `json:"description"`
}
func (a *App) CheckVaultPath(vaultPath string) (*CheckVaultPathResult, error) {
if vaultPath == "" {
return nil, fmt.Errorf("path is empty")
}
info, err := os.Stat(vaultPath)
exists := err == nil
hasVault := false
writable := false
if exists {
if info.IsDir() {
writable = checkDirWritable(vaultPath)
hasVault = vaultExistsAt(vaultPath)
}
} else {
// Path doesn't exist - check if parent is writable
parent := filepath.Dir(vaultPath)
parentInfo, pErr := os.Stat(parent)
if pErr == nil && parentInfo.IsDir() {
writable = checkDirWritable(parent)
}
}
desc := describeVaultPath(exists, hasVault)
return &CheckVaultPathResult{
Exists: exists,
HasVault: hasVault,
Writable: writable,
Description: desc,
}, nil
}
func checkDirWritable(dir string) bool {
testFile := filepath.Join(dir, ".verstak-write-test")
if err := os.WriteFile(testFile, []byte{}, 0o640); err != nil {
return false
}
os.Remove(testFile)
return true
}
func describeVaultPath(exists, hasVault bool) string {
if !exists {
return "Путь не существует. Будет создан новый vault."
}
if hasVault {
return "Найден существующий vault. Можно подключиться."
}
return "Папка существует, но vault не найден. Можно создать новый vault."
}
// VaultInfo returns information about the currently open vault.
type VaultInfo struct {
Path string `json:"path"`
DBPath string `json:"dbPath"`
FilesPath string `json:"filesPath"`
TrashPath string `json:"trashPath"`
Healthy bool `json:"healthy"`
NodeCount int `json:"nodeCount"`
FileCount int `json:"fileCount"`
}
func (a *App) GetVaultInfo() (*VaultInfo, error) {
if !a.IsReady() {
return nil, fmt.Errorf("vault not open")
}
a.mu.RLock()
vp := a.vault
nodesCount := 0
if a.nodes != nil {
roots, _ := a.nodes.ListRoots(true)
nodesCount = len(roots)
}
fileCount := 0
_ = a.db.QueryRow("SELECT COUNT(*) FROM files").Scan(&fileCount)
a.mu.RUnlock()
return &VaultInfo{
Path: vp,
DBPath: filepath.Join(vp, ".verstak", "index.db"),
FilesPath: filepath.Join(vp, "spaces"),
TrashPath: filepath.Join(vp, ".verstak", "trash"),
Healthy: true,
NodeCount: nodesCount,
FileCount: fileCount,
}, nil
}

View File

@ -0,0 +1,26 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
)
// WriteDebugLog appends a line to <vault>/.verstak/debug.log.
// Called from frontend to log JS-side diagnostics in production GUI builds.
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)
if err != nil {
return
}
defer f.Close()
f.WriteString(line)
}

View File

@ -0,0 +1,218 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
)
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
records, err := a.files.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]FileDTO, len(records))
for i := range records {
rec := &records[i]
result[i] = FileDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Name: rec.Filename,
Path: rec.Path,
Size: rec.Size,
Mime: rec.MIME,
IsDir: rec.MIME == "inode/directory",
Missing: rec.Missing,
}
}
return result, nil
}
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
result := make([]FileTreeItemDTO, 0, len(children))
for i := range children {
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: typ,
}
if typ == nodes.TypeFolder {
kids, _ := a.nodes.ListChildren(children[i].ID, false)
item.HasKids = len(kids) > 0
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)
}
return result, nil
}
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileDeleted
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
}
return a.files.DeleteNodeAndChildren(nodeID)
}
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, err := a.files.CreateEmptyFile(parentID, filename)
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, err := a.files.Duplicate(nodeID)
if err != nil {
return nil, err
}
n, err2 := a.nodes.GetActive(nodeID)
pid := ""
if err2 == nil && n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
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
}
return a.files.PreviewImport(sourcePath)
}

View File

@ -0,0 +1,231 @@
package main
import (
"fmt"
"time"
"verstak/internal/core/activity"
)
type InboxNodeDTO struct {
NodeDTO
CaptureKind string `json:"captureKind"`
CaptureSource string `json:"captureSource"`
CaptureStatus string `json:"captureStatus"`
CaptureContextType string `json:"captureContextType"`
CaptureContextNodeID string `json:"captureContextNodeId"`
CaptureContextSection string `json:"captureContextSection"`
SuggestedTargetNodeID string `json:"suggestedTargetNodeId"`
CaptureContextLabel string `json:"captureContextLabel"`
SuggestedTargetLabel string `json:"suggestedTargetLabel"`
CapturedAt string `json:"capturedAt"`
SourceKind string `json:"sourceKind"`
URL string `json:"url,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListInboxRoots(false)
if err != nil {
return nil, err
}
dtos := make([]InboxNodeDTO, 0, len(list))
for _, n := range list {
dto, err := a.inboxNodeDTO(&n)
if err != nil {
return nil, err
}
dtos = append(dtos, *dto)
}
for i := range dtos {
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
if err != nil {
return nil, err
}
dtos[i].HasChildren = n > 0
}
return dtos, nil
}
func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if nodeID == "" {
return []InboxNodeDTO{}, nil
}
list, err := a.ListInboxNodes()
if err != nil {
return nil, err
}
out := make([]InboxNodeDTO, 0, len(list))
for _, item := range list {
if item.CaptureContextNodeID == nodeID || item.SuggestedTargetNodeID == nodeID {
out = append(out, item)
}
}
return out, nil
}
func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
return a.ResolveInboxNode(nodeID, targetParentID)
}
func (a *App) ResolveInboxNodeHere(nodeID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
dto, err := a.inboxNodeByID(nodeID)
if err != nil {
return nil, err
}
if dto.SuggestedTargetNodeID == "" {
return nil, fmt.Errorf("suggested target is required")
}
return a.ResolveInboxNode(nodeID, dto.SuggestedTargetNodeID)
}
func (a *App) ResolveInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if !a.isInboxCaptureNode(nodeID) {
return nil, fmt.Errorf("node is not an inbox artifact")
}
if targetParentID == "" {
return nil, fmt.Errorf("target parent is required")
}
sourceKind := a.captureMeta(nodeID, "capture.source_kind")
if sourceKind == "" {
sourceKind = a.captureMeta(nodeID, "capture.kind")
}
if sourceKind == "url" {
if err := a.resolveURLInboxNode(nodeID, targetParentID); err != nil {
return nil, err
}
return a.GetNodeDetail(targetParentID)
}
if err := a.MoveNode(nodeID, targetParentID); err != nil {
return nil, err
}
if err := a.clearCaptureMeta(nodeID); err != nil {
return nil, err
}
dto, err := a.GetNodeDetail(nodeID)
if err != nil {
return nil, err
}
return dto, nil
}
func (a *App) inboxNodeByID(nodeID string) (*InboxNodeDTO, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
return a.inboxNodeDTO(n)
}
func (a *App) resolveURLInboxNode(nodeID, targetParentID string) error {
if _, err := a.nodes.GetActive(targetParentID); err != nil {
return fmt.Errorf("target parent not found: %w", err)
}
rawURL := a.captureMeta(nodeID, "capture.url")
if rawURL == "" {
return fmt.Errorf("captured url is missing")
}
title := a.captureMeta(nodeID, "capture.title")
if title == "" {
if n, err := a.nodes.GetActive(nodeID); err == nil {
title = n.Title
}
}
source := a.captureMeta(nodeID, "capture.source")
capturedAt := a.captureMeta(nodeID, "capture.created_at")
if _, err := a.createResolvedLink(targetParentID, rawURL, title, "", source, capturedAt); err != nil {
return err
}
if err := a.DeleteNode(nodeID); err != nil {
return err
}
return a.clearCaptureMeta(nodeID)
}
func (a *App) DeleteInboxNode(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
if !a.isInboxCaptureNode(nodeID) {
return fmt.Errorf("node is not an inbox artifact")
}
if err := a.DeleteNode(nodeID); err != nil {
return err
}
return a.clearCaptureMeta(nodeID)
}
func (a *App) filterInboxCaptureNodes(list []NodeDTO) []NodeDTO {
out := make([]NodeDTO, 0, len(list))
for _, item := range list {
if !a.isInboxCaptureNode(item.ID) {
out = append(out, item)
}
}
return out
}
func (a *App) isInboxCaptureNode(nodeID string) bool {
v, ok, err := a.nodes.MetaGet(nodeID, "capture.inbox")
if err != nil || !ok || v != "true" {
return false
}
status, ok, err := a.nodes.MetaGet(nodeID, "capture.status")
if err != nil {
return false
}
return !ok || status == "" || status == "unresolved"
}
func (a *App) clearCaptureMeta(nodeID string) error {
_, err := a.db.Exec(`DELETE FROM node_meta WHERE node_id = ? AND key LIKE 'capture.%'`, nodeID)
return err
}
// ListTodayCaptures returns inbox nodes captured today.
func (a *App) ListTodayCaptures() ([]InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
all, err := a.ListInboxNodes()
if err != nil {
return nil, err
}
start, end := activity.TodayBoundaries()
startTime, _ := time.Parse(time.RFC3339, start)
endTime, _ := time.Parse(time.RFC3339, end)
result := make([]InboxNodeDTO, 0, len(all))
for _, item := range all {
if item.CapturedAt == "" {
continue
}
t, err := time.Parse(time.RFC3339, item.CapturedAt)
if err != nil {
continue
}
if (t.Equal(startTime) || t.After(startTime)) && t.Before(endTime) {
result = append(result, item)
}
}
return result, nil
}
func (a *App) captureMeta(nodeID, key string) string {
v, ok, err := a.nodes.MetaGet(nodeID, key)
if err != nil || !ok {
return ""
}
return v
}

View File

@ -0,0 +1,210 @@
package main
import (
"fmt"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
"verstak/internal/core/util"
)
type LinkDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Title string `json:"title"`
URL string `json:"url"`
Hostname string `json:"hostname"`
Note string `json:"note"`
Source string `json:"source"`
CapturedAt string `json:"capturedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func (a *App) ListLinks(nodeID string) ([]LinkDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if nodeID == "" {
return []LinkDTO{}, nil
}
rows, err := a.db.Query(
`SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at
FROM links
WHERE node_id = ?
ORDER BY created_at DESC, title`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LinkDTO
for rows.Next() {
var l LinkDTO
if err := rows.Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (a *App) UpdateLink(id, title, rawURL, note string) (*LinkDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
id = strings.TrimSpace(id)
title = strings.TrimSpace(title)
rawURL = strings.TrimSpace(rawURL)
if id == "" {
return nil, fmt.Errorf("link id required")
}
if rawURL == "" {
return nil, fmt.Errorf("url required")
}
if title == "" {
title = linkTitle(rawURL, "")
}
updatedAt := time.Now().UTC().Format(time.RFC3339)
res, err := a.db.Exec(
`UPDATE links SET title=?, url=?, hostname=?, note=?, updated_at=? WHERE id=?`,
title, rawURL, hostnameForURL(rawURL), note, updatedAt, id)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("link not found")
}
return a.getLink(id)
}
func (a *App) DeleteLink(id string) error {
if err := a.requireVault(); err != nil {
return err
}
res, err := a.db.Exec(`DELETE FROM links WHERE id = ?`, strings.TrimSpace(id))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("link not found")
}
return nil
}
func (a *App) OpenLink(id string) error {
if err := a.requireVault(); err != nil {
return err
}
l, err := a.getLink(id)
if err != nil {
return err
}
return openExternalURL(l.URL)
}
func (a *App) OpenURL(rawURL string) error {
if err := a.requireVault(); err != nil {
return err
}
return openExternalURL(rawURL)
}
func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt string) (*LinkDTO, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("url required")
}
normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return nil, fmt.Errorf("invalid url")
}
rawURL = normalizedURL
title = linkTitle(rawURL, title)
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,
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)
}
func (a *App) getLink(id string) (*LinkDTO, error) {
var l LinkDTO
err := a.db.QueryRow(
`SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at
FROM links WHERE id = ?`, strings.TrimSpace(id)).
Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt)
if err != nil {
return nil, err
}
return &l, nil
}
func linkTitle(rawURL, title string) string {
title = strings.TrimSpace(title)
if title != "" {
return firstLineTitle(title, title)
}
if h := hostnameForURL(rawURL); h != "" {
return h
}
return rawURL
}
func isURLLike(text string) bool {
_, ok := normalizeHTTPURL(text)
return ok
}
func normalizeHTTPURL(text string) (string, bool) {
text = strings.TrimSpace(text)
if text == "" {
return "", false
}
if strings.ContainsAny(text, " \t\r\n") || strings.Contains(text, "@") {
return "", false
}
u, err := url.Parse(text)
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return text, true
}
if u != nil && u.Scheme != "" {
return "", false
}
withScheme := "https://" + text
u, err = url.Parse(withScheme)
if err != nil || u.Host == "" {
return "", false
}
host := u.Hostname()
if host == "" || !strings.Contains(host, ".") {
return "", false
}
return withScheme, true
}
func openExternalURL(rawURL string) error {
normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return fmt.Errorf("invalid url")
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", normalizedURL)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", normalizedURL)
default:
cmd = exec.Command("xdg-open", normalizedURL)
}
return cmd.Start()
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,142 @@
package main
import (
"time"
"verstak/internal/core/activity"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
)
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
// 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
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
}
node, fileRec, err := a.notes.Create(parentID, title, "")
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, ""))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) ReadNote(noteID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.notes.Read(noteID)
}
func (a *App) SaveNote(noteID, content string) error {
if err := a.requireVault(); err != nil {
return err
}
if err := a.notes.Save(noteID, content); err != nil {
return err
}
if n, err := a.nodes.GetActive(noteID); err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
"node_id": noteID,
"content": content,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
}
return nil
}
func (a *App) RenameNote(noteID, newTitle string) error {
if err := a.requireVault(); err != nil {
return err
}
return a.notes.Rename(noteID, newTitle)
}
func (a *App) DeleteNote(noteID string) error {
if err := a.requireVault(); err != nil {
return err
}
// Record activity and sync op before delete (need node info).
n, _ := a.nodes.GetActive(noteID)
pid := ""
if n != nil && n.ParentID != nil {
pid = *n.ParentID
}
title := ""
if n != nil {
title = n.Title
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteDeleted, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpDelete, nil)
return a.notes.Delete(noteID)
}

View File

@ -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)
}

View File

@ -0,0 +1,402 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"verstak/internal/core/config"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/i18n"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// ===== Template management =====
// AllTemplates returns all registered templates with their enabled status.
type TemplateWithStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
}
func (a *App) AllTemplates() ([]TemplateWithStatus, error) {
if !a.IsReady() || a.templates == nil {
return nil, fmt.Errorf("vault not ready")
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, id := range appCfg.EnabledTemplates {
enabledSet[id] = true
}
}
all := a.templates.All()
result := make([]TemplateWithStatus, len(all))
for i, t := range all {
// If config has explicit list, use it; otherwise default to true
enabled := true
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
enabled = enabledSet[t.ID]
}
result[i] = TemplateWithStatus{
ID: t.ID,
Title: t.Title,
Type: t.Type,
Icon: t.Icon,
Enabled: enabled,
}
}
return result, nil
}
func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
if !a.IsReady() || a.templates == nil {
return fmt.Errorf("vault not ready")
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
// Update enabled templates list
existing := make(map[string]bool)
for _, id := range appCfg.EnabledTemplates {
existing[id] = true
}
if enabled {
existing[templateID] = true
} else {
delete(existing, templateID)
}
appCfg.EnabledTemplates = make([]string, 0, len(existing))
for id := range existing {
appCfg.EnabledTemplates = append(appCfg.EnabledTemplates, id)
}
if err := config.SaveAppConfig(appCfg); err != nil {
return err
}
// Update in-memory registry
if enabled {
_ = a.templates.Enable(templateID)
} else {
_ = a.templates.Disable(templateID)
}
return nil
}
func (a *App) ListTemplates() []TemplateDTO {
if !a.IsReady() {
return nil
}
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
for _, t := range templates {
out = append(out, TemplateDTO{
ID: t.Name,
Title: t.Name,
Type: t.RootType,
Icon: t.Icon,
})
}
return out
}
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
var tmpl *plugins.TemplateDefinition
for _, t := range a.plugins.Templates() {
if t.Name == template {
tmpl = &t
break
}
}
if tmpl == nil {
return nil, nil
}
root, err := a.nodes.Create(strPtr(parentID), tmpl.RootType, title, 0, "", "")
if err != nil {
return nil, err
}
var createTree func(parentID string, nodes []plugins.TreeNode) error
createTree = func(parentID string, nodes []plugins.TreeNode) error {
for _, tn := range nodes {
child, err := a.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
if err != nil {
return err
}
if len(tn.Children) > 0 {
if err := createTree(child.ID, tn.Children); err != nil {
return err
}
}
}
return nil
}
if err := createTree(root.ID, tmpl.Tree); err != nil {
return nil, err
}
dto := toNodeDTO(root)
return &dto, nil
}
// ===== File picking =====
func (a *App) PickFile() (string, error) {
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: i18n.TF("ru", "file.pickSingle"),
})
}
func (a *App) PickFiles() ([]string, error) {
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: i18n.TF("ru", "file.pickMultiple"),
})
}
func (a *App) PickDirectory() (string, error) {
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: i18n.TF("ru", "file.pickDirectory"),
})
}
func (a *App) OpenFile(fileID string) error {
if err := a.requireVault(); err != nil {
return err
}
return a.files.Open(fileID)
}
func (a *App) ReadFileText(fileID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.files.ReadText(fileID)
}
func (a *App) GetFileBase64(fileID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.files.ReadBase64(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
var fileRecordPath string
if n.Type == nodes.TypeFile && n.FsPath == "" {
records, _ := a.files.ListByNode(nodeID)
if len(records) > 0 {
fileRecordPath = records[0].Path
}
}
target := resolveOpenFolderTarget(a.vault, n, fileRecordPath)
if _, err := os.Stat(target); os.IsNotExist(err) {
target = a.vault
}
cmd := exec.Command("xdg-open", target)
return cmd.Run()
}
func resolveOpenFolderTarget(vault string, n *nodes.Node, fileRecordPath string) string {
if n.Type == nodes.TypeFile && n.FsPath == "" {
if fileRecordPath == "" {
return vault
}
return filepath.Dir(filepath.Join(vault, fileRecordPath))
}
target := filepath.Join(vault, n.FsPath)
if n.Type == nodes.TypeFile {
return filepath.Dir(target)
}
return target
}
func (a *App) OpenVaultFolder() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
cmd := exec.Command("xdg-open", a.vault)
return cmd.Run()
}
func ensurePluginsFolder(vaultPath string) (string, error) {
path := filepath.Join(vaultPath, ".verstak", "plugins")
if err := os.MkdirAll(path, 0o750); err != nil {
return "", err
}
return path, nil
}
func (a *App) OpenPluginsFolder() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
target, err := ensurePluginsFolder(a.vault)
if err != nil {
return err
}
cmd := exec.Command("xdg-open", target)
return cmd.Run()
}
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
}
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"
}

View File

@ -0,0 +1,371 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"verstak/internal/core/activity"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
// GetSuggestions analyzes today's activity and returns conservative suggestions.
// Only events not already linked in worklog_entry_events are considered.
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListTodayEvents()
if err != nil || len(events) == 0 {
return nil, err
}
// Determine which event IDs are already accounted for in any worklog entry.
accounted := make(map[string]bool)
rows, err := a.db.Query(`SELECT DISTINCT event_id FROM worklog_entry_events`)
if err == nil {
defer rows.Close()
for rows.Next() {
var eid string
if rows.Scan(&eid) == nil {
accounted[eid] = true
}
}
}
rows, err = a.db.Query(`SELECT DISTINCT event_id FROM worklog_dismissed_events`)
if err == nil {
defer rows.Close()
for rows.Next() {
var eid string
if rows.Scan(&eid) == nil {
accounted[eid] = true
}
}
}
type acc struct {
title string
kind string
events []activity.Event
}
grouped := make(map[string]*acc)
for _, e := range events {
if accounted[e.ID] {
continue
}
grp, ok := grouped[e.NodeID]
if !ok {
n, err := a.nodes.GetActive(e.NodeID)
title := ""
kind := ""
if err == nil && n != nil {
title = n.Title
kind = n.Type
}
grp = &acc{title: title, kind: kind}
grouped[e.NodeID] = grp
}
grp.events = append(grp.events, e)
}
var suggestions []activity.Suggestion
for nodeID, grp := range grouped {
if grp.title == "" || len(grp.events) == 0 {
continue
}
notes, files, actions, other := countByType(grp.events)
summary := buildSuggestionSummary(notes, files, actions, other)
if summary == "" {
continue
}
spread := timeSpread(grp.events)
bursts := countBursts(grp.events, 10)
min := estimateMinutes(bursts, spread, len(grp.events))
conf, reason := confidence(bursts, spread, len(grp.events))
eventIDs := make([]string, 0, len(grp.events))
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
for _, e := range grp.events {
eventIDs = append(eventIDs, e.ID)
evDetails = append(evDetails, activity.SuggestionDetail{
ID: e.ID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
Title: e.Title,
CreatedAt: e.CreatedAt,
NodeID: e.NodeID,
NodePath: a.nodes.Path(e.NodeID),
})
}
suggestions = append(suggestions, activity.Suggestion{
NodeID: nodeID,
NodeTitle: grp.title,
Summary: summary,
SuggestedMin: min,
EventCount: len(grp.events),
NodeKind: grp.kind,
Confidence: conf,
ConfidenceReason: reason,
TimeSpreadMin: spread,
EventIDs: eventIDs,
Events: evDetails,
})
}
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].EventCount > suggestions[j].EventCount
})
return suggestions, nil
}
func (a *App) DismissSuggestion(nodeID, eventIDsJSON string) error {
if err := a.requireVault(); err != nil {
return err
}
var eventIDs []string
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
return fmt.Errorf("unmarshal eventIDs: %w", err)
}
if len(eventIDs) == 0 {
return fmt.Errorf("eventIDs required")
}
now := time.Now().UTC().Format(time.RFC3339)
tx, err := a.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, eventID := range eventIDs {
var n int
if err := tx.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, nodeID).Scan(&n); err != nil {
return fmt.Errorf("check event %s: %w", eventID, err)
}
if n == 0 {
return fmt.Errorf("event %s not found for node", eventID)
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO worklog_dismissed_events(event_id,node_id,created_at) VALUES(?,?,?)`, eventID, nodeID, now); err != nil {
return err
}
}
return tx.Commit()
}
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDsJSON)
}
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
return a.AcceptSuggestionFull(nodeID, summary, "", date, minutes, true, false, eventIDsJSON)
}
// AcceptSuggestionFull creates a worklog entry from an edited suggestion and links events in a single transaction.
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionFull(nodeID, summary, details, date string, minutes int, approximate, billable bool, eventIDsJSON string) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
d := date
if d == "" {
d = time.Now().Format("2006-01-02")
}
var eventIDs []string
if eventIDsJSON != "" {
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
return nil, fmt.Errorf("unmarshal eventIDs: %w", err)
}
}
// Validate that every eventID actually exists in activity_events.
for _, eid := range eventIDs {
var n int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eid).Scan(&n); err != nil {
return nil, fmt.Errorf("check event %s: %w", eid, err)
}
if n == 0 {
return nil, fmt.Errorf("event %s not found in activity_events", eid)
}
}
// Use a transaction to atomically create entry + link events
tx, err := a.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, details, d, minutes, approximate, billable, worklog.SourceSuggestion)
if err != nil {
return nil, fmt.Errorf("create entry: %w", err)
}
for _, eid := range eventIDs {
if _, err := tx.Exec(
`INSERT INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
entry.ID, eid); err != nil {
return nil, fmt.Errorf("link event %s: %w", eid, err)
}
}
if len(eventIDs) > 0 {
var linked int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events wle
JOIN activity_events ae ON ae.id = wle.event_id
WHERE wle.entry_id = ?`, entry.ID).Scan(&linked); err != nil {
return nil, fmt.Errorf("verify links: %w", err)
}
if linked != len(eventIDs) {
return nil, fmt.Errorf("expected %d linked events, got %d", len(eventIDs), linked)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit tx: %w", err)
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
return entryToDTO(entry), nil
}
// HideSuggestion marks a suggestion as hidden for the session.
// The frontend tracks visibility; this is a no-op on the backend.
func (a *App) HideSuggestion(_ activity.Suggestion) error {
return nil
}
// --- event analysis ---
func countByType(events []activity.Event) (notes, files, actions, other int) {
for _, e := range events {
switch e.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated, activity.TypeNoteDeleted:
notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed,
activity.TypeFileCopied, activity.TypeFileMoved,
activity.TypeFolderAdded, activity.TypeFolderDeleted, activity.TypeFolderRenamed:
files++
case activity.TypeActionCreated, activity.TypeActionDone:
actions++
default:
other++
}
}
return
}
// timeSpread returns minutes between first and last event.
func timeSpread(events []activity.Event) int {
if len(events) < 2 {
return 0
}
minTime := events[0].CreatedAt
maxTime := events[0].CreatedAt
for _, e := range events {
if e.CreatedAt < minTime {
minTime = e.CreatedAt
}
if e.CreatedAt > maxTime {
maxTime = e.CreatedAt
}
}
t1, err1 := time.Parse(time.RFC3339, minTime)
t2, err2 := time.Parse(time.RFC3339, maxTime)
if err1 != nil || err2 != nil {
return 0
}
diff := t2.Sub(t1)
return int(diff.Minutes())
}
// countBursts groups events into bursts where consecutive events
// are within `windowMin` minutes of each other.
func countBursts(events []activity.Event, windowMin int) int {
if len(events) == 0 {
return 0
}
times := make([]time.Time, 0, len(events))
for _, e := range events {
t, err := time.Parse(time.RFC3339, e.CreatedAt)
if err != nil {
continue
}
times = append(times, t)
}
if len(times) == 0 {
return 1
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
bursts := 1
last := times[0]
for _, t := range times[1:] {
if t.Sub(last) > time.Duration(windowMin)*time.Minute {
bursts++
}
last = t
}
return bursts
}
// estimateMinutes conservatively estimates suggested minutes.
func estimateMinutes(bursts, spread, totalEvents int) int {
if totalEvents <= 1 {
return 5
}
switch {
case spread >= 60 && bursts >= 3 && totalEvents >= 8:
return 30
case spread >= 30 && bursts >= 2 && totalEvents >= 5:
return 20
case spread >= 15 && bursts >= 2 && totalEvents >= 3:
return 15
case totalEvents >= 3:
return 10
default:
return 5
}
}
// confidence returns a label and reason string for the estimate.
func confidence(bursts, spread, totalEvents int) (string, string) {
if spread >= 60 && totalEvents >= 10 {
return activity.ConfidenceHigh, fmt.Sprintf("активность растянута на %d минут, %d всплесков", spread, bursts)
}
if spread >= 30 && totalEvents >= 5 && bursts >= 2 {
return activity.ConfidenceMedium, fmt.Sprintf("несколько всплесков активности за %d минут", spread)
}
return activity.ConfidenceLow, fmt.Sprintf("%d событий за %d минут, %d всплесков", totalEvents, spread, bursts)
}
func buildSuggestionSummary(notes, files, actions, other int) string {
var parts []string
if notes > 0 {
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
}
if files > 0 {
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
}
if actions > 0 {
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
}
if other > 0 {
parts = append(parts, fmt.Sprintf("события (%d)", other))
}
return strings.Join(parts, ", ")
}

View File

@ -0,0 +1,370 @@
package main
import (
"fmt"
"log"
"os"
"time"
"verstak/internal/core/config"
syncsvc "verstak/internal/core/sync"
)
type SyncStatusDTO struct {
Configured bool `json:"configured"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
Connected bool `json:"connected"`
Revoked bool `json:"revoked"`
TokenStored bool `json:"tokenStored"`
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"`
LastError string `json:"lastError"`
StatusLabel string `json:"statusLabel"` // human-readable status
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
if !a.IsReady() {
return &SyncStatusDTO{}, nil
}
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
if err != nil {
return &SyncStatusDTO{}, nil
}
appCfg, _ := config.LoadAppConfig()
deviceToken := config.LoadDeviceToken(a.vault)
dto := &SyncStatusDTO{
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
ServerURL: serverURL,
LastSyncAt: lastSyncAt,
UnpushedOps: 0,
TokenStored: deviceToken != "",
}
if appCfg != nil {
dto.DeviceID = appCfg.Vault.Sync.DeviceID
dto.SyncInterval = appCfg.Vault.Sync.SyncInterval
dto.LastError = appCfg.Vault.Sync.LastError
}
unpushed, _ := a.sync.GetUnpushedOps()
dto.UnpushedOps = len(unpushed)
if deviceToken != "" {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
client.DeviceToken = deviceToken
if appCfg != nil {
client.DeviceID = appCfg.Vault.Sync.DeviceID
}
if info, err := client.GetMe(); err == nil {
dto.DeviceName = info.DeviceName
dto.DeviceID = info.DeviceID
dto.Connected = true
if info.RevokedAt != "" {
dto.Revoked = true
dto.Connected = false
}
}
}
// Build status label
switch {
case dto.Revoked:
dto.StatusLabel = "revoked"
case dto.Connected:
dto.StatusLabel = "connected"
case dto.Configured:
dto.StatusLabel = "disconnected"
default:
dto.StatusLabel = "disabled"
}
// Update config with latest status
if appCfg != nil {
changed := false
if dto.LastSyncAt != "" && appCfg.Vault.Sync.LastSyncAt != dto.LastSyncAt {
appCfg.Vault.Sync.LastSyncAt = dto.LastSyncAt
changed = true
}
if appCfg.Vault.Sync.LastStatus != dto.StatusLabel {
appCfg.Vault.Sync.LastStatus = dto.StatusLabel
changed = true
}
if changed {
_ = config.SaveAppConfig(appCfg)
}
}
return dto, nil
}
type SyncSettingsDTO struct {
Enabled bool `json:"enabled"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
SyncInterval int `json:"syncInterval"`
LastStatus string `json:"lastStatus"`
LastSyncAt string `json:"lastSyncAt"`
LastError string `json:"lastError"`
TokenStored bool `json:"tokenStored"`
}
func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
deviceToken := config.LoadDeviceToken(a.vault)
return &SyncSettingsDTO{
Enabled: appCfg.Vault.Sync.Enabled,
ServerURL: appCfg.Vault.Sync.ServerURL,
DeviceID: appCfg.Vault.Sync.DeviceID,
DeviceName: appCfg.Vault.Sync.DeviceName,
SyncInterval: appCfg.Vault.Sync.SyncInterval,
LastStatus: appCfg.Vault.Sync.LastStatus,
LastSyncAt: appCfg.Vault.Sync.LastSyncAt,
LastError: appCfg.Vault.Sync.LastError,
TokenStored: deviceToken != "",
}, nil
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
if err := a.requireVault(); err != nil {
return err
}
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
if err != nil {
return fmt.Errorf("pair: %w", err)
}
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
return fmt.Errorf("save token: %w", err)
}
if err := a.sync.SetState(serverURL, ""); err != nil {
return err
}
// Update global config
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.Vault.Sync.Enabled = true
appCfg.Vault.Sync.ServerURL = serverURL
appCfg.Vault.Sync.DeviceID = deviceID
appCfg.Vault.Sync.DeviceName = hostname
appCfg.Vault.Sync.LastStatus = "connected"
_ = config.SaveAppConfig(appCfg)
return nil
}
func (a *App) SyncDisconnect() error {
if err := a.requireVault(); err != nil {
return err
}
deviceToken := config.LoadDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
if deviceToken != "" {
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", "", a.vault)
client.DeviceToken = deviceToken
_ = client.RevokeCurrent()
}
config.RemoveDeviceToken(a.vault)
appCfg.Vault.Sync.Enabled = false
appCfg.Vault.Sync.ServerURL = ""
appCfg.Vault.Sync.DeviceID = ""
appCfg.Vault.Sync.DeviceName = ""
appCfg.Vault.Sync.LastStatus = "disabled"
appCfg.Vault.Sync.LastError = ""
if err := config.SaveAppConfig(appCfg); err != nil {
return err
}
return a.sync.SetState("", "")
}
func (a *App) SyncTestConnection(serverURL, username, password string) error {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
return client.TestAuth(serverURL, username, password)
}
func (a *App) SyncSetInterval(minutes int) error {
if err := a.requireVault(); err != nil {
return err
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.Vault.Sync.SyncInterval = minutes
if appCfg.Vault.Sync.DeviceID == "" && a.sync != nil {
appCfg.Vault.Sync.DeviceID = a.sync.GetDeviceID()
}
return config.SaveAppConfig(appCfg)
}
func (a *App) SyncNow() (map[string]interface{}, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
deviceToken := config.LoadDeviceToken(a.vault)
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
return nil, fmt.Errorf("sync not configured")
}
deviceID := ""
appCfg, _ := config.LoadAppConfig()
if appCfg != nil {
deviceID = appCfg.Vault.Sync.DeviceID
}
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
client.DeviceToken = deviceToken
unpushed, err := a.sync.GetUnpushedOps()
if err != nil {
return nil, fmt.Errorf("get ops: %w", err)
}
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastPullSeq
}
pushResult := &syncsvc.PushResponse{}
if len(unpushed) > 0 {
pushResult, err = client.Push(unpushed)
if err != nil {
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
return nil, fmt.Errorf("push: %w", err)
}
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
return nil, fmt.Errorf("mark pushed: %w", err)
}
}
pullResult, err := client.Pull(lastPullSeq)
if err != nil {
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
return nil, fmt.Errorf("pull: %w", err)
}
var applyErrors []string
for _, op := range pullResult.Ops {
if err := a.applyRemoteOp(op); err != nil {
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
}
_ = a.sync.RecordRemoteOp(op)
}
if len(pullResult.Ops) > 0 {
opIDs := make([]string, len(pullResult.Ops))
for i, op := range pullResult.Ops {
opIDs[i] = op.OpID
}
_ = a.sync.MarkApplied(opIDs)
}
if len(pushResult.Conflicts) > 0 {
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
for _, c := range pushResult.Conflicts {
log.Printf("[sync] conflict: op=%v entity=%v/%v",
c["op_id"], c["entity_type"], c["entity_id"])
}
}
if pullResult.ServerSequence > lastPullSeq {
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
}
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
// Update config with success
now := time.Now().UTC().Format(time.RFC3339)
a.updateSyncSuccess(now)
result := map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
"serverSequence": pullResult.ServerSequence,
}
if len(applyErrors) > 0 {
result["applyErrors"] = applyErrors
}
if len(pushResult.Conflicts) > 0 {
result["conflicts"] = pushResult.Conflicts
}
return result, nil
}
func (a *App) updateSyncError(errMsg string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastError = errMsg
appCfg.Vault.Sync.LastStatus = "error"
return config.SaveAppConfig(appCfg)
}
func (a *App) updateSyncSuccess(lastSyncAt string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastError = ""
appCfg.Vault.Sync.LastStatus = "connected"
appCfg.Vault.Sync.LastSyncAt = lastSyncAt
return config.SaveAppConfig(appCfg)
}
// CheckSyncConnection tests the current sync connection.
func (a *App) CheckSyncConnection() (bool, string) {
if !a.IsReady() {
return false, "vault not open"
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil || !appCfg.Vault.Sync.Enabled {
return false, "sync not configured"
}
deviceToken := config.LoadDeviceToken(a.vault)
if deviceToken == "" {
return false, "no device token"
}
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", appCfg.Vault.Sync.DeviceID, a.vault)
client.DeviceToken = deviceToken
info, err := client.GetMe()
if err != nil {
return false, err.Error()
}
if info.RevokedAt != "" {
return false, "device revoked"
}
return true, ""
}
// ResetSyncKey clears the device token and resets sync state.
func (a *App) ResetSyncKey() error {
if err := a.requireVault(); err != nil {
return err
}
config.RemoveDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil
}
appCfg.Vault.Sync.LastStatus = "disabled"
appCfg.Vault.Sync.LastError = ""
return config.SaveAppConfig(appCfg)
}

View File

@ -0,0 +1,510 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"verstak/internal/core/nodes"
)
type TrashDTO struct {
TrashPath string `json:"trashPath"`
Count int `json:"count"`
Nodes []TrashNodeDTO `json:"nodes"`
Entries []TrashEntryDTO `json:"entries"`
}
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"`
TrashFsPath string `json:"trashFsPath"`
DeletedAt string `json:"deletedAt"`
}
type TrashEntryDTO struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
func (a *App) ListTrash() (*TrashDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
deleted, err := a.nodes.ListDeleted()
if err != nil {
return nil, err
}
// 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)
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
dto := &TrashNodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
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)
if err != nil {
return nil, err
}
return &TrashDTO{TrashPath: trashPath, Count: len(nodes), Nodes: nodes, Entries: entries}, nil
}
func (a *App) TrashCount() (int, error) {
trash, err := a.ListTrash()
if err != nil {
return 0, err
}
return trash.Count, nil
}
func (a *App) RestoreTrashNode(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
chain, err := a.deletedAncestorChain(nodeID)
if err != nil {
return err
}
for _, n := range chain {
if err := a.restoreTrashPath(n.ID, n.FsPath); err != nil {
return err
}
if _, err := a.db.Exec(`UPDATE nodes SET deleted_at = NULL, updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), n.ID); err != nil {
return err
}
}
return nil
}
func (a *App) RestoreTrashNodesJSON(nodeIDsJSON string) error {
var ids []string
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
return err
}
for _, id := range ids {
if err := a.RestoreTrashNode(id); err != nil {
return err
}
}
return nil
}
func (a *App) PurgeTrashNodesJSON(nodeIDsJSON string) error {
if err := a.requireVault(); err != nil {
return err
}
var ids []string
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
return err
}
for _, id := range ids {
if err := a.purgeTrashNode(id); err != nil {
return err
}
}
return nil
}
func (a *App) EmptyTrash() error {
if err := a.requireVault(); err != nil {
return err
}
trash, err := a.ListTrash()
if err != nil {
return err
}
ids := make([]string, 0, len(trash.Nodes))
for _, n := range trash.Nodes {
if n.ParentID == "" {
ids = append(ids, n.ID)
}
}
if len(ids) == 0 {
for _, n := range trash.Nodes {
ids = append(ids, n.ID)
}
}
for _, id := range ids {
if err := a.purgeTrashNode(id); err != nil {
return err
}
}
return os.RemoveAll(filepath.Join(a.vault, ".verstak", "trash"))
}
func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
var reversed []TrashNodeDTO
current := nodeID
for current != "" {
n, err := a.nodes.Get(current)
if err != nil {
return nil, err
}
if n.DeletedAt == nil {
break
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
reversed = append(reversed, TrashNodeDTO{ID: n.ID, ParentID: parentID, Title: n.Title, Type: n.Type, FsPath: n.FsPath})
current = parentID
}
if len(reversed) == 0 {
return nil, fmt.Errorf("deleted node not found")
}
chain := make([]TrashNodeDTO, 0, len(reversed))
for i := len(reversed) - 1; i >= 0; i-- {
chain = append(chain, reversed[i])
}
return chain, nil
}
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 // parent may have already been restored
}
dst := filepath.Join(a.vault, fsPath)
if _, err := os.Stat(dst); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
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)
if err != nil {
return "", err
}
prefix := nodeID + "_"
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
return filepath.Join(trashPath, entry.Name()), nil
}
}
return "", fmt.Errorf("trash entry not found")
}
func (a *App) purgeTrashNode(nodeID string) error {
ids, err := a.deletedSubtreeIDs(nodeID)
if err != nil {
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 {
return err
}
defer tx.Rollback()
for i := len(ids) - 1; i >= 0; i-- {
id := ids[i]
_, _ = tx.Exec(`DELETE FROM node_meta WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM notes WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM actions WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM links WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM worklog_entry_events WHERE entry_id IN (SELECT id FROM worklog_entries WHERE node_id = ?)`, id)
_, _ = tx.Exec(`DELETE FROM worklog_entries WHERE node_id = ?`, id)
if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil {
return err
}
}
return tx.Commit()
}
func (a *App) deletedSubtreeIDs(nodeID string) ([]string, error) {
rows, err := a.db.Query(
`WITH RECURSIVE subtree(id) AS (
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NOT NULL
UNION ALL
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
WHERE n.deleted_at IS NOT NULL
) SELECT id FROM subtree`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, fmt.Errorf("deleted node not found")
}
return ids, rows.Err()
}
func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return nil, err
}
dirEntries, err := os.ReadDir(trashPath)
if err != nil {
return nil, err
}
out := make([]TrashEntryDTO, 0, len(dirEntries))
for _, entry := range dirEntries {
info, err := entry.Info()
if err != nil {
continue
}
out = append(out, TrashEntryDTO{
Name: entry.Name(),
Path: filepath.Join(trashPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
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
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return err
}
return exec.Command("xdg-open", trashPath).Run()
}

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
}

View File

@ -0,0 +1,287 @@
package main
import (
"fmt"
"os"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
rows, err := a.worklog.ListReport(worklog.ReportFilter{NodeID: nodeID, IncludeChildren: true})
if err != nil {
return nil, err
}
result := make([]WorklogDTO, 0, len(rows))
for _, row := range rows {
result = append(result, reportRowToWorklogDTO(row))
}
return result, nil
}
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
return a.CreateWorklogFull(nodeID, summary, "", "", minutes, false, false)
}
func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if date == "" {
entry, err := a.worklog.Add(nodeID, summary, details, minutes, approximate, billable)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
return entryToDTO(entry), nil
}
entry, err := a.worklog.AddWithDate(nodeID, summary, details, date, minutes, approximate, billable)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
return entryToDTO(entry), nil
}
func (a *App) UpdateWorklogEntry(id, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if err := a.worklog.UpdateWithDate(id, summary, details, date, minutes, approximate, billable); err != nil {
return nil, err
}
entry, err := a.worklog.Get(id)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpUpdate, worklogPayload(entry))
return entryToDTO(entry), nil
}
func (a *App) DeleteWorklogEntry(id string) error {
if err := a.requireVault(); err != nil {
return err
}
if err := a.worklog.Delete(id); err != nil {
return err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, id, syncsvc.OpDelete, nil)
return nil
}
// --- report bindings ---
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
rows, err := a.worklog.ListReport(f)
if err != nil {
return nil, err
}
a.worklog.BuildReportPaths(rows)
return rows, nil
}
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (*worklog.ReportSummary, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.Summary(f)
}
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportCSV(f)
}
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportMarkdown(f)
}
func (a *App) ExportWorklogPDF(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]byte, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportPDF(f)
}
func boolPtr(s string) *bool {
switch s {
case "yes":
v := true
return &v
case "no":
v := false
return &v
default:
return nil
}
}
func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) worklog.ReportFilter {
return worklog.ReportFilter{
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
Billable: boolPtr(billableFilter),
Approximate: boolPtr(approxFilter),
}
}
// SaveWorklogReport generates a worklog report and opens a SaveFileDialog.
func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
var data []byte
var ext string
switch format {
case "csv":
s, err := a.worklog.ExportCSV(f)
if err != nil {
return "", err
}
data = []byte(s)
ext = ".csv"
case "markdown":
s, err := a.worklog.ExportMarkdown(f)
if err != nil {
return "", err
}
data = []byte(s)
ext = ".md"
case "pdf":
var err error
data, err = a.worklog.ExportPDF(f)
if err != nil {
return "", err
}
ext = ".pdf"
default:
return "", fmt.Errorf("unknown format: %s", format)
}
from := dateFrom
if from == "" {
from = "all"
}
to := dateTo
if to == "" {
to = "all"
}
defaultName := fmt.Sprintf("verstak-worklog-%s--%s%s", from, to, ext)
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
DefaultFilename: defaultName,
Filters: []wailsruntime.FileFilter{
{DisplayName: format, Pattern: "*" + ext},
},
})
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("отменено пользователем")
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("не удалось сохранить файл: %w", err)
}
return fmt.Sprintf("Отчёт сохранён: %s", path), nil
}
// GetWorklogEntryEvents returns activity events linked to a worklog entry.
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
rows, err := a.db.Query(
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
e.title, COALESCE(e.metadata,''), e.created_at
FROM activity_events e
JOIN worklog_entry_events wle ON wle.event_id = e.id
WHERE wle.entry_id = ?
ORDER BY e.created_at ASC`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EventDTO
for rows.Next() {
var d EventDTO
if err := rows.Scan(&d.ID, &d.NodeID, &d.EventType, &d.TargetType,
&d.TargetID, &d.TargetPath, &d.Title, &d.DetailsJSON, &d.CreatedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// --- helpers ---
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
result := make([]WorklogDTO, len(list))
for i := range list {
result[i] = *entryToDTO(&list[i])
}
return result
}
func entryToDTO(e *worklog.Entry) *WorklogDTO {
mins := 0
if e.Minutes != nil {
mins = *e.Minutes
}
return &WorklogDTO{
ID: e.ID,
NodeID: e.NodeID,
Summary: e.Summary,
Minutes: mins,
Date: e.Date,
Details: e.Details,
Approximate: e.Approximate,
Billable: e.Billable,
Source: e.Source,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func reportRowToWorklogDTO(r worklog.ReportRow) WorklogDTO {
return WorklogDTO{
ID: r.ID,
NodeID: r.NodeID,
NodeTitle: r.NodeTitle,
NodePath: r.NodePath,
Summary: r.Summary,
Minutes: r.Minutes,
Date: r.Date,
Details: r.Details,
Approximate: r.Approximate,
Billable: r.Billable,
Source: r.Source,
CreatedAt: r.CreatedAt,
}
}

View File

@ -0,0 +1,303 @@
package main
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCaptureTextCreatesInboxArtifact(t *testing.T) {
app, _ := setupTestApp(t)
dto, err := app.CaptureText("Нужно разобрать этот текст")
if err != nil {
t.Fatalf("CaptureText: %v", err)
}
if dto.ID == "" {
t.Fatal("empty captured node id")
}
if dto.CaptureKind != "text" {
t.Fatalf("CaptureKind = %q, want text", dto.CaptureKind)
}
if dto.CaptureSource != "clipboard" {
t.Fatalf("CaptureSource = %q, want clipboard", dto.CaptureSource)
}
content, err := app.ReadNote(dto.ID)
if err != nil {
t.Fatalf("ReadNote: %v", err)
}
if !strings.Contains(content, "Нужно разобрать этот текст") {
t.Fatalf("captured content missing: %q", content)
}
var path string
if err := app.db.QueryRow(`SELECT f.path FROM notes n JOIN files f ON f.id = n.file_id WHERE n.node_id = ?`, dto.ID).Scan(&path); err != nil {
t.Fatalf("query note file path: %v", err)
}
if !strings.HasPrefix(path, ".verstak/inbox/") {
t.Fatalf("path = %q, want .verstak/inbox prefix", path)
}
inbox, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
var found bool
for _, item := range inbox {
if item.ID == dto.ID {
found = true
}
}
if !found {
t.Fatal("captured text missing from inbox")
}
}
func TestCaptureURLCreatesInboxArtifact(t *testing.T) {
app, _ := setupTestApp(t)
dto, err := app.CaptureURL("https://example.test/page", "Example Page")
if err != nil {
t.Fatalf("CaptureURL: %v", err)
}
if dto.CaptureKind != "url" {
t.Fatalf("CaptureKind = %q, want url", dto.CaptureKind)
}
if dto.Title != "Example Page" {
t.Fatalf("Title = %q, want Example Page", dto.Title)
}
if dto.Type != "link" {
t.Fatalf("Type = %q, want link", dto.Type)
}
if dto.SourceKind != "url" {
t.Fatalf("SourceKind = %q, want url", dto.SourceKind)
}
if dto.URL != "https://example.test/page" {
t.Fatalf("URL = %q, want captured URL", dto.URL)
}
if dto.Hostname != "example.test" {
t.Fatalf("Hostname = %q, want example.test", dto.Hostname)
}
}
func TestCapturePathCopiesFileIntoInbox(t *testing.T) {
app, vaultRoot := setupTestApp(t)
sourceDir := t.TempDir()
source := filepath.Join(sourceDir, "brief.pdf")
if err := os.WriteFile(source, []byte("pdf content"), 0o640); err != nil {
t.Fatalf("write source: %v", err)
}
dto, err := app.CapturePath(source)
if err != nil {
t.Fatalf("CapturePath: %v", err)
}
if dto.CaptureKind != "file" {
t.Fatalf("CaptureKind = %q, want file", dto.CaptureKind)
}
records, err := app.files.ListByNode(dto.ID)
if err != nil {
t.Fatalf("ListByNode: %v", err)
}
if len(records) != 1 {
t.Fatalf("records = %d, want 1", len(records))
}
if !strings.HasPrefix(records[0].Path, ".verstak/inbox/") {
t.Fatalf("path = %q, want .verstak/inbox prefix", records[0].Path)
}
if _, err := os.Stat(filepath.Join(vaultRoot, records[0].Path)); err != nil {
t.Fatalf("captured file missing in vault: %v", err)
}
}
func TestCapturePathCopiesDirectoryIntoInbox(t *testing.T) {
app, _ := setupTestApp(t)
source := t.TempDir()
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o750); err != nil {
t.Fatalf("mkdir nested: %v", err)
}
if err := os.WriteFile(filepath.Join(source, "nested", "note.txt"), []byte("nested"), 0o640); err != nil {
t.Fatalf("write nested file: %v", err)
}
dto, err := app.CapturePath(source)
if err != nil {
t.Fatalf("CapturePath: %v", err)
}
if dto.CaptureKind != "folder" {
t.Fatalf("CaptureKind = %q, want folder", dto.CaptureKind)
}
items, err := app.ListItems(dto.ID)
if err != nil {
t.Fatalf("ListItems: %v", err)
}
var foundNested bool
for _, item := range items {
if item.Name == "nested" && item.Type == "folder" {
foundNested = true
}
}
if !foundNested {
t.Fatalf("captured folder children missing: %+v", items)
}
}
func TestCaptureFileDataCreatesImageInboxArtifact(t *testing.T) {
app, _ := setupTestApp(t)
data := base64.StdEncoding.EncodeToString([]byte("fake image bytes"))
dto, err := app.CaptureFileData("pasted.png", data)
if err != nil {
t.Fatalf("CaptureFileData: %v", err)
}
if dto.CaptureKind != "image" {
t.Fatalf("CaptureKind = %q, want image", dto.CaptureKind)
}
records, err := app.files.ListByNode(dto.ID)
if err != nil {
t.Fatalf("ListByNode: %v", err)
}
if len(records) != 1 || records[0].MIME != "image/png" {
t.Fatalf("records = %+v, want one png image", records)
}
}
func TestCaptureTextWithSectionContextCreatesUnresolvedArtifact(t *testing.T) {
app, _ := setupTestApp(t)
dto, err := app.CaptureTextWithContext("Dropped on today", "paste", `{"contextType":"section","section":"today"}`)
if err != nil {
t.Fatalf("CaptureTextWithContext: %v", err)
}
if dto.CaptureStatus != "unresolved" {
t.Fatalf("CaptureStatus = %q, want unresolved", dto.CaptureStatus)
}
if dto.SourceKind != "text" {
t.Fatalf("SourceKind = %q, want text", dto.SourceKind)
}
if dto.CaptureSource != "paste" {
t.Fatalf("CaptureSource = %q, want paste", dto.CaptureSource)
}
if dto.CaptureContextType != "section" {
t.Fatalf("CaptureContextType = %q, want section", dto.CaptureContextType)
}
if dto.CaptureContextSection != "today" {
t.Fatalf("CaptureContextSection = %q, want today", dto.CaptureContextSection)
}
if dto.CaptureContextNodeID != "" {
t.Fatalf("CaptureContextNodeID = %q, want empty", dto.CaptureContextNodeID)
}
if dto.SuggestedTargetNodeID != "" {
t.Fatalf("SuggestedTargetNodeID = %q, want empty", dto.SuggestedTargetNodeID)
}
}
func TestCapturePathWithNodeContextUsesNodeIDForLocalInbox(t *testing.T) {
app, _ := setupTestApp(t)
sourceDir := t.TempDir()
source := filepath.Join(sourceDir, "brief.pdf")
if err := os.WriteFile(source, []byte("pdf"), 0o640); err != nil {
t.Fatalf("write source: %v", err)
}
projectA, err := app.CreateNodeFromTemplate("", "DuckLM", "folder.default")
if err != nil {
t.Fatalf("create project A: %v", err)
}
projectB, err := app.CreateNodeFromTemplate("", "DuckLM", "folder.default")
if err != nil {
t.Fatalf("create project B: %v", err)
}
ctx := `{"contextType":"node","nodeId":"` + projectA.ID + `","suggestedTargetNodeId":"` + projectA.ID + `"}`
dto, err := app.CapturePathWithContext(source, "drop", ctx)
if err != nil {
t.Fatalf("CapturePathWithContext: %v", err)
}
if dto.CaptureContextType != "node" {
t.Fatalf("CaptureContextType = %q, want node", dto.CaptureContextType)
}
if dto.CaptureContextNodeID != projectA.ID {
t.Fatalf("CaptureContextNodeID = %q, want %q", dto.CaptureContextNodeID, projectA.ID)
}
if dto.SuggestedTargetNodeID != projectA.ID {
t.Fatalf("SuggestedTargetNodeID = %q, want %q", dto.SuggestedTargetNodeID, projectA.ID)
}
localA, err := app.ListInboxNodesForTarget(projectA.ID)
if err != nil {
t.Fatalf("ListInboxNodesForTarget(A): %v", err)
}
if len(localA) != 1 || localA[0].ID != dto.ID {
t.Fatalf("local inbox A = %+v, want captured artifact", localA)
}
localB, err := app.ListInboxNodesForTarget(projectB.ID)
if err != nil {
t.Fatalf("ListInboxNodesForTarget(B): %v", err)
}
if len(localB) != 0 {
t.Fatalf("local inbox B = %+v, want empty for same title different node", localB)
}
}
func TestClassifyClipboardTextRoutesURLBeforePlainText(t *testing.T) {
kind, value := classifyClipboardText(" https://example.test/page ")
if kind != "url" {
t.Fatalf("kind = %q, want url", kind)
}
if value != "https://example.test/page" {
t.Fatalf("value = %q, want trimmed URL", value)
}
kind, value = classifyClipboardText("not a url\nwith more text")
if kind != "text" {
t.Fatalf("kind = %q, want text", kind)
}
if value != "not a url\nwith more text" {
t.Fatalf("value = %q, want trimmed text", value)
}
}
func TestClassifyClipboardTextTreatsBareDomainsAsURLs(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "apex domain", input: " mirv.top ", want: "https://mirv.top"},
{name: "www domain", input: "www.example.com", want: "https://www.example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kind, value := classifyClipboardText(tt.input)
if kind != "url" {
t.Fatalf("kind = %q, want url", kind)
}
if value != tt.want {
t.Fatalf("value = %q, want %q", value, tt.want)
}
})
}
}
func TestCaptureURLNormalizesBareDomain(t *testing.T) {
app, _ := setupTestApp(t)
dto, err := app.CaptureURL("mirv.top", "")
if err != nil {
t.Fatalf("CaptureURL: %v", err)
}
if dto.URL != "https://mirv.top" {
t.Fatalf("URL = %q, want https://mirv.top", dto.URL)
}
if dto.Hostname != "mirv.top" {
t.Fatalf("Hostname = %q, want mirv.top", dto.Hostname)
}
}

View File

@ -0,0 +1,122 @@
package main
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/nodes"
)
func TestResolveOpenFolderTarget_FileNodeUsesRecordPath(t *testing.T) {
vault := t.TempDir()
got := resolveOpenFolderTarget(vault, &nodes.Node{Type: nodes.TypeFile}, filepath.Join("Parent", "Nested", "file.txt"))
want := filepath.Join(vault, "Parent", "Nested")
if got != want {
t.Fatalf("target = %q, want %q", got, want)
}
got = resolveOpenFolderTarget(vault, &nodes.Node{Type: nodes.TypeFile}, "")
if got != vault {
t.Fatalf("target without file record = %q, want vault %q", got, vault)
}
got = resolveOpenFolderTarget(vault, &nodes.Node{Type: nodes.TypeFile, FsPath: filepath.Join("Parent", "file.txt")}, "")
want = filepath.Join(vault, "Parent")
if got != want {
t.Fatalf("target with fs_path = %q, want %q", got, want)
}
}
func TestFileManagerRecursiveImportListItemsIsFlat(t *testing.T) {
app, _ := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Files Parent", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
sourceRoot := filepath.Join(t.TempDir(), "drop")
if err := os.MkdirAll(filepath.Join(sourceRoot, "nested"), 0o750); err != nil {
t.Fatalf("mkdir source: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "root.txt"), []byte("root"), 0o640); err != nil {
t.Fatalf("write root file: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "nested", "deep.txt"), []byte("deep"), 0o640); err != nil {
t.Fatalf("write nested file: %v", err)
}
imported, err := app.AddPathCopy(parent.ID, sourceRoot)
if err != nil {
t.Fatalf("AddPathCopy: %v", err)
}
if len(imported) < 4 {
t.Fatalf("imported %d nodes, want folder + nested folder + files", len(imported))
}
rootItems, err := app.ListItems(parent.ID)
if err != nil {
t.Fatalf("ListItems(parent): %v", err)
}
if hasItemNamed(rootItems, "deep.txt") {
t.Fatal("parent file view includes nested file deep.txt")
}
drop := findItem(rootItems, "drop", nodes.TypeFolder)
if drop == nil {
t.Fatalf("parent file view missing imported root folder: %#v", rootItems)
}
dropItems, err := app.ListItems(drop.ID)
if err != nil {
t.Fatalf("ListItems(drop): %v", err)
}
if !hasItemNamed(dropItems, "root.txt") {
t.Fatal("imported root folder missing root.txt")
}
nested := findItem(dropItems, "nested", nodes.TypeFolder)
if nested == nil {
t.Fatalf("imported root folder missing nested folder: %#v", dropItems)
}
nestedItems, err := app.ListItems(nested.ID)
if err != nil {
t.Fatalf("ListItems(nested): %v", err)
}
if !hasItemNamed(nestedItems, "deep.txt") {
t.Fatal("nested folder missing deep.txt")
}
seen := map[string]string{}
for level, items := range map[string][]FileTreeItemDTO{
"parent": rootItems,
"drop": dropItems,
"nested": nestedItems,
} {
for _, item := range items {
if prev, ok := seen[item.ID]; ok {
t.Fatalf("file manager listed ID %s in both %s and %s", item.ID, prev, level)
}
seen[item.ID] = level
}
}
}
func findItem(items []FileTreeItemDTO, name, typ string) *FileTreeItemDTO {
for i := range items {
if items[i].Name == name && items[i].Type == typ {
return &items[i]
}
}
return nil
}
func hasItemNamed(items []FileTreeItemDTO, name string) bool {
for _, item := range items {
if item.Name == name {
return true
}
}
return false
}

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: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,10 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/wails.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/assets/app-icons/icon_16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/app-icons/icon_32x32.png" />
<link rel="icon" type="image/png" sizes="64x64" href="/assets/app-icons/icon_64x64.png" />
<link rel="icon" type="image/png" sizes="128x128" href="/assets/app-icons/icon_128x128.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Верстак</title> <title>Верстак</title>
<style> <style>
@ -16,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script> <script type="module" crossorigin src="/assets/main-DTT9uc7y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css"> <link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -1,157 +0,0 @@
:root {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: rgba(27, 38, 54, 1);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
src: local(""),
url("./Inter-Medium.ttf") format("truetype");
}
h3 {
font-size: 3em;
line-height: 1.1;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
button {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.result {
height: 20px;
line-height: 20px;
}
body {
margin: 0;
display: flex;
place-items: center;
place-content: center;
min-width: 320px;
min-height: 100vh;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #e80000aa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #f7df1eaa);
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
text-align: center;
}
.footer {
margin-top: 1rem;
align-content: center;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
color: black;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}

View File

@ -0,0 +1,219 @@
package main
import (
"errors"
"testing"
"verstak/internal/core/nodes"
)
func TestListInboxNodesReturnsOnlyCapturedArtifacts(t *testing.T) {
app, _ := setupTestApp(t)
manual, err := app.CreateNodeFromTemplate("", "Manual Root", "folder.default")
if err != nil {
t.Fatalf("create manual root: %v", err)
}
legacyInbox, err := app.CreateNodeFromTemplate("", "Legacy Inbox Root", "folder.default")
if err != nil {
t.Fatalf("create legacy inbox root: %v", err)
}
captured, err := app.CreateNodeFromTemplate("", "Captured Artifact", "folder.default")
if err != nil {
t.Fatalf("create captured artifact: %v", err)
}
child, err := app.CreateNodeFromTemplate(captured.ID, "Nested Child", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
if _, err := app.db.Exec(`UPDATE nodes SET section = 'inbox' WHERE id = ?`, legacyInbox.ID); err != nil {
t.Fatalf("mark legacy inbox: %v", err)
}
if err := app.nodes.MetaSet(captured.ID, "capture.inbox", "true"); err != nil {
t.Fatalf("mark captured: %v", err)
}
if err := app.nodes.MetaSet(captured.ID, "capture.kind", "text"); err != nil {
t.Fatalf("mark capture kind: %v", err)
}
if err := app.nodes.MetaSet(captured.ID, "capture.source", "clipboard"); err != nil {
t.Fatalf("mark capture source: %v", err)
}
list, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
got := map[string]bool{}
for _, item := range list {
got[item.ID] = true
}
if !got[captured.ID] {
t.Fatal("captured artifact missing from inbox")
}
for _, item := range list {
if item.ID == captured.ID {
if item.CaptureKind != "text" {
t.Fatalf("CaptureKind = %q, want text", item.CaptureKind)
}
if item.CaptureSource != "clipboard" {
t.Fatalf("CaptureSource = %q, want clipboard", item.CaptureSource)
}
}
}
if got[manual.ID] {
t.Fatal("manual root should not be in inbox")
}
if got[legacyInbox.ID] {
t.Fatal("section=inbox root without capture metadata should not be in inbox")
}
if got[child.ID] {
t.Fatal("nested child should not be in inbox")
}
workspace, err := app.ListWorkspaceTree()
if err != nil {
t.Fatalf("ListWorkspaceTree: %v", err)
}
for _, item := range workspace {
if item.ID == captured.ID {
t.Fatal("captured artifact should not be shown in workspace tree")
}
}
}
func TestAssignInboxNodeMovesArtifactIntoCase(t *testing.T) {
app, _ := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Target Case", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
captured, err := app.CaptureText("Captured task material")
if err != nil {
t.Fatalf("CaptureText: %v", err)
}
moved, err := app.AssignInboxNode(captured.ID, parent.ID)
if err != nil {
t.Fatalf("AssignInboxNode: %v", err)
}
if moved.ParentID == nil || *moved.ParentID != parent.ID {
t.Fatalf("ParentID = %v, want %q", moved.ParentID, parent.ID)
}
inbox, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
for _, item := range inbox {
if item.ID == captured.ID {
t.Fatal("assigned artifact should leave inbox")
}
}
notes, err := app.ListNotes(parent.ID)
if err != nil {
t.Fatalf("ListNotes: %v", err)
}
var found bool
for _, note := range notes {
if note.ID == captured.ID {
found = true
}
}
if !found {
t.Fatal("assigned text artifact missing from target case notes")
}
}
func TestDeleteInboxNodeRemovesArtifactFromInbox(t *testing.T) {
app, _ := setupTestApp(t)
captured, err := app.CaptureText("Delete this captured material")
if err != nil {
t.Fatalf("CaptureText: %v", err)
}
if err := app.DeleteInboxNode(captured.ID); err != nil {
t.Fatalf("DeleteInboxNode: %v", err)
}
inbox, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
for _, item := range inbox {
if item.ID == captured.ID {
t.Fatal("deleted artifact should leave inbox")
}
}
if _, err := app.nodes.GetActive(captured.ID); !errors.Is(err, nodes.ErrNotFound) {
t.Fatalf("GetActive err = %v, want ErrNotFound", err)
}
}
func TestResolveCapturedURLCreatesLinkForTargetOnly(t *testing.T) {
app, _ := setupTestApp(t)
projectA, err := app.CreateNodeFromTemplate("", "Project A", "folder.default")
if err != nil {
t.Fatalf("create project A: %v", err)
}
projectB, err := app.CreateNodeFromTemplate("", "Project B", "folder.default")
if err != nil {
t.Fatalf("create project B: %v", err)
}
ctx := `{"contextType":"node","nodeId":"` + projectA.ID + `","suggestedTargetNodeId":"` + projectA.ID + `"}`
captured, err := app.CaptureURLWithContext("https://example.test/article", "Example Article", "drop", ctx)
if err != nil {
t.Fatalf("CaptureURLWithContext: %v", err)
}
localBefore, err := app.ListInboxNodesForTarget(projectA.ID)
if err != nil {
t.Fatalf("ListInboxNodesForTarget before: %v", err)
}
if len(localBefore) != 1 || localBefore[0].ID != captured.ID {
t.Fatalf("local inbox before = %+v, want captured URL", localBefore)
}
if _, err := app.ResolveInboxNode(captured.ID, projectA.ID); err != nil {
t.Fatalf("ResolveInboxNode: %v", err)
}
globalAfter, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes after: %v", err)
}
for _, item := range globalAfter {
if item.ID == captured.ID {
t.Fatal("resolved URL should leave global inbox")
}
}
localAfter, err := app.ListInboxNodesForTarget(projectA.ID)
if err != nil {
t.Fatalf("ListInboxNodesForTarget after: %v", err)
}
if len(localAfter) != 0 {
t.Fatalf("local inbox after = %+v, want empty", localAfter)
}
linksA, err := app.ListLinks(projectA.ID)
if err != nil {
t.Fatalf("ListLinks(A): %v", err)
}
if len(linksA) != 1 {
t.Fatalf("links A = %+v, want one link", linksA)
}
if linksA[0].Title != "Example Article" || linksA[0].URL != "https://example.test/article" || linksA[0].Hostname != "example.test" {
t.Fatalf("link A = %+v, want captured URL data", linksA[0])
}
linksB, err := app.ListLinks(projectB.ID)
if err != nil {
t.Fatalf("ListLinks(B): %v", err)
}
if len(linksB) != 0 {
t.Fatalf("links B = %+v, want empty", linksB)
}
}

View File

@ -3,17 +3,8 @@ package main
import ( import (
"embed" "embed"
"log" "log"
"os"
"path/filepath"
"verstak/internal/core/actions" "verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
@ -24,49 +15,17 @@ import (
var assets embed.FS var assets embed.FS
func main() { func main() {
vaultPath := "." app := &App{}
if len(os.Args) > 1 {
vaultPath = os.Args[1]
}
abs, err := filepath.Abs(vaultPath) // Fix WebKit signal handler for Go 1.24+ compatibility
if err != nil { ensureSignalOnStack()
log.Fatal(err)
}
dbPath := filepath.Join(abs, ".verstak", "index.db") err := wails.Run(&options.App{
db, err := storage.Open(dbPath) Title: "Верстак",
if err != nil { Width: 1280,
log.Fatalf("Open vault: %v", err) Height: 800,
} MinWidth: 800,
defer db.Close() MinHeight: 600,
// Init core services
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
app := &App{
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
vault: abs,
}
err = wails.Run(&options.App{
Title: "Верстак",
Width: 1280,
Height: 800,
MinWidth: 800,
MinHeight: 600,
BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1}, BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1},
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
@ -81,4 +40,7 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Ensure config dir exists for logging/cli usage
config.EnsureConfigDir()
} }

View File

@ -0,0 +1,255 @@
package main
import (
"strings"
"testing"
"verstak/internal/core/nodes"
)
// setupTree creates a simple tree: A -> B -> C for MoveNode testing.
func setupTree(t *testing.T) (*App, *NodeDTO, *NodeDTO, *NodeDTO) {
t.Helper()
app, _ := setupTestApp(t)
a, err := app.CreateNodeFromTemplate("", "A", "folder.default")
if err != nil {
t.Fatalf("create A: %v", err)
}
b, err := app.CreateNodeFromTemplate(a.ID, "B", "folder.default")
if err != nil {
t.Fatalf("create B: %v", err)
}
c, err := app.CreateNodeFromTemplate(b.ID, "C", "folder.default")
if err != nil {
t.Fatalf("create C: %v", err)
}
return app, a, b, c
}
// listTree returns a flat mapping of all visible node IDs in the workspace.
func listTreeIDs(t *testing.T, app *App) map[string]int {
t.Helper()
roots, err := app.ListWorkspaceTree()
if err != nil {
t.Fatalf("ListWorkspaceTree: %v", err)
}
ids := make(map[string]int)
var walk func(parentID string)
walk = func(parentID string) {
var nodes []NodeDTO
var err error
if parentID == "" {
nodes = roots
} else {
nodes, err = app.ListWorkspaceChildren(parentID)
if err != nil {
return
}
}
for _, n := range nodes {
ids[n.ID]++
if n.HasChildren {
walk(n.ID)
}
}
}
walk("")
return ids
}
func TestMoveNode_DescendantToAncestor(t *testing.T) {
app, a, b, c := setupTree(t)
// Move C into A — must succeed
err := app.MoveNode(c.ID, a.ID)
if err != nil {
t.Fatalf("MoveNode(C, A): %v", err)
}
// Verify C's parent is A
nc, err := app.nodes.GetActive(c.ID)
if err != nil {
t.Fatalf("GetActive(C): %v", err)
}
if nc.ParentID == nil || *nc.ParentID != a.ID {
t.Errorf("C parent_id = %v, want %s", nc.ParentID, a.ID)
}
// C should no longer be a child of B
bKids, _ := app.nodes.ListChildren(b.ID, false)
for _, child := range bKids {
if child.ID == c.ID {
t.Error("C still appears under B after move")
}
}
// C should be a child of A
aKids, _ := app.nodes.ListChildren(a.ID, false)
found := false
for _, child := range aKids {
if child.ID == c.ID {
found = true
break
}
}
if !found {
t.Error("C not found under A after move")
}
}
func TestMoveNode_ChildToRoot(t *testing.T) {
app, a, b, _ := setupTree(t)
// Move B to root — must succeed
err := app.MoveNode(b.ID, "")
if err != nil {
t.Fatalf("MoveNode(B, root): %v", err)
}
// Verify B has no parent
nb, err := app.nodes.GetActive(b.ID)
if err != nil {
t.Fatalf("GetActive(B): %v", err)
}
if nb.ParentID != nil {
t.Errorf("B parent_id = %v, want nil", nb.ParentID)
}
// B should NOT be under A
aKids, _ := app.nodes.ListChildren(a.ID, false)
for _, child := range aKids {
if child.ID == b.ID {
t.Error("B still appears under A after move to root")
}
}
// B should appear in roots
roots, _ := app.nodes.ListRoots(false)
found := false
for _, root := range roots {
if root.ID == b.ID {
found = true
break
}
}
if !found {
t.Error("B not found in roots after move")
}
}
func TestMoveNode_ParentIntoChild_Rejected(t *testing.T) {
app, a, b, _ := setupTree(t)
err := app.MoveNode(a.ID, b.ID)
if err == nil {
t.Fatal("MoveNode(A, B): expected error, got nil")
}
if !strings.Contains(err.Error(), "descendant") && !strings.Contains(err.Error(), "cycle") {
t.Errorf("MoveNode(A, B): unexpected error: %v", err)
}
// Verify A's parent unchanged
na, _ := app.nodes.GetActive(a.ID)
if na.ParentID != nil {
t.Errorf("A parent_id changed to %v after rejected move", *na.ParentID)
}
}
func TestMoveNode_ParentIntoDeepDescendant_Rejected(t *testing.T) {
app, a, _, c := setupTree(t)
err := app.MoveNode(a.ID, c.ID)
if err == nil {
t.Fatal("MoveNode(A, C): expected error, got nil")
}
if !strings.Contains(err.Error(), "descendant") && !strings.Contains(err.Error(), "cycle") {
t.Errorf("MoveNode(A, C): unexpected error: %v", err)
}
}
func TestMoveNode_IntoSelf_Rejected(t *testing.T) {
app, a, _, _ := setupTree(t)
err := app.MoveNode(a.ID, a.ID)
if err == nil {
t.Fatal("MoveNode(A, A): expected error, got nil")
}
if !strings.Contains(err.Error(), "into itself") {
t.Errorf("MoveNode(A, A): unexpected error: %v", err)
}
}
func TestMoveNode_NoDuplicateIDs(t *testing.T) {
app, a, b, c := setupTree(t)
// Initial check: no duplicates
ids := listTreeIDs(t, app)
for id, count := range ids {
if count > 1 {
t.Errorf("Duplicate ID %q found %d times before move", id, count)
}
}
// Move C into A
if err := app.MoveNode(c.ID, a.ID); err != nil {
t.Fatalf("MoveNode(C, A): %v", err)
}
ids = listTreeIDs(t, app)
for id, count := range ids {
if count > 1 {
t.Errorf("Duplicate ID %q found %d times after C→A move", id, count)
}
}
// Move B to root
if err := app.MoveNode(b.ID, ""); err != nil {
t.Fatalf("MoveNode(B, root): %v", err)
}
ids = listTreeIDs(t, app)
for id, count := range ids {
if count > 1 {
t.Errorf("Duplicate ID %q found %d times after B→root move", id, count)
}
}
}
func TestMoveNode_FsPathUpdated(t *testing.T) {
app, _, _, c := setupTree(t)
ncBefore, _ := app.nodes.GetActive(c.ID)
origPath := ncBefore.FsPath
// Move C to root — fs_path should change
if err := app.MoveNode(c.ID, ""); err != nil {
t.Fatalf("MoveNode(C, root): %v", err)
}
nc, _ := app.nodes.GetActive(c.ID)
if nc.FsPath == origPath {
t.Errorf("C fs_path unchanged after move to root: %q", nc.FsPath)
}
}
func TestMoveNode_NonContainerRejected(t *testing.T) {
app, a, _, _ := setupTree(t)
// Create a root-level note (not inside A's subtree)
note, err := app.nodes.Create(nil, nodes.TypeNote, "Test Note", 0, "", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
err = app.MoveNode(a.ID, note.ID)
if err == nil {
t.Fatal("MoveNode(A, note): expected error for non-container, got nil")
}
if !strings.Contains(err.Error(), "not a container") {
t.Errorf("MoveNode(A, note): unexpected error: %v", err)
}
}

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")
}
}
}
}

View File

@ -0,0 +1,26 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestEnsurePluginsFolder(t *testing.T) {
vault := t.TempDir()
path, err := ensurePluginsFolder(vault)
if err != nil {
t.Fatalf("ensurePluginsFolder: %v", err)
}
want := filepath.Join(vault, ".verstak", "plugins")
if path != want {
t.Fatalf("plugins path = %q, want %q", path, want)
}
if info, err := os.Stat(path); err != nil {
t.Fatalf("stat plugins dir: %v", err)
} else if !info.IsDir() {
t.Fatalf("plugins path is not a directory: %s", path)
}
}

38
cmd/verstak-gui/sigfix.go Normal file
View File

@ -0,0 +1,38 @@
package main
/*
#define _GNU_SOURCE
#include <signal.h>
#include <stdbool.h>
// fixSigsegvOnStack adds SA_ONSTACK to the current SIGSEGV handler.
// Go 1.24+ requires all signal handlers to have SA_ONSTACK set,
// but WebKit/JavaScriptCore installs a SIGSEGV handler without it.
void fixSigsegvOnStack(void) {
struct sigaction act;
if (sigaction(SIGSEGV, NULL, &act) == 0) {
if (!(act.sa_flags & SA_ONSTACK)) {
act.sa_flags |= SA_ONSTACK;
sigaction(SIGSEGV, &act, NULL);
}
}
}
*/
import "C"
import "time"
// ensureSignalOnStack periodically ensures SIGSEGV handler has SA_ONSTACK.
// This is needed because WebKit/JavaScriptCore installs a SIGSEGV handler
// without SA_ONSTACK, which causes Go 1.24+ to crash with:
// "non-Go code set up signal handler without SA_ONSTACK flag"
func ensureSignalOnStack() {
// Apply once after a short delay to let WebKit initialize
go func() {
// Retry a few times since WebKit may re-install its handler
for i := 0; i < 10; i++ {
time.Sleep(200 * time.Millisecond)
C.fixSigsegvOnStack()
}
}()
}

View File

@ -0,0 +1,547 @@
package main
import (
"encoding/json"
"testing"
"time"
"verstak/internal/core/activity"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/util"
"verstak/internal/core/worklog"
)
// nowISO returns the current time in RFC3339, usable as an activity_events.created_at.
func nowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}
// insertTestEvent inserts an activity event at the given created_at and returns its ID.
func insertTestEventAt(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title, createdAt string) string {
t.Helper()
id := util.UUID7()
_, err := app.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, "", title, "{}", createdAt)
if err != nil {
t.Fatalf("insert event: %v", err)
}
return id
}
// insertTestEvent creates a "now" event.
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string {
return insertTestEventAt(t, app, nodeID, eventType, targetType, targetID, title, nowISO())
}
// countLinked returns the number of worklog_entry_events for an entry.
func countLinked(t *testing.T, app *App, entryID string) int {
t.Helper()
var n int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, entryID).Scan(&n); err != nil {
t.Fatalf("count worklog_entry_events: %v", err)
}
return n
}
// countJoined returns the number of events in worklog_entry_events that successfully
// join to activity_events for a given entry.
func countJoined(t *testing.T, app *App, entryID string) int {
t.Helper()
var n int
if err := app.db.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events wle
JOIN activity_events ae ON ae.id = wle.event_id
WHERE wle.entry_id = ?`, entryID).Scan(&n); err != nil {
t.Fatalf("join count: %v", err)
}
return n
}
// ---------------------------------------------------------------------------
// Test 1: Full journal regression — GetSuggestions + Accept + verify
// ---------------------------------------------------------------------------
func TestJournalFullRegression(t *testing.T) {
app, _ := setupTestApp(t)
// Create a node
n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// Create activity events (use now() so GetSuggestions picks them up)
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Создана заметка")
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "f1", "Добавлен файл")
// Call GetSuggestions
suggestions, err := app.GetSuggestions()
if err != nil {
t.Fatalf("GetSuggestions: %v", err)
}
// Must find at least one suggestion for our node
if len(suggestions) == 0 {
t.Fatal("GetSuggestions returned 0 suggestions, expected at least 1")
}
var found bool
for _, s := range suggestions {
if s.NodeID == n.ID {
found = true
// Verify events match what we inserted
// 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))
}
// Each eventId must be in the events list
idSet := make(map[string]bool, len(s.Events))
for _, ev := range s.Events {
idSet[ev.ID] = true
}
for _, eid := range s.EventIDs {
if !idSet[eid] {
t.Errorf("eventId %s not found in events list", eid)
}
}
}
}
if !found {
t.Fatalf("no suggestion found for node %s", n.ID)
}
// Accept the suggestion via AcceptSuggestionWith (JSON-serialised eventIDs)
eventIDs := []string{eid1, eid2, eid3}
eventIDsJSON, _ := json.Marshal(eventIDs)
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON))
if err != nil {
t.Fatalf("AcceptSuggestionWith: %v", err)
}
// Check worklog_entry_events count
if n := countLinked(t, app, dto.ID); n != 3 {
t.Errorf("worklog_entry_events count = %d, want 3", n)
}
// Check JOIN with activity_events
if n := countJoined(t, app, dto.ID); n != 3 {
t.Errorf("JOIN count = %d, want 3", n)
}
// Check GetWorklogEntryEvents returns 3 events
events, err := app.GetWorklogEntryEvents(dto.ID)
if err != nil {
t.Fatalf("GetWorklogEntryEvents: %v", err)
}
if len(events) != 3 {
t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events))
}
// All three event IDs must be present
returnedIDs := make(map[string]bool, len(events))
for _, ev := range events {
returnedIDs[ev.ID] = true
}
for _, eid := range eventIDs {
if !returnedIDs[eid] {
t.Errorf("event %s missing from GetWorklogEntryEvents", eid)
}
}
// Source must be suggestion
var src string
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
if src != worklog.SourceSuggestion {
t.Errorf("source = %q, want %q", src, worklog.SourceSuggestion)
}
}
// ---------------------------------------------------------------------------
// Test 2: Repeated activity on same node — suggestion must still appear
// ---------------------------------------------------------------------------
func TestSuggestionOnRepeatedActivity(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Repeat Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// Create a first event and accept it
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Первая заметка")
eid1JSON, _ := json.Marshal([]string{eid1})
_, err = app.AcceptSuggestionWith(n.ID, "Создание заметки", 5, "", string(eid1JSON))
if err != nil {
t.Fatalf("first AcceptSuggestionWith: %v", err)
}
// Now create a brand new event on the same node
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
// GetSuggestions must still return a suggestion for this node (new event is unaccounted)
suggestions, err := app.GetSuggestions()
if err != nil {
t.Fatalf("GetSuggestions: %v", err)
}
var found bool
for _, s := range suggestions {
if s.NodeID == n.ID {
found = true
// 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))
}
// eid2 must be among the suggested events
var hasNew bool
for _, ev := range s.Events {
if ev.ID == eid2 {
hasNew = true
break
}
}
if !hasNew {
t.Errorf("eid2 not found among suggestion events")
}
}
}
if !found {
t.Fatal("GetSuggestions did not return a suggestion for the new event — it should")
}
// Accept the new suggestion
eid2JSON, _ := json.Marshal([]string{eid2})
dto2, err := app.AcceptSuggestionWith(n.ID, "Обновление заметки", 5, "", string(eid2JSON))
if err != nil {
t.Fatalf("second AcceptSuggestionWith: %v", err)
}
// Each entry must have exactly 1 linked event
if n := countLinked(t, app, dto2.ID); n != 1 {
t.Errorf("second entry: worklog_entry_events count = %d, want 1", n)
}
}
// ---------------------------------------------------------------------------
// Test 3: Manual worklog entry — source, billable, details, empty events
// ---------------------------------------------------------------------------
func TestManualWorklogEntry(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Manual Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// Create a manual entry
dto, err := app.CreateWorklogFull(n.ID, "Ручная работа", "Подробное описание", "", 30, true, true)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
// Source must be manual
var src string
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
if src != worklog.SourceManual {
t.Errorf("source = %q, want %q", src, worklog.SourceManual)
}
// Billable, approximate, details must be correct
var details string
var billable, approximate int
app.db.QueryRow(
`SELECT details, billable, approximate FROM worklog_entries WHERE id = ?`, dto.ID,
).Scan(&details, &billable, &approximate)
if details != "Подробное описание" {
t.Errorf("details = %q, want %q", details, "Подробное описание")
}
if billable != 1 {
t.Errorf("billable = %d, want 1", billable)
}
if approximate != 1 {
t.Errorf("approximate = %d, want 1", approximate)
}
// Minutes must be 30
if dto.Minutes != 30 {
t.Errorf("minutes = %d, want 30", dto.Minutes)
}
// GetWorklogEntryEvents must be empty for a manual entry
events, err := app.GetWorklogEntryEvents(dto.ID)
if err != nil {
t.Fatalf("GetWorklogEntryEvents: %v", err)
}
if len(events) != 0 {
t.Errorf("manual entry has %d linked events, want 0", len(events))
}
// DTO should distinguish manual from suggestion
if dto.Source != worklog.SourceManual {
t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual)
}
}
func hasWorklogSyncOp(t *testing.T, app *App, entryID, opType string) bool {
t.Helper()
ops, err := app.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
for _, op := range ops {
if op.EntityType == syncsvc.EntityWorklog && op.EntityID == entryID && op.OpType == opType {
return true
}
}
return false
}
func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Editable Worklog Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
dto, err := app.CreateWorklogFull(n.ID, "Old summary", "Old details", "2026-01-01", 30, false, false)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Связанное событие")
if _, err := app.db.Exec(`INSERT INTO worklog_entry_events(entry_id,event_id) VALUES(?,?)`, dto.ID, eventID); err != nil {
t.Fatalf("insert worklog event link: %v", err)
}
updated, err := app.UpdateWorklogEntry(dto.ID, "New summary", "New details", "2026-01-02", 45, true, true)
if err != nil {
t.Fatalf("UpdateWorklogEntry: %v", err)
}
if updated.Summary != "New summary" || updated.Details != "New details" || updated.Date != "2026-01-02" {
t.Fatalf("updated DTO = %#v", updated)
}
if updated.Minutes != 45 || !updated.Approximate || !updated.Billable {
t.Fatalf("updated flags/minutes = %#v", updated)
}
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpUpdate) {
t.Fatal("missing worklog update sync op")
}
if n := countLinked(t, app, dto.ID); n != 1 {
t.Fatalf("event links after update = %d, want 1", n)
}
if err := app.DeleteWorklogEntry(dto.ID); err != nil {
t.Fatalf("DeleteWorklogEntry: %v", err)
}
if _, err := app.worklog.Get(dto.ID); err == nil {
t.Fatal("expected deleted worklog entry to be gone")
}
if n := countLinked(t, app, dto.ID); n != 0 {
t.Fatalf("event links after delete = %d, want 0", n)
}
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpDelete) {
t.Fatal("missing worklog delete sync op")
}
}
func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) {
app, _ := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
parentLog, err := app.ListWorklog(parent.ID)
if err != nil {
t.Fatalf("ListWorklog(parent): %v", err)
}
if len(parentLog) != 1 || parentLog[0].ID != entry.ID {
t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID)
}
if parentLog[0].NodeID != child.ID {
t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID)
}
if parentLog[0].NodePath != "Parent Project > Documents" {
t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath)
}
parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0)
if err != nil {
t.Fatalf("ListActivityByNode(parent): %v", err)
}
var foundEvent *EventDTO
for i := range parentActivity {
if parentActivity[i].ID == eventID {
foundEvent = &parentActivity[i]
}
}
if foundEvent == nil {
t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID)
}
if foundEvent.NodePath != "Parent Project > Documents" {
t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath)
}
var physicalEvents int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil {
t.Fatalf("count physical event: %v", err)
}
if physicalEvents != 1 {
t.Fatalf("physical child event count = %d, want 1", physicalEvents)
}
var copiedToParent int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil {
t.Fatalf("count copied parent event: %v", err)
}
if copiedToParent != 0 {
t.Fatalf("copied parent events = %d, want 0", copiedToParent)
}
}
func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default")
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 {
t.Fatalf("DismissSuggestion: %v", err)
}
suggestions, err := app.GetSuggestions()
if err != nil {
t.Fatalf("GetSuggestions: %v", err)
}
for _, s := range suggestions {
if s.NodeID == n.ID {
t.Fatalf("dismissed suggestion still visible: %+v", s)
}
}
var eventCount int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil {
t.Fatalf("count event: %v", err)
}
if eventCount != 1 {
t.Fatalf("activity event count = %d, want 1", eventCount)
}
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json: %v", err)
}
return data
}
func TestApplyRemoteWorklogUpdate(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Remote Worklog Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
dto, err := app.CreateWorklogFull(n.ID, "Before remote", "", "2026-01-03", 15, false, false)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
payload, err := json.Marshal(map[string]interface{}{
"id": dto.ID,
"node_id": n.ID,
"summary": "After remote",
"details": "Remote details",
"minutes": 75,
"date": "2026-01-04",
"approximate": true,
"billable": true,
"updated_at": nowISO(),
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
if err := app.applyRemoteWorklogOp(syncsvc.Op{
EntityType: syncsvc.EntityWorklog,
EntityID: dto.ID,
OpType: syncsvc.OpUpdate,
PayloadJSON: string(payload),
}); err != nil {
t.Fatalf("applyRemoteWorklogOp update: %v", err)
}
got, err := app.worklog.Get(dto.ID)
if err != nil {
t.Fatalf("get updated entry: %v", err)
}
if got.Summary != "After remote" || got.Details != "Remote details" || got.Date != "2026-01-04" {
t.Fatalf("remote updated entry = %#v", got)
}
if got.Minutes == nil || *got.Minutes != 75 || !got.Approximate || !got.Billable {
t.Fatalf("remote updated minutes/flags = %#v", got)
}
}
func TestAcceptSuggestionFullUsesEditedFields(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Edited Suggestion Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
eventID := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
eventIDsJSON, _ := json.Marshal([]string{eventID})
dto, err := app.AcceptSuggestionFull(n.ID, "Edited summary", "Edited details", "2026-01-05", 55, false, true, string(eventIDsJSON))
if err != nil {
t.Fatalf("AcceptSuggestionFull: %v", err)
}
if dto.Summary != "Edited summary" || dto.Details != "Edited details" || dto.Date != "2026-01-05" {
t.Fatalf("dto = %#v", dto)
}
if dto.Minutes != 55 || dto.Approximate || !dto.Billable {
t.Fatalf("dto minutes/flags = %#v", dto)
}
if dto.Source != worklog.SourceSuggestion {
t.Fatalf("dto.Source = %q, want %q", dto.Source, worklog.SourceSuggestion)
}
if n := countLinked(t, app, dto.ID); n != 1 {
t.Fatalf("linked events = %d, want 1", n)
}
}

View File

@ -0,0 +1,961 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/util"
)
// applyRemoteOp dispatches a remote sync operation to the correct entity handler.
func (a *App) applyRemoteOp(op syncsvc.Op) error {
switch op.EntityType {
case syncsvc.EntityNode:
return a.applyRemoteNodeOp(op)
case syncsvc.EntityNote:
return a.applyRemoteNoteOp(op)
case syncsvc.EntityFile, syncsvc.EntityFolder:
return a.applyRemoteFileOrFolderOp(op)
case syncsvc.EntityAction:
return a.applyRemoteActionOp(op)
case syncsvc.EntityWorklog:
return a.applyRemoteWorklogOp(op)
}
return nil
}
// --- apply helpers ---
func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteNodeCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNodeUpdate(op)
case syncsvc.OpMove:
return a.applyRemoteNodeMove(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
return nil
}
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
ParentID string `json:"parent_id"`
Type string `json:"type"`
Title string `json:"title"`
Slug string `json:"slug"`
TemplateID string `json:"template_id"`
FsPath string `json:"fs_path"`
Section string `json:"section"`
SortOrder int `json:"sort_order"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal node create: %w", err)
}
if payload.ID == "" || payload.Type == "" || payload.Title == "" {
return fmt.Errorf("incomplete node payload")
}
if _, err := a.nodes.Get(payload.ID); err == nil {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.CreatedAt == "" {
payload.CreatedAt = now
}
if payload.UpdatedAt == "" {
payload.UpdatedAt = now
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
var section interface{}
if payload.Section != "" {
section = payload.Section
}
slug := payload.Slug
if slug == "" {
slug = nodes.Slugify(payload.Title)
}
// Determine fs_path for folder-like nodes
fsPath := payload.FsPath
if fsPath == "" {
isFolderLike := payload.Type != "note" && payload.Type != "file"
if isFolderLike {
seg := templates.SafeDisplayNameToPathSegment(payload.Title)
if seg == "" {
seg = "node"
}
if payload.ParentID != "" {
if parent, err := a.nodes.Get(payload.ParentID); err == nil && parent.FsPath != "" {
fsPath = filepath.Join(parent.FsPath, seg)
}
}
if fsPath == "" {
fsPath = filepath.Join(".verstak", "remote-inbox")
}
// Ensure unique path
fullPath := filepath.Join(a.vault, fsPath)
fullPath = templates.UniquePath(fullPath)
rel, _ := filepath.Rel(a.vault, fullPath)
fsPath = rel
}
}
archived := 0
if payload.Archived {
archived = 1
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,NULL)`,
payload.ID, parent, payload.Type, payload.Title, slug,
payload.TemplateID, fsPath, section, payload.SortOrder, archived,
payload.CreatedAt, payload.UpdatedAt,
)
if err != nil {
return err
}
// Create physical folder for folder-like nodes
if fsPath != "" {
isFolderLike := payload.Type != "note" && payload.Type != "file"
if isFolderLike {
physPath := filepath.Join(a.vault, fsPath)
if err := os.MkdirAll(physPath, 0o755); err != nil {
log.Printf("[sync] create folder for remote node %s: %v", payload.ID, err)
}
}
}
// If the node was created from a template, also create child nodes
// for any default_files and default_folders that were not already synced
// as individual ops (backward compatibility with devices that do not
// sync template children).
_ = a.ensureTemplateChildren(payload.ID, payload.TemplateID, fsPath, payload.Title)
return nil
}
// ensureTemplateChildren creates child nodes for a template's default files
// and folders if they don't already exist. This handles backward compatibility
// with devices that do not sync template children as individual ops.
func (a *App) ensureTemplateChildren(nodeID, templateID, parentFsPath, title string) error {
if templateID == "" {
return nil
}
tmpl, ok := a.templates.Get(templateID)
if !ok {
return nil
}
nowRFC := time.Now().UTC().Format(time.RFC3339)
if len(tmpl.DefaultFolders) == 0 && len(tmpl.DefaultFiles) == 0 {
return nil
}
// Check existing children to avoid duplicates.
existing, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return err
}
exists := make(map[string]bool, len(existing))
for i := range existing {
exists[existing[i].Title] = true
}
for _, folderName := range tmpl.DefaultFolders {
if exists[folderName] {
continue
}
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
if folderSeg == "" {
folderSeg = "folder"
}
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeFolder, folderName, 0, "", "")
if childErr != nil {
continue
}
childFsPath := folderSeg
if parentFsPath != "" {
childFsPath = filepath.Join(parentFsPath, folderSeg)
}
fullPath := filepath.Join(a.vault, childFsPath)
fullPath = templates.UniquePath(fullPath)
rel, _ := filepath.Rel(a.vault, fullPath)
childFsPath = rel
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
_ = os.MkdirAll(fullPath, 0o755)
_ = a.activity.Record(nodeID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
}
for _, df := range tmpl.DefaultFiles {
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
if fileTitle == "" {
fileTitle = "Overview"
}
if exists[fileTitle] {
continue
}
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeNote, fileTitle, 0, "", "")
if childErr != nil {
continue
}
content := fmt.Sprintf("# %s\n\n", title)
fpath := filepath.Join(a.vault, parentFsPath, df.Path)
_ = os.MkdirAll(filepath.Dir(fpath), 0o750)
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
_ = a.nodes.SoftDelete(childNode.ID)
continue
}
relPath, _ := filepath.Rel(a.vault, fpath)
fi, _ := os.Stat(fpath)
size := int64(0)
if fi != nil {
size = fi.Size()
}
fileID := util.UUID7()
_, _ = a.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
_, _ = a.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
childNode.ID, fileID, "markdown")
_ = a.activity.Record(nodeID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
}
return nil
}
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
var payload struct {
Title string `json:"title"`
FsPath string `json:"fs_path"`
TemplateID string `json:"template_id"`
Archived *bool `json:"archived,omitempty"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal node update: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
// FS-first: rename folder on disk before touching DB
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.FsPath)
if err != nil {
return fmt.Errorf("unsafe fs_path in node update: %w", err)
}
payload.FsPath = cleanPath
oldPhys := filepath.Join(a.vault, n.FsPath)
newPhys := filepath.Join(a.vault, payload.FsPath)
if _, err := os.Stat(oldPhys); err == nil {
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
if err := os.Rename(oldPhys, newPhys); err != nil {
return fmt.Errorf("rename folder for update %s -> %s: %w", oldPhys, newPhys, err)
}
}
}
// Any title/fs_path/template_id changes? Then do atomic DB transaction.
if payload.Title != "" || payload.FsPath != "" || payload.TemplateID != "" {
tx, err := a.db.Begin()
if err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if payload.Title != "" {
slug := nodes.Slugify(payload.Title)
if _, err := tx.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
payload.Title, slug, now, op.EntityID); err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
}
if payload.FsPath != "" && isFolderLike {
if _, err := tx.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
if n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
}
if payload.TemplateID != "" {
if _, err := tx.Exec(
`UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`,
payload.TemplateID, now, op.EntityID); err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
}
if err := tx.Commit(); err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
if payload.Archived != nil {
v := 0
if *payload.Archived {
v = 1
}
_, err := a.db.Exec(
`UPDATE nodes SET archived=?, updated_at=? WHERE id=?`,
v, now, op.EntityID)
return err
}
_, err = a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
return err
}
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
var payload struct {
ParentID string `json:"parent_id"`
FsPath string `json:"fs_path"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal node move: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
if isFolderLike {
// Folder-like: FS-first rename, then DB transaction
if payload.FsPath != "" && n.FsPath != "" {
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.FsPath)
if err != nil {
return fmt.Errorf("unsafe fs_path in node move: %w", err)
}
payload.FsPath = cleanPath
oldPhys := filepath.Join(a.vault, n.FsPath)
newPhys := filepath.Join(a.vault, payload.FsPath)
if _, err := os.Stat(oldPhys); err == nil {
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
if err := os.Rename(oldPhys, newPhys); err != nil {
return fmt.Errorf("move folder %s -> %s: %w", oldPhys, newPhys, err)
}
}
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
tx, err := a.db.Begin()
if err != nil {
if payload.FsPath != "" && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, op.EntityID); err != nil {
if payload.FsPath != "" && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
if payload.FsPath != "" && n.FsPath != "" {
if _, err := tx.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
return err
}
}
if err := tx.Commit(); err != nil {
if payload.FsPath != "" && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
// Note/file: FS-first move, then DB transaction
return a.moveNodeFiles(n, payload.ParentID, now)
}
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := a.db.Exec(
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
now, now, op.EntityID)
return err
}
func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteNoteCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNoteUpdate(op)
case syncsvc.OpMove:
return a.applyRemoteNoteMove(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
return nil
}
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
var payload struct {
NodeID string `json:"node_id"`
ParentID string `json:"parent_id"`
Title string `json:"title"`
FileID string `json:"file_id"`
Format string `json:"format"`
Content string `json:"content"`
Filename string `json:"filename"`
Path string `json:"path"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal note create: %w", err)
}
if payload.NodeID == "" {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
title := payload.Title
if title == "" {
title = "remote-note"
}
slug := nodes.Slugify(title)
if _, err := a.nodes.Get(payload.NodeID); err != nil {
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
_, e := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
VALUES (?,?,'note',?,?,'','',?,?,1)`,
payload.NodeID, parent, title, slug, now, now)
if e != nil {
return e
}
} else if payload.ParentID != "" {
// Update parent_id on existing node (e.g., created by old version without parent_id).
_, _ = a.db.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=? AND (parent_id IS NULL OR parent_id='')`,
payload.ParentID, now, payload.NodeID)
}
var dest string
if payload.Path == "" {
filename := payload.Filename
if filename == "" {
filename = payload.NodeID[:8] + ".md"
}
parentFsPath := ""
if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil {
if parent, err := a.nodes.GetActive(*noteNode.ParentID); err == nil {
parentFsPath = parent.FsPath
}
}
if parentFsPath == "" {
parentFsPath = filepath.Join(".verstak", "remote-inbox")
}
dest = filepath.Join(a.vault, parentFsPath, filename)
payload.Path, _ = filepath.Rel(a.vault, dest)
} else {
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.Path)
if err != nil {
return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err)
}
dest = filepath.Join(a.vault, cleanPath)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
return err
}
if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil {
return err
}
info, _ := os.Stat(dest)
size := int64(0)
if info != nil {
size = info.Size()
}
fileID := payload.FileID
if fileID == "" {
fileID = util.UUID7()
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
if err != nil {
return err
}
format := payload.Format
if format == "" {
format = "markdown"
}
_, err = a.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
payload.NodeID, fileID, format)
return err
}
func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
var payload struct {
NodeID string `json:"node_id"`
Content string `json:"content"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal note update: %w", err)
}
if payload.NodeID == "" {
return nil
}
var filePath, storageMode string
err := a.db.QueryRow(
`SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`,
payload.NodeID).Scan(&filePath, &storageMode)
if err != nil {
return fmt.Errorf("note record not found: %w", err)
}
if storageMode == "vault" {
clean, err := syncsvc.SafeVaultPath(a.vault, filePath)
if err != nil {
return fmt.Errorf("unsafe vault path in note update: %w", err)
}
abs := filepath.Join(a.vault, clean)
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
return err
}
info, _ := os.Stat(abs)
size := int64(0)
if info != nil {
size = info.Size()
}
now := time.Now().UTC().Format(time.RFC3339)
_, e := a.db.Exec(
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
size, now, filePath, storageMode)
return e
}
log.Printf("applyRemoteNoteUpdate: skipping non-vault note update for node %s (mode=%s, path=%s)",
payload.NodeID, storageMode, filePath)
return nil
}
func (a *App) moveNodeFiles(n *nodes.Node, newParentID, now string) error {
var parentFsPath string
if newParentID != "" {
parent, err := a.nodes.GetActive(newParentID)
if err == nil && parent.FsPath != "" {
parentFsPath = parent.FsPath
}
}
type fileMove struct {
id string
oldPath string
oldAbs string
newRelPath string
newAbs string
}
var fileMoves []fileMove
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, n.ID)
if ferr == nil {
for frows.Next() {
var fm fileMove
if err := frows.Scan(&fm.id, &fm.oldPath); err != nil {
continue
}
if fm.oldPath == "" {
continue
}
fm.oldAbs = filepath.Join(a.vault, fm.oldPath)
filename := filepath.Base(fm.oldPath)
fm.newRelPath = filename
if parentFsPath != "" {
fm.newRelPath = filepath.Join(parentFsPath, filename)
}
fm.newAbs = filepath.Join(a.vault, fm.newRelPath)
fileMoves = append(fileMoves, fm)
}
frows.Close()
}
if len(fileMoves) == 0 {
return nil
}
// FS-first: move all files (with rollback on partial failure)
for i, fm := range fileMoves {
if _, err := os.Stat(fm.oldAbs); err != nil {
for j := 0; j < i; j++ {
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
}
return fmt.Errorf("source file not found for move: %w", err)
}
_ = os.MkdirAll(filepath.Dir(fm.newAbs), 0o750)
if err := os.Rename(fm.oldAbs, fm.newAbs); err != nil {
for j := 0; j < i; j++ {
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
}
return fmt.Errorf("move file %s -> %s: %w", fm.oldAbs, fm.newAbs, err)
}
}
// Atomic DB transaction: parent_id + file paths
tx, err := a.db.Begin()
if err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var parent interface{}
if newParentID != "" {
parent = newParentID
}
if _, err := tx.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, n.ID); err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return err
}
for _, fm := range fileMoves {
if _, err := tx.Exec(`UPDATE files SET path=? WHERE id=?`,
fm.newRelPath, fm.id); err != nil {
for _, fm2 := range fileMoves {
_ = os.Rename(fm2.newAbs, fm2.oldAbs)
}
return err
}
}
if err := tx.Commit(); err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func (a *App) applyRemoteNoteMove(op syncsvc.Op) error {
var payload struct {
ParentID string `json:"parent_id"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal note move: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
// FS-first move, then DB transaction (handled inside moveNodeFiles)
return a.moveNodeFiles(n, payload.ParentID, now)
}
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteFileCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNodeUpdate(op)
case syncsvc.OpMove:
return a.applyRemoteNodeMove(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
return nil
}
func (a *App) applyRemoteFileCreate(op syncsvc.Op) error {
var payload struct {
NodeID string `json:"node_id"`
Type string `json:"type"`
Title string `json:"title"`
Slug string `json:"slug"`
ParentID string `json:"parent_id"`
Filename string `json:"filename"`
Path string `json:"path"`
StorageMode string `json:"storage_mode"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
MIME string `json:"mime"`
FileID string `json:"file_id"`
BlobSHA256 string `json:"blob_sha256"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal file create: %w", err)
}
if payload.NodeID == "" {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := a.nodes.Get(payload.NodeID); err != nil {
slug := payload.Slug
if slug == "" {
slug = nodes.Slugify(payload.Title)
}
ntype := payload.Type
if ntype == "" {
ntype = "file"
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
_, e := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision)
VALUES (?,?,?,?,?,?,?,1)`,
payload.NodeID, parent, ntype, payload.Title, slug, now, now)
if e != nil {
return e
}
}
if payload.BlobSHA256 != "" && payload.StorageMode == "vault" {
blobsDir := syncsvc.BlobDir(a.vault)
blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256)
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
serverURL, apiKey, _, _, _ := a.sync.GetState()
deviceToken := config.LoadDeviceToken(a.vault)
cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault)
cli.DeviceToken = deviceToken
if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil {
log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err)
}
}
cleanPath, pathErr := syncsvc.SafeVaultPath(a.vault, payload.Path)
if pathErr != nil {
return fmt.Errorf("unsafe path in file: %w", pathErr)
}
dest := filepath.Join(a.vault, cleanPath)
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil {
input, rErr := os.ReadFile(blobPath)
if rErr == nil {
_ = os.WriteFile(dest, input, 0o640)
}
}
}
fileID := payload.FileID
if fileID == "" {
fileID = util.UUID7()
}
storageMode := payload.StorageMode
if storageMode == "" {
storageMode = "vault"
}
mime := payload.MIME
if mime == "" {
mime = "application/octet-stream"
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
fileID, payload.NodeID, payload.Filename, payload.Path, storageMode,
payload.Size, payload.SHA256, mime, now, now)
return err
}
func (a *App) applyRemoteActionOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteActionCreate(op)
case syncsvc.OpDelete:
_, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID)
return err
}
return nil
}
func (a *App) applyRemoteActionCreate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Title string `json:"title"`
Kind string `json:"kind"`
Command string `json:"command"`
Args []string `json:"args"`
WorkingDir string `json:"working_dir"`
URL string `json:"url"`
ConfirmRequired bool `json:"confirm_required"`
CaptureOutput bool `json:"capture_output"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal action create: %w", err)
}
if payload.ID == "" || payload.NodeID == "" {
return nil
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
payload.ID, payload.NodeID, payload.Title, payload.Kind,
payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL,
boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput),
payload.CreatedAt, payload.UpdatedAt)
return err
}
func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteWorklogCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteWorklogUpdate(op)
case syncsvc.OpDelete:
if _, err := a.db.Exec(`DELETE FROM worklog_entry_events WHERE entry_id=?`, op.EntityID); err != nil {
return err
}
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
return err
}
return nil
}
func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Summary string `json:"summary"`
Details string `json:"details"`
Minutes int `json:"minutes"`
Date string `json:"date"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal worklog create: %w", err)
}
if payload.ID == "" || payload.NodeID == "" {
return nil
}
_, err := a.db.Exec(
`INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt),
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
return err
}
func (a *App) applyRemoteWorklogUpdate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
Summary string `json:"summary"`
Details string `json:"details"`
Minutes int `json:"minutes"`
Date string `json:"date"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal worklog update: %w", err)
}
id := payload.ID
if id == "" {
id = op.EntityID
}
if id == "" {
return nil
}
updatedAt := payload.UpdatedAt
if updatedAt == "" {
updatedAt = time.Now().UTC().Format(time.RFC3339)
}
_, err := a.db.Exec(
`UPDATE worklog_entries SET date=?, minutes=?, approximate=?, billable=?,
summary=?, details=?, updated_at=? WHERE id=?`,
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
payload.Summary, payload.Details, updatedAt, id)
return err
}

View File

@ -0,0 +1,457 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Trash Me", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
if err := app.DeleteNode(n.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var foundNode bool
for _, node := range trash.Nodes {
if node.ID == n.ID && node.Title == "Trash Me" && node.DeletedAt != "" {
foundNode = true
break
}
}
if !foundNode {
t.Fatalf("deleted node %s missing from trash nodes: %#v", n.ID, trash.Nodes)
}
var foundPhysical bool
for _, entry := range trash.Entries {
if strings.Contains(entry.Name, n.ID) && entry.IsDir {
foundPhysical = true
break
}
}
if !foundPhysical {
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
}
}
func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T) {
app, vault := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
child, err := app.CreateNodeFromTemplate(parent.ID, "Specs", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
other, err := app.CreateNodeFromTemplate(parent.ID, "Drafts", "folder.default")
if err != nil {
t.Fatalf("create other: %v", err)
}
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
if err := app.RestoreTrashNode(child.ID); err != nil {
t.Fatalf("RestoreTrashNode(child): %v", err)
}
for _, id := range []string{parent.ID, child.ID} {
if _, err := app.nodes.GetActive(id); err != nil {
t.Fatalf("node %s should be active after restore: %v", id, err)
}
}
if _, err := app.nodes.GetActive(other.ID); err == nil {
t.Fatalf("unselected sibling should remain deleted")
}
if _, err := os.Stat(filepath.Join(vault, "Documents", "Specs")); err != nil {
t.Fatalf("restored child path missing: %v", err)
}
}
func TestRestoreTrashNodeFromNestedDeletedFolderRestoresFullPath(t *testing.T) {
app, vault := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Verstak", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
templates, err := app.CreateNodeFromTemplate(parent.ID, "templates", "folder.default")
if err != nil {
t.Fatalf("create templates: %v", err)
}
registry, err := app.CreateNodeFromTemplate(templates.ID, "registry.go", "folder.default")
if err != nil {
t.Fatalf("create registry: %v", err)
}
other, err := app.CreateNodeFromTemplate(templates.ID, "other.go", "folder.default")
if err != nil {
t.Fatalf("create other: %v", err)
}
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
if err := app.RestoreTrashNode(registry.ID); err != nil {
t.Fatalf("RestoreTrashNode(registry): %v", err)
}
for _, id := range []string{parent.ID, templates.ID, registry.ID} {
if _, err := app.nodes.GetActive(id); err != nil {
t.Fatalf("node %s should be active after restore: %v", id, err)
}
}
if _, err := app.nodes.GetActive(other.ID); err == nil {
t.Fatalf("unselected nested sibling should remain deleted")
}
if _, err := os.Stat(filepath.Join(vault, "Verstak", "templates", "registry.go")); err != nil {
t.Fatalf("restored nested path missing: %v", err)
}
}
func TestTrashCountPurgeAndEmpty(t *testing.T) {
app, _ := setupTestApp(t)
a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default")
b, _ := app.CreateNodeFromTemplate("", "Trash B", "folder.default")
if err := app.DeleteNode(a.ID); err != nil {
t.Fatalf("delete A: %v", err)
}
if err := app.DeleteNode(b.ID); err != nil {
t.Fatalf("delete B: %v", err)
}
count, err := app.TrashCount()
if err != nil {
t.Fatalf("TrashCount: %v", err)
}
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 != 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)
}
count, _ = app.TrashCount()
if count != 0 {
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)
}
}
}

View File

@ -0,0 +1,149 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"verstak/internal/core/nodes"
)
type VaultCheckResult struct {
TotalNodes int `json:"total_nodes"`
TotalFiles int `json:"total_files"`
NodesWithFsPath int `json:"nodes_with_fs_path"`
FoldersOnDisk int `json:"folders_on_disk"`
FilesOnDisk int `json:"files_on_disk"`
FilesMissing int `json:"files_missing"`
PathEscapeCount int `json:"path_escape_count"`
ParentIDEmptyCount int `json:"parent_id_empty_count"`
OrphanDescendantCount int `json:"orphan_descendant_count"`
Errors []string `json:"errors,omitempty"`
Details []string `json:"details,omitempty"`
Healthy bool `json:"healthy"`
}
func (a *App) VaultCheck() (*VaultCheckResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
result := &VaultCheckResult{Healthy: true}
// Build a set of all node IDs for ancestor check
allNodes := make(map[string]*nodes.Node)
roots, err := a.nodes.ListRoots(true)
if err != nil {
return nil, fmt.Errorf("list roots: %w", err)
}
var collectDescendants func(id string)
collectDescendants = func(id string) {
n, err := a.nodes.Get(id)
if err == nil {
allNodes[n.ID] = n
}
children, _ := a.nodes.ListChildren(id, true)
for _, c := range children {
allNodes[c.ID] = &c
collectDescendants(c.ID)
}
}
for _, n := range roots {
allNodes[n.ID] = &n
collectDescendants(n.ID)
}
// Check parent_id consistency
for id, n := range allNodes {
if !n.IsRoot() && n.ParentID != nil && *n.ParentID == "" {
result.ParentIDEmptyCount++
result.Errors = append(result.Errors,
fmt.Sprintf("node %s (%s): parent_id is empty string, should be nil", id, n.Title))
result.Healthy = false
}
}
// Check each node
for _, n := range allNodes {
if n.IsDeleted() {
continue
}
result.TotalNodes++
// Check if ancestor is deleted
if n.ParentID != nil && *n.ParentID != "" {
if parent, ok := allNodes[*n.ParentID]; ok && parent.IsDeleted() {
result.OrphanDescendantCount++
result.Errors = append(result.Errors,
fmt.Sprintf("node %s (%s) is active but parent %s is deleted", n.ID, n.Title, *n.ParentID))
result.Healthy = false
}
}
// Check fs_path
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
if isFolderLike && n.FsPath != "" {
result.NodesWithFsPath++
physPath := filepath.Join(a.vault, n.FsPath)
rel, err := filepath.Rel(a.vault, physPath)
if err != nil || strings.HasPrefix(rel, "..") {
result.PathEscapeCount++
result.Errors = append(result.Errors,
fmt.Sprintf("node %s (%s): fs_path escapes vault: %s", n.ID, n.Title, n.FsPath))
result.Healthy = false
continue
}
if info, err := os.Stat(physPath); err == nil {
if info.IsDir() {
result.FoldersOnDisk++
}
} else {
result.FilesMissing++
result.Details = append(result.Details,
fmt.Sprintf("node %s (%s): folder missing on disk: %s", n.ID, n.Title, physPath))
}
}
}
// Check file records
rows, err := a.db.Query(`SELECT f.id, f.node_id, f.path, f.storage_mode FROM files f LEFT JOIN nodes n ON f.node_id = n.id WHERE n.deleted_at IS NULL`)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err))
result.Healthy = false
return result, nil
}
defer rows.Close()
for rows.Next() {
var id, nodeID, path, mode string
if err := rows.Scan(&id, &nodeID, &path, &mode); err != nil {
continue
}
result.TotalFiles++
if mode == "vault" {
absPath := filepath.Join(a.vault, path)
rel, err := filepath.Rel(a.vault, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
result.PathEscapeCount++
result.Errors = append(result.Errors,
fmt.Sprintf("file %s: path escapes vault: %s", id, path))
result.Healthy = false
continue
}
if _, err := os.Stat(absPath); err == nil {
result.FilesOnDisk++
} else {
result.FilesMissing++
result.Details = append(result.Details,
fmt.Sprintf("file %s (%s): missing on disk: %s", id, path, absPath))
}
}
}
if result.FilesMissing > 0 || result.PathEscapeCount > 0 ||
result.ParentIDEmptyCount > 0 || result.OrphanDescendantCount > 0 {
result.Healthy = false
}
return result, nil
}

View File

@ -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")
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
package main
import (
"fmt"
"os"
"path/filepath"
"verstak/internal/core/nodes"
"verstak/internal/core/templates"
)
// MigrateVaultLayout rebuilds fs_path for all existing nodes based on
// parent-child relationships and creates human-readable folders in the vault.
// It performs a dry-run if dryRun is true.
func (a *App) MigrateVaultLayout(dryRun bool) (*MigrationReport, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
report := &MigrationReport{}
// Load all nodes
allNodes, err := a.nodes.ListRoots(true)
if err != nil {
return nil, fmt.Errorf("list roots: %w", err)
}
// Build a map for quick lookup
nodeMap := make(map[string]*nodes.Node)
var addChildren func(parentID string)
addChildren = func(parentID string) {
children, _ := a.nodes.ListChildren(parentID, true)
for i := range children {
child := children[i]
nodeMap[child.ID] = &child
addChildren(child.ID)
}
}
for i := range allNodes {
n := allNodes[i]
nodeMap[n.ID] = &n
addChildren(n.ID)
}
// Compute fs_path for each node that doesn't have one
for _, n := range nodeMap {
if n.FsPath != "" {
continue
}
seg := templates.SafeDisplayNameToPathSegment(n.Title)
fsPath := seg
if n.ParentID != nil {
if parent, ok := nodeMap[*n.ParentID]; ok {
parentSeg := templates.SafeDisplayNameToPathSegment(parent.Title)
if parent.FsPath != "" {
fsPath = filepath.Join(parent.FsPath, seg)
} else {
fsPath = filepath.Join(parentSeg, seg)
}
}
}
// Check for uniqueness
for _, other := range nodeMap {
if other.ID != n.ID && other.FsPath == fsPath {
fsPath = templates.UniquePath(filepath.Join(a.vault, fsPath))
rel, _ := filepath.Rel(a.vault, fsPath)
fsPath = rel
break
}
}
physPath := filepath.Join(a.vault, fsPath)
if dryRun {
report.DryRun = true
report.Actions = append(report.Actions, fmt.Sprintf("WOULD create folder: %s (node: %s)", physPath, n.Title))
} else {
if err := os.MkdirAll(physPath, 0o755); err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("mkdir %s: %v", physPath, err))
continue
}
if err := a.nodes.UpdateFsPath(n.ID, fsPath); err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("update fs_path %s: %v", n.ID, err))
continue
}
report.FoldersCreated++
}
// Also set template_id based on type if not set
if n.TemplateID == "" {
tmplID := typeToTemplateID(n.Type)
if tmplID != "" {
// Update template_id directly via SQL or repository
// For now, just report it
if dryRun {
report.Actions = append(report.Actions, fmt.Sprintf("WOULD set template_id=%s for node %s", tmplID, n.Title))
} else {
report.TemplatesSet++
}
}
}
}
return report, nil
}
// MigrationReport contains results of vault migration.
type MigrationReport struct {
DryRun bool `json:"dry_run"`
FoldersCreated int `json:"folders_created"`
TemplatesSet int `json:"templates_set"`
Actions []string `json:"actions,omitempty"`
Errors []string `json:"errors,omitempty"`
}
func typeToTemplateID(typ string) string {
switch typ {
case "folder":
return "folder.default"
case "project":
return "project.default"
case "client":
return "client.default"
case "document":
return "document.default"
case "recipe":
return "recipe.default"
case "space", "case":
return "folder.default"
default:
return ""
}
}

View File

@ -0,0 +1,12 @@
{
"name": "verstak-gui",
"assetdir": "frontend/dist",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"wailsjsdir": "frontend/wailsjs",
"version": "2",
"outputfilename": "verstak-gui",
"projectdir": "cmd/verstak-gui"
}

View File

@ -0,0 +1,89 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sync"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
type AdminUser struct {
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
}
type Config struct {
Port int `yaml:"port"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
}
func LoadConfig(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, "config.yml")
cfg := &Config{
Port: 47732,
Admin: nil,
path: path,
}
data, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
}
return cfg, nil
}
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
}
func (c *Config) SetAdmin(username, password string) error {
c.mu.Lock()
defer c.mu.Unlock()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := AdminUser{Username: username, PasswordHash: string(hash)}
// Replace existing or append.
for i, u := range c.Admin {
if u.Username == username {
c.Admin[i] = user
return c.saveLocked()
}
}
c.Admin = append(c.Admin, user)
return c.saveLocked()
}
func (c *Config) CheckAdmin(username, password string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range c.Admin {
if u.Username == username {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return true
}
}
}
return false
}
func (c *Config) saveLocked() error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
}

View File

@ -0,0 +1,372 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(adminLoginHTML(s.locale())))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.cfg.CheckAdmin(user, pass) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(401)
w.Write([]byte(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/admin/login")))
return
}
tok := s.tokens.Create()
http.SetCookie(w, &http.Cookie{
Name: "session", Value: tok, Path: "/admin",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var deviceCount, opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
smtpHost := s.smtpGet("smtp_host")
smtpPort := s.smtpGet("smtp_port")
smtpUser := s.smtpGet("smtp_user")
smtpFrom := s.smtpGet("smtp_from")
smtpSecurity := s.smtpGet("smtp_security")
srvURL := s.smtpGet("server_url")
w.Write([]byte(adminDashboardHTML(s.locale(), deviceCount, opsCount, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL)))
}
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(adminUsersHTML(s.locale())))
}
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
jsonOK(w, map[string]int{"ops": opsCount})
}
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var req struct {
Host string `json:"smtp_host"`
Port string `json:"smtp_port"`
User string `json:"smtp_user"`
Pass string `json:"smtp_pass"`
Security string `json:"smtp_security"`
From string `json:"smtp_from"`
To string `json:"test_to"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
host := req.Host
port := req.Port
user := req.User
pass := req.Pass
security := req.Security
from := req.From
to := req.To
if to == "" {
to = from
}
if host == "" || port == "" || from == "" {
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
return
}
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
return
}
jsonOK(w, map[string]interface{}{"ok": true})
}
func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/admin")
switch {
case path == "/api/devices" && r.Method == "GET":
rows, err := s.db.Query(`
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
COALESCE(u.username,'')
FROM server_devices d
LEFT JOIN server_users u ON u.id = d.user_id
ORDER BY d.created_at DESC`)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type devDTO struct {
ID string `json:"id"`
Name string `json:"name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
User string `json:"user"`
}
var out []devDTO
for rows.Next() {
var d devDTO
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
out = append(out, d)
}
jsonOK(w, out)
case path == "/api/keys" && r.Method == "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
var out []map[string]string
for rows.Next() {
var id, name, key string
rows.Scan(&id, &name, &key)
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
}
jsonOK(w, out)
case path == "/api/keys" && r.Method == "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
name := r.FormValue("name")
if name == "" {
jsonErr(w, 400, "name required")
return
}
b := make([]byte, 20)
rand.Read(b)
apiKey := hex.EncodeToString(b)
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
apiKey[:12], name, apiKey, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE":
id := strings.TrimPrefix(path, "/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
jsonOK(w, map[string]string{"status": "deleted"})
case path == "/api/smtp" && r.Method == "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
val := r.FormValue(key)
if val != "" {
s.smtpSet(key, val)
}
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
case path == "/api/users" && r.Method == "GET":
filter := r.URL.Query().Get("filter")
sort := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
where := ""
var args []interface{}
if filter != "" {
where = " WHERE u.username LIKE ?"
args = append(args, "%"+filter+"%")
}
validSorts := map[string]string{
"username": "u.username",
"email": "u.email",
"confirmed": "u.confirmed",
"blocked": "u.blocked",
"created_at": "u.created_at",
"last_seen": "u.last_seen",
"devices": "devices",
}
orderClause := "u.created_at DESC"
if col, ok := validSorts[sort]; ok {
if order != "asc" {
order = "desc"
}
orderClause = col + " " + order
}
// Count total.
var total int
countSQL := "SELECT COUNT(*) FROM server_users u" + where
s.db.QueryRow(countSQL, args...).Scan(&total)
// Fetch page.
offset := (page - 1) * perPage
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
args = append(args, perPage, offset)
rows, err := s.db.Query(sql, args...)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type userRow struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Confirmed int `json:"confirmed"`
Blocked int `json:"blocked"`
LastSeen string `json:"last_seen"`
CreatedAt string `json:"created_at"`
Devices int `json:"devices"`
}
var users []userRow
for rows.Next() {
var u userRow
var lastSeen *string
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
if lastSeen != nil {
u.LastSeen = *lastSeen
}
users = append(users, u)
}
jsonOK(w, map[string]interface{}{
"users": users,
"total": total,
"page": page,
"per_page": perPage,
})
case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
sub := strings.TrimPrefix(path, "/api/users/")
if strings.HasSuffix(sub, "/block") {
id := strings.TrimSuffix(sub, "/block")
id = strings.TrimSuffix(id, "/")
var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
newVal := 1
if blocked != 0 {
newVal = 0
}
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
return
}
if strings.HasSuffix(sub, "/reset-password") {
id := strings.TrimSuffix(sub, "/reset-password")
id = strings.TrimSuffix(id, "/")
b := make([]byte, 12)
rand.Read(b)
newPass := hex.EncodeToString(b)
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
return
}
if strings.HasSuffix(sub, "/edit") {
id := strings.TrimSuffix(sub, "/edit")
id = strings.TrimSuffix(id, "/")
var req struct {
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Username == "" || req.Email == "" {
jsonErr(w, 400, "username and email required")
return
}
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]interface{}{"status": "ok"})
return
}
jsonErr(w, 404, "unknown action")
case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
id := strings.TrimPrefix(path, "/api/users/")
id = strings.TrimSuffix(id, "/")
// Get user devices to delete.
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
var deviceIDs []string
for rows.Next() {
var did string
rows.Scan(&did)
deviceIDs = append(deviceIDs, did)
}
rows.Close()
for _, did := range deviceIDs {
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
}
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
jsonOK(w, map[string]interface{}{"status": "deleted"})
default:
jsonErr(w, 404, "not found")
}
}

View File

@ -0,0 +1,325 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("Verstak Sync Server\n"))
return
}
jsonErr(w, 404, "not found")
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]interface{}{
"status": "ok",
"version": "verstak-server/v1",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
ip = ip[:idx]
}
if !s.pairLimit.allow(ip) {
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
jsonErr(w, 429, "too many attempts")
return
}
var req struct {
Login string `json:"login"`
Password string `json:"password"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Login == "" || req.Password == "" {
jsonErr(w, 400, "login and password required")
return
}
if req.DeviceName == "" {
req.DeviceName = "unknown"
}
// Look up user.
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
jsonErr(w, 401, "invalid credentials")
return
}
// Generate device.
devID := make([]byte, 12)
rand.Read(devID)
deviceID := "dev_" + hex.EncodeToString(devID)
token, prefix, suffix := genDeviceToken()
tokenHash := sha256Hex(token)
now := time.Now().UTC().Format(time.RFC3339)
apiKey := make([]byte, 20)
rand.Read(apiKey)
_, err = s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
userID, req.ClientVersion, ip, now, now)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
s.pairLimit.reset(ip)
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
jsonOK(w, map[string]interface{}{
"user_id": userID,
"device_id": deviceID,
"device_token": token,
"server_time": now,
"initial_sync_cursor": 0,
})
}
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
var hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
jsonOK(w, map[string]string{"status": "ok"})
}
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID string
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
if err != nil {
jsonErr(w, 401, "invalid token")
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
}
var req struct {
DeviceID string `json:"device_id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.DeviceID == "" || req.Password == "" {
jsonErr(w, 400, "device_id and password required")
return
}
// Verify password.
var pwHash string
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash)
if err != nil {
jsonErr(w, 403, "access denied")
return
}
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
jsonErr(w, 403, "wrong password")
return
}
// Verify device belongs to user.
var devUserID string
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
if err != nil {
jsonErr(w, 404, "device not found")
return
}
if devUserID != userID {
jsonErr(w, 403, "device does not belong to you")
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
FROM server_devices d WHERE d.token_hash=?`, hash).
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
if err != nil {
jsonErr(w, 401, "invalid token")
return
}
var username string
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"user_id": userID,
"username": username,
"device_name": name,
"client_version": clientVer,
"last_seen": lastSeen,
"revoked_at": revokedAt,
"created_at": createdAt,
})
}
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Name == "" {
jsonErr(w, 400, "name required")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 401, "username and password required")
return
}
// Look up user by username or email.
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
b := make([]byte, 20)
rand.Read(b)
apiKey := hex.EncodeToString(b)
deviceID := apiKey[:12]
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
// Link device to user.
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"api_key": apiKey,
})
}

View File

@ -0,0 +1,541 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/i18n"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
jsonErr(w, 400, "username, email and password required")
return
}
if err := validatePassword(req.Password); err != "" {
jsonErr(w, 400, err)
return
}
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
jsonErr(w, 400, "invalid email")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
jsonErr(w, 409, "username or email already taken")
return
}
jsonErr(w, 500, err.Error())
return
}
// Confirmation token.
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
// Try to send email.
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
var confirmURL string
if srvURL != "" {
confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr)
} else {
confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr)
}
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailConfirmBody"), confirmURL)
if err := s.smtpSend(req.Email, i18n.T(s.locale(), "server.emailConfirmSubject"), body); err != nil {
log.Printf("register: failed to send confirm email: %v", err)
}
} else {
log.Printf("register: SMTP not configured, confirmation token=%s for user %s", tokenStr, req.Username)
}
jsonOK(w, map[string]string{"status": "confirmation_sent"})
}
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
return
}
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
jsonErr(w, 400, "token required")
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
tokenStr).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
jsonErr(w, 400, "token expired")
return
}
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
log.Printf("confirm: user %s confirmed email", userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(confirmedHTML(s.locale())))
}
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
tok := s.userTokens.Create(userID)
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
}
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Email == "" {
jsonErr(w, 400, "email required")
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
if err != nil {
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
if srvURL != "" {
resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr)
}
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailResetBody"), resetURL)
s.smtpSend(req.Email, i18n.T(s.locale(), "server.emailResetSubject"), body)
}
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Token == "" || req.NewPassword == "" {
jsonErr(w, 400, "token and new_password required")
return
}
if err := validatePassword(req.NewPassword); err != "" {
jsonErr(w, 400, err)
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
req.Token).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
jsonErr(w, 400, "token expired")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
jsonOK(w, map[string]string{"status": "password reset"})
}
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
userID, ok := s.requireUser(w, r)
if !ok {
return
}
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
return
}
rows, err := s.db.Query(`
SELECT d.id, d.name, d.last_seen, d.created_at
FROM server_devices d
JOIN server_user_devices ud ON ud.device_id = d.id
WHERE ud.user_id = ?
ORDER BY d.created_at`, userID)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type deviceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
LastSeen string `json:"last_seen"`
CreatedAt string `json:"created_at"`
}
var devices []deviceDTO
for rows.Next() {
var d deviceDTO
var lastSeen sql.NullString
if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil {
continue
}
d.LastSeen = lastSeen.String
devices = append(devices, d)
}
if devices == nil {
devices = []deviceDTO{}
}
jsonOK(w, map[string]interface{}{"devices": devices})
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
} `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON: "+err.Error())
return
}
// Idempotency: if request-level key provided, check for cached response.
if req.IdempotencyKey != "" {
var cachedJSON string
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(cachedJSON))
return
}
}
now := time.Now().UTC().Format(time.RFC3339)
var accepted []string
var conflicts []map[string]interface{}
for _, op := range req.Ops {
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
continue
}
// Conflict detection: check if another device already created ops for this entity
// with a server_sequence higher than what this client last saw.
if op.LastSeenServerSeq > 0 {
conflictRows, err := s.db.Query(`
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
WHERE entity_type=? AND entity_id=? AND device_id!=?
AND server_sequence > ? AND op_type != 'delete'
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq)
if err == nil {
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
var cSeq int
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
conflicts = append(conflicts, map[string]interface{}{
"op_id": cOpID,
"device_id": cDevID,
"op_type": cOpType,
"server_sequence": cSeq,
"entity_type": op.EntityType,
"entity_id": op.EntityID,
})
}
conflictRows.Close()
}
}
res, err := s.db.Exec(
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
)
if err != nil {
continue
}
n, _ := res.RowsAffected()
if n == 0 {
continue // duplicate op_id
}
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
if err != nil {
continue
}
seq, _ := seqRes.LastInsertId()
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
if op.OpType == "delete" {
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
op.EntityType, op.EntityID, op.OpID, now)
}
accepted = append(accepted, op.OpID)
}
resp := map[string]interface{}{
"accepted": accepted,
"count": len(accepted),
"conflicts": conflicts,
}
// Cache response for idempotency.
if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil {
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
req.IdempotencyKey, string(respJSON), now)
}
}
jsonOK(w, resp)
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
SinceSequence int `json:"since_sequence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
var serverSeq int
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
rows, err := s.db.Query(`
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
FROM server_ops
WHERE server_sequence > ? AND server_sequence IS NOT NULL
ORDER BY server_sequence`, req.SinceSequence)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type opDTO struct {
OpID string `json:"op_id"`
ServerSequence int `json:"server_sequence"`
DeviceID string `json:"device_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
var ops []opDTO
for rows.Next() {
var o opDTO
if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
continue
}
ops = append(ops, o)
}
jsonOK(w, map[string]interface{}{
"server_sequence": serverSeq,
"ops": ops,
})
}
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
return
}
switch r.Method {
case "POST":
// Upload: accept multipart file, store by SHA-256.
if err := r.ParseMultipartForm(200 << 20); err != nil {
jsonErr(w, 400, "multipart error: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "file field required")
return
}
defer file.Close()
// Read content and compute SHA-256.
data, err := io.ReadAll(file)
if err != nil {
jsonErr(w, 500, "read error")
return
}
hash := sha256.Sum256(data)
shaHex := hex.EncodeToString(hash[:])
// Store at blobs/ab/cd/sha256.
blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4])
if err := os.MkdirAll(blobDir, 0750); err != nil {
jsonErr(w, 500, "mkdir error")
return
}
blobPath := filepath.Join(blobDir, shaHex)
if err := os.WriteFile(blobPath, data, 0640); err != nil {
jsonErr(w, 500, "write error")
return
}
_ = header
// Record in blobs table.
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
shaHex, len(data), now)
jsonOK(w, map[string]interface{}{
"sha256": shaHex,
"size": len(data),
})
case "GET":
// Download: GET /api/v1/blobs/{sha256}
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
if len(shaHex) != 64 {
jsonErr(w, 400, "invalid SHA-256")
return
}
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
jsonErr(w, 404, "blob not found")
return
}
data, err := os.ReadFile(blobPath)
if err != nil {
jsonErr(w, 500, "read error")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
w.Write(data)
default:
jsonErr(w, 405, "method not allowed")
}
}

View File

@ -0,0 +1,336 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
"time"
"verstak/internal/i18n"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
cookie, err := r.Cookie("user_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
userID, ok := s.userTokens.Check(cookie.Value)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
return userID, true
}
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userRegisterHTML(s.locale())))
case "POST":
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(s.locale(), "400 Bad request", "400 Bad request", "/register")))
return
}
username := r.FormValue("username")
email := r.FormValue("email")
password := r.FormValue("password")
if username == "" || email == "" || password == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.allFieldsRequired"), "/register")))
return
}
if err := validatePassword(password); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err, "/register")))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(500)
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "error.generic"), "/register")))
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, username, strings.ToLower(email), string(hash), now,
)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if strings.Contains(err.Error(), "UNIQUE") {
w.WriteHeader(409)
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), "Username or email already taken", "/register")))
} else {
w.WriteHeader(500)
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err.Error(), "/register")))
}
return
}
// Confirmation token.
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
// Try to send email.
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
var confirmURL string
if srvURL != "" {
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
} else {
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
}
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailConfirmBody"), confirmURL)
if err := s.smtpSend(email, i18n.T(s.locale(), "server.emailConfirmSubject"), body); err != nil {
log.Printf("register web: failed to send confirm email: %v", err)
}
} else {
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
regMsg := registrationOKHTML(s.locale())
if host == "" {
regMsg = registrationAutoHTML(s.locale())
}
w.Write([]byte(regMsg))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotPasswordHTML(s.locale())))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
email := strings.ToLower(r.FormValue("email"))
if email == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.needEmail"), "/forgot")))
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotSentHTML(s.locale())))
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
if srvURL != "" {
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
}
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailResetBody"), resetURL)
if err := s.smtpSend(email, i18n.T(s.locale(), "server.emailResetSubject"), body); err != nil {
log.Printf("forgot web: failed to send reset email: %v", err)
}
} else {
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotSentHTML(s.locale())))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
token := r.URL.Query().Get("token")
if token == "" {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
// Validate token exists and not expired before showing form.
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
token).Scan(&userID, &expiresAt)
if err != nil {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := strings.ReplaceAll(resetPasswordHTML(s.locale()), "{TOKEN}", token)
w.Write([]byte(html))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
token := r.FormValue("token")
newPass := r.FormValue("password")
confirm := r.FormValue("confirm")
if token == "" || newPass == "" || confirm == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.allFieldsRequired"), "/forgot")))
return
}
if err := validatePassword(newPass); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err, "/reset?token="+token)))
return
}
if newPass != confirm {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.passwordsDoNotMatch"), "/reset?token="+token)))
return
}
var userID string
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
if err != nil {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
log.Printf("reset: user %s reset password", userID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(resetDoneHTML(s.locale())))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userLoginHTML(s.locale())))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
username := r.FormValue("username")
password := r.FormValue("password")
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(401)
w.Write([]byte(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/login")))
return
}
tok := s.userTokens.Create(userID)
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: tok, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
}
var username string
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
// Get devices with status info.
type dev struct {
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
}
var devices []dev
rows, err := s.db.Query(`
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
FROM server_devices d
JOIN server_user_devices ud ON ud.device_id = d.id
WHERE ud.user_id = ?
ORDER BY d.created_at DESC`, userID)
if err == nil {
defer rows.Close()
for rows.Next() {
var d dev
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
devices = append(devices, d)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
deviceRows := ""
if len(devices) == 0 {
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>" + i18n.T(s.locale(), "userDashboard.noDevices") + "</td></tr>"
} else {
for _, d := range devices {
ls := d.LastSeen
if ls == "" {
ls = "—"
}
created := d.CreatedAt
if len(created) > 10 {
created = created[:10]
}
status := "<span style='color:#34d399'>" + i18n.T(s.locale(), "userDashboard.active") + "</span>"
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">%s</button>`, d.ID, i18n.T(s.locale(), "userDashboard.revoke"))
if d.RevokedAt != "" {
status = "<span style='color:#ff6b6b'>" + i18n.T(s.locale(), "userDashboard.revoked") + "</span>"
revokeBtn = ""
}
deviceRows += fmt.Sprintf(`<tr>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s %s</td>
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
}
}
w.Write([]byte(userDashboardHTML(s.locale(), username, deviceRows)))
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: "", Path: "/",
HttpOnly: true, MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusFound)
}

110
cmd/verstak-server/install.sh Executable file
View File

@ -0,0 +1,110 @@
#!/bin/sh
#
# install.sh — установка Verstak Sync Server
#
# Использование:
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
#
# Флаги:
# --port Порт сервера (по умолчанию: 47732)
# --user Системный пользователь (по умолчанию: verstak)
# --admin-user Логин администратора (обязательный)
# --admin-pass Пароль администратора (обязательный)
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
#
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
BIN="./verstak-server"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
exit 1
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
# 1. Create system user if not exists.
if ! id -u "$USER" >/dev/null 2>&1; then
echo "Creating user: $USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
fi
# 2. Install binary.
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
exit 1
fi
echo "Installing binary to /usr/local/bin/verstak-server"
cp "$BIN" /usr/local/bin/verstak-server
chmod 755 /usr/local/bin/verstak-server
# 3. Create data directory.
echo "Creating /var/lib/verstak-server"
mkdir -p /var/lib/verstak-server
chown "$USER:$USER" /var/lib/verstak-server
chmod 750 /var/lib/verstak-server
# 4. Set up admin user (first run).
echo "Setting up admin user"
/usr/local/bin/verstak-server \
--port "$PORT" \
--data /var/lib/verstak-server \
--admin-user "$ADMIN_USER" \
--admin-pass "$ADMIN_PASS" &
SERVER_PID=$!
sleep 2
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
# 5. Install systemd unit.
echo "Installing systemd unit"
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
# Set port in environment file.
mkdir -p /etc/verstak-server
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
# 6. Enable and start.
echo "Enabling and starting service"
systemctl daemon-reload
systemctl enable verstak-server
systemctl start verstak-server
echo ""
echo "=== Installation complete ==="
echo "Service: verstak-server"
echo "Port: $PORT"
echo "Admin: http://localhost:$PORT/admin/login"
echo ""
echo "Check status: systemctl status verstak-server"
echo "View logs: journalctl -u verstak-server -f"

View File

@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
port := flag.Int("port", 47732, "HTTP port")
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
flag.Parse()
absData, err := filepath.Abs(*dataDir)
if err != nil {
log.Fatalf("data dir: %v", err)
}
if err := os.MkdirAll(absData, 0750); err != nil {
log.Fatalf("create data dir: %v", err)
}
cfg, err := LoadConfig(absData)
if err != nil {
log.Fatalf("config: %v", err)
}
// First-run admin setup.
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
}
// Open server DB.
dbPath := filepath.Join(absData, "server.db")
srv, err := NewServer(dbPath, absData, cfg)
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
addr := fmt.Sprintf(":%d", *port)
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatalf("serve: %v", err)
}
}

View File

@ -0,0 +1,104 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"strings"
"time"
)
func jsonOK(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool {
key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
if key == "" {
key = r.URL.Query().Get("api_key")
}
if key == "" {
jsonErr(w, 401, "API key required")
return false
}
// First try device token (hashed).
hash := sha256Hex(key)
var deviceID, userID, revokedAt sql.NullString
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID, &revokedAt)
if err == nil {
if revokedAt.Valid && revokedAt.String != "" {
jsonErr(w, 401, "device revoked")
return false
}
// Check user not blocked.
var blocked int
if userID.Valid && userID.String != "" {
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userID.String).Scan(&blocked)
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return false
}
}
r.Header.Set("X-Device-ID", deviceID.String)
r.Header.Set("X-User-ID", userID.String)
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceID.String)
return true
}
// Fallback to plain api_key (legacy).
var count int
err = s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
if err != nil || count == 0 {
jsonErr(w, 401, "invalid API key")
return false
}
return true
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie("session")
if err != nil || !s.tokens.Check(cookie.Value) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
return true
}
type PasswordError string
const (
ErrPasswordTooShort PasswordError = "PASSWORD_TOO_SHORT"
ErrPasswordTooLong PasswordError = "PASSWORD_TOO_LONG"
)
func validatePassword(password string) string {
if len(password) < 8 {
return string(ErrPasswordTooShort)
}
if len(password) > 256 {
return string(ErrPasswordTooLong)
}
return ""
}
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) {
key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
if key == "" {
jsonErr(w, 401, "authorization required")
return "", false
}
userID, ok := s.userTokens.Check(key)
if !ok {
jsonErr(w, 401, "invalid or expired token")
return "", false
}
return userID, true
}

View File

@ -0,0 +1,37 @@
package main
import "net/http"
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/health", s.handleHealth)
mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
mux.HandleFunc("/api/client/pair", s.handleClientPair)
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
mux.HandleFunc("/api/client/me", s.handleClientMe)
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
mux.HandleFunc("/forgot", s.handleUserWebForgot)
mux.HandleFunc("/reset", s.handleUserWebReset)
mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
mux.HandleFunc("/register", s.handleUserWebRegister)
mux.HandleFunc("/login", s.handleUserWebLogin)
mux.HandleFunc("/dashboard", s.handleUserDashboard)
mux.HandleFunc("/logout", s.handleUserWebLogout)
mux.HandleFunc("/admin/login", s.handleAdminLogin)
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
mux.HandleFunc("/admin/users", s.handleAdminUsers)
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
mux.HandleFunc("/admin/", s.handleAdminAPI)
mux.HandleFunc("/", s.handleNotFound)
return mux
}

View File

@ -0,0 +1,100 @@
package main
const serverSchema = `
CREATE TABLE IF NOT EXISTS server_devices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE,
token_hash TEXT,
token_prefix TEXT,
token_suffix TEXT,
user_id TEXT,
client_version TEXT,
last_ip TEXT,
last_seen TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_revisions (
rev INTEGER PRIMARY KEY AUTOINCREMENT,
op_id TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS server_ops (
op_id TEXT PRIMARY KEY,
server_sequence INTEGER,
device_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
idempotency_key TEXT,
client_sequence INTEGER DEFAULT 0,
last_seen_server_seq INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS server_tombstones (
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id)
);
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_blobs (
sha256 TEXT PRIMARY KEY,
size INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_smtp_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
confirmed INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT 0,
last_seen TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_email_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_user_devices (
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id)
);
CREATE TABLE IF NOT EXISTS server_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
user_id TEXT,
device_id TEXT,
ip TEXT,
message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`

View File

@ -0,0 +1,131 @@
package main
import (
"database/sql"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
type pairRateLimit struct {
mu sync.Mutex
attempts map[string]int
}
func (p *pairRateLimit) allow(ip string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.attempts == nil {
p.attempts = make(map[string]int)
}
p.attempts[ip]++
return p.attempts[ip] <= 5
}
func (p *pairRateLimit) reset(ip string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.attempts, ip)
}
type Server struct {
db *sql.DB
cfg *Config
tokens *tokenStore
userTokens *userTokenStore
blobsDir string
mux *http.ServeMux
pairLimit *pairRateLimit
}
func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) {
s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339))
}
func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath))
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
// Run schema.
for _, stmt := range strings.Split(serverSchema, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.Exec(stmt); err != nil {
db.Close()
return nil, fmt.Errorf("schema: %w", err)
}
}
// Migrations for older databases.
db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN token_hash TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN token_prefix TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN token_suffix TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN user_id TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN client_version TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN last_ip TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN revoked_at TEXT")
// Migration: add server_sequence and tombstones.
db.Exec("ALTER TABLE server_ops ADD COLUMN server_sequence INTEGER")
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
db.Exec(`CREATE TABLE IF NOT EXISTS server_tombstones (
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id)
)`)
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
db.Exec(`CREATE TABLE IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL
)`)
db.Exec(`ALTER TABLE server_ops ADD COLUMN idempotency_key TEXT`)
db.Exec(`ALTER TABLE server_ops ADD COLUMN client_sequence INTEGER DEFAULT 0`)
db.Exec(`ALTER TABLE server_ops ADD COLUMN last_seen_server_seq INTEGER DEFAULT 0`)
blobsDir := filepath.Join(dataDir, "blobs")
if err := os.MkdirAll(blobsDir, 0750); err != nil {
db.Close()
return nil, err
}
s := &Server{
db: db,
cfg: cfg,
tokens: newTokenStore(),
userTokens: newUserTokenStore(),
blobsDir: blobsDir,
pairLimit: &pairRateLimit{},
}
s.mux = s.routes()
return s, nil
}
func (s *Server) locale() string {
return "ru"
}
func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.mux)
}

156
cmd/verstak-server/smtp.go Normal file
View File

@ -0,0 +1,156 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"fmt"
"log"
"net"
"net/smtp"
"time"
)
func (s *Server) smtpGet(key string) string {
var val string
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
return val
}
func (s *Server) smtpSet(key, val string) error {
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
return err
}
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func genDeviceToken() (token, prefix, suffix string) {
b := make([]byte, 32)
rand.Read(b)
token = "vs_dev_" + hex.EncodeToString(b)
prefix = token[:16]
suffix = token[len(token)-8:]
return
}
func sel(v, want string) string {
if v == want {
return " selected"
}
return ""
}
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
addr := net.JoinHostPort(host, port)
switch security {
case "tls":
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return nil, fmt.Errorf("tls dial: %w", err)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("smtp client: %w", err)
}
return cl, nil
default:
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("smtp client: %w", err)
}
if security != "none" {
if ok, _ := cl.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host}
if err := cl.StartTLS(tlsCfg); err != nil {
cl.Close()
return nil, fmt.Errorf("starttls: %w", err)
}
}
}
return cl, nil
}
}
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
if user != "" {
auth := smtp.PlainAuth("", user, pass, host)
if err := cl.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := cl.Mail(from); err != nil {
return fmt.Errorf("mail from: %w", err)
}
if err := cl.Rcpt(to); err != nil {
return fmt.Errorf("rcpt: %w", err)
}
w, err := cl.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err := w.Write(msg); err != nil {
w.Close()
return fmt.Errorf("write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
func (s *Server) smtpSend(to, subject, body string) error {
host := s.smtpGet("smtp_host")
port := s.smtpGet("smtp_port")
user := s.smtpGet("smtp_user")
pass := s.smtpGet("smtp_pass")
from := s.smtpGet("smtp_from")
security := s.smtpGet("smtp_security")
if host == "" || port == "" || from == "" {
err := fmt.Errorf("SMTP not configured")
log.Printf("smtp: %v (to=%s)", err, to)
return err
}
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
msg := []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
cl, err := s.smtpConnect(host, port, user, pass, security)
if err != nil {
log.Printf("smtp: connect error: %v", err)
return err
}
defer cl.Close()
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
log.Printf("smtp: send error: %v", err)
return err
}
log.Printf("smtp: sent OK to %s", to)
return nil
}
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
if host == "" || port == "" || from == "" {
return fmt.Errorf("SMTP not configured")
}
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
cl, err := s.smtpConnect(host, port, user, pass, security)
if err != nil {
return err
}
defer cl.Close()
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
}

View File

@ -0,0 +1,800 @@
package main
import (
"fmt"
"strings"
"verstak/internal/i18n"
)
func userRegisterHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
</style>
</head><body>
<form method="POST">
<h1>%s</h1>
<label>%s</label>
<input type="text" name="username" autofocus required>
<label>%s</label>
<input type="email" name="email" required>
<label>%s</label>
<input type="password" name="password" required minlength="8" maxlength="256">
<button>%s</button>
<p>%s <a href="/login">%s</a></p>
</form>
</body></html>`,
i18n.T(locale, "server.registerTitle"),
i18n.T(locale, "server.register"),
i18n.T(locale, "server.username"),
i18n.T(locale, "server.email"),
i18n.T(locale, "server.password"),
i18n.T(locale, "server.registerBtn"),
i18n.T(locale, "server.alreadyHaveAccount"),
i18n.T(locale, "server.loginBtn"),
)
}
func userLoginHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
.links a{color:#6366f1;text-decoration:none}
.links a:hover{text-decoration:underline}</style>
</head><body>
<form method="POST">
<h1>Verstak Sync</h1>
<label>%s</label>
<input type="text" name="username" autofocus required>
<label>%s</label>
<input type="password" name="password" required>
<button>%s</button>
<div class="links">
<a href="/forgot">%s</a><br>
<a href="/register">%s</a> · <a href="/admin/login">%s</a>
</div>
</form>
</body></html>`,
i18n.T(locale, "server.loginTitle"),
i18n.T(locale, "server.usernameOrEmail"),
i18n.T(locale, "server.password"),
i18n.T(locale, "server.loginBtn"),
i18n.T(locale, "server.forgotPassword"),
i18n.T(locale, "server.registerBtn"),
i18n.T(locale, "server.adminLink"),
)
}
func adminLoginHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}</style>
</head><body>
<form method="POST">
<h1>Verstak Sync</h1>
<label>%s</label>
<input type="text" name="username" autofocus required>
<label>%s</label>
<input type="password" name="password" required>
<button>%s</button>
</form>
</body></html>`,
i18n.T(locale, "admin.login"),
i18n.T(locale, "admin.username"),
i18n.T(locale, "admin.password"),
i18n.T(locale, "admin.loginBtn"),
)
}
func adminUsersHTML(locale string) string {
newPassResult := i18n.T(locale, "server.newPasswordResult")
newPassParts := strings.SplitN(newPassResult, "%s", 2)
newPassPrefix := newPassParts[0]
newPassSuffix := strings.ReplaceAll(newPassParts[1], "\n", "\\n")
deleteMsg := i18n.T(locale, "admin.deleteUserMessage")
deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2)
delMsgPrefix := deleteMsgParts[0]
delMsgSuffix := deleteMsgParts[1]
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%[1]s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
table{width:100%%;border-collapse:collapse;margin-top:12px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
th:hover{color:#b0b0c0}
th.sorted{color:#6366f1}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.btn-sm{padding:2px 8px;font-size:11px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;box-sizing:border-box}
input:focus{outline:none;border-color:#6366f1}
.toolbar{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap;align-items:center}
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;justify-content:center}
.pagination span{padding:4px 8px;font-size:12px;color:#888}
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
.badge-green{background:#064e3b;color:#34d399}
.badge-red{background:#4a2222;color:#ff6b6b}
.badge-yellow{background:#4a3e00;color:#fbbf24}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:400px;max-width:90vw;position:relative}
.modal h2{margin-top:0;font-size:16px}
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
.modal-close:hover{color:#e4e4ef}
.form-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
.form-row input{flex:1}
</style>
</head><body>
<h1>%[2]s</h1>
<p><a href="/admin/dashboard">%[3]s</a></p>
<div class="toolbar">
<input id="filter-input" placeholder="%[4]s" style="width:200px" onkeyup="loadUsers()">
</div>
<table>
<thead><tr>
<th onclick="sortBy('username')">%[5]s <span id="s-username"></span></th>
<th onclick="sortBy('email')">%[6]s <span id="s-email"></span></th>
<th onclick="sortBy('confirmed')">%[7]s <span id="s-confirmed"></span></th>
<th onclick="sortBy('devices')">%[8]s <span id="s-devices"></span></th>
<th onclick="sortBy('last_seen')">%[9]s <span id="s-last_seen"></span></th>
<th>%[10]s</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
<div id="confirm-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeConfirm()">&times;</button>
<h2 id="confirm-title">%[11]s</h2>
<p id="confirm-text"></p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeConfirm()">%[12]s</button>
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">%[13]s</button>
</div>
</div>
</div>
<div id="edit-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeEdit()">&times;</button>
<h2>%[14]s</h2>
<div class="form-row"><label>%[15]s</label><input id="edit-username"></div>
<div class="form-row"><label>%[16]s</label><input id="edit-email" type="email"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeEdit()">%[17]s</button>
<button class="btn btn-primary" onclick="saveEdit()">%[18]s</button>
</div>
</div>
</div>
<div id="result-modal" class="modal-overlay" style="display:none">
<div class="modal" style="width:320px">
<button class="modal-close" onclick="closeResult()">&times;</button>
<h2 id="result-title">%[19]s</h2>
<p id="result-text" style="white-space:pre-wrap"></p>
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">%[20]s</button>
</div>
</div>
<script>
var currentPage=1,currentSort='',currentOrder='',editUserId='',pendingAction=''
function loadUsers(){
var f=document.getElementById('filter-input').value
var u='/admin/api/users?page='+currentPage+'&per_page=20&filter='+encodeURIComponent(f)
if(currentSort){u+='&sort='+currentSort+'&order='+currentOrder}
fetch(u).then(function(r){return r.json()}).then(function(d){
var tbody=document.getElementById('users-tbody')
tbody.innerHTML=''
d.users.forEach(function(u){
var status=u.confirmed?'<span class="badge badge-green">%[21]s</span>':'<span class="badge badge-yellow">%[22]s</span>'
if(u.blocked){status='<span class="badge badge-red">%[23]s</span>'}
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
var blockText=u.blocked?'%[24]s':'%[25]s'
var tr=document.createElement('tr')
tr.innerHTML='<td>'+esc(u.username)+'</td><td>'+esc(u.email)+'</td><td>'+status+'</td><td>'+u.devices+'</td><td>'+lastSeen+'</td>'+
'<td><button class="btn btn-sm" onclick="editUser(\''+u.id+'\',\''+escJS(u.username)+'\',\''+escJS(u.email)+'\')"></button> '+
'<button class="btn btn-sm" onclick="askBlock(\''+u.id+'\','+u.blocked+')">'+blockText+'</button> '+
'<button class="btn btn-sm" onclick="askReset(\''+u.id+'\')">%[26]s</button> '+
'<button class="btn btn-sm btn-danger" onclick="askDelete(\''+u.id+'\',\''+escJS(u.username)+'\')"></button></td>'
tbody.appendChild(tr)
})
if(!d.users.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:#666">%[27]s</td></tr>'}
var totalPages=Math.ceil(d.total/d.per_page)
var pag=document.getElementById('pagination')
pag.innerHTML=''
if(totalPages>1){
var prev=document.createElement('button')
prev.className='btn btn-sm';prev.textContent='←';prev.onclick=function(){if(currentPage>1){currentPage--;loadUsers()}}
pag.appendChild(prev)
var s=document.createElement('span')
s.textContent=d.page+' / '+totalPages
pag.appendChild(s)
var next=document.createElement('button')
next.className='btn btn-sm';next.textContent='→';next.onclick=function(){if(currentPage<totalPages){currentPage++;loadUsers()}}
pag.appendChild(next)
}
})
}
function sortBy(col){
if(currentSort===col){currentOrder=currentOrder==='asc'?'desc':'asc'}
else{currentSort=col;currentOrder='asc'}
document.querySelectorAll('th').forEach(function(th){th.classList.remove('sorted')})
var el=document.getElementById('s-'+col)
if(el){el.parentElement.classList.add('sorted');el.textContent=currentOrder==='asc'?' ':' '}
loadUsers()
}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'&quot;')}
function editUser(id,username,email){
editUserId=id;document.getElementById('edit-username').value=username;document.getElementById('edit-email').value=email;document.getElementById('edit-modal').style.display='flex'}
function closeEdit(){document.getElementById('edit-modal').style.display='none'}
function saveEdit(){
var un=document.getElementById('edit-username').value,em=document.getElementById('edit-email').value
if(!un||!em)return
fetch('/admin/api/users/'+editUserId+'/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,email:em})}).then(function(r){return r.json()}).then(function(d){closeEdit();if(d.status==='ok')loadUsers()})
}
function askBlock(id,blocked){
pendingAction=function(){fetch('/admin/api/users/'+id+'/block',{method:'POST'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent=blocked?'%[35]s':'%[36]s'
document.getElementById('confirm-text').textContent=blocked?'%[37]s':'%[38]s'
document.getElementById('confirm-btn').textContent=blocked?'%[24]s':'%[25]s'
document.getElementById('confirm-modal').style.display='flex'}
function askReset(id){
pendingAction=function(){
fetch('/admin/api/users/'+id+'/reset-password',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
document.getElementById('confirm-modal').style.display='none'
document.getElementById('result-title').textContent='%[28]s'
document.getElementById('result-text').textContent='%[29]s' + d.new_password + '%[30]s'
document.getElementById('result-modal').style.display='flex'})}
document.getElementById('confirm-title').textContent='%[31]s'
document.getElementById('confirm-text').textContent='%[32]s'
document.getElementById('confirm-btn').textContent='%[33]s'
document.getElementById('confirm-modal').style.display='flex'}
function askDelete(id,username){
pendingAction=function(){fetch('/admin/api/users/'+id,{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent='%[34]s'
document.getElementById('confirm-text').textContent='%[35]s' + username + '%[36]s'
document.getElementById('confirm-btn').textContent='%[37]s'
document.getElementById('confirm-modal').style.display='flex'}
function closeConfirm(){document.getElementById('confirm-modal').style.display='none';pendingAction=''}
function confirmAction(){if(pendingAction){pendingAction();pendingAction=''}}
function closeResult(){document.getElementById('result-modal').style.display='none'}
loadUsers()
</script>
</body></html>`,
i18n.T(locale, "admin.users"),
i18n.T(locale, "admin.usersHeading"),
i18n.T(locale, "server.dashboard"),
i18n.T(locale, "admin.filterPlaceholder"),
i18n.T(locale, "admin.username"),
i18n.T(locale, "admin.email"),
i18n.T(locale, "admin.status"),
i18n.T(locale, "admin.devices"),
i18n.T(locale, "admin.lastSeen"),
i18n.T(locale, "admin.actions"),
i18n.T(locale, "admin.confirmTitle"),
i18n.T(locale, "admin.modalCancel"),
i18n.T(locale, "admin.modalConfirm"),
i18n.T(locale, "admin.editUser"),
i18n.T(locale, "admin.username"),
i18n.T(locale, "admin.email"),
i18n.T(locale, "admin.modalCancel"),
i18n.T(locale, "admin.editBtn"),
i18n.T(locale, "admin.resultTitle"),
i18n.T(locale, "common.ok"),
i18n.T(locale, "admin.confirmed"),
i18n.T(locale, "admin.unconfirmed"),
i18n.T(locale, "admin.blocked"),
i18n.T(locale, "admin.unblock"),
i18n.T(locale, "admin.block"),
i18n.T(locale, "admin.resetPassword"),
i18n.T(locale, "admin.noUsers"),
i18n.T(locale, "server.newPassword"),
newPassPrefix,
newPassSuffix,
i18n.T(locale, "admin.resetPasswordConfirm"),
i18n.T(locale, "admin.resetPasswordMessage"),
i18n.T(locale, "admin.resetBtn"),
i18n.T(locale, "admin.deleteUser"),
delMsgPrefix,
delMsgSuffix,
i18n.T(locale, "admin.deleteBtn"),
i18n.T(locale, "admin.unblockUserTitle"),
i18n.T(locale, "admin.blockUserTitle"),
i18n.T(locale, "admin.unblockUserMessage"),
i18n.T(locale, "admin.blockUserMessage"),
)
}
func confirmedHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 20px}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
i18n.T(locale, "server.emailConfirmed"),
i18n.T(locale, "server.emailConfirmed"),
i18n.T(locale, "server.emailConfirmedMessage"),
i18n.T(locale, "server.loginBtn"),
)
}
func registrationOKHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
i18n.T(locale, "server.registerTitle"),
i18n.T(locale, "server.registrationSuccess"),
i18n.T(locale, "server.registrationEmailSent"),
i18n.T(locale, "server.registrationCheckEmail"),
i18n.T(locale, "server.loginBtn"),
)
}
func registrationAutoHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
i18n.T(locale, "server.registerTitle"),
i18n.T(locale, "server.registrationSuccess"),
i18n.T(locale, "server.registrationAutoMessage"),
i18n.T(locale, "server.loginBtn"),
)
}
func forgotPasswordHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:18px;margin:0 0 8px;text-align:center}
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
.links a{color:#6366f1;text-decoration:none}
.links a:hover{text-decoration:underline}</style>
</head><body>
<form method="POST">
<h1>%s</h1>
<p>%s</p>
<label>%s</label>
<input type="email" name="email" autofocus required>
<button>%s</button>
<div class="links"><a href="/login">%s</a></div>
</form>
</body></html>`,
i18n.T(locale, "server.resetPasswordTitle"),
i18n.T(locale, "server.resetPassword"),
i18n.T(locale, "server.resetInstruction"),
i18n.T(locale, "server.email"),
i18n.T(locale, "server.sendLink"),
i18n.T(locale, "server.backToLogin"),
)
}
func forgotSentHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:18px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
i18n.T(locale, "server.emailSentTitle"),
i18n.T(locale, "server.emailSent"),
i18n.T(locale, "server.emailSentMessage"),
i18n.T(locale, "server.goHome"),
)
}
func resetPasswordHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:18px;margin:0 0 20px;text-align:center}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
</head><body>
<form method="POST">
<h1>%s</h1>
<input type="hidden" name="token" value="{TOKEN}">
<label>%s</label>
<input type="password" name="password" minlength="8" maxlength="256" required autofocus>
<label>%s</label>
<input type="password" name="confirm" minlength="8" maxlength="256" required>
<button style="margin-top:8px">%s</button>
</form>
</body></html>`,
i18n.T(locale, "server.newPasswordTitle"),
i18n.T(locale, "server.newPassword"),
i18n.T(locale, "server.password"),
i18n.T(locale, "server.passwordConfirm"),
i18n.T(locale, "server.save"),
)
}
func resetDoneHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:18px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
i18n.T(locale, "server.passwordChanged"),
i18n.T(locale, "server.passwordChanged"),
i18n.T(locale, "server.passwordChangedMessage"),
i18n.T(locale, "server.loginBtn"),
)
}
func adminDashboardHTML(locale string, deviceCount, opsCount int, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%[1]s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px}
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
table{width:100%%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
input:focus{outline:none;border-color:#6366f1}
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
.form-row input{flex:1}
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
.modal h2{margin-top:0}
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
.modal-close:hover{color:#e4e4ef}
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
</style>
</head><body>
<h1>Verstak Sync Server</h1>
<div style="display:flex;gap:20px;flex-wrap:wrap">
<div class="stat" style="margin:0"><strong>%[2]s</strong> <span id="dev-count">%[40]d</span></div>
<div class="stat" style="margin:0"><strong>%[3]s</strong> <span id="op-count">%[41]d</span></div>
</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openSMTP()">%[15]s</button>
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">%[16]s</button></a>
<button class="btn" onclick="openHealth()">%[17]s</button>
</div>
<h2>%[4]s</h2>
<div id="devices"></div>
<script>
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
const div=document.getElementById('devices')
if(!devices.length){div.innerHTML='<p>%[5]s</p>';return}
div.innerHTML='<table><tr><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th><th>%[9]s</th><th>%[10]s</th><th></th></tr>'+
devices.map(d=>{
var status=d.revoked_at?'<span style="color:#ff6b6b">%[12]s</span>':'<span style="color:#34d399">%[11]s</span>'
var ls=d.last_seen||'\u2014'
var revBtn=''
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">%[13]s</button>'
return '<tr><td>'+d.name+'</td><td>'+(d.user||'\u2014')+'</td><td>'+(d.client_version||'\u2014')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
}).join('')+'</table>'
document.getElementById('dev-count').textContent=devices.length
})
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
document.getElementById('op-count').textContent=stats.ops||'0'
})
function revokeDevice(id){
if(!confirm('%[31]s'))return
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
}
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='%[14]s';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
function testSMTP(){
var f=document.querySelector('#smtp-modal form')
var fd=new FormData(f)
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
var r=document.getElementById('smtp-test-result')
r.textContent='%[29]s';r.style.color='#888'
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
r.textContent=d.ok?'%[30]s':'\u2717 '+d.error
r.style.color=d.ok?'#4ade80':'#ff6b6b'
}).catch(function(e){r.textContent='\u2717 '+e;r.style.color='#ff6b6b'})
}
</script>
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
<div class="modal">
<button class="modal-close" onclick="closeSMTP()">&times;</button>
<h2>%[28]s</h2>
<form action="/admin/api/smtp" method="POST">
<div class="form-row"><label>%[18]s</label><input name="smtp_host" value="%[32]s" placeholder="smtp.example.com"></div>
<div class="form-row"><label>%[19]s</label><input name="smtp_port" value="%[33]s" placeholder="587"></div>
<div class="form-row"><label>%[20]s</label><select name="smtp_security" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
<option value="starttls"%[34]s>STARTTLS</option>
<option value="tls"%[35]s>TLS</option>
<option value="none"%[36]s>%[21]s</option>
</select></div>
<div class="form-row"><label>%[22]s</label><input name="smtp_user" value="%[37]s" placeholder="user@example.com"></div>
<div class="form-row"><label>%[23]s</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
<div class="form-row"><label>%[24]s</label><input name="smtp_from" value="%[38]s" placeholder="noreply@example.com"></div>
<div class="form-row"><label>%[25]s</label><input name="server_url" value="%[39]s" placeholder="https://example.com:47732"></div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button class="btn btn-primary">%[26]s</button>
<button class="btn" type="button" onclick="testSMTP()">%[27]s</button>
<span id="smtp-test-result" style="font-size:12px"></span>
</div>
</form>
</div>
</div>
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
<div class="modal">
<button class="modal-close" onclick="closeHealth()">&times;</button>
<h2>%[17]s</h2>
<pre id="health-result">%[14]s</pre>
</div>
</div>
</body></html>`,
i18n.T(locale, "admin.dashboard"),
i18n.T(locale, "admin.deviceCount"),
i18n.T(locale, "admin.opsCount"),
i18n.T(locale, "admin.devices"),
i18n.T(locale, "admin.noDevices"),
i18n.T(locale, "admin.device"),
i18n.T(locale, "admin.user"),
i18n.T(locale, "admin.version"),
i18n.T(locale, "admin.status"),
i18n.T(locale, "admin.lastSeen"),
i18n.T(locale, "admin.active"),
i18n.T(locale, "admin.revoked"),
i18n.T(locale, "admin.revoke"),
i18n.T(locale, "common.loading"),
i18n.T(locale, "admin.smtp"),
i18n.T(locale, "admin.users"),
i18n.T(locale, "admin.healthCheck"),
i18n.T(locale, "admin.smtpServer"),
i18n.T(locale, "admin.smtpPort"),
i18n.T(locale, "admin.smtpType"),
i18n.T(locale, "admin.smtpNoEncryption"),
i18n.T(locale, "admin.smtpUsername"),
i18n.T(locale, "admin.smtpPassword"),
i18n.T(locale, "admin.smtpFrom"),
i18n.T(locale, "admin.smtpServerURL"),
i18n.T(locale, "admin.smtpSave"),
i18n.T(locale, "admin.smtpTest"),
i18n.T(locale, "admin.smtpTitle"),
i18n.T(locale, "admin.smtpTesting"),
i18n.T(locale, "admin.smtpPassed"),
i18n.T(locale, "admin.revokeConfirm"),
smtpHost,
smtpPort,
sel(smtpSecurity, "starttls"),
sel(smtpSecurity, "tls"),
sel(smtpSecurity, "none"),
smtpUser,
smtpFrom,
srvURL,
deviceCount,
opsCount,
)
}
func userDashboardHTML(locale, username, deviceRows string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %[1]s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px}
table{width:100%%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.btn-sm{padding:2px 8px;font-size:11px}
.top{display:flex;justify-content:space-between;align-items:center}
a{color:#6366f1}
</style>
</head><body>
<div class="top">
<h1>Verstak Sync</h1>
<span>%[1]s · <a href="/logout">%[2]s</a></span>
</div>
<h2>%[3]s</h2>
<table><tr><th>%[4]s</th><th>%[5]s</th><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th></tr>%[9]s</table>
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
<h2 style="margin-top:0">%[10]s</h2>
<p style="font-size:13px;color:#888">%[11]s</p>
</div>
<script>
function revokeDevice(id){
if(!confirm('%[12]s'))return
var pw=prompt('%[13]s')
if(!pw)return
fetch('/api/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,password:pw})}).then(function(r){return r.json()}).then(function(d){
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
})
}
</script>
</body></html>`,
username,
i18n.T(locale, "server.logout"),
i18n.T(locale, "userDashboard.devices"),
i18n.T(locale, "userDashboard.device"),
i18n.T(locale, "userDashboard.status"),
i18n.T(locale, "userDashboard.connected"),
i18n.T(locale, "userDashboard.lastSeen"),
i18n.T(locale, "userDashboard.version"),
deviceRows,
i18n.T(locale, "userDashboard.connectNew"),
i18n.T(locale, "userDashboard.connectNewHint"),
i18n.T(locale, "userDashboard.revokeConfirm"),
i18n.T(locale, "userDashboard.revokePrompt"),
)
}
func errorPageHTML(locale, title, msg, backURL string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;text-align:center;max-width:360px}
h1{font-size:18px;margin:0 0 12px;color:#ff6b6b}
p{font-size:13px;color:#b0b0c0;margin:0 0 16px}
a{color:#6366f1;text-decoration:none}
a:hover{text-decoration:underline}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="%s">%s</a>
</div>
</body></html>`, title, title, msg, backURL, i18n.T(locale, "server.back"))
}

View File

@ -0,0 +1,80 @@
package main
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
type tokenStore struct {
mu sync.Mutex
tokens map[string]time.Time
}
func newTokenStore() *tokenStore {
return &tokenStore{tokens: make(map[string]time.Time)}
}
func (ts *tokenStore) Create() string {
ts.mu.Lock()
defer ts.mu.Unlock()
b := make([]byte, 16)
rand.Read(b)
tok := hex.EncodeToString(b)
ts.tokens[tok] = time.Now().Add(24 * time.Hour)
return tok
}
func (ts *tokenStore) Check(tok string) bool {
ts.mu.Lock()
defer ts.mu.Unlock()
exp, ok := ts.tokens[tok]
if !ok {
return false
}
if time.Now().After(exp) {
delete(ts.tokens, tok)
return false
}
return true
}
// userTokenStore embeds tokenStore but also tracks the user_id per token.
type userTokenStore struct {
mu sync.Mutex
tokens map[string]userTokenEntry
}
type userTokenEntry struct {
UserID string
ExpiresAt time.Time
}
func newUserTokenStore() *userTokenStore {
return &userTokenStore{tokens: make(map[string]userTokenEntry)}
}
func (uts *userTokenStore) Create(userID string) string {
uts.mu.Lock()
defer uts.mu.Unlock()
b := make([]byte, 16)
rand.Read(b)
tok := hex.EncodeToString(b)
uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)}
return tok
}
func (uts *userTokenStore) Check(tok string) (string, bool) {
uts.mu.Lock()
defer uts.mu.Unlock()
entry, ok := uts.tokens[tok]
if !ok {
return "", false
}
if time.Now().After(entry.ExpiresAt) {
delete(uts.tokens, tok)
return "", false
}
return entry.UserID, true
}

View File

@ -0,0 +1,22 @@
[Unit]
Description=Verstak Sync Server
Documentation=https://github.com/anomalyco/verstak
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
Restart=on-failure
RestartSec=5
User=verstak
Group=verstak
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
StateDirectory=verstak-server
RuntimeDirectory=verstak-server
[Install]
WantedBy=multi-user.target

View File

@ -8,9 +8,11 @@ import (
"strings" "strings"
"verstak/internal/core/actions" "verstak/internal/core/actions"
"verstak/internal/core/config"
"verstak/internal/core/plugins" "verstak/internal/core/plugins"
"verstak/internal/core/search" "verstak/internal/core/search"
"verstak/internal/core/storage" "verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/vault" "verstak/internal/core/vault"
"verstak/internal/core/worklog" "verstak/internal/core/worklog"
) )
@ -38,6 +40,8 @@ func main() {
runLog(os.Args[2:]) runLog(os.Args[2:])
case "index": case "index":
runIndex(os.Args[2:]) runIndex(os.Args[2:])
case "sync":
runSync(os.Args[2:])
case "plugin": case "plugin":
runPlugin(os.Args[2:]) runPlugin(os.Args[2:])
default: default:
@ -56,6 +60,7 @@ func usage() {
fmt.Println(" node Manage nodes") fmt.Println(" node Manage nodes")
fmt.Println(" action Manage actions") fmt.Println(" action Manage actions")
fmt.Println(" --version Show version") fmt.Println(" --version Show version")
fmt.Println(" sync Sync with server (push/pull/status)")
fmt.Println(" --help Show this help") fmt.Println(" --help Show this help")
} }
@ -597,6 +602,170 @@ func runIndexRebuild(args []string) {
fmt.Printf("indexed %d nodes\n", count) fmt.Printf("indexed %d nodes\n", count)
} }
// --- sync ---
func runSync(args []string) {
if len(args) == 0 {
fmt.Println("verstak sync — synchronize with server")
fmt.Println()
fmt.Println("Usage: verstak sync <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" push Push local changes to server")
fmt.Println(" pull Pull remote changes from server")
fmt.Println(" status Show sync status")
os.Exit(0)
}
switch args[0] {
case "push":
runSyncPush(args[1:])
case "pull":
runSyncPull(args[1:])
case "status":
runSyncStatus(args[1:])
case "--help", "-h":
runSync(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
os.Exit(1)
}
}
func openSyncDB(args []string) (*storage.DB, string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
return db, abs
}
func runSyncPush(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
unpushed, err := syncSvc.GetUnpushedOps()
if err != nil {
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
os.Exit(1)
}
if len(unpushed) == 0 {
fmt.Println("Nothing to push.")
return
}
_, _, lastSeq, _, _ := syncSvc.GetState()
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastSeq
}
result, err := client.Push(unpushed)
if err != nil {
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
os.Exit(1)
}
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
if len(result.Conflicts) > 0 {
fmt.Printf("WARNING: %d conflict(s) detected\n", len(result.Conflicts))
}
}
func runSyncPull(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
_, _, lastSeq, _, err := syncSvc.GetState()
if err != nil {
lastSeq = 0
}
result, err := client.Pull(lastSeq)
if err != nil {
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
os.Exit(1)
}
var opIDs []string
for _, op := range result.Ops {
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
opIDs = append(opIDs, op.OpID)
}
if len(opIDs) > 0 {
syncSvc.MarkApplied(opIDs)
}
fmt.Printf("Pulled %d ops (server seq: %d)\n", len(result.Ops), result.ServerSequence)
}
func runSyncStatus(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
serverURL := ""
deviceID := ""
if cfg != nil {
serverURL = cfg.Sync.ServerURL
deviceID = cfg.Sync.DeviceID
}
unpushed := 0
if configured {
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
ops, _ := syncSvc.GetUnpushedOps()
unpushed = len(ops)
}
fmt.Println("Sync Status")
fmt.Println(" Configured:", configured)
fmt.Println(" Server:", serverURL)
fmt.Println(" Device:", deviceID)
fmt.Println(" Unpushed ops:", unpushed)
}
// --- plugin --- // --- plugin ---
func runPlugin(args []string) { func runPlugin(args []string) {

View File

@ -18,7 +18,11 @@ func runNodeCreate(vault, parentID, typ, title string) error {
defer db.Close() defer db.Close()
repo := nodes.NewRepository(db) repo := nodes.NewRepository(db)
n, err := repo.Create(parentID, typ, title, "") var pid *string
if parentID != "" {
pid = &parentID
}
n, err := repo.Create(pid, typ, title, 0, "", "")
if err != nil { if err != nil {
return err return err
} }
@ -65,7 +69,7 @@ func runNodeList(vault, parentID string) error {
repo := nodes.NewRepository(db) repo := nodes.NewRepository(db)
var list []nodes.Node var list []nodes.Node
if parentID == "" { if parentID == "" {
list, err = repo.ListRoots(false, "") list, err = repo.ListRoots(false)
} else { } else {
list, err = repo.ListChildren(parentID, false) list, err = repo.ListChildren(parentID, false)
} }
@ -87,7 +91,11 @@ func runNodeMove(vault, id, parentID string, sortOrder int) error {
defer db.Close() defer db.Close()
repo := nodes.NewRepository(db) repo := nodes.NewRepository(db)
if err := repo.Move(id, parentID, sortOrder); err != nil { var pid *string
if parentID != "" {
pid = &parentID
}
if err := repo.Move(id, pid, sortOrder); err != nil {
return err return err
} }
fmt.Println("moved") fmt.Println("moved")

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")

View File

@ -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'))
);

View File

@ -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);

View File

@ -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>

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"
]
}

View File

@ -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

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("========================")

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