Author SHA1 Message Date
mirv ceee03959b cleanup: remove stale frontend-dist assets 2026-06-05 16:17:31 +08:00
mirv 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
mirv c8aaf36533 fix: stabilize trash navigation and action icons 2026-06-05 14:41:40 +08:00
mirv 1fa009b1e2 feat: complete trash restore and batch actions 2026-06-05 12:43:30 +08:00
mirv 10b287de7b feat: aggregate journals across node subtrees 2026-06-05 12:37:25 +08:00
mirv 23f517dee3 feat: simplify inbox actions and group task tabs 2026-06-05 12:32:36 +08:00
mirv 6d15639b41 fix: normalize bare URLs in capture flow 2026-06-05 12:29:19 +08:00
mirv 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
mirv 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
mirv 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
mirv 0bebcdce8c docs: update AGENTS.md with WriteDebugLog, regression tests, resolveActivityTarget 2026-06-03 17:07:00 +08:00
mirv 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
mirv 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
mirv db869a7c97 docs: update AGENTS.md with Wails []string fix, event link validation, openActivityTarget 2026-06-03 16:00:54 +08:00
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
mirv 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
199 changed files with 29013 additions and 2906 deletions
+16 -2
View File
@@ -20,10 +20,17 @@ go.work
# Wails
frontend/dist/
frontend/frontend-dist/
frontend/node_modules/
frontend/bindings/
verstak-gui
verstak-cli
/verstak-gui
/verstak-cli
/verstak-server
/verstak
# Vault data
.verstak/
spaces/
# VS Code
.vscode/
@@ -35,3 +42,10 @@ Thumbs.db
# Vault test data
test-vault/
server-data/
Ромашка/
Тестовая папка/
# Build output
build/
build.log
BIN
View File
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
# 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.
## 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/
```
+154 -37
View File
@@ -1,46 +1,163 @@
# Верстак
**Верстак** — локальная программа, где по каждому клиенту или проекту
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
**Верстак** — local-first рабочий vault. Всё организовано вокруг **дел**, а не задач.
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди,
у которых работа организована через дела, а не через задачи:
```
дело → файлы → заметки → документы → действия → история → вернуться через месяц
```
## Для кого
Один продукт — разные входные двери:
| Кто | Как видит Верстак |
|-----|-------------------|
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
## Универсальные сущности
Базовая модель предельно проста — плагины добавляют функционал:
- **Дело** — контекст для всего остального
- **Заметка** — Markdown внутри vault
- **Файл / Документ** — любой файл, привязанный к делу
- **Действие** — кнопка запуска: URL, файл, папка, команда
- **Журнал** — записи о затраченном времени
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
эти сущности без перекомпиляции программы.
Дело может быть: клиентом, проектом, набором документов, рецептом, архивом, разовой работой.
Внутри дела: вложенные папки, Markdown-заметки, файлы, действия (URL/файл/папка/команда), журнал работ, история активности.
## Стек
Go + SQLite + Lua (плагины) + Wails + Bubble Tea.
| Слой | Технология |
|------|------------|
| GUI | Wails v2 + Svelte 4 |
| CLI/TUI | Go (bubbletea-like) |
| 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-linux-amd64` — GUI-приложение
- `verstak-server-linux-amd64` — опциональный сервер синхронизации
### Запуск
```bash
# GUI (после сборки)
./build/verstak-gui-linux-amd64
# Сервер (после сборки)
./build/verstak-server-linux-amd64 --help
# CLI
go run ./cmd/verstak/ --help
```
## Структура проекта
```
.
├── cmd/ # Точки входа
│ ├── verstak/ # CLI/TUI
│ ├── 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/PLAN.md](docs/PLAN.md)
| Раздел | Описание |
|--------|----------|
| [Описание продукта](docs/01_Product_Spec.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/TUI |
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
| [План](docs/PLAN.md) | Дорожная карта |
| [Шаблоны](docs/TEMPLATES.md) | Шаблоны дел |
## Лицензия
MIT
-4
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
-355
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}}"
@@ -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

-51
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"
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

-79
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
-212
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"]
-41
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"]
-224
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"
+338 -410
View File
@@ -2,36 +2,56 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"log"
"path/filepath"
"strings"
"sync"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"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/worklog"
)
// App is the Wails v2 application adapter. It wraps core services.
type App struct {
ctx context.Context
db *storage.DB
nodes *nodes.Repository
files *files.Service
notes *notes.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
vault string
ctx context.Context
mu sync.RWMutex
vaultOpen bool
db *storage.DB
nodes *nodes.Repository
templates *templates.Registry
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
vault string
}
// 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.
@@ -44,18 +64,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
// ============================================================
type NodeDTO struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Type string `json:"type"`
Section string `json:"section"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
TemplateID string `json:"template_id"`
FsPath string `json:"fs_path"`
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 {
@@ -72,24 +165,24 @@ type NoteDTO struct {
}
type FileDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Mime string `json:"mime"`
IsDir bool `json:"isDir"`
Missing bool `json:"missing"`
ID string `json:"id"`
NodeID string `json:"nodeId"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Mime string `json:"mime"`
IsDir bool `json:"isDir"`
Missing bool `json:"missing"`
}
type FileTreeItemDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "folder" | "file"
FileID string `json:"fileId,omitempty"`
Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"`
HasKids bool `json:"hasKids"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
FileID string `json:"fileId,omitempty"`
Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"`
HasKids bool `json:"hasKids"`
}
type ActionDTO struct {
@@ -101,11 +194,18 @@ type ActionDTO struct {
}
type WorklogDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Summary string `json:"summary"`
Minutes int `json:"minutes"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle,omitempty"`
NodePath string `json:"nodePath,omitempty"`
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 {
@@ -115,367 +215,46 @@ type SearchResultDTO struct {
Type string `json:"type"`
}
// ============================================================
// Sections
// ============================================================
func (a *App) ListSections() []SectionDTO {
return []SectionDTO{
{ID: "today", Label: "Сегодня"},
{ID: "inbox", Label: "Неразобранное"},
{ID: "clients", Label: "Клиенты"},
{ID: "projects", Label: "Проекты"},
{ID: "recipes", Label: "Рецепты"},
{ID: "documents", Label: "Документы"},
{ID: "archive", Label: "Архив"},
}
type EventDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
NodePath string `json:"nodePath,omitempty"`
EventType string `json:"eventType"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
TargetPath string `json:"targetPath"`
Title string `json:"title"`
DetailsJSON string `json:"detailsJson"`
CreatedAt string `json:"createdAt"`
}
// ============================================================
// Nodes
// ============================================================
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
type CaseActivityDTO struct {
Node NodeDTO `json:"node"`
Events []EventDTO `json:"events"`
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
type SummaryDTO struct {
ChangedCases int `json:"changedCases"`
Notes int `json:"notes"`
Files int `json:"files"`
Actions int `json:"actions"`
TimeEntries int `json:"timeEntries"`
}
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(n)
return &dto, nil
type TodayGroupDTO struct {
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
NodeKind string `json:"nodeKind"`
Section string `json:"section"`
LastActivityAt string `json:"lastActivityAt"`
Events []EventDTO `json:"events"`
}
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
n, err := a.nodes.Create(parentID, nodeType, title, section)
if err != nil {
return nil, err
}
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"
type TodayDashboardDTO struct {
Date string `json:"date"`
Summary SummaryDTO `json:"summary"`
Groups []TodayGroupDTO `json:"groups"`
Events []EventDTO `json:"events"`
}
// ============================================================
@@ -483,22 +262,17 @@ func (a *App) VerstakVersion() string {
// ============================================================
func toNodeDTO(n *nodes.Node) NodeDTO {
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
path := ""
if n.Path != nil {
path = *n.Path
}
return NodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
Section: n.Section,
Path: path,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
ID: n.ID,
ParentID: n.ParentID,
Type: n.Type,
Title: n.Title,
TemplateID: n.TemplateID,
FsPath: n.FsPath,
SortOrder: n.SortOrder,
Archived: n.Archived,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
}
}
@@ -510,4 +284,158 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
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
}
+64
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
}
+262
View File
@@ -0,0 +1,262 @@
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"`
}
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
}
+475
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
}
+42
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
}
+403
View File
@@ -0,0 +1,403 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"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/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.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)
}
}
}
// 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)
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.vault = abs
a.vaultOpen = true
a.mu.Unlock()
// Start auto-sync loop
go a.autoSyncLoop()
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
}
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.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
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"fmt"
"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
}
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)
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"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 {
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 {
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) {
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) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.files.PreviewImport(sourcePath)
}
+231
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
}
+208
View File
@@ -0,0 +1,208 @@
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)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now); 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()
}
+984
View File
@@ -0,0 +1,984 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/util"
)
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListRoots(false)
if err != nil {
return nil, err
}
dtos := filterContainers(a.filterInboxCaptureNodes(toNodeDTOs(list)))
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) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
dtos := filterContainers(a.filterInboxCaptureNodes(toNodeDTOs(list)))
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 filterContainers(dtos []NodeDTO) []NodeDTO {
out := make([]NodeDTO, 0, len(dtos))
for _, d := range dtos {
if isContainerType(d.Type) {
out = append(out, d)
}
}
return out
}
func isContainerType(typ string) bool {
switch typ {
case "case", "client", "project", "folder", "document", "recipe":
return true
}
return false
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
}
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(n)
return &dto, nil
}
func (a *App) GetNodeTitle(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return "", err
}
return n.Title, nil
}
func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
tmpl, ok := a.templates.Get(templateID)
if !ok {
return nil, fmt.Errorf("template %q not found", templateID)
}
seg := templates.SafeDisplayNameToPathSegment(title)
if seg == "" {
seg = "node"
}
var parent *nodes.Node
var parentFsPath string
if parentID != "" {
var err error
parent, err = a.nodes.GetActive(parentID)
if err != nil {
return nil, fmt.Errorf("parent not found: %w", err)
}
parentFsPath = parent.FsPath
}
fsPath := seg
if parentFsPath != "" {
fsPath = filepath.Join(parentFsPath, seg)
}
physPath := filepath.Join(a.vault, fsPath)
physPath = templates.UniquePath(physPath)
rel, _ := filepath.Rel(a.vault, physPath)
fsPath = rel
if _, err := syncsvc.SafeVaultPath(a.vault, fsPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
var pID *string
if parentID != "" {
pID = &parentID
}
sortOrder := 0
n, err := a.nodes.Create(pID, tmpl.Type, title, sortOrder, tmpl.ID, fsPath)
if err != nil {
return nil, fmt.Errorf("create node: %w", err)
}
if err := os.MkdirAll(physPath, 0o755); err != nil {
_ = a.nodes.SoftDelete(n.ID)
return nil, fmt.Errorf("create folder: %w", err)
}
// Create child nodes for default files and folders with rollback on failure.
nowRFC := time.Now().UTC().Format(time.RFC3339)
type childInfo struct {
id string
file bool
}
var created []childInfo
rollbackChildren := func() {
for i := len(created) - 1; i >= 0; i-- {
_ = a.nodes.SoftDelete(created[i].id)
}
}
for _, df := range tmpl.DefaultFiles {
fpath := filepath.Join(physPath, df.Path)
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
rollbackChildren()
return nil, fmt.Errorf("create directory for %s: %w", df.Path, err)
}
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
if fileTitle == "" {
fileTitle = "Overview"
}
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "")
if childErr != nil {
rollbackChildren()
return nil, fmt.Errorf("create child node for %s: %w", df.Path, childErr)
}
created = append(created, childInfo{id: childNode.ID, file: true})
content := fmt.Sprintf("# %s\n\n", title)
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
rollbackChildren()
return nil, fmt.Errorf("write file %s: %w", df.Path, err)
}
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(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
"node_id": childNode.ID,
"parent_id": n.ID,
"title": fileTitle,
"file_id": fileID,
"format": "markdown",
"content": content,
"filename": filepath.Base(fpath),
"path": relPath,
"created_at": nowRFC,
"updated_at": nowRFC,
})
}
for _, folderName := range tmpl.DefaultFolders {
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
if folderSeg == "" {
folderSeg = "folder"
}
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
if childErr != nil {
rollbackChildren()
return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr)
}
created = append(created, childInfo{id: childNode.ID, file: false})
childFsPath := folderSeg
if fsPath != "" {
childFsPath = filepath.Join(fsPath, folderSeg)
}
childPhysPath := filepath.Join(a.vault, childFsPath)
childPhysPath = templates.UniquePath(childPhysPath)
childRel, _ := filepath.Rel(a.vault, childPhysPath)
childFsPath = childRel
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
rollbackChildren()
return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err)
}
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
}
pid := ""
if parentID != "" {
pid = parentID
}
_ = a.activity.Record(pid, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, `{"template":"`+templateID+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
dto := toNodeDTO(n)
return &dto, nil
}
func (a *App) DeleteNode(id string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(id)
if err != nil {
return a.nodes.SoftDelete(id)
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
var entity string
var targetType string
var evType string
switch n.Type {
case nodes.TypeNote:
entity = syncsvc.EntityNote
targetType = activity.TargetNote
evType = activity.TypeNoteDeleted
case nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient, nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace:
entity = syncsvc.EntityFolder
targetType = activity.TargetFolder
evType = activity.TypeFolderDeleted
case nodes.TypeFile:
entity = syncsvc.EntityFile
targetType = activity.TargetFile
evType = activity.TypeFileDeleted
default:
entity = syncsvc.EntityNode
targetType = activity.TargetNode
evType = activity.TypeNodeDeleted
}
_ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "")
_ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil)
return a.files.DeleteNodeAndChildren(id)
}
func (a *App) RenameNode(nodeID, newTitle string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
seg := templates.SafeDisplayNameToPathSegment(newTitle)
if seg == "" {
seg = "node"
}
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
if isFolderLike {
return a.renameFolderNode(nodeID, n, newTitle, seg)
}
return a.renameNoteFileNode(nodeID, n, newTitle, seg)
}
// renameFolderNode renames a folder-like node atomically: FS first, then DB transaction.
func (a *App) renameFolderNode(nodeID string, n *nodes.Node, newTitle, seg string) error {
if n.FsPath == "" {
return fmt.Errorf("cannot rename node %s (%s): fs_path is empty", n.ID, n.Title)
}
oldFsPath := n.FsPath
oldPhysPath := filepath.Join(a.vault, oldFsPath)
oldTitle := n.Title
parentFsPath := ""
if n.ParentID != nil {
if p, err := a.nodes.GetActive(*n.ParentID); err == nil {
parentFsPath = p.FsPath
}
}
newFsPath := seg
if parentFsPath != "" {
newFsPath = filepath.Join(parentFsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath)
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
return fmt.Errorf("path safety: %w", err)
}
// Pre-collect all descendant fs_path updates (before transaction, to avoid deadlock).
type pathUp struct{ id, path string }
var updates []pathUp
var walk func(string, string)
walk = func(id, p string) {
updates = append(updates, pathUp{id, p})
children, _ := a.nodes.ListChildren(id, false)
for _, c := range children {
cseg := templates.SafeDisplayNameToPathSegment(c.Title)
walk(c.ID, filepath.Join(p, cseg))
}
}
walk(nodeID, newFsPath)
// Check source exists before filesystem rename
if _, err := os.Stat(oldPhysPath); err != nil {
return fmt.Errorf("source folder not found: %w", err)
}
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("rename folder: %w", err)
}
// Atomic DB transaction: title + own fs_path + all descendant fs_paths
nowT := time.Now().UTC().Format(time.RFC3339)
slug := nodes.Slugify(newTitle)
tx, err := a.db.Begin()
if err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
newTitle, slug, nowT, nodeID); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
for _, u := range updates {
if _, err := tx.Exec(
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
u.path, nowT, u.id); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
}
if err := tx.Commit(); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return fmt.Errorf("commit tx: %w", err)
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderRenamed, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle,
"fs_path": newFsPath,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
// renameNoteFileNode renames a note or file node atomically: FS first, then DB transaction.
func (a *App) renameNoteFileNode(nodeID string, n *nodes.Node, newTitle, seg string) error {
// Collect file records before any mutations (avoid deadlock with SetMaxOpenConns=1).
type fileRec struct {
id, path, filename string
}
var records []fileRec
rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID)
if err == nil {
for rows.Next() {
var r fileRec
if err := rows.Scan(&r.id, &r.path, &r.filename); err != nil {
continue
}
records = append(records, r)
}
rows.Close()
}
// Compute new filenames and paths (no FS modifications yet).
type renameOp struct {
id string
oldAbs string
newAbs string
newFilename string
newPath string
}
var renameOps []renameOp
for _, r := range records {
if r.path == "" {
continue
}
oldAbs := filepath.Join(a.vault, r.path)
dir := filepath.Dir(r.path)
var newFilename string
if n.Type == nodes.TypeNote {
ext := filepath.Ext(r.filename)
if ext == "" {
ext = ".md"
}
newFilename = seg + ext
} else {
newFilename = newTitle
}
newPath := filepath.Join(dir, newFilename)
newAbs := filepath.Join(a.vault, newPath)
// Check for collision
if _, err := os.Stat(newAbs); err == nil {
ext := filepath.Ext(newFilename)
base := strings.TrimSuffix(newFilename, ext)
baseSeg := templates.SafeDisplayNameToPathSegment(base)
if baseSeg == "" {
baseSeg = "renamed"
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s (%d)%s", baseSeg, i, ext)
candidatePath := filepath.Join(dir, candidate)
if _, err := os.Stat(filepath.Join(a.vault, candidatePath)); os.IsNotExist(err) {
newFilename = candidate
newPath = candidatePath
newAbs = filepath.Join(a.vault, newPath)
break
}
}
}
if _, err := os.Stat(oldAbs); err != nil {
return fmt.Errorf("source file not found for rename: %w", err)
}
renameOps = append(renameOps, renameOp{
id: r.id, oldAbs: oldAbs, newAbs: newAbs,
newFilename: newFilename, newPath: newPath,
})
}
// Perform all physical renames first
successCount := 0
for i, rop := range renameOps {
if err := os.Rename(rop.oldAbs, rop.newAbs); err != nil {
// Rollback completed FS renames
for j := 0; j < i; j++ {
_ = os.Rename(renameOps[j].newAbs, renameOps[j].oldAbs)
}
return fmt.Errorf("rename file: %w", err)
}
successCount++
}
// Atomic DB transaction: title + all file record updates
nowT := time.Now().UTC().Format(time.RFC3339)
slug := nodes.Slugify(newTitle)
tx, err := a.db.Begin()
if err != nil {
for _, rop := range renameOps[:successCount] {
_ = os.Rename(rop.newAbs, rop.oldAbs)
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
newTitle, slug, nowT, nodeID); err != nil {
for _, rop := range renameOps[:successCount] {
_ = os.Rename(rop.newAbs, rop.oldAbs)
}
return err
}
for _, rop := range renameOps {
if _, err := tx.Exec(
`UPDATE files SET filename=?, path=? WHERE id=?`,
rop.newFilename, rop.newPath, rop.id); err != nil {
for _, rop2 := range renameOps[:successCount] {
_ = os.Rename(rop2.newAbs, rop2.oldAbs)
}
return err
}
}
if err := tx.Commit(); err != nil {
for _, rop := range renameOps[:successCount] {
_ = os.Rename(rop.newAbs, rop.oldAbs)
}
return fmt.Errorf("commit tx: %w", err)
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
var evType string
var targetType string
var syncEntity string
if n.Type == nodes.TypeNote {
evType = activity.TypeNoteUpdated
targetType = activity.TargetNote
syncEntity = syncsvc.EntityNote
} else {
evType = activity.TypeFileRenamed
targetType = activity.TargetFile
syncEntity = syncsvc.EntityFile
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "")
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle,
"updated_at": nowT,
})
return nil
}
// wouldCreateCycle checks that moving nodeID into newParentID does not create a cycle.
// It walks from newParentID up toward root: if nodeID is encountered, then newParentID
// is inside nodeID's subtree, and the move would create a cycle.
func (a *App) wouldCreateCycle(nodeID, newParentID string) error {
if nodeID == "" {
return fmt.Errorf("node ID is empty")
}
if newParentID == "" {
return nil
}
current := newParentID
depth := 0
for current != "" && depth < 1000 {
if current == nodeID {
return fmt.Errorf("cannot move a node into itself or its descendant")
}
n, err := a.nodes.Get(current)
if err != nil || n.ParentID == nil {
return nil
}
current = *n.ParentID
depth++
}
return nil
}
func (a *App) MoveNode(nodeID, newParentID string) error {
if err := a.requireVault(); err != nil {
return err
}
if nodeID == "" {
return fmt.Errorf("node ID is required")
}
node, err := a.nodes.GetActive(nodeID)
if err != nil {
return fmt.Errorf("node not found: %w", err)
}
// Cannot move into itself
if nodeID == newParentID {
return fmt.Errorf("cannot move a node into itself")
}
// Cannot move into descendant (would create cycle)
if err := a.wouldCreateCycle(nodeID, newParentID); err != nil {
return err
}
isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile
// Resolve new parent
var parent *nodes.Node
if newParentID != "" {
parent, err = a.nodes.GetActive(newParentID)
if err != nil {
return fmt.Errorf("new parent not found: %w", err)
}
if !isContainerType(parent.Type) {
return fmt.Errorf("target %q is not a container (type: %s)", parent.Title, parent.Type)
}
}
// Compute new title (name conflict resolution) but don't commit yet.
nodeTitle := node.Title
titleChanged := false
if parent != nil {
destChildren, _ := a.nodes.ListChildren(newParentID, false)
for i := range destChildren {
if destChildren[i].Title == nodeTitle && destChildren[i].ID != nodeID {
nodeTitle = fmt.Sprintf("%s (%d)", nodeTitle, 2)
titleChanged = true
break
}
}
}
if isFolderLike {
return a.moveFolderNode(nodeID, node, parent, newParentID, nodeTitle, titleChanged)
}
return a.moveNoteFileNode(nodeID, node, parent, newParentID, nodeTitle, titleChanged)
}
// moveFolderNode moves a folder-like node atomically: FS first, then DB transaction.
func (a *App) moveFolderNode(nodeID string, node *nodes.Node, parent *nodes.Node, newParentID, nodeTitle string, titleChanged bool) error {
if node.FsPath == "" {
return fmt.Errorf("cannot move node %s (%s): fs_path is empty", node.ID, node.Title)
}
oldFsPath := node.FsPath
oldPhysPath := filepath.Join(a.vault, oldFsPath)
seg := templates.SafeDisplayNameToPathSegment(nodeTitle)
newFsPath := seg
if parent != nil && parent.FsPath != "" {
newFsPath = filepath.Join(parent.FsPath, seg)
}
newPhysPath := filepath.Join(a.vault, newFsPath)
newPhysPath = templates.UniquePath(newPhysPath)
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
return fmt.Errorf("path safety: %w", err)
}
// Pre-collect all descendant fs_path updates (before transaction).
type pathUp struct{ id, path string }
var updates []pathUp
var walk func(string, string)
walk = func(id, p string) {
updates = append(updates, pathUp{id, p})
children, _ := a.nodes.ListChildren(id, false)
for _, c := range children {
cseg := templates.SafeDisplayNameToPathSegment(c.Title)
walk(c.ID, filepath.Join(p, cseg))
}
}
walk(nodeID, newFsPath)
// Check source exists and do filesystem rename first
if _, err := os.Stat(oldPhysPath); err != nil {
return fmt.Errorf("source folder not found: %w", err)
}
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("move folder: %w", err)
}
// Atomic DB transaction: title + parent_id + all fs_paths
nowT := time.Now().UTC().Format(time.RFC3339)
tx, err := a.db.Begin()
if err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if titleChanged {
slug := nodes.Slugify(nodeTitle)
if _, err := tx.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
nodeTitle, slug, nowT, nodeID); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
}
var parentVal interface{}
if newParentID != "" {
parentVal = newParentID
}
if _, err := tx.Exec(
`UPDATE nodes SET parent_id=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
parentVal, nowT, nodeID); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
// Touch new parent
if newParentID != "" {
_, _ = tx.Exec(`UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nowT, newParentID)
}
// Touch old parent if different
if node.ParentID != nil && *node.ParentID != newParentID {
_, _ = tx.Exec(`UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nowT, *node.ParentID)
}
for _, u := range updates {
if _, err := tx.Exec(
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
u.path, nowT, u.id); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
}
if err := tx.Commit(); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return fmt.Errorf("commit tx: %w", err)
}
// Update node.ParentID for activity/sync below
if newParentID == "" {
node.ParentID = nil
} else {
node.ParentID = &newParentID
}
node.FsPath = newFsPath
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
_ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderMoved, nodeTitle, `{"to":"`+newParentID+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpMove, map[string]interface{}{
"parent_id": newParentID,
"fs_path": newFsPath,
"updated_at": nowT,
})
return nil
}
// moveNoteFileNode moves a note/file atomically: FS first, then DB transaction.
func (a *App) moveNoteFileNode(nodeID string, node *nodes.Node, parent *nodes.Node, newParentID, nodeTitle string, titleChanged bool) error {
// Collect file records before any mutations.
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=?`, nodeID)
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 parent != nil && parent.FsPath != "" {
fm.newRelPath = filepath.Join(parent.FsPath, filename)
}
fm.newAbs = filepath.Join(a.vault, fm.newRelPath)
fileMoves = append(fileMoves, fm)
}
frows.Close()
}
// Perform filesystem moves first (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: %w", err)
}
}
// Atomic DB transaction: title + parent_id + file paths
nowT := time.Now().UTC().Format(time.RFC3339)
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()
if titleChanged {
slug := nodes.Slugify(nodeTitle)
if _, err := tx.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
nodeTitle, slug, nowT, nodeID); err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return err
}
}
var parentVal interface{}
if newParentID != "" {
parentVal = newParentID
}
if _, err := tx.Exec(
`UPDATE nodes SET parent_id=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
parentVal, nowT, nodeID); 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)
}
if newParentID == "" {
node.ParentID = nil
} else {
node.ParentID = &newParentID
}
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
var targetType string
var evType string
var syncEntity string
if node.Type == nodes.TypeNote {
targetType = activity.TargetNote
evType = activity.TypeNoteUpdated
syncEntity = syncsvc.EntityNote
} else {
targetType = activity.TargetFile
evType = activity.TypeFileMoved
syncEntity = syncsvc.EntityFile
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, nodeTitle, `{"to":"`+newParentID+`"}`)
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
"parent_id": newParentID,
"updated_at": nowT,
})
return nil
}
func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
if !a.IsReady() || a.templates == nil {
return []TemplateDTO{}, nil
}
list := a.templates.Enabled()
result := make([]TemplateDTO, len(list))
for i, t := range list {
result[i] = TemplateDTO{
ID: t.ID,
Title: t.Title,
Type: t.Type,
Icon: t.Icon,
}
}
return result, nil
}
type fileMoveInfo struct {
id, oldPath, newRelPath string
}
func (a *App) rollbackFileMoves(moves []fileMoveInfo) error {
for _, fm := range moves {
oldAbs := filepath.Join(a.vault, fm.oldPath)
newAbs := filepath.Join(a.vault, fm.newRelPath)
_ = os.Rename(newAbs, oldAbs)
}
return nil
}
type SearchNodeResult struct {
ID string `json:"id"`
Title string `json:"title"`
Path string `json:"path"`
Type string `json:"type"`
}
func (a *App) SearchNodes(query string) ([]SearchNodeResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.nodes.Search(query, 20)
if err != nil {
return nil, err
}
results := make([]SearchNodeResult, 0, len(nodes))
for i := range nodes {
if nodes[i].IsDeleted() {
continue
}
path := a.nodes.Path(nodes[i].ID)
results = append(results, SearchNodeResult{
ID: nodes[i].ID,
Title: nodes[i].Title,
Path: path,
Type: nodes[i].Type,
})
}
return results, nil
}
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return "", err
}
if n.FsPath == "" {
return "", fmt.Errorf("node %s has no physical path", nodeID)
}
physPath := filepath.Join(a.vault, n.FsPath)
return physPath, nil
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"time"
"verstak/internal/core/activity"
"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
}
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
}
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
}
+286
View File
@@ -0,0 +1,286 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"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
}
if 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
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
}
+371
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, ", ")
}
+370
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)
}
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
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"`
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
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
for _, n := range deleted {
deletedAt := ""
if n.DeletedAt != nil {
deletedAt = n.DeletedAt.Format(time.RFC3339)
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
nodes = append(nodes, TrashNodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
FsPath: n.FsPath,
NodePath: a.nodes.Path(n.ID),
DeletedAt: deletedAt,
})
}
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 == "" {
return nil
}
trashEntry, err := a.findTrashEntryForNode(nodeID)
if err != nil {
return nil
}
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 (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 {
if path, err := a.findTrashEntryForNode(id); err == nil {
_ = os.RemoveAll(path)
}
}
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
}
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()
}
+287
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,
}
}
+303
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)
}
}
+122
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
+6 -3
View File
@@ -2,7 +2,10 @@
<html lang="ru">
<head>
<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" />
<title>Верстак</title>
<style>
@@ -16,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
<script type="module" crossorigin src="/assets/main-DNvOo-CH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CXWRKHDk.css">
</head>
<body>
<div id="app"></div>
-157
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);
}
+219
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)
}
}
+13 -51
View File
@@ -3,17 +3,8 @@ package main
import (
"embed"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"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"
"verstak/internal/core/config"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -24,49 +15,17 @@ import (
var assets embed.FS
func main() {
vaultPath := "."
if len(os.Args) > 1 {
vaultPath = os.Args[1]
}
app := &App{}
abs, err := filepath.Abs(vaultPath)
if err != nil {
log.Fatal(err)
}
// Fix WebKit signal handler for Go 1.24+ compatibility
ensureSignalOnStack()
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
log.Fatalf("Open vault: %v", err)
}
defer db.Close()
// 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,
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},
AssetServer: &assetserver.Options{
Assets: assets,
@@ -81,4 +40,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Ensure config dir exists for logging/cli usage
config.EnsureConfigDir()
}
+255
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)
}
}
+26
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
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()
}
}()
}
+528
View File
@@ -0,0 +1,528 @@
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
if len(s.Events) != 3 {
t.Fatalf("suggestion has %d events, want 3", 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
// Only the second event should be suggested
if len(s.Events) != 1 {
t.Fatalf("expected 1 new event, got %d", len(s.Events))
}
if s.Events[0].ID != eid2 {
t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID)
}
if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 {
t.Errorf("eventIds does not contain the new event")
}
}
}
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)
}
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)
}
}
+961
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
}
+156
View File
@@ -0,0 +1,156 @@
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 != 2 {
t.Fatalf("TrashCount = %d, want 2", count)
}
if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil {
t.Fatalf("PurgeTrashNodesJSON: %v", err)
}
count, _ = app.TrashCount()
if count != 1 {
t.Fatalf("TrashCount after purge = %d, want 1", count)
}
if 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)
}
}
+149
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
}
File diff suppressed because it is too large Load Diff
+137
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 ""
}
}
+12
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"
}
+89
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)
}
+372
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")
}
}
+325
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,
})
}
+541
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")
}
}
+336
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
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"
+53
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)
}
}
+104
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
}
+37
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
}
+100
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'))
);
`
+131
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
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)
}
+800
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"))
}
+80
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
}
+22
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
+169
View File
@@ -8,9 +8,11 @@ import (
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
@@ -38,6 +40,8 @@ func main() {
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
case "sync":
runSync(os.Args[2:])
case "plugin":
runPlugin(os.Args[2:])
default:
@@ -56,6 +60,7 @@ func usage() {
fmt.Println(" node Manage nodes")
fmt.Println(" action Manage actions")
fmt.Println(" --version Show version")
fmt.Println(" sync Sync with server (push/pull/status)")
fmt.Println(" --help Show this help")
}
@@ -597,6 +602,170 @@ func runIndexRebuild(args []string) {
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 ---
func runPlugin(args []string) {
+11 -3
View File
@@ -18,7 +18,11 @@ func runNodeCreate(vault, parentID, typ, title string) error {
defer db.Close()
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 {
return err
}
@@ -65,7 +69,7 @@ func runNodeList(vault, parentID string) error {
repo := nodes.NewRepository(db)
var list []nodes.Node
if parentID == "" {
list, err = repo.ListRoots(false, "")
list, err = repo.ListRoots(false)
} else {
list, err = repo.ListChildren(parentID, false)
}
@@ -87,7 +91,11 @@ func runNodeMove(vault, id, parentID string, sortOrder int) error {
defer db.Close()
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
}
fmt.Println("moved")
+1
View File
@@ -37,6 +37,7 @@
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
## Главные принципы
+21
View File
@@ -68,6 +68,12 @@ Markdown-файл внутри vault. Резервная копия при пе
Любой файл, привязанный к делу. Открывается системным приложением.
### Ссылка
Отдельный артефакт дела, а не заметка. Хранит URL, название,
hostname, заметку и дату добавления. Открывается во внешнем браузере
и показывается во вкладке "Ссылки" конкретного дела.
### Действие
Кнопка запуска: URL, файл, папка, команда. Опасные — с подтверждением.
@@ -81,6 +87,21 @@ Markdown-файл внутри vault. Резервная копия при пе
Следы работы: открыт файл, изменена заметка, запущено действие.
Используется для восстановления времени.
### Неразобранное
Единый входящий поток. Любой внешний материал — файл, папка,
изображение, URL или текст из drag-and-drop, Ctrl+V или кнопки
"Вставить из буфера" — сначала создаётся как unresolved artifact.
Верстак хранит контекст захвата по `node_id` или системному разделу,
показывает материал в глобальном "Неразобранном" и, если захват был
внутри дела, в локальной вкладке "Неразобранное" этого дела.
При разборе artifact маршрутизируется по типу:
- файл / папка / изображение → "Файлы" выбранного дела;
- текст → "Заметки" выбранного дела;
- URL → "Ссылки" выбранного дела.
## 5. Примеры дерева (универсальные)
```text
+4 -6
View File
@@ -4,12 +4,12 @@
```text
┌────────────────────┐
│ GUI/Wails │
│ GUI/Wails v2 │
└─────────┬──────────┘
│
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
└─────────────┘ └───────┬────────┘ └─────────────┘
┌───────▼────────┐ ┌─────────────┐
│ Core Library │◀───│ CLI Commands │
└───────┬────────┘ └─────────────┘
│
┌─────────▼──────────┐
│ Local Vault+SQLite │
@@ -161,8 +161,6 @@ verstak/
activity/
search/
importers/
sync/
security/
config/
gui/
+83 -27
View File
@@ -9,8 +9,10 @@
.verstak/
index.db
config.yml
device.yml
sync_state.db
device_token.json
blobs/
inbox/
plugins/
trash/
history/
originals/
@@ -51,6 +53,10 @@ CREATE TABLE nodes (
title TEXT NOT NULL,
slug TEXT NOT NULL,
path TEXT NULL,
section TEXT NULL,
template_id TEXT NOT NULL DEFAULT '',
fs_path TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -102,6 +108,26 @@ CREATE TABLE notes (
);
```
### links
Resolved URL artifacts. Unresolved captured URLs live as inbox artifacts
with capture metadata until the user assigns them to a target node.
```sql
CREATE TABLE links (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
title TEXT NOT NULL,
url TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
captured_at TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
### actions
```sql
@@ -135,6 +161,7 @@ CREATE TABLE worklog_entries (
billable INTEGER NOT NULL DEFAULT 0,
summary TEXT NOT NULL,
details TEXT NULL,
source TEXT NOT NULL DEFAULT 'unknown',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -145,38 +172,25 @@ CREATE TABLE worklog_entries (
```sql
CREATE TABLE activity_events (
id TEXT PRIMARY KEY,
node_id TEXT NULL REFERENCES nodes(id),
source TEXT NOT NULL,
node_id TEXT NOT NULL,
parent_id TEXT,
event_type TEXT NOT NULL,
title TEXT NULL,
path TEXT NULL,
url TEXT NULL,
app_name TEXT NULL,
process_name TEXT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NULL,
duration_seconds INTEGER NULL,
metadata_json TEXT NULL,
confidence INTEGER NOT NULL DEFAULT 100,
target_type TEXT,
target_id TEXT,
target_path TEXT,
title TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
```
### worklog_suggestions
### worklog_entry_events
```sql
CREATE TABLE worklog_suggestions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
date TEXT NOT NULL,
started_at TEXT NULL,
ended_at TEXT NULL,
suggested_minutes INTEGER NULL,
summary TEXT NOT NULL,
evidence_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
CREATE TABLE worklog_entry_events (
entry_id TEXT NOT NULL REFERENCES worklog_entries(id) ON DELETE CASCADE,
event_id TEXT NOT NULL,
PRIMARY KEY (entry_id, event_id)
);
```
@@ -208,10 +222,45 @@ CREATE TABLE sync_ops (
pushed_at TEXT NULL,
applied_at TEXT NULL
);
CREATE TABLE sync_state (
device_id TEXT PRIMARY KEY,
server_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
last_push_rev INTEGER NOT NULL DEFAULT 0,
last_pull_rev INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT,
last_pull_seq INTEGER NOT NULL DEFAULT 0
);
```
## 3. Правила хранения
### Capture / Inbox
Внешний захват материала не создаёт сразу обычную заметку, файл или
ссылку внутри дела. Сначала создаётся root-level inbox artifact с
metadata в `node_meta`:
```text
capture.inbox = true
capture.status = unresolved | resolved
capture.context_type = node | section | global
capture.context_node_id = <node_id>
capture.context_section = today | journal | settings | inbox | root | ...
capture.suggested_target_node_id = <node_id>
capture.source_kind = file | folder | url | text | image
capture.source = drop | paste | clipboard_button
capture.created_at = RFC3339
```
Контекст дела хранится только по `node_id`, не по названию.
Файлы, папки и изображения до разбора лежат в `.verstak/inbox/...`.
После "Оставить здесь" или "Разложить" они физически перемещаются в
нормальную vault-иерархию выбранного дела через существующую логику
`MoveNode`.
### Заметки
- physical `.md` file;
@@ -224,6 +273,13 @@ CREATE TABLE sync_ops (
- open with system app;
- metadata in SQLite.
### Ссылки
- unresolved URL хранится как inbox artifact типа `link` с URL metadata;
- после разбора создаётся запись в `links`;
- вкладка "Ссылки" показывает только resolved links текущего node;
- URL открывается системным браузером.
### Secret notes
- encrypted file, e.g. `access.secret.md.enc`;
+22 -14
View File
@@ -13,14 +13,20 @@
`verstak-server` — простой Go HTTP server.
Функции:
Реализовано в коде:
- API key auth;
- device registry;
- user auth + device registry;
- device token / API key auth;
- operation log;
- blob storage by sha256;
- snapshots;
- push/pull operation exchange;
- blob upload/download;
- web pages for login/register/dashboard/admin.
Ещё не реализовано:
- restore manifest;
- snapshots;
- deleted item retention.
Пример server storage:
@@ -42,9 +48,10 @@ GET /api/v1/health
POST /api/v1/device/register
POST /api/v1/sync/push
POST /api/v1/sync/pull
POST /api/v1/blobs/upload
POST /api/v1/blobs/
GET /api/v1/blobs/{sha256}
GET /api/v1/restore/manifest
POST /api/v1/auth/register
POST /api/v1/auth/login
```
Auth:
@@ -60,8 +67,7 @@ Client push:
```json
{
"device_id": "device-uuid",
"ops": [],
"blobs": ["sha256..."]
"ops": []
}
```
@@ -69,8 +75,9 @@ Server response:
```json
{
"accepted": true,
"missing_blobs": ["sha256..."]
"accepted": ["op-id"],
"count": 1,
"conflicts": []
}
```
@@ -78,8 +85,7 @@ Client pull:
```json
{
"device_id": "device-uuid",
"since_revision": 42
"since_sequence": 42
}
```
@@ -87,7 +93,7 @@ Response:
```json
{
"server_revision": 50,
"server_sequence": 50,
"ops": []
}
```
@@ -157,7 +163,7 @@ retention:
Верстак не должен быть шпионской программой. Activity tracking должен быть явным и настраиваемым.
### Источники MVP
### Источники текущего MVP
- opened node;
- edited note;
@@ -165,6 +171,8 @@ retention:
- changed file through app;
- launched action.
На текущем этапе события используются для conservative worklog suggestions и для глобальной/узловой ленты активности.
### Источники later
- file watcher/scanner;
+52 -23
View File
@@ -18,23 +18,23 @@
- import/dokuwiki/mysql-cleanup.txt
Быстрые действия:
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
[Новое дело] [Добавить файл] [Журнал] [Sync]
```
## 2. Общий layout
```text
┌────────────────────────────────────────────────────┐
│ Search | Quick Add | Sync Status | Settings │
├───────────────┬────────────────────────────────────┤
│ Tree sidebar │ Main content │
│ │ │
│ Сегодня │ Case / Note / File / Search │
│ Клиенты │ │
┌───────────────┬────────────────────────────────────┐
│ Tree sidebar │ Header: Section/Node Title │
│ ├────────────────────────────────────┤
│ Сегодня │ Main content │
│ Клиенты │ Case / Note / File / Search │
│ Проекты │ │
│ Рецепты │ │
│ Документы │ │
└───────────────┴────────────────────────────────────┘
*Примечание: Sync Status реализован в footer сайдбара и открывает Settings → Sync. Quick Add как отдельная глобальная панель пока не реализован.*
```
## 3. Sidebar tree
@@ -82,7 +82,7 @@
2026-05-12 — чистка кеша и проверка формы — 40м
Вкладки:
Обзор | Заметки | Файлы | Документы | Скрипты | Действия | Журнал | Активность
Обзор | Заметки | Файлы | Неразобранное | Ссылки | Действия | Журнал | Активность
```
## 5. Заметки
@@ -100,7 +100,8 @@
Actions:
- add file;
- drag & drop;
- drag & drop в приложении создаёт unresolved artifact в Неразобранном;
- явные кнопки добавления файла/папки импортируют в выбранный раздел файлов;
- copy into vault;
- link external;
- open with system app;
@@ -167,6 +168,8 @@ Backup сайта run_script, confirm required
## 9. Активность
Текущий MVP фиксирует события внутри приложения: создание/изменение заметок, добавление/перемещение/удаление файлов и папок, создание/изменение узлов, запуск actions и добавление worklog. На основе этих событий строятся предложения для журнала работ.
```text
14:05 opened admin.romashka.ru
14:18 opened catalog.xlsx
@@ -197,24 +200,50 @@ Search results:
## 11. Неразобранное
Неразобранное — единый входящий лоток для внешнего материала.
Пользователь может кинуть или вставить материал в любое место
приложения; Верстак сначала сохраняет его как unresolved artifact,
а не добавляет напрямую в дело.
Туда попадают:
- импортированные DokuWiki pages;
- файлы без дела;
- папки из scan work;
- конфликты sync;
- activity suggestions без уверенного дела.
- файлы и папки из drag-and-drop;
- изображения из paste/drop, если webview отдаёт данные;
- URL из браузера (`text/uri-list`, `text/x-moz-url`, `text/plain`);
- обычный текст из Ctrl+V;
- текст/URL из кнопки "Вставить из буфера" через backend clipboard bridge;
- будущие импортированные материалы без уверенного назначения;
- будущие конфликты sync.
Глобальное "Неразобранное" показывает все unresolved artifacts.
Локальная вкладка "Неразобранное" внутри дела показывает artifacts,
захваченные в контексте этого `node_id` или предложенные для него.
Actions:
- создать дело;
- привязать к делу;
- переместить;
- архивировать;
- игнорировать;
- удалить в trash.
- "Оставить здесь" — если есть suggested target;
- "Разложить" / "Переместить..." — выбрать target node;
- удалить artifact и staged данные.
## 12. TUI UX
Маршрутизация при разборе:
- файл / папка / изображение → вкладка "Файлы" target node;
- текст → вкладка "Заметки" target node;
- URL → вкладка "Ссылки" target node.
## 12. Ссылки
Вкладка "Ссылки" внутри дела показывает только resolved URL artifacts
этого node. Ссылки не смешиваются с файлами, заметками и действиями.
Минимальные действия:
- открыть во внешнем браузере;
- скопировать URL;
- редактировать название, URL и заметку;
- удалить ссылку.
## 13. TUI UX
TUI нужен для быстрого:
@@ -231,7 +260,7 @@ TUI нужен для быстрого:
- preview документов;
- настройки sync.
## 13. CLI UX
## 14. CLI UX
CLI команды:
+36 -13
View File
@@ -1,11 +1,13 @@
# Верстак — roadmap
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
## Milestone 0 — Skeleton
- Go module;
- cmd/verstak;
- cmd/verstak-gui stub;
- cmd/verstak-server stub;
- cmd/verstak-gui entrypoint;
- cmd/verstak-server entrypoint;
- internal/core;
- SQLite connection;
- migrations;
@@ -65,6 +67,8 @@ Acceptance:
## Milestone 4 — GUI MVP
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
- Wails app;
- sidebar tree;
- create node/note;
@@ -131,28 +135,42 @@ Acceptance:
## Milestone 9 — Work folder intake
- scan folder;
- show unassigned folders/files;
- create case from folder;
- copy/move/link into vault.
- unified Capture / Inbox pipeline for external drop, paste and clipboard button;
- stage captured files/folders/images under `.verstak/inbox`;
- store capture context by `node_id` or system section;
- global Inbox view for all unresolved artifacts;
- local Inbox tab inside a node;
- resolve artifacts into Files / Notes / Links;
- dedicated Links tab for resolved URL artifacts;
- scanner for a whole work folder remains future work.
Acceptance:
- можно постепенно разобрать папку `work`.
- можно кинуть или вставить файл, папку, картинку, URL или текст в приложение;
- материал не теряется и сначала виден в "Неразобранном";
- после разбора он физически или логически оказывается в выбранном деле.
## Milestone 10 — Sync server MVP
## Milestone 10 — Sync server/client MVP
- HTTP server;
- API key;
- user auth + device registration;
- API key / device token flow;
- server db;
- push/pull ops;
- blob upload/download;
- restore manifest.
- manual sync from GUI;
- sync settings in Settings.
Acceptance:
- две машины обмениваются изменениями через сервер.
Current gaps:
- restore manifest/download flow is not implemented;
- conflict resolver UI is not implemented;
- retention/snapshots are not implemented.
## Milestone 11 — Conflicts and restore
- conflict copy;
@@ -179,7 +197,12 @@ Acceptance:
- Верстак предлагает worklog на основе следов.
## Milestone 13 — File scanner/watcher
Current gaps:
- file watcher/scanner events are not implemented;
- browser/active-window tracking is not implemented.
## Milestone 13 — File scanner/watcher (PAUSED)
- snapshot scanner;
- fsnotify watcher;
@@ -191,7 +214,7 @@ Acceptance:
- изменения файлов снаружи приложения обнаруживаются.
## Milestone 14 — TUI MVP
## Milestone 14 — TUI MVP (PAUSED)
- Bubble Tea;
- tree/search;
@@ -203,7 +226,7 @@ Acceptance:
- можно из терминала быстро работать с Верстаком.
## Milestone 15 — 1.0 polish
## Milestone 15 — 1.0 polish (PAUSED)
- installers;
- backup export;
+1 -1
View File
@@ -216,7 +216,7 @@ Acceptance:
- GET /health;
- POST /sync/push;
- POST /sync/pull;
- POST /blobs/upload;
- POST /api/v1/blobs/;
- GET /blobs/{sha256};
- blob storage by sha256.
```
+22 -16
View File
@@ -6,34 +6,40 @@
## Обязательные функции
- [ ] Создать vault.
- [ ] Создать дерево дел.
- [ ] Создать Markdown-заметку.
- [ ] Редактировать Markdown-заметку.
- [ ] Добавить файл в дело.
- [ ] Открыть файл системным приложением.
- [ ] Создать action “Открыть URL”.
- [ ] Создать action “Открыть папку”.
- [ ] Создать action “Запустить команду”.
- [ ] Добавить запись работы.
- [ ] Скопировать отчёт по работам.
- [ ] Поиск по заметкам.
- [ ] Поиск по именам файлов.
- [x] Создать vault.
- [x] Создать дерево дел.
- [x] Создать Markdown-заметку.
- [x] Редактировать Markdown-заметку.
- [x] Добавить файл в дело.
- [x] Открыть файл системным приложением.
- [x] Создать action “Открыть URL”.
- [x] Создать action “Открыть папку”.
- [x] Создать action “Запустить команду”.
- [x] Добавить запись работы.
- [x] Скопировать отчёт по работам.
- [x] Поиск по заметкам.
- [x] Поиск по именам файлов.
- [x] Поиск по журналу работ.
- [x] Базовый импорт DokuWiki (плагин).
- [x] Базовая ручная синхронизация через сервер.
- [x] Базовые activity events и предложения для журнала работ.
- [x] Настройки vault/sync/templates/plugins.
## Необязательные, но желательные
- [ ] Неразобранное.
- [ ] Скан папки work.
- [ ] Внешние linked files.
- [ ] Action log.
- [ ] Activity events MVP.
- [x] Action log.
- [x] Activity events MVP.
- [ ] Conflict resolver UI.
- [ ] Restore from sync server.
- [ ] Backup export / retention snapshots.
- [ ] TUI MVP.
## Не входит в первый MVP
- [ ] Полная синхронизация.
- [ ] Полная синхронизация с restore/conflict UI/retention.
- [ ] Активные окна.
- [ ] Browser tracking.
- [ ] Encrypted secret notes.
+244
View File
@@ -0,0 +1,244 @@
# Сервер синхронизации Верстак — руководство
## 1. Зачем нужен сервер
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
Он не обязателен — Верстак работает полностью локально и без сервера.
## 2. Установка
### Быстрая установка (systemd)
```bash
# Сборка бинарника
go build -o verstak-server ./cmd/verstak-server/
# Установка (от root)
sudo ./verstak-server/install.sh \
--port 47732 \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Скрипт:
- создаёт системного пользователя `verstak`;
- копирует бинарник в `/usr/local/bin/verstak-server`;
- создаёт `/var/lib/verstak-server` для данных;
- записывает админа в конфиг;
- устанавливает systemd-сервис `verstak-server.service`;
- запускает и включает автозапуск.
### Ручной запуск (для тестирования)
```bash
# Одной командой
./verstak-server \
--port 47732 \
--data ./server-data \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Флаги:
| Флаг | По умолчанию | Описание |
|---|---|---|
| `--port` | `47732` | Порт сервера |
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
| `--admin-pass` | — | Пароль администратора |
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
## 3. Админ-панель
### Как открыть
Откройте в браузере: `http://<сервер>:47732/admin/login`
Войдите с логином и паролем, указанными при установке.
### Что там есть
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей, форма настройки SMTP.
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
- **Настройка SMTP** — сервер, порт, логин, пароль, email отправителя, URL сервера (для ссылок в письмах). SMTP нужен для отправки писем подтверждения email и сброса пароля.
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
### Несколько админов
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
## 4. Регистрация пользователей
### Как зарегистрироваться
Откройте в браузере: `http://<сервер>:47732/register`
Форма принимает:
- **Логин** — латинские буквы и цифры
- **Email** — для подтверждения и сброса пароля
- **Пароль** — минимум 8 символов, латинские буквы и цифры
После отправки формы на email приходит письмо с ссылкой подтверждения (если SMTP настроен). Если SMTP не настроен, подтверждение происходит автоматически, и можно сразу логиниться.
### Как войти
`http://<сервер>:47732/login` — форма логина принимает логин или email.
### Личный кабинет
После входа — `/dashboard` — список устройств пользователя с полными API-ключами, кнопками копирования и удаления, форма добавления нового устройства.
## 5. API-ключи (устройства)
### Что такое API-ключ
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
### Как создать
#### Через веб-интерфейс пользователя
1. Зайти в `/login`, войти.
2. В `/dashboard` ввести имя устройства и нажать "Connect".
3. Скопировать сгенерированный ключ.
#### Через админ-панель
1. Зайти в `/admin/login`.
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
3. Скопировать сгенерированный ключ.
#### Через API (требует логин+пароль пользователя)
```bash
curl -X POST http://localhost:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-ноутбук","username":"ivan","password":"пароль-пользователя"}'
```
Ответ: `{"device_id":"...","api_key":"..."}`
### Как использовать
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
```bash
curl -X POST http://localhost:47732/api/v1/sync/push \
-H "Authorization: Bearer b10c5d8e3f2a..." \
-H "Content-Type: application/json" \
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
```
В GUI Верстак достаточно указать URL сервера, логин и пароль — устройство зарегистрируется автоматически.
### Один ключ на все устройства или отдельный на каждое?
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в личном кабинете), не затронув остальные.
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
- его нельзя будет удалить, не отключив все устройства разом;
- в логах сервера все операции будут выглядеть как от одного устройства;
- это не поддерживаемый сценарий.
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
## 6. Настройка клиента
### CLI
```bash
verstak sync push --vault /путь/к/vault
verstak sync pull --vault /путь/к/vault
verstak sync status --vault /путь/к/vault
```
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
```yaml
sync:
server_url: http://мой-сервер:47732
api_key: мой-ключ
device_id: id-устройства # опционально
auto_sync: false
```
API-ключ и device_id можно получить, зарегистрировав устройство через API:
```bash
curl -s http://сервер:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-пк","username":"ivan","password":"мой-пароль"}' | jq .
```
### GUI
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера, логин и пароль, нажмите "Test Connection" для проверки, затем "Сохранить". Кнопка "Синхронизировать" (или пункт меню в сайдбаре) запускает push + pull.
## 7. Безопасность
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
Что стоит учесть:
- Регистрация устройств требует аутентификации пользователя.
- Подтверждение email обязательно, если настроен SMTP (иначе подтверждение автоматическое).
- Нет logout'а для админа — сессия живёт 24 часа или до перезапуска сервера.
- Нет rate limiting'а — возможен перебор пароля.
- Пароли хранятся в bcrypt — база данных не должна быть общедоступной.
- SMTP-пароль хранится в открытом виде в конфиге сервера.
Рекомендации для production:
- Закрыть порт сервера фаерволом (только доверенные IP).
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
- Поставить nginx/Caddy перед сервером с HTTPS.
- Настроить SMTP для полноценного подтверждения email и сброса пароля.
## 8. Полный API
### Открытые endpoint'ы
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/health` | Проверка здоровья сервера |
### Требуют API-ключ (Authorization: Bearer)
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
### Требуют логин+пароль (body JSON)
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/auth/register` | Регистрация (username, email, password) |
| GET | `/api/v1/auth/confirm?token=` | Подтверждение email |
| POST | `/api/v1/auth/login` | Вход, возвращает Bearer-токен |
| POST | `/api/v1/auth/forgot` | Запрос сброса пароля (email) |
| POST | `/api/v1/auth/reset` | Сброс пароля (token, password) |
| POST | `/api/v1/device/register` | Регистрация устройства (name, username, password) |
### Требуют сессию пользователя (Bearer token или cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/user/devices` | Список устройств пользователя |
### Требуют сессию админа (cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET/POST | `/admin/login` | Страница входа / отправка формы |
| GET | `/admin/dashboard` | Дашборд |
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
| POST | `/admin/api/keys` | Создать ключ |
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
| POST | `/admin/api/smtp` | Сохранить SMTP-конфигурацию |
### Пользовательский веб-интерфейс (cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET/POST | `/register` | Форма регистрации |
| GET/POST | `/login` | Форма входа |
| GET | `/dashboard` | Личный кабинет (устройства) |
| GET | `/logout` | Выход |
+171 -190
View File
@@ -21,65 +21,23 @@
| 8 | FTS5 Search | ✅ выполнен |
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
| 13 | **Drag-and-drop** | ⬜ не начат |
| 14 | **MVP stabilization** | ⬜ не начат |
| 15 | Sync Server Skeleton | 🔒 PAUSED |
| 16 | Sync Client MVP | 🔒 PAUSED |
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
| 19 | Integrity Check + Repair | 🔒 PAUSED |
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
| 22 | Calendar/Kanban | 🔒 PAUSED |
| 23 | New templates/integrations | 🔒 PAUSED |
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
**GUI Build (Wails v2):**
```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
./verstak-gui
```
**GUI Build (Wails v2):**
```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
./verstak-gui
```
Или для dev режима: `wails dev` (требует Wails v2 CLI)
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
| 14 | **MVP stabilization** | ✅ выполнено |
| 15 | **Sync Server + Client** | ✅ выполнено |
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence |
| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
---
## Текущий этап: Wails v2 Vertical MVP
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
**Прогресс:**
- ✅ Wails v2 shell (window opens, no SIGSEGV)
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
- 🔄 Notes bindings + UI
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- 🔄 Node creation
- 🔄 Section filtering
**Пауза (не начинать до завершения vertical MVP):**
- Файлы/папки workflow
- Drag-and-drop
- Sync, plugins, Lua, browser extension, TUI
- Новые шаблоны, DokuWiki importer
---
## Выполненные шаги (1-10)
## Выполненные шаги (1-14)
### ШАГ 1 — Git Init + Skeleton
- go module `verstak`, структура cmd/internal/migrations
@@ -138,11 +96,28 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
- CLI: `plugin list/enable/disable/templates`
- Lua runtime — stub (placeholder)
---
### ШАГ 11 — Wails Desktop GUI
## Текущий этап: ШАГ 11 — Wails Desktop GUI
**Целевой commit:** `gui/wails-file-workflow`
**Прогресс:**
- ✅ Wails v2 shell (window opens, no SIGSEGV)
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
- ✅ Notes bindings + UI (create/read/save/dirty state)
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- ✅ Node creation + template selection (FromTemplate)
- ✅ Section filtering
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
- ✅ Drag-and-drop (internal + external OS file drops)
- ✅ Actions CRUD (create/list/run/delete)
- ✅ Worklog entry form
- ✅ Today dashboard + Activity feed (global + per-case)
- ✅ Search
- ✅ Import dialog with safety checks (PreviewImport)
- ✅ Keyboard shortcuts
- ✅ i18n (ru/en)
- ✅ First-run/recovery flow без авто-создания vault
- ✅ Settings window: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup
- ✅ Sync settings перенесены в Settings; в основном UI остался status/manual sync widget
- ✅ Rendered GUI smoke: first-run, recovery, Settings icons, workspace, tabs, files back button, create flows, worklog, sync conflict/applyError warnings, mobile viewport
Архитектура:
@@ -151,15 +126,16 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
│ Frontend (Wails) │
│ frontend/src/ │
│ App.svelte │
│ components/Sidebar, TopBar, CaseView, ... │
│ stores/selection, nodes, files, ui │
│ styles/theme.css │
│ FileTreeRow, TreeNode │
│ lib/FileBreadcrumbs, FilePreviewModal, ... │
│ lib/i18n/ locales (ru, en) │
└──────────────────┬──────────────────────────────┘
│ Wails bindings
┌──────────────────▼──────────────────────────────┐
│ Go Core (internal/core/) │
│ nodes, vault, storage, notes, files, │
│ actions, worklog, search, plugins │
│ actions, worklog, search, plugins, sync, │
│ activity │
└──────────────────┬──────────────────────────────┘
│
┌──────────────────▼──────────────────────────────┐
@@ -167,137 +143,84 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
└─────────────────────────────────────────────────┘
```
### Действия
### ШАГ 12 — Files/Folders full workflow
1. Создать Wails app skeleton (`wails init`)
2. Структура frontend/ — Svelte/Vue/vanilla TS
3. Backend bindings — методы Wails над core services
4. Перенести текущий UI shell из inline HTML в frontend
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
Core service extensions:
- `AddPathCopy` — рекурсивный импорт директорий
- `AddPathLink` — привязка внешних файлов без копирования
- Folder model: node type `folder`
- Name conflict resolution: `name (2)`, `name (3)`
- Safety checks: PreviewImport с summary (count, size, warnings)
- Trash: soft delete → `.verstak/trash`
### Backend bindings (минимум)
### ШАГ 13 — Drag-and-drop
```go
// Nodes
ListSections() ([]SectionDTO, error)
ListNodes(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
DeleteNode(id string) error
MoveNode(id, parentID string) error
- Internal DnD: перетаскивание узлов в дереве
- External DnD: файлы из ОС → активное дело / вкладка Файлы
- Диалог подтверждения с выбором: копировать / переместить / привязать
// Notes
CreateNote(parentID, title string) (NodeDTO, error)
ReadNote(noteID string) (string, error)
SaveNote(noteID, content string) error
### ШАГ 14 — MVP stabilization
// Files
ListFiles(nodeID string) ([]FileDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
DeleteFileOrFolder(id string) error
OpenFile(id string) error
OpenFolder(id string) error
PickFile() (string, error)
PickDirectory() (string, error)
// Actions/Worklog/Search
ListActions(nodeID string) ([]ActionDTO, error)
RunAction(id string) error
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
Search(query string) ([]SearchResultDTO, error)
```
- Все операции атомарны (DB+FS)
- Template файлы/папки создаются как полноценные ноды с rollback
- fs_path валидируется (SafeVaultPath)
- sync_apply FS-first
- Delete находит ошибки trash-переноса
- 24+ integration tests проходят
- **Известные пробелы:** Restore из trash не реализован, ensureTemplateChildren продолжает при ошибках (backward compat), parent_id+title в applyRemoteNodeUpdate без полной транзакции
---
## ШАГ 12 — Files/Folders full workflow
## Завершённый базовый этап: ШАГ 16 — Activity Suggestions
### Core service extensions
**Статус:** ✅ базово выполнено — activity-based suggestions + global worklog dashboard + conservative estimator.
Расширить `files.Service`:
```go
AddPathCopy(nodeID string, sourcePath string) ([]Node, error)
AddPathLink(nodeID string, sourcePath string) ([]Node, error)
```
Логика:
- `os.Stat(sourcePath)` → если директория → рекурсивный обход
- Каждый файл → File node + file record
- Каждая папка → Folder node
- Структура сохраняется через parent_id
### Folder model
Папка = node type `folder` (не file record с mime=directory).
При импорте `romashka-docs/`:
```
Folder node: romashka-docs (type=folder)
File node: dogovor.docx (type=file)
Folder node: screenshots (type=folder)
File node: error.png (type=file)
```
### Name conflict resolution
Если в target уже есть `docs`:
```
docs
docs (2)
docs (3)
```
### Safety checks
При добавлении папки показать summary:
- количество файлов/папок
- общий размер
- предупреждение если > 1000 файлов, > 1 GB, содержит `.git`/`node_modules`/`.cache`
### Trash
- Soft delete node + children
- Vault files → `.verstak/trash`
- External files — только удалить связь
- Не `rm -rf`
### Tests
1. Copy single file → vault, record created, source intact
2. Link single file → no copy, external path saved
3. Copy folder → tree created, files in vault
4. Link folder → node created, no content copied
5. Delete vault file → soft-deleted, file in trash
6. Delete vault folder → children soft-deleted
7. Name conflict → no overwrite, safe suffix
8. Open file → mocked opener (no real app launch)
Что реализовано:
- `suggest.go` — структура Suggestion (nodeId, nodeTitle, summary, suggestedMin, confidence)
- `bindings_suggest.go` — GetSuggestions с burst detection + conservative estimator (5–30 min)
- `bindings_suggest.go` — AcceptSuggestionWith (date/minutes overrides)
- `worklog/report.go` — ReportFilter, ListReport, Summary, ExportCSV, ExportMarkdown
- `app.go` — WorklogDTO расширен: date, details, approximate, billable, nodeTitle
- `bindings_worklog.go` — CreateWorklogFull, ListWorklogReport, WorklogSummary, Export*
- Новый системный раздел "Журнал" с таблицей, фильтрами (даты, includeChildren), сводкой
- Предложения на экране "Сегодня" + вкладке "Журнал" + вкладке "Журнал дела"
- Badge с количеством предложений в боковом меню
- Консервативный estimator:
- burst detection (группировка событий в окне 10 мин)
- time spread analysis
- 5–30 мин, 60+ только при сильном evidence
- Confidence: low/medium/high + reason
---
## ШАГ 13 — Drag-and-drop
## Ожидающие шаги (17-23)
### External D&D
### ШАГ 17 — File Scanner/Watcher
- fsnotify watcher на vault
- snapshot scanner для обнаружения изменений при закрытом приложении
- обнаружение missing файлов
Drop target: активное дело / вкладка Файлы / Неразобранное.
### ШАГ 18 — TUI MVP (Bubble Tea)
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
После drop → диалог:
```
Добавить в "ООО Ромашка / Сайт"
Файлов: 3, Папок: 1, 240 MB
[Скопировать] [Переместить] [Привязать] [Отмена]
```
### ШАГ 19 — Plugins: Lua runtime
- gopher-lua runtime
- хуки на события (node created, file added, etc.)
- песочница
---
### ШАГ 20 — Browser Extension
- Расширение для браузера: трекинг времени, скриншоты, evidence
## ШАГ 14 — MVP stabilization
### ШАГ 21 — Calendar/Kanban
- Календарный вид по датам
- Канбан-доска по статусам
- Smoke tests базовых сценариев
- Проверка: дело → заметка → файл → папка → trash → перезапуск
- go test ./... pass
- Обновление документации
- Остановка перед следующими фичами
### ШАГ 22 — Integrity Check + Repair
- Проверка целостности vault
- Контрольные суммы, восстановление после сбоев
### ШАГ 23 — New templates/integrations
- Community-плагины и шаблоны
---
@@ -307,22 +230,33 @@ Drop target: активное дело / вкладка Файлы / Нераз
verstak/
go.mod
README.md
PLAN.md
AGENTS.md # Инструкции для AI-ассистента
cmd/
verstak/ # CLI
verstak-gui/ # Wails GUI main
verstak-tui/ # Bubble Tea TUI
verstak-server/ # Sync server
verstak/ # CLI
verstak-gui/ # Wails v2 GUI main
verstak-server/ # Sync server
frontend/ # Wails frontend
frontend/ # Wails v2 Svelte frontend
package.json
wails.json
vite.config.js
src/
App.svelte
components/
stores/
styles/
FileTreeRow.svelte
TreeNode.svelte
lib/
FileBreadcrumbs.svelte
FilePreviewModal.svelte
ConfirmModal.svelte
FileIcon.svelte
FileActions.svelte
fileUtils.js
i18n/ # Локали (ru, en)
api/verstak.js
scripts/
build.sh # Раздельная сборка (gui/server/all)
check-i18n.sh # Проверка интернационализации
internal/
core/
@@ -336,9 +270,11 @@ verstak/
activity/
search/
sync/
security/
config/
plugins/ # manager, lua (stub)
templates/
plugins/ # manager, lua (stub)
i18n/ # Go-локали (ru, en)
contrib/
plugins/
@@ -351,11 +287,56 @@ verstak/
004_add_notes.sql
005_add_actions.sql
006_add_worklog.sql
007_search.sql
008_sync.sql
009_section.sql
...
docs/
00_README.md
01_Product_Spec.md
02_Architecture.md
03_Data_Model_Storage.md
04_Sync_Backup_Activity.md
05_UI_UX.md
06_Roadmap.md
09_Extensibility.md
10_Sync_Server_Guide.md
PLAN.md
TEMPLATES.md
VAULT_LAYOUT.md
```
## Сборка
```bash
# Полная сборка
./scripts/build.sh
# По отдельности
./scripts/build.sh gui # Wails v2 GUI
./scripts/build.sh server # Sync server
./scripts/build.sh all # Оба
# GUI smoke check перед коммитом
./scripts/check-gui.sh
# Полная проверка GUI:
# - i18n/source hygiene
# - frontend production build
# - embedded Wails assets == frontend/dist
# - go build с тегами "webkit2_41 desktop production"
# - rendered Chromium smoke с Wails-mock и action-flow проверками
# Только rendered smoke фронтенда с Wails-mock
cd frontend && npm run smoke:gui
# Dev-режим GUI
cd frontend && npm run dev
```
## RAID
- **Критично:** Wails требует Node.js для frontend-сборки
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
- **Зависимость:** Steps 15+ ждут завершения step 14
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
- **Сборка GUI:** требуется `webkit2_41` tag для WebKitGTK 4.1+
+58
View File
@@ -0,0 +1,58 @@
# Template System
## What is a Template?
A template defines the **type**, **default structure**, and **available modules**
for a Node. Every workspace node is created from a template.
## System Templates
Verstak ships with these built-in (system) templates:
| ID | Type | Default Modules | Default Files | Default Folders |
|---|---|---|---|---|
| `folder.default` | folder | overview, children, activity | — | — |
| `project.default` | project | overview, notes, files, activity, actions, worklog | Overview.md | Documents, Notes, Files |
| `client.default` | client | overview, notes, files, activity, actions | Overview.md | Notes, Files |
| `document.default` | document | overview, files, activity | — | — |
| `recipe.default` | recipe | overview, notes, files, activity | Overview.md | — |
## Template Structure
```json
{
"id": "project.default",
"title": "Проект",
"type": "project",
"enabled": true,
"system": true,
"icon": "project",
"default_modules": ["overview", "notes", "files", "activity", "actions", "worklog"],
"default_files": [
{"path": "Overview.md", "content_template": "project_overview"}
],
"default_folders": ["Documents", "Notes", "Files"],
"allowed_parent_types": ["folder", "root"],
"allowed_child_templates": ["*"]
}
```
## How Templates Drive the UI
1. The creation menu is built from **enabled templates**.
2. When you right-click a node, you see "Create inside" → [list of enabled templates].
3. Selecting a template creates a Node with that template's type and defaults.
4. Disabling a template removes it from the creation menu.
## Template Fields
- **id** — unique identifier
- **title** — i18n key for display name
- **type** — technical type (folder, project, client, etc.)
- **enabled** — whether it appears in the creation menu
- **system** — whether it's a built-in template
- **default_modules** — which tabs/modules are available in the node view
- **default_files** — files to create inside the node folder
- **default_folders** — subfolders to create inside the node folder
- **allowed_parent_types** — which node types can be parents of this template
- **allowed_child_templates** — which templates are allowed as children ("*" = any)
+79
View File
@@ -0,0 +1,79 @@
# Vault Layout
## Philosophy
Verstak's vault is designed to be **human-readable**. You can close Verstak, open your vault
in any file manager, and find materials by browsing the folder structure.
## Structure
```
vault/
.verstak/ # App data — not user-facing
index.db
config.yml
device_token.json
blobs/
originals/
thumbnails/
plugins/
trash/
history/
Проекты/ # User-created workspace nodes
Рабочие/
Разработка серверной/
Overview.md
Documents/
Notes/
Files/
Archive/
Клиенты/
ИТ-Вектор/
Projects/
LMS/
Overview.md
Documents/
Contracts/
```
## Rules
1. **Every Node has a folder** — the folder name matches the node title (sanitized).
2. **Nesting reflects parent-child relationships** — if Node A is parent of Node B,
B's folder is inside A's folder.
3. **Safe names** — folder names preserve Cyrillic, spaces, and readable characters.
Illegal filename chars (`/\:*?"<>|`) are replaced. Control chars are removed.
4. **Uniqueness** — if a folder already exists, a numeric suffix is added:
`Project`, `Project (2)`, `Project (3)`
5. **No UUIDs in user paths** — UUIDs are stored in the database and in `.verstak/`,
never in the user-visible folder tree.
6. **Template default files** — templates may create default files like `Overview.md`
inside the node folder.
7. **Archive/Delete** — archiving sets `archived = true` but doesn't move the folder.
Deletion moves the folder to `.verstak/trash/`.
## `.verstak/` directory
Contains all application internal data:
- `index.db` — SQLite database
- `config.yml` — vault-local settings and vault id
- `device_token.json` — sync device token, stored separately from general config
- `blobs/` — sync blob cache
- `originals/` — imported originals
- `thumbnails/` — generated thumbnails
- `plugins/` — optional local plugins
- `trash/` — moved here on deletion
- `history/` — file version history
Global application settings live outside the vault at
`~/.config/verstak/config.json`. This includes the active vault path, UI
settings, enabled templates and sync UI settings.
## Migration
Existing vaults can be migrated with the `MigrateVaultLayout()` command,
which computes `fs_path` for every node based on the parent-child tree
and creates the corresponding folders on disk.
@@ -0,0 +1,389 @@
# Unified Capture Inbox Links Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build one predictable Capture / Inbox / Links pipeline for external drag-and-drop, paste, and clipboard-button input.
**Architecture:** All external payloads become unresolved inbox artifacts first. Backend stores capture status/context/source metadata on artifact nodes, stages binary payloads under `.verstak/inbox`, resolves artifacts by source kind into Files, Notes, or a dedicated Links table, and exposes global/local inbox APIs. Frontend uses one `resolveCaptureContext()` and one capture dispatcher for drop, paste, and clipboard button instead of screen-specific handlers.
**Tech Stack:** Go, SQLite migrations, Wails v2 bindings, Svelte 4, Vite 5, existing rendered GUI smoke harness.
---
### Task 1: Backend Capture Metadata And Inbox Queries
**Files:**
- Modify: `cmd/verstak-gui/bindings_capture.go`
- Modify: `cmd/verstak-gui/bindings_inbox.go`
- Modify: `cmd/verstak-gui/capture_test.go`
- Modify: `cmd/verstak-gui/inbox_test.go`
- [ ] **Step 1: Write failing tests for context metadata**
Add tests that capture text on a section and a file while a node is active. Assert:
- `captureStatus == "unresolved"`
- `captureContextType == "section"` or `"node"`
- `captureContextNodeId` equals the target node ID for node context
- `suggestedTargetNodeId` equals the node ID for node context
- local inbox lookup uses node ID, not title
- [ ] **Step 2: Verify RED**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestCapture.*Context|TestListInboxNodesForTarget' -count=1
```
Expected: fail because context-aware capture APIs and local inbox query do not exist yet.
- [ ] **Step 3: Implement metadata helpers**
Add a JSON context DTO:
```go
type CaptureContextDTO struct {
ContextType string `json:"contextType"`
NodeID string `json:"nodeId,omitempty"`
Section string `json:"section,omitempty"`
SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"`
}
```
Extend capture metadata with:
```text
capture.status
capture.context_type
capture.context_node_id
capture.context_section
capture.suggested_target_node_id
capture.source_kind
capture.source
capture.created_at
capture.inbox
capture.kind
```
Keep old `CaptureText`, `CaptureURL`, `CapturePath`, and `CaptureFileData` as compatibility wrappers.
- [ ] **Step 4: Implement local inbox API**
Add:
```go
func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error)
```
Return unresolved artifacts where `capture.context_node_id = nodeID OR capture.suggested_target_node_id = nodeID`.
- [ ] **Step 5: Verify GREEN and commit**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestCapture.*Context|TestListInboxNodesForTarget|TestListInboxNodesReturnsOnlyCapturedArtifacts' -count=1
git add cmd/verstak-gui/bindings_capture.go cmd/verstak-gui/bindings_inbox.go cmd/verstak-gui/capture_test.go cmd/verstak-gui/inbox_test.go docs/superpowers/plans/2026-06-05-unified-capture-inbox-links.md
git commit -m "feat: track capture context in inbox"
git push
```
### Task 2: Dedicated Link Artifacts And Resolve Routing
**Files:**
- Create: `internal/core/storage/migrations_015.sql.go`
- Modify: `internal/core/storage/storage.go`
- Create: `cmd/verstak-gui/bindings_links.go`
- Modify: `cmd/verstak-gui/bindings_capture.go`
- Modify: `cmd/verstak-gui/bindings_inbox.go`
- Modify: `cmd/verstak-gui/inbox_test.go`
- [ ] **Step 1: Write failing tests for URL capture and resolve**
Add tests that capture URL, resolve it into Project A, and assert:
- unresolved URL uses `sourceKind == "url"`
- it appears in global and local inbox before resolve
- after resolve it disappears from inbox
- `ListLinks(ProjectA)` contains exactly the resolved link
- `ListLinks(ProjectB)` does not contain it
- [ ] **Step 2: Verify RED**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestResolve.*URL|TestListLinks' -count=1
```
Expected: fail because link table/API and URL routing do not exist.
- [ ] **Step 3: Add links table and bindings**
Create `links` table:
```sql
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
title TEXT NOT NULL,
url TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
captured_at TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_links_node ON links(node_id);
```
Expose `ListLinks`, `UpdateLink`, `DeleteLink`, and `OpenLink`.
- [ ] **Step 4: Implement resolve routing**
Add:
```go
func (a *App) ResolveInboxNode(nodeID, targetParentID string) (*NodeDTO, error)
func (a *App) ResolveInboxNodeHere(nodeID string) (*NodeDTO, error)
```
Route source kinds:
- `file`, `folder`, `image`: move node into target via existing `MoveNode`
- `text`: move note into target via existing `MoveNode`
- `url`: create a row in `links`, then remove/hide the unresolved artifact
Keep `AssignInboxNode` as a compatibility alias to `ResolveInboxNode`.
- [ ] **Step 5: Verify GREEN and commit**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestResolve.*URL|TestAssignInboxNode|TestDeleteInboxNode|TestListLinks' -count=1
git add internal/core/storage/storage.go internal/core/storage/migrations_015.sql.go cmd/verstak-gui/bindings_links.go cmd/verstak-gui/bindings_capture.go cmd/verstak-gui/bindings_inbox.go cmd/verstak-gui/inbox_test.go
git commit -m "feat: resolve inbox links separately"
git push
```
### Task 3: Native Clipboard Bridge And Capture Bindings
**Files:**
- Create: `cmd/verstak-gui/bindings_clipboard.go`
- Modify: `frontend/src/wailsjs/go/main/App.js`
- Modify: `cmd/verstak-gui/capture_test.go`
- [ ] **Step 1: Write failing test for clipboard text routing**
Add a focused test for a helper that classifies clipboard text as URL before plain text.
- [ ] **Step 2: Verify RED**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestClassifyClipboardText' -count=1
```
Expected: fail because helper does not exist.
- [ ] **Step 3: Implement backend clipboard bridge**
Expose:
```go
func (a *App) ReadClipboardText() (string, error)
func (a *App) CaptureClipboardTextWithContext(contextJSON string) (*InboxNodeDTO, error)
```
Use Wails v2 `runtime.ClipboardGetText(a.ctx)`. Convert backend errors to a user-facing message; do not surface browser `NotAllowedError`.
- [ ] **Step 4: Verify GREEN and commit**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestClassifyClipboardText|TestCapture.*' -count=1
git add cmd/verstak-gui/bindings_clipboard.go cmd/verstak-gui/capture_test.go frontend/src/wailsjs/go/main/App.js
git commit -m "feat: add native clipboard capture bridge"
git push
```
### Task 4: Frontend Unified Capture Pipeline
**Files:**
- Modify: `frontend/src/App.svelte`
- Modify: `frontend/src/wailsjs/go/main/App.js`
- Modify: `frontend/src/lib/i18n/locales/ru.js`
- Modify: `frontend/src/lib/i18n/locales/en.js`
- Modify: `scripts/check-gui-render.mjs`
- [ ] **Step 1: Update smoke mock for new bindings**
Add mock methods:
- `CaptureTextWithContext`
- `CaptureURLWithContext`
- `CapturePathWithContext`
- `CaptureFileDataWithContext`
- `CaptureClipboardTextWithContext`
- `ResolveInboxNode`
- `ResolveInboxNodeHere`
- `ListInboxNodesForTarget`
- `ListLinks`
- `UpdateLink`
- `DeleteLink`
- `OpenLink`
- [ ] **Step 2: Refactor capture inputs**
Implement:
```js
function resolveCaptureContext()
async function captureTextPayload(text, source)
async function captureUrlPayload(url, title, source)
async function capturePathPayload(paths, source)
async function captureFilePayload(file, source)
async function handleGlobalPaste(event)
async function handleGlobalDrop(event)
```
Use `event.clipboardData` for Ctrl+V and Wails/native clipboard binding for the button.
- [ ] **Step 3: Add drop overlay**
Show:
- `Будет добавлено в Неразобранное для: <node title/path>` when a node is selected
- `Будет добавлено в глобальное Неразобранное` on system/global sections
- [ ] **Step 4: Verify frontend build and commit**
Run:
```bash
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH npm run build
git add frontend/src/App.svelte frontend/src/wailsjs/go/main/App.js frontend/src/lib/i18n/locales/ru.js frontend/src/lib/i18n/locales/en.js scripts/check-gui-render.mjs
git commit -m "feat: unify frontend capture pipeline"
git push
```
### Task 5: Local Inbox And Links UI
**Files:**
- Modify: `frontend/src/App.svelte`
- Modify: `frontend/src/lib/i18n/locales/ru.js`
- Modify: `frontend/src/lib/i18n/locales/en.js`
- Modify: `scripts/check-gui-render.mjs`
- [ ] **Step 1: Add tabs**
Add node tabs:
- `Неразобранное`
- `Ссылки`
Local Inbox shows unresolved artifacts for the current node only. Links shows resolved link artifacts for the current node only.
- [ ] **Step 2: Add link actions**
Implement minimal actions:
- open external browser via `OpenLink`
- edit title / URL / note via modal
- delete via `DeleteLink`
- copy URL using `navigator.clipboard.writeText` only as an optional best-effort UI action
- [ ] **Step 3: Verify GUI smoke and commit**
Run:
```bash
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh
git add frontend/src/App.svelte frontend/src/lib/i18n/locales/ru.js frontend/src/lib/i18n/locales/en.js scripts/check-gui-render.mjs
git commit -m "feat: add local inbox and links tabs"
git push
```
### Task 6: Editable Hotkey Guard
**Files:**
- Modify: `frontend/src/App.svelte`
- Modify: `frontend/src/lib/FilePreviewModal.svelte`
- Modify: `frontend/src/lib/FirstRun.svelte`
- Modify: `scripts/check-gui-render.mjs`
- [ ] **Step 1: Add failing smoke assertion**
Open the add-action modal, type a title containing spaces, and assert the modal remains open until save/cancel.
- [ ] **Step 2: Implement editable target guard**
Use:
```js
function isEditableTarget(target) {
if (!target || !(target instanceof Element)) return false
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
}
```
Global hotkeys and `onKeyActivate` must return early for editable targets.
- [ ] **Step 3: Verify and commit**
Run:
```bash
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh
git add frontend/src/App.svelte frontend/src/lib/FilePreviewModal.svelte frontend/src/lib/FirstRun.svelte scripts/check-gui-render.mjs
git commit -m "fix: ignore global hotkeys in editable fields"
git push
```
### Task 7: Full Verification And Documentation Alignment
**Files:**
- Modify: `docs/01_Product_Spec.md`
- Modify: `docs/03_Data_Model_Storage.md`
- Modify: `docs/05_UI_UX.md`
- Modify: `docs/06_Roadmap.md`
- [ ] **Step 1: Update docs**
Document current behavior:
- all external capture creates unresolved inbox artifacts
- local/global inbox semantics
- capture context metadata by node ID
- Links tab and link artifact model
- backend clipboard bridge for button, `paste` event for Ctrl+V
- [ ] **Step 2: Run full required checks**
Run:
```bash
env GOCACHE=/tmp/verstak-go-cache go test ./...
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH npm run build
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH node scripts/check-gui-render.mjs
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH node scripts/check-i18n.sh
```
If `scripts/check-i18n.sh` is not a Node script or does not exist, inspect scripts and run the repository’s actual i18n check.
- [ ] **Step 3: Commit and push**
```bash
git add docs/01_Product_Spec.md docs/03_Data_Model_Storage.md docs/05_UI_UX.md docs/06_Roadmap.md
git commit -m "docs: describe unified capture inbox flow"
git push
```
### Self-Review Checklist
- [ ] External drop anywhere creates unresolved artifact, never direct case content.
- [ ] Ctrl+V uses `paste` event `clipboardData`.
- [ ] Clipboard button uses backend/native bridge.
- [ ] URL artifacts resolve into `links`, not notes.
- [ ] Local Inbox is metadata view by node ID.
- [ ] Global Inbox shows all unresolved artifacts.
- [ ] Files/folders/images are staged under `.verstak/inbox` and moved to canonical target filesystem path on resolve.
- [ ] Space inside editable fields never triggers global UI actions.
- [ ] GUI smoke covers capture, resolve, links, and modal-space regression.
+4 -1
View File
@@ -2,7 +2,10 @@
<html lang="ru">
<head>
<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" />
<title>Верстак</title>
<style>
+382
View File
@@ -10,6 +10,9 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"svelte": "^4.2.19",
"svelte-language-server": "^0.18.1",
"typescript": "^6.0.3",
"typescript-language-server": "^5.3.0",
"vite": "^5.4.21"
}
},
@@ -27,6 +30,33 @@
"node": ">=6.0.0"
}
},
"node_modules/@emmetio/abbreviation": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-abbreviation": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/scanner": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -855,6 +885,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/emmet-helper": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz",
"integrity": "sha512-lUki5QLS47bz/U8IlG9VQ+1lfxMtxMZENmU5nu4Z71eOD5j9FK0SmYGL5NiVJg9WBWeAU0VxRADMY2Qpq7BfVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"emmet": "^2.3.0",
"jsonc-parser": "^2.3.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.15.1",
"vscode-nls": "^5.0.0",
"vscode-uri": "^2.1.2"
}
},
"node_modules/@vscode/emmet-helper/node_modules/vscode-uri": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz",
"integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==",
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -888,6 +947,22 @@
"node": ">= 0.4"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/code-red": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
@@ -934,6 +1009,13 @@
}
}
},
"node_modules/dedent-js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
"integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -944,6 +1026,23 @@
"node": ">=0.10.0"
}
},
"node_modules/emmet": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
"dev": true,
"license": "MIT",
"workspaces": [
"./packages/scanner",
"./packages/abbreviation",
"./packages/css-abbreviation",
"./"
],
"dependencies": {
"@emmetio/abbreviation": "^2.3.3",
"@emmetio/css-abbreviation": "^2.1.8"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -993,6 +1092,24 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1008,6 +1125,13 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1018,6 +1142,13 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/jsonc-parser": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
"dev": true,
"license": "MIT"
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1035,6 +1166,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1126,6 +1264,47 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz",
"integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -1178,6 +1357,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1227,6 +1426,101 @@
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/svelte-language-server": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/svelte-language-server/-/svelte-language-server-0.18.1.tgz",
"integrity": "sha512-rQ1uQxxiul2zOUaDUsY6B115Xh8Yjb2Tmby/hjGoq8VKCmd7jD1qaVNqMdxW0nvoocRnJdJ3uXOOaGmXPTxOcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@vscode/emmet-helper": "2.8.4",
"chokidar": "^4.0.1",
"estree-walker": "^2.0.1",
"fdir": "^6.2.0",
"globrex": "^0.1.2",
"lodash": "^4.17.21",
"prettier": "~3.3.3",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^4.2.19",
"svelte2tsx": "~0.7.56",
"typescript-auto-import-cache": "^0.3.6",
"vscode-css-languageservice": "~6.3.5",
"vscode-html-languageservice": "~5.4.0",
"vscode-languageserver": "9.0.1",
"vscode-languageserver-protocol": "3.17.5",
"vscode-languageserver-types": "3.17.5",
"vscode-uri": "~3.1.0"
},
"bin": {
"svelteserver": "bin/server.js"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"typescript": "^5.9.2 || ^6.0.2"
}
},
"node_modules/svelte-language-server/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/svelte2tsx": {
"version": "0.7.56",
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.56.tgz",
"integrity": "sha512-NTvqqL+goYlW8gWNajk81L07+uu7jw5V2m1Az5MZbYm3GEydcHXh+uTrLHM9SuGuaqCtF90vlMXkOVBotfH94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dedent-js": "^1.0.1",
"scule": "^1.3.0"
},
"peerDependencies": {
"svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0",
"typescript": "^4.9.4 || ^5.0.0 || ^6.0.0"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-auto-import-cache": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.3.8"
}
},
"node_modules/typescript-language-server": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz",
"integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"typescript-language-server": "lib/cli.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1301,6 +1595,94 @@
"optional": true
}
}
},
"node_modules/vscode-css-languageservice": {
"version": "6.3.10",
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
"integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-html-languageservice": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.4.0.tgz",
"integrity": "sha512-9/cbc90BSYCghmHI7/VbWettHZdC7WYpz2g5gBK6UDUI1MkZbM773Q12uAYJx9jzAiNHPpyo6KzcwmcnugncAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-nls": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
}
}
}

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