Compare commits

..

No commits in common. "master" and "gui/wails-file-workflow" have entirely different histories.

295 changed files with 3116 additions and 51726 deletions

View File

@ -1,21 +0,0 @@
[mcp_servers.go_lsp]
command = "mcp-language-server"
args = [
"--workspace", "/home/mirivlad/git/verstak",
"--lsp", "gopls"
]
enabled = true
default_tools_approval_mode = "approve"
tool_timeout_sec = 30
[mcp_servers.ts_lsp]
command = "mcp-language-server"
args = [
"--workspace", "/home/mirivlad/git/verstak",
"--lsp", "typescript-language-server",
"--",
"--stdio"
]
enabled = true
default_tools_approval_mode = "approve"
tool_timeout_sec = 30

View File

@ -1,22 +0,0 @@
# Verstak Firefox Extension — environment template
# Copy to .env and fill in real values.
# DO NOT commit .env — it contains AMO secrets.
# AMO API credentials (from https://addons.mozilla.org/developers/)
WEB_EXT_API_KEY=
WEB_EXT_API_SECRET=
# Build channel: "unlisted" for self-distributed builds
WEB_EXT_CHANNEL=unlisted
# Source directory for the Firefox extension
WEB_EXT_SOURCE_DIR=extension-firefox
# Output directory for signed XPI artifacts
WEB_EXT_ARTIFACTS_DIR=web-ext-artifacts
# Base URL for Firefox self-hosted updates
VERSTAK_FIREFOX_UPDATE_BASE_URL=https://mirv.top/verstak/firefox
# Optional: HTTP proxy for AMO API requests (e.g. http://localhost:12334)
# WEB_EXT_API_PROXY=

32
.gitignore vendored
View File

@ -20,17 +20,10 @@ go.work
# Wails
frontend/dist/
frontend/frontend-dist/
frontend/node_modules/
frontend/bindings/
/verstak-gui
/verstak-cli
/verstak-server
/verstak
# Vault data
.verstak/
spaces/
verstak-gui
verstak-cli
# VS Code
.vscode/
@ -42,24 +35,3 @@ Thumbs.db
# Vault test data
test-vault/
server-data/
Ромашка/
Тестовая папка/
# Build output
build/
build.log
# Release artifacts
release/
# Environment / secrets
.env
.env.*
!.env.example
# Firefox extension
web-ext-artifacts/
*.xpi
*.zip
node_modules/

BIN
.verstak/index.db Normal file

Binary file not shown.

187
AGENTS.md
View File

@ -1,187 +0,0 @@
# Verstak project rules
## Project quality rule: no MVP shortcuts
This project is not an investor demo, not a throwaway prototype, and not a “good enough for MVP” product.
Do not justify known inconsistencies, broken invariants, unsafe behavior, architectural shortcuts, or incomplete data-model operations by calling them “MVP acceptable”.
If you find a mismatch between logical state and physical state, treat it as a bug unless the project owner explicitly says otherwise.
For Verstak specifically:
* Rename must keep logical metadata and filesystem representation consistent, or the design must explicitly document why filenames are stable IDs and not display names.
* Delete must remove or tombstone all related state consistently.
* File paths must never depend on unsafe user input.
* Database state and filesystem state must not silently diverge.
* “Works because the file is not lost” is not enough.
* “Can be fixed later” is not an acceptable reason to leave architectural debt.
* If a feature cannot be completed properly in the current step, stop and report it as incomplete, with a concrete fix plan.
Use these categories in reports:
1. Done — implemented and tested.
2. Incomplete — implemented partially, must not be treated as finished.
3. Blocked — cannot continue without a decision.
4. Design decision required — multiple valid approaches exist.
5. Known bug — must be fixed before moving on.
Forbidden report pattern:
> This is acceptable for MVP.
Required replacement:
> This violates the current project invariants. I either fixed it, or I am reporting it as incomplete and proposing the smallest correct fix.
## Project identity
Verstak is a local-first workbench for clients, projects, notes, files, tasks, activity and sync.
It must remain practical, simple, and filesystem-aware.
## Stack
- Backend: Go
- Storage: SQLite
- GUI: Wails v2
- Frontend: Svelte 4
- Build tooling: Vite 5
- Do not migrate to Wails v3, Svelte 5, or Vite 8 unless explicitly asked.
## Architecture rules
- Keep local-first behavior.
- Do not turn the project into SaaS.
- Do not replace SQLite with another database.
- Do not introduce cloud storage assumptions.
- Preserve recursive folder import semantics.
- Preserve stable node IDs.
- Do not duplicate nodes when moving items.
- Do not create parallel state systems for the same entity.
## UI rules
- Fix GUI behavior at root cause.
- Do not redesign the whole interface unless explicitly asked.
- Preserve active tab state correctly.
- Context menus must open near the cursor.
- Drag-and-drop must show clear visual target feedback.
- Moving nodes must never duplicate the same ID in two places.
- Nested selection must not collapse the parent unexpectedly.
## Files
- File view is not a tree.
- Sidebar shows logical hierarchy.
- Vault filesystem layout must remain human-readable without the app.
- Drag-and-drop folders must perform real recursive copy/move into the vault.
- Do not fake folder support with external links.
## Sync
- Sync settings belong in Settings.
- Main UI may keep only manual sync/status controls.
- Existing URL + login/password device registration flow should be preserved unless explicitly changed.
- Secrets must not be logged.
## Verification
For backend changes:
- Run `go test ./...` if possible.
For frontend changes:
- Run the relevant frontend build/check command if available.
- If unsure, inspect package scripts first.
For GUI bugs:
- Add targeted tests only where practical.
- If manual GUI clicking is required and unavailable, state exact manual verification steps for the user.
# Session summary
## Bugs fixed (this session)
1. **webkit2_41 build tag** — binary wouldn't start without it. Added to build instructions.
2. **Sidebar refresh**`reloadTreePreservingExpanded` patches children in-place so expand/collapse state stays intact.
3. **Context menu off-screen** — changed to `position: fixed` with cursor coordinates.
4. **"Show in explorer" only for folder types** — `OpenFolder` in backend falls back to file record path for `TypeFile` nodes.
5. **Context menu not closing on action**`handleShowInFolder` calls `closeMenu()`.
6. **Wrong folder when opening file's parent folder**`OpenFolder` checks `n.FsPath == ""` for TypeFile and uses first file record path.
7. **Tab highlight not updating visually** — was using `class={tabClass(tab.id)}` which didn't trigger reactive class updates in Svelte. Switched to `class="tab" class:active={activeTab === tab.id}`.
8. **Journal table expand/collapse** — added explicit ▸/▾ toggle column so it's clear rows are expandable.
9. **Per-node worklog entries** — made entries expandable with ▸/▾, showing details + billable/approximate tags.
10. **Manual worklog entry form** — converted inline form to modal dialog ("+ Добавить запись") with all fields: date, summary, minutes, details, billable, approximate.
11. **"С подзадачами" → "Учитывать вложенные дела"** — renamed, now hidden when no node selected.
12. **Filter/export layout** — split into separate "Фильтры" and "Экспорт отчёта" sections with headings.
13. **Suggestion events** — added "Показать в проводнике" button for file-type events in suggestion detail.
14. **Removed duplicate i18n keys** in `ru.js` (worklog.suggestions, worklog.apply).
15. **Removed unused CSS** (`.journal-filters`, `.wl-meta`, `.worklog-form`).
16. **Added `openNodeFolder(nodeOrId)`** — accepts both string ID and node object.
17. **Added `resetJournalFilters()`** — resets all filters and reloads.
18. **Source field** — added `worklog_entries.source` column (migration 014). Values: manual, suggestion. Old entries default to 'unknown'.
19. **Suggestions now use worklog_entry_events** instead of `HasTodayEntries` — only events already linked to worklog entries are excluded. Repeated activity on the same node today now produces new suggestions.
20. **Activity target navigation** — clicking activity events for notes opens the note tab and loads the specific note. File events open the files tab.
21. **Source display** — detail sections now show accurate source: "Ручная запись", "Из предложения", "Из предложения, но связанные события отсутствуют", or "Источник неизвестен".
22. **Wails `[]string` marshalling bug** — Wails v2.12.0 silently drops `[]string` positional args from JS→Go. **Fix**: pass all string arrays as `JSON.stringify()``string``json.Unmarshal` on Go side.
23. **Event link validation**`AcceptSuggestionWith` pre-checks each eventID against `activity_events`, uses plain `INSERT` (not `INSERT OR IGNORE`), and verifies with JOIN `COUNT(*)` after commit.
24. **GetWorklogEntryEvents column fix** — query used `e.details_json` but the column is `e.metadata`. Fixed to `COALESCE(e.metadata,'')`.
25. **"Посмотреть" button** — `openActivityTarget(ev)` navigates to the specific target: note tab + open note for `targetType=note`, files tab + `OpenFolder(targetPath)` for `file/folder`.
26. **End-to-end test**`TestAcceptSuggestionWithEndToEnd` creates node, 3 activity events, accepts suggestion, verifies all 3 linked via `worklog_entry_events` + JOIN.
27. **WriteDebugLog binding**`bindings_debug.go` writes frontend logs to `<vault>/.verstak/debug.log` for production GUI debugging.
28. **Journal regression tests**`TestJournalFullRegression`, `TestSuggestionOnRepeatedActivity`, `TestManualWorklogEntry`.
29. **resolveActivityTarget helper** — pure function returning `{ nodeId, tab, noteId/fileId/targetPath }`, used by `openActivityTarget`.
30. **First-run flow** — no auto-vault creation. New `GetStartupStatus` binding returns `first_run`/`recovery`/`ready`. Frontend shows FirstRun.svelte or VaultRecovery.svelte accordingly.
31. **Global config.json** — moved vault path, sync settings, templates, theme, language from implicit CLI args to `~/.config/verstak/config.json` (`AppConfig` struct).
32. **Sync settings in Settings** — extracted sync modal into Settings → Sync section. Removed all inline sync form fields from `App.svelte`. Added `SyncStatus.svelte` widget replacing navbar sync button.
33. **Settings window** — modal with sidebar (8 sections: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup). ESC to close. Lazy-loaded content panels.
34. **Template enable/disable**`AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
## Bugs fixed (this session)
1. **Trash preview "trash file not found" for TypeFile nodes**`resolveTrashPath` only searched `<nodeID>_*` in trash dir, but TypeFile node files are moved by file record ID (`<recordID>_<filename>`). Added file record fallback via `ListTrashedByNode` + `ReadTrashFile(trashFsPath)` binding.
2. **Trash restore creates empty files**`DeleteToTrash` permanently `DELETE`'d file records from DB and `restoreTrashPath` silently `return nil` for `FsPath=""` nodes. Changed `deleteFileRecords` to `trashRecord` (sets `missing=1`, keeps record). `restoreTrashPath` now restores file records: moves file back from trash, sets `missing=0`.
3. **`resolveTrashPath` inner loop corrupts `anc` variable** — `anc = child` inside the inner loop caused wrong path computation for nesting depth > 2. Replaced with direct `chain[0].FsPath` prefix computation.
4. **`ListTrash` missing `TrashFsPath` for TypeFile nodes** — Phase 1 only checked `<nodeID>_*` entries. Added `ListTrashedByNode` fallback to set `TrashFsPath` for TypeFile nodes.
5. **`ListByNode` returning trashed records** — Added `AND missing != 1` filter to exclude trashed file records from active node file listings.
## Key patterns (this session)
- **TypeFile node trash**: files are moved by file record ID (`<recordID>_<filename>`), NOT by node ID. Never search by `<nodeID>_*` alone — always fall back to file records via `ListTrashedByNode`.
- **File record soft-trash**: use `UPDATE files SET missing=1` instead of `DELETE` to keep records restorable. Restore via `UPDATE ... SET missing=0` + `os.Rename` from trash.
- `ReadTrashFile(trashFsPath)` is preferred over `ReadTrashFileContent(nodeID)` — frontend has `trashFsPath` precomputed by `ListTrash`.
- **`restoreTrashPath` for TypeFile nodes**: when `fsPath == ""`, find file records with `missing=1` and restore each one.
- **Full test coverage**: `TestTrashTypeFilePreviewAndRestore`, `TestTrashTypeFileInsideFolderRestorePreservesContent`, `TestTrashTypeFileMultipleRecords`.
## Key patterns
- Always use explicit toggle icons (▸/▾) on expandable rows.
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
- `openNodeFolder(id)` accepts a string ID or a node object.
- `GetSuggestions` filters out only events already in `worklog_entry_events`, not entire nodes.
- New worklog entries get `source=manual` via `Add`/`AddWithDate`; suggestion entries get `source=suggestion` via `AcceptSuggestionWith`.
- **NEVER pass `[]string` through Wails v2 bindings** — always JSON-serialize to `string` first. Wails v2.12.0 silently drops slice arguments.
- **Always wrap create-entry + link-events in a transaction** with pre-validation and post-commit verification to prevent orphan entries.
- Frontend debug logs in production: use `wailsCall('WriteDebugLog', msg)` → writes to `<vault>/.verstak/debug.log`.
- `AppConfig` stores all global settings in `~/.config/verstak/config.json`. Vault-specific config stays in `.verstak/config.yml`.
- Use `GetStartupStatus` to determine first-run vs recovery vs normal startup.
- Settings window uses a sidebar with 8 sections; each section is a separate Svelte component imported lazily.
- Template enable/disable state is stored in `appCfg.EnabledTemplates` and applied to the registry during `initVault`.
# Build instructions
## GUI binary (Wails v2)
```bash
# From project root:
cp -r frontend/dist/* cmd/verstak-gui/frontend-dist/
go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak-gui-linux-amd64 ./cmd/verstak-gui/
```
## Server binary
```bash
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
```

257
README.md
View File

@ -1,229 +1,46 @@
# Верстак
**Верстак** — local-first рабочий vault. Всё организовано вокруг **дел**, а не задач.
**Верстак** — локальная программа, где по каждому клиенту или проекту
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
Дело может быть: клиентом, проектом, набором документов, рецептом, архивом, разовой работой.
Внутри дела: вложенные папки, Markdown-заметки, файлы, действия (URL/файл/папка/команда), журнал работ, история активности.
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди,
у которых работа организована через дела, а не через задачи:
```
дело → файлы → заметки → документы → действия → история → вернуться через месяц
```
## Для кого
Один продукт — разные входные двери:
| Кто | Как видит Верстак |
|-----|-------------------|
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
## Универсальные сущности
Базовая модель предельно проста — плагины добавляют функционал:
- **Дело** — контекст для всего остального
- **Заметка** — Markdown внутри vault
- **Файл / Документ** — любой файл, привязанный к делу
- **Действие** — кнопка запуска: URL, файл, папка, команда
- **Журнал** — записи о затраченном времени
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
эти сущности без перекомпиляции программы.
## Стек
| Слой | Технология |
|------|------------|
| GUI | Wails v2 + Svelte 4 |
| CLI | Go |
| Backend | Go |
| Хранилище | SQLite (индекс) + файловая система (vault) |
| Плагины | Lua |
| Синхронизация | HTTP API (опциональный сервер) |
## Архитектура
```
┌────────────────────┐
│ GUI (Wails v2) │
└─────────┬──────────┘
┌─────────▼────────┐ ┌─────────────┐
│ Core Library │◄──│ CLI Commands │
└─────────┬────────┘ └─────────────┘
┌─────────▼──────────┐
│ Local Vault+SQLite │
└─────────┬──────────┘
┌─────────▼──────────┐
│ Sync Client │
└─────────┬──────────┘
┌─────────▼──────────┐
│ Sync Server │
└────────────────────┘
```
## Быстрый старт
### Требования
- Go 1.25+
- Node.js 20+
- libwebkit2gtk-4.1-dev, libgtk-3-dev и другие Wails-зависимости (см. [wails.io/docs/desktop/linux](https://wails.io/docs/desktop/linux))
- npm
### Сборка
```bash
# Всё сразу (GUI + сервер)
./scripts/build.sh
# Или по отдельности
./scripts/build.sh gui # только GUI
./scripts/build.sh server # только сервер
```
Проверка GUI перед коммитом:
```bash
./scripts/check-gui.sh
```
Она проверяет локали, production-сборку фронтенда, актуальность embedded Wails assets и компиляцию GUI-бинаря.
Дополнительно запускается headless Chromium smoke через Wails-mock: проверяются first-run, recovery, основное окно, Settings, workspace, вкладки дела, файлы, журнал, активность и мобильный viewport. Smoke выполняет реальные UI-действия: создание заметки, запись worklog, создание узла, вход в папку и возврат назад, а также Sync Now с предупреждениями о conflicts/applyErrors. Скриншоты пишутся в `/tmp/verstak-gui-smoke`.
Бинарники попадают в `build/`:
- `verstak` — GUI-приложение
- `verstak-server` — опциональный сервер синхронизации
### Запуск
```bash
# GUI (после сборки)
./build/verstak
# Сервер (после сборки)
./build/verstak-server --help
# CLI
go run ./cmd/verstak/ --help
```
## Firefox Extension
Расширение для Firefox — `extension-firefox/`. Распространяется как signed XPI: **Mozilla только подписывает** XPI через AMO (unlisted channel), а мы самостоятельно хостим signed XPI и управляем обновлениями через `updates.json`.
### Сборка (unsigned)
```bash
./scripts/build.sh firefox
# → build/verstak-bridge-firefox-unsigned.zip
```
### Подпись (требуются AMO-токены)
```bash
# 1. Скопировать .env.example → .env и заполнить WEB_EXT_API_KEY / WEB_EXT_API_SECRET
cp .env.example .env
# 2. Установить зависимости
cd extension-firefox && npm install && cd ..
# 3. Подписать XPI
./scripts/sign-firefox-xpi.sh
# → web-ext-artifacts/*.xpi
# 4. Полный релиз: подпись + release/firefox/ + updates.json
./scripts/release-firefox-xpi.sh
# → release/firefox/verstak-firefox-VERSION.xpi
# → release/firefox/updates.json
```
### Firefox Release Artifacts
```
release/firefox/verstak-firefox-VERSION.xpi
release/firefox/updates.json
```
Обновления: Firefox проверяет `update_url` из manifest.json, указывающий на наш `updates.json`. При выходе новой версии достаточно:
1. Подписать новый XPI
2. Заменить файл на сервере
3. Обновить `updates.json`
## Release
```bash
# Полная сборка с упаковкой в DEB/RPM
./scripts/release.sh # dry-run (без git tag); требует AMO токены
./scripts/release.sh --publish # с git tag + GitHub release
# Без подписи Firefox
./scripts/release.sh --skip-firefox-sign
```
Артефакты релиза (после `./scripts/release.sh`):
```
release/linux/verstak # GUI binary
release/linux/verstak-server # Server binary
release/linux/verstak.deb # DEB-пакет GUI
release/linux/verstak-server.deb # DEB-пакет сервера
release/linux/verstak.rpm # RPM-пакет GUI
release/linux/verstak-server.rpm # RPM-пакет сервера
release/firefox/verstak-firefox-VERSION.xpi # Signed XPI
release/firefox/updates.json # Firefox update manifest
```
## Структура проекта
```
.
├── cmd/ # Точки входа
│ ├── verstak/ # CLI
│ ├── verstak-gui/ # Wails GUI
│ └── verstak-server/ # Sync server
├── internal/
│ ├── core/ # Бизнес-логика
│ │ ├── actions/ # Действия (URL, папка, команда)
│ │ ├── config/ # Конфигурация
│ │ ├── files/ # Файлы и импорт
│ │ ├── i18n/ # Интернационализация (Go)
│ │ ├── nodes/ # Дела/узлы дерева
│ │ ├── plugins/ # Lua-плагины
│ │ ├── search/ # Поиск
│ │ ├── storage/ # SQLite + миграции
│ │ ├── sync/ # Синхронизация
│ │ ├── templates/ # Шаблоны дел
│ │ ├── vault/ # Vault layout
│ │ └── worklog/ # Журнал работ
│ └── gui/ # Wails bridge (embedded HTML)
├── frontend/ # Svelte-приложение
│ └── src/
│ ├── lib/
│ │ └── i18n/ # Локали (JS)
│ └── ...svelte # Компоненты
├── migrations/ # SQL-миграции
├── docs/ # Документация
└── scripts/ # Вспомогательные скрипты и сборка
```
## CLI команды
```bash
go run ./cmd/verstak/ sync # Синхронизация с сервером
go run ./cmd/verstak/ sync configure # Настройка сервера
go run ./cmd/verstak/ sync status # Статус синхронизации
```
## Vault layout
Данные хранятся в локальной папке (vault). Структура на диске:
```
vault/
.verstak/ # Служебные данные: index.db, config.yml, trash, blobs
Проекты/ # Пользовательские папки-дела
Клиенты/
Рабочие/
Archive/
```
Внутри папок-дел: `Notes/`, `Files/`, `Documents/`, `Overview.md`.
Vault открывается в любом файловом менеджере без специальных инструментов.
Go + SQLite + Lua (плагины) + Wails + Bubble Tea.
## Документация
| Раздел | Описание |
|--------|----------|
| [Описание продукта](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 |
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
| [План](docs/PLAN.md) | Дорожная карта |
| [Шаблоны](docs/TEMPLATES.md) | Шаблоны дел |
## Лицензия
MIT
- Описание продукта: [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)

View File

@ -1 +0,0 @@
0.1.0

4
build.sh Executable file
View File

@ -0,0 +1,4 @@
#!/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
build/Taskfile.yml Normal file
View File

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

View File

@ -0,0 +1,9 @@
<?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>

After

Width:  |  Height:  |  Size: 698 B

View File

@ -0,0 +1,51 @@
{
"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
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

79
build/config.yml Normal file
View File

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

View File

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

View File

@ -0,0 +1,41 @@
# 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
build/linux/Taskfile.yml Normal file
View File

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

View File

@ -2,64 +2,38 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"strings"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/bridge"
"verstak/internal/core/browser"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
// App is the Wails v2 application adapter. It wraps core services.
type App struct {
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
fileWatcher *watcher.Service
bridge *bridge.Server
browser *browser.Store
vault string
}
// requireVault returns an error if no vault is open and services are not initialized.
// All binding methods that access vault services MUST call this first.
func (a *App) requireVault() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
return nil
}
// startup is called when the app starts. Store context and wire drag-and-drop.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
@ -70,91 +44,18 @@ 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:"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"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Type string `json:"type"`
Icon string `json:"icon,omitempty"`
Section string `json:"section"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
}
type SectionDTO struct {
@ -184,21 +85,13 @@ type FileDTO struct {
type FileTreeItemDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
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"`
}
// PreflightFileAction describes what should happen when opening a file from the Files tab.
type PreflightFileAction struct {
Action string `json:"action"` // "note" | "preview" | "external"
NoteID string `json:"noteId,omitempty"`
NoteTitle string `json:"noteTitle,omitempty"`
FileName string `json:"fileName"`
}
type ActionDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
@ -210,68 +103,379 @@ type ActionDTO struct {
type WorklogDTO struct {
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 {
NodeID string `json:"nodeId"`
TargetID string `json:"targetId,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Type string `json:"type"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
}
type EventDTO struct {
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"`
// ============================================================
// 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 CaseActivityDTO struct {
Node NodeDTO `json:"node"`
Events []EventDTO `json:"events"`
// ============================================================
// 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 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) ListChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
return toNodeDTOs(list), 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) GetNodeDetail(nodeID string) (*NodeDTO, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(n)
return &dto, nil
}
type TodayDashboardDTO struct {
Date string `json:"date"`
Summary SummaryDTO `json:"summary"`
Groups []TodayGroupDTO `json:"groups"`
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"
}
// ============================================================
@ -279,17 +483,22 @@ type TodayDashboardDTO struct {
// ============================================================
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: n.ParentID,
Type: n.Type,
ParentID: parentID,
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),
Type: n.Type,
Section: n.Section,
Path: path,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
@ -301,158 +510,4 @@ 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
}

View File

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

View File

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

View File

@ -1,169 +0,0 @@
package main
import (
"fmt"
"log"
"verstak/internal/core/bridge"
"verstak/internal/core/browser"
"verstak/internal/core/config"
)
// startBridge creates and starts the local HTTP bridge for browser extension.
func (a *App) startBridge(appCfg *config.AppConfig) {
// Determine bridge config
bc := a.bridgeConfig(appCfg)
handler := func(events []bridge.Event) {
// Convert to browser events and store in staging.
be := make([]browser.Event, 0, len(events))
for _, ev := range events {
be = append(be, bridgeToBrowser(ev))
}
n, err := a.browser.InsertEvents(be)
if err != nil {
log.Printf("[bridge] store events: %v", err)
return
}
if n > 0 {
log.Printf("[bridge] stored %d/%d events", n, len(be))
}
}
srv := bridge.NewServer(bc.Secret, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
AutoGenPort: bc.AutoGenPort,
Secret: bc.Secret,
})
if err != nil {
log.Printf("[bridge] failed to start: %v", err)
return
}
// Save the actual port back to config if auto-generated.
if bc.AutoGenPort {
bc.Port = port
}
a.saveBridgeConfig(appCfg, bc)
a.mu.Lock()
a.bridge = srv
a.mu.Unlock()
}
// bridgeConfig extracts bridge config from app config.
func (a *App) bridgeConfig(appCfg *config.AppConfig) *config.BridgeConfig {
if appCfg != nil && appCfg.Vault.Bridge.Port != 0 {
return &appCfg.Vault.Bridge
}
return &config.BridgeConfig{
Port: 9786,
AutoGenPort: false,
}
}
// saveBridgeConfig persists the bridge config to disk.
func (a *App) saveBridgeConfig(appCfg *config.AppConfig, bc *config.BridgeConfig) {
if appCfg == nil {
// Load or create fresh
loaded, err := config.LoadAppConfig()
if err != nil || loaded == nil {
loaded = config.DefaultAppConfig()
}
appCfg = loaded
}
appCfg.Vault.Bridge = *bc
if err := config.SaveAppConfig(appCfg); err != nil {
log.Printf("[bridge] save config: %v", err)
}
}
// BridgeInfo returns the current bridge server status.
func (a *App) BridgeInfo() map[string]interface{} {
info := map[string]interface{}{
"running": false,
"port": 0,
}
if a.bridge != nil {
info["running"] = a.bridge.Running()
info["port"] = a.bridge.Port()
}
return info
}
// bridgeToBrowser converts a bridge.Event to a browser.Event.
func bridgeToBrowser(ev bridge.Event) browser.Event {
return browser.Event{
ID: ev.ID,
DeviceID: ev.DeviceID,
Type: ev.Type,
URL: ev.URL,
Title: ev.Title,
Domain: ev.Domain,
ActiveSeconds: ev.ActiveSeconds,
TSStart: ev.TSStart,
TSEnd: ev.TSEnd,
TS: ev.TS,
SelectedText: ev.SelectedText,
Note: ev.Note,
}
}
// RestartBridge stops and restarts the bridge server with current config.
func (a *App) RestartBridge() error {
// Stop existing server outside the lock to avoid blocking other bindings.
a.mu.Lock()
oldBridge := a.bridge
a.bridge = nil
a.mu.Unlock()
if oldBridge != nil {
oldBridge.Stop()
}
// Load config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
bc := a.bridgeConfig(appCfg)
if !bc.Enabled {
return nil
}
handler := func(events []bridge.Event) {
be := make([]browser.Event, 0, len(events))
for _, ev := range events {
be = append(be, bridgeToBrowser(ev))
}
n, err := a.browser.InsertEvents(be)
if err != nil {
log.Printf("[bridge] store events: %v", err)
return
}
if n > 0 {
log.Printf("[bridge] stored %d/%d events", n, len(be))
}
}
srv := bridge.NewServer(bc.Secret, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
AutoGenPort: bc.AutoGenPort,
Secret: bc.Secret,
})
if err != nil {
return fmt.Errorf("bridge restart: %w", err)
}
a.mu.Lock()
a.bridge = srv
a.mu.Unlock()
log.Printf("[bridge] restarted on port %d", port)
return nil
}

View File

@ -1,41 +0,0 @@
package main
import (
"log"
"verstak/internal/core/browser"
)
// ListBrowserEvents returns staged browser events, optionally filtered by status.
func (a *App) ListBrowserEvents(status string, limit, offset int) ([]browser.Event, error) {
if status == "" || status == "all" {
return a.browser.ListAll(limit, offset)
}
if status == "pending" {
return a.browser.ListPending(limit, offset)
}
return a.browser.ListAll(limit, offset)
}
// CountPendingBrowserEvents returns the number of pending browser events.
func (a *App) CountPendingBrowserEvents() (int, error) {
return a.browser.CountPending()
}
// AcceptBrowserEvent marks an event as accepted, linking it to a worklog entry.
func (a *App) AcceptBrowserEvent(eventID, worklogID string) error {
log.Printf("[browser] accept event %s -> worklog %s", eventID, worklogID)
return a.browser.Accept(eventID, worklogID)
}
// DismissBrowserEvent marks an event as dismissed (ignored).
func (a *App) DismissBrowserEvent(eventID string) error {
log.Printf("[browser] dismiss event %s", eventID)
return a.browser.Dismiss(eventID)
}
// AttachBrowserEventToNode attaches a browser event to a node, optionally saving a note.
func (a *App) AttachBrowserEventToNode(eventID, nodeID string) error {
log.Printf("[browser] attach event %s -> node %s", eventID, nodeID)
return a.browser.Attach(eventID, nodeID)
}

View File

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

View File

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

View File

@ -1,479 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/browser"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/templates"
"verstak/internal/core/vault"
"verstak/internal/core/watcher"
"verstak/internal/core/worklog"
)
// StartupStatus describes the application startup state.
type StartupStatus struct {
Status string `json:"status"` // "first_run", "recovery", "ready"
VaultPath string `json:"vaultPath"` // configured or default vault path
VaultExists bool `json:"vaultExists"` // whether index.db exists at the path
DefaultPath string `json:"defaultPath"` // default vault path suggestion
Error string `json:"error,omitempty"`
AppConfig *config.AppConfig `json:"appConfig,omitempty"`
}
// GetStartupStatus checks the global config and vault state.
func (a *App) GetStartupStatus() (*StartupStatus, error) {
defaultPath, _ := config.DefaultVaultPath()
appCfg, err := config.LoadAppConfig()
if err != nil {
return &StartupStatus{
Status: "first_run",
DefaultPath: defaultPath,
Error: fmt.Sprintf("config load error: %v", err),
}, nil
}
// No config at all → first run
if appCfg == nil {
return &StartupStatus{
Status: "first_run",
DefaultPath: defaultPath,
}, nil
}
// Config says not completed → first run
if !appCfg.FirstRunCompleted {
return &StartupStatus{
Status: "first_run",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
}, nil
}
// Config has no vault path → first run
if appCfg.VaultPath == "" {
appCfg.VaultPath = defaultPath
_ = config.SaveAppConfig(appCfg)
}
// Check if vault exists
vaultExists := vaultExistsAt(appCfg.VaultPath)
if !vaultExists {
return &StartupStatus{
Status: "recovery",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
AppConfig: appCfg,
}, nil
}
// Initialize services so that the vault is ready for use
if err := a.initVault(appCfg.VaultPath); err != nil {
return &StartupStatus{
Status: "recovery",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
Error: fmt.Sprintf("init vault: %v", err),
}, nil
}
return &StartupStatus{
Status: "ready",
VaultPath: appCfg.VaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
func vaultExistsAt(vaultPath string) bool {
dbPath := filepath.Join(vaultPath, ".verstak", "index.db")
if _, err := os.Stat(dbPath); err != nil {
return false
}
return true
}
// CreateVault creates a new vault at the given path and initializes all services.
func (a *App) CreateVault(vaultPath string) (*StartupStatus, error) {
if vaultPath == "" {
return nil, fmt.Errorf("vault path is empty")
}
// Create vault directories and database
if err := vault.Init(vaultPath); err != nil {
return nil, fmt.Errorf("create vault: %w", err)
}
// Initialize services for this vault
if err := a.initVault(vaultPath); err != nil {
return nil, fmt.Errorf("init vault services: %w", err)
}
// Save global config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.VaultPath = vaultPath
appCfg.FirstRunCompleted = true
if err := config.SaveAppConfig(appCfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
log.Printf("[startup] vault created at %s", vaultPath)
return &StartupStatus{
Status: "ready",
VaultPath: vaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
// OpenVault opens an existing vault and initializes services.
func (a *App) OpenVault(vaultPath string) (*StartupStatus, error) {
if vaultPath == "" {
return nil, fmt.Errorf("vault path is empty")
}
if !vaultExistsAt(vaultPath) {
return nil, fmt.Errorf("vault not found at %s", vaultPath)
}
if err := a.initVault(vaultPath); err != nil {
return nil, fmt.Errorf("init vault: %w", err)
}
// Update config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
appCfg.VaultPath = vaultPath
appCfg.FirstRunCompleted = true
if err := config.SaveAppConfig(appCfg); err != nil {
return nil, fmt.Errorf("save config: %w", err)
}
log.Printf("[startup] vault opened at %s", vaultPath)
return &StartupStatus{
Status: "ready",
VaultPath: vaultPath,
VaultExists: true,
AppConfig: appCfg,
}, nil
}
// initVault opens the vault DB and initializes all core services.
func (a *App) initVault(vaultPath string) error {
// Close previous vault if any
a.closeVault()
abs, err := filepath.Abs(vaultPath)
if err != nil {
return err
}
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
pm := plugins.NewManager(abs)
pm.Services = &plugins.CoreServices{
NodeRepo: nodeRepo,
DB: db,
ActivitySvc: activitySvc,
WorklogSvc: worklogSvc,
FilesSvc: fileSvc,
VaultPath: abs,
}
pm.Discover()
templatesReg := templates.NewRegistry()
if err := templatesReg.LoadSystem(); err != nil {
log.Printf("warning: failed to load system templates: %v", err)
}
// Apply enabled templates from config
appCfg, _ := config.LoadAppConfig()
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
enabledSet := make(map[string]bool)
for _, id := range appCfg.EnabledTemplates {
enabledSet[id] = true
}
for _, t := range templatesReg.All() {
if !enabledSet[t.ID] {
_ = templatesReg.Disable(t.ID)
}
}
}
// Apply installed/enabled state from config to discovered plugins
pm.SyncConfig(appCfg)
// Sync service
deviceID := ""
_ = appCfg // will store sync settings
if appCfg != nil && appCfg.Vault.Sync.DeviceID != "" {
deviceID = appCfg.Vault.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
// File watcher service
watcherSvc := watcher.NewService(abs, nodeRepo, fileSvc, activitySvc)
// Determine if real-time watching is enabled.
// Priority: CLI --no-watcher > env VERSTAK_NO_WATCHER > config file > default (true)
fileWatcherEnabled := true
if appCfg != nil && appCfg.Vault.FileWatcher != nil {
fileWatcherEnabled = *appCfg.Vault.FileWatcher
}
// Env override
if os.Getenv("VERSTAK_NO_WATCHER") == "1" {
fileWatcherEnabled = false
log.Println("[watcher] disabled by VERSTAK_NO_WATCHER=1")
}
// CLI override
for _, arg := range os.Args[1:] {
if arg == "--no-watcher" {
fileWatcherEnabled = false
log.Println("[watcher] disabled by --no-watcher")
break
}
}
a.mu.Lock()
a.db = db
a.nodes = nodeRepo
a.files = fileSvc
a.notes = noteSvc
a.activity = activitySvc
a.actions = actionSvc
a.worklog = worklogSvc
a.search = searchSvc
a.plugins = pm
a.templates = templatesReg
a.sync = syncSvc
a.fileWatcher = watcherSvc
a.browser = browser.NewStore(db)
a.vault = abs
a.vaultOpen = true
a.mu.Unlock()
// Snapshot scan (always runs). Real-time watcher depends on config.
scanResult, err := watcherSvc.Start(fileWatcherEnabled)
if err != nil {
log.Printf("[watcher] start error: %v", err)
} else {
log.Printf("[watcher] snapshot: %d missing, %d restored, %d modified, %d new",
scanResult.MissingFiles, scanResult.RestoredFiles, scanResult.ModifiedFiles, scanResult.NewFiles)
}
// Start auto-sync loop
go a.autoSyncLoop()
// Start plugin runtimes for enabled plugins (creates VM, loads scripts, calls on_init)
pm.InitRuntimes()
pm.CallInitHooks()
pm.StartSchedulers()
// Start bridge server for browser extension integration (if enabled).
if appCfg == nil || appCfg.Vault.Bridge.Enabled {
a.startBridge(appCfg)
} else {
log.Println("[bridge] disabled by config")
}
return nil
}
// closeVault shuts down current vault services if any.
func (a *App) closeVault() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.vaultOpen {
return
}
// Stop file watcher first.
if a.fileWatcher != nil {
a.fileWatcher.Stop()
}
// Stop plugin runtimes (schedulers → on_shutdown → close VMs)
if a.plugins != nil {
a.plugins.StopSchedulers()
a.plugins.CallShutdownHooks()
a.plugins.CloseRuntimes()
}
// Stop bridge server.
if a.bridge != nil {
a.bridge.Stop()
}
if a.db != nil {
a.db.Close()
}
a.db = nil
a.nodes = nil
a.files = nil
a.notes = nil
a.activity = nil
a.actions = nil
a.worklog = nil
a.search = nil
a.plugins = nil
a.templates = nil
a.sync = nil
a.fileWatcher = nil
a.bridge = nil
a.browser = nil
a.vault = ""
a.vaultOpen = false
}
// IsReady returns true if a vault is open and services are initialized.
func (a *App) IsReady() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.vaultOpen
}
// GetAppConfig returns the current global app config.
func (a *App) GetAppConfig() (*config.AppConfig, error) {
cfg, err := config.LoadAppConfig()
if err != nil {
return config.DefaultAppConfig(), nil
}
if cfg == nil {
return config.DefaultAppConfig(), nil
}
return cfg, nil
}
// SaveAppConfig saves the global app config.
func (a *App) SaveAppConfig(cfg *config.AppConfig) error {
return config.SaveAppConfig(cfg)
}
// GetDefaultVaultPath returns the default vault path.
func (a *App) GetDefaultVaultPath() (string, error) {
return config.DefaultVaultPath()
}
// CheckVaultPath checks whether a given path is usable as a vault.
type CheckVaultPathResult struct {
Exists bool `json:"exists"`
HasVault bool `json:"hasVault"`
Writable bool `json:"writable"`
Description string `json:"description"`
}
func (a *App) CheckVaultPath(vaultPath string) (*CheckVaultPathResult, error) {
if vaultPath == "" {
return nil, fmt.Errorf("path is empty")
}
info, err := os.Stat(vaultPath)
exists := err == nil
hasVault := false
writable := false
if exists {
if info.IsDir() {
writable = checkDirWritable(vaultPath)
hasVault = vaultExistsAt(vaultPath)
}
} else {
// Path doesn't exist - check if parent is writable
parent := filepath.Dir(vaultPath)
parentInfo, pErr := os.Stat(parent)
if pErr == nil && parentInfo.IsDir() {
writable = checkDirWritable(parent)
}
}
desc := describeVaultPath(exists, hasVault)
return &CheckVaultPathResult{
Exists: exists,
HasVault: hasVault,
Writable: writable,
Description: desc,
}, nil
}
func checkDirWritable(dir string) bool {
testFile := filepath.Join(dir, ".verstak-write-test")
if err := os.WriteFile(testFile, []byte{}, 0o640); err != nil {
return false
}
os.Remove(testFile)
return true
}
func describeVaultPath(exists, hasVault bool) string {
if !exists {
return "Путь не существует. Будет создан новый vault."
}
if hasVault {
return "Найден существующий vault. Можно подключиться."
}
return "Папка существует, но vault не найден. Можно создать новый vault."
}
// VaultInfo returns information about the currently open vault.
type VaultInfo struct {
Path string `json:"path"`
DBPath string `json:"dbPath"`
FilesPath string `json:"filesPath"`
TrashPath string `json:"trashPath"`
Healthy bool `json:"healthy"`
NodeCount int `json:"nodeCount"`
FileCount int `json:"fileCount"`
}
func (a *App) GetVaultInfo() (*VaultInfo, error) {
if !a.IsReady() {
return nil, fmt.Errorf("vault not open")
}
a.mu.RLock()
vp := a.vault
nodesCount := 0
if a.nodes != nil {
roots, _ := a.nodes.ListRoots(true)
nodesCount = len(roots)
}
fileCount := 0
_ = a.db.QueryRow("SELECT COUNT(*) FROM files").Scan(&fileCount)
a.mu.RUnlock()
return &VaultInfo{
Path: vp,
DBPath: filepath.Join(vp, ".verstak", "index.db"),
FilesPath: filepath.Join(vp, "spaces"),
TrashPath: filepath.Join(vp, ".verstak", "trash"),
Healthy: true,
NodeCount: nodesCount,
FileCount: fileCount,
}, nil
}

View File

@ -1,26 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
)
// WriteDebugLog appends a line to <vault>/.verstak/debug.log.
// Called from frontend to log JS-side diagnostics in production GUI builds.
func (a *App) WriteDebugLog(msg string) {
if !a.IsReady() {
return
}
log.Printf("[js] %s", msg)
logPath := filepath.Join(a.vault, ".verstak", "debug.log")
line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), msg)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
f.WriteString(line)
}

View File

@ -1,218 +0,0 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
)
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
records, err := a.files.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]FileDTO, len(records))
for i := range records {
rec := &records[i]
result[i] = FileDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Name: rec.Filename,
Path: rec.Path,
Size: rec.Size,
Mime: rec.MIME,
IsDir: rec.MIME == "inode/directory",
Missing: rec.Missing,
}
}
return result, nil
}
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
result := make([]FileTreeItemDTO, 0, len(children))
for i := range children {
typ := children[i].Type
if typ != nodes.TypeFolder && typ != nodes.TypeFile && typ != nodes.TypeNote {
continue
}
item := FileTreeItemDTO{
ID: children[i].ID,
Name: children[i].Title,
Type: typ,
}
if typ == nodes.TypeFolder {
kids, _ := a.nodes.ListChildren(children[i].ID, false)
item.HasKids = len(kids) > 0
item.Mime = "inode/directory"
} else if typ == nodes.TypeFile {
records, _ := a.files.ListByNode(children[i].ID)
if len(records) > 0 {
item.FileID = records[0].ID
item.Size = records[0].Size
item.Mime = records[0].MIME
}
} else if typ == nodes.TypeNote {
records, _ := a.files.ListByNode(children[i].ID)
if len(records) > 0 {
item.FileID = records[0].ID
item.Size = records[0].Size
item.Mime = "text/markdown"
}
}
result = append(result, item)
}
return result, nil
}
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileDeleted
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
}
return a.files.DeleteNodeAndChildren(nodeID)
}
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, err := a.files.CreateEmptyFile(parentID, filename)
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, err := a.files.Duplicate(nodeID)
if err != nil {
return nil, err
}
n, err2 := a.nodes.GetActive(nodeID)
pid := ""
if err2 == nil && n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
fileRec, err := a.files.Get(fileID)
if err != nil {
return nil, fmt.Errorf("get file: %w", err)
}
name := strings.ToLower(fileRec.Filename)
isMD := strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown")
if !isMD {
return &PreflightFileAction{Action: "external", FileName: fileRec.Filename}, nil
}
// .md file — check for linked note
noteRec, err := a.notes.FindByFileID(fileID)
if err == nil && noteRec != nil {
noteNode, nodeErr := a.nodes.Get(noteRec.NodeID)
title := fileRec.Filename
if nodeErr == nil && noteNode != nil {
title = noteNode.Title
}
return &PreflightFileAction{Action: "note", NoteID: noteRec.NodeID, NoteTitle: title, FileName: fileRec.Filename}, nil
}
// .md inside Notes/ with no note record — auto-link
pathLower := strings.ToLower(fileRec.Path)
insideNotes := strings.Contains(pathLower, string(filepath.Separator)+"notes"+string(filepath.Separator)) ||
strings.HasPrefix(pathLower, "notes"+string(filepath.Separator))
if insideNotes {
noteNode, nodeErr := a.nodes.Get(fileRec.NodeID)
if nodeErr == nil && noteNode != nil {
_ = a.notes.LinkFile(noteNode.ID, fileID, "markdown")
return &PreflightFileAction{Action: "note", NoteID: noteNode.ID, NoteTitle: noteNode.Title, FileName: fileRec.Filename}, nil
}
}
// .md outside Notes/ — internal preview
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.files.PreviewImport(sourcePath)
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,142 +0,0 @@
package main
import (
"time"
"verstak/internal/core/activity"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
syncsvc "verstak/internal/core/sync"
)
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
// Return empty for non-notes-capable parents.
if !a.notes.SupportsNotes(nodeID) {
return []NodeDTO{}, nil
}
// Try the canonical layout: notes live under a "Notes" folder.
// Also fall back to direct TypeNote children so that notes placed
// directly by AssignInboxNode / old layout are still visible.
notesFolder := a.notes.FindNotesFolder(nodeID)
// Direct children (old layout / inbox-assigned notes)
directChildren, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
seen := map[string]bool{}
var result []NodeDTO
processNote := func(n nodes.Node) {
if n.Type == nodes.TypeNote && !seen[n.ID] {
seen[n.ID] = true
result = append(result, toNodeDTO(&n))
}
}
if notesFolder != nil {
// Canonical layout: notes inside the Notes folder
notesChildren, err := a.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
return nil, err
}
for i := range notesChildren {
processNote(notesChildren[i])
}
}
// Also include direct TypeNote children (old / inbox-assigned notes)
for i := range directChildren {
processNote(directChildren[i])
}
// Trigger repair in background for old-layout notes
go func() {
if _, err := a.notes.RepairNotesLayout(); err != nil {
// log only
}
}()
return result, nil
}
func (a *App) RepairNotesLayout() (*notes.RepairResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.notes.RepairNotesLayout()
}
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, fileRec, err := a.notes.Create(parentID, title, "")
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, ""))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) ReadNote(noteID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.notes.Read(noteID)
}
func (a *App) SaveNote(noteID, content string) error {
if err := a.requireVault(); err != nil {
return err
}
if err := a.notes.Save(noteID, content); err != nil {
return err
}
if n, err := a.nodes.GetActive(noteID); err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
"node_id": noteID,
"content": content,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
}
return nil
}
func (a *App) RenameNote(noteID, newTitle string) error {
if err := a.requireVault(); err != nil {
return err
}
return a.notes.Rename(noteID, newTitle)
}
func (a *App) DeleteNote(noteID string) error {
if err := a.requireVault(); err != nil {
return err
}
// Record activity and sync op before delete (need node info).
n, _ := a.nodes.GetActive(noteID)
pid := ""
if n != nil && n.ParentID != nil {
pid = *n.ParentID
}
title := ""
if n != nil {
title = n.Title
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteDeleted, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpDelete, nil)
return a.notes.Delete(noteID)
}

View File

@ -1,356 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"verstak/internal/core/config"
)
// PluginDTO represents a discovered plugin with its current state.
type PluginDTO struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author,omitempty"`
Description string `json:"description,omitempty"`
Active bool `json:"active"`
Installed bool `json:"installed"`
HasInstall bool `json:"hasInstall"`
HasPanel bool `json:"hasPanel"`
HasSettings bool `json:"hasSettings"`
UIContribs UIContribDTO `json:"uiContribs"`
}
// UIContribDTO describes what a plugin adds to the UI.
type UIContribDTO struct {
SidebarItems []SidebarItemDTO `json:"sidebarItems"`
NodeTabs []NodeTabDTO `json:"nodeTabs"`
}
type SidebarItemDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Icon string `json:"icon,omitempty"`
}
type NodeTabDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Page string `json:"page"`
}
// ListPlugins returns all discovered plugins with their current enabled/disabled state.
func (a *App) ListPlugins() []PluginDTO {
if a.plugins == nil {
return nil
}
all := a.plugins.Plugins()
out := make([]PluginDTO, 0, len(all))
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
enabledSet[name] = true
}
}
for _, p := range all {
active := enabledSet[p.Meta.Name] || p.Active
contribs := UIContribDTO{}
for _, item := range p.Meta.UI.SidebarItems {
contribs.SidebarItems = append(contribs.SidebarItems, SidebarItemDTO{
ID: item.ID,
Label: item.Label,
Icon: item.Icon,
})
}
for _, tab := range p.Meta.UI.NodeTabs {
contribs.NodeTabs = append(contribs.NodeTabs, NodeTabDTO{
ID: tab.ID,
Label: tab.Label,
Page: tab.Page,
})
}
hasPanel := false
if p.Meta.Panel != "" {
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
if _, err := os.Stat(panelPath); err == nil {
hasPanel = true
}
}
out = append(out, PluginDTO{
Name: p.Meta.Name,
Version: p.Meta.Version,
Author: p.Meta.Author,
Description: p.Meta.Description,
Active: active,
Installed: p.Installed,
HasInstall: p.HasInstall,
HasPanel: hasPanel,
UIContribs: contribs,
})
}
return out
}
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
// Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
// Disable: deactivates runtime, then marks plugin as disabled in config.
func (a *App) SetPluginEnabled(name string, enabled bool) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
if enabled {
// Enable first (sets Enabled=true on the plugin struct)
if err := a.plugins.Enable(name); err != nil {
return err
}
// Activate runtime — if this fails, do NOT persist to config
if err := a.plugins.ActivatePlugin(name); err != nil {
// Rollback: deactivate runtime AND un-enable in-memory state
a.plugins.DeactivatePlugin(name)
a.plugins.Disable(name)
return fmt.Errorf("activate %q: %w", name, err)
}
// Only persist to config after successful activation
if err := a.saveEnabledPlugin(name); err != nil {
// Config save failed — rollback runtime too
a.plugins.DeactivatePlugin(name)
a.plugins.Disable(name)
return fmt.Errorf("save config for %q: %w", name, err)
}
} else {
// Deactivate runtime first, then disable
a.plugins.DeactivatePlugin(name)
if err := a.plugins.Disable(name); err != nil {
return err
}
// Remove from config
if err := a.removeEnabledPlugin(name); err != nil {
return fmt.Errorf("save config for %q: %w", name, err)
}
}
return nil
}
// saveEnabledPlugin adds name to EnabledPlugins in config and saves.
func (a *App) saveEnabledPlugin(name string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
for _, n := range appCfg.EnabledPlugins {
if n == name {
return nil // already present
}
}
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
return config.SaveAppConfig(appCfg)
}
// removeEnabledPlugin removes name from EnabledPlugins in config and saves.
func (a *App) removeEnabledPlugin(name string) error {
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
return nil // nothing to remove
}
var updated []string
for _, n := range appCfg.EnabledPlugins {
if n != name {
updated = append(updated, n)
}
}
appCfg.EnabledPlugins = updated
return config.SaveAppConfig(appCfg)
}
// GetPluginPanelHTML returns the HTML panel content for a plugin.
// Validates that the panel path is safe: no absolute paths, no .. traversal,
// must be within the plugin directory, and must end with .html.
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready")
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
enabledSet[name] = true
}
}
for _, p := range a.plugins.Plugins() {
active := enabledSet[p.Meta.Name] || p.Active
if p.Meta.Name != pluginName || !active {
continue
}
if p.Meta.Panel == "" {
return "", nil
}
// Validate panel path: must be relative, no .., within plugin dir, .html only
panel := p.Meta.Panel
if filepath.IsAbs(panel) {
return "", fmt.Errorf("panel path %q must be relative", panel)
}
if strings.Contains(panel, "..") {
return "", fmt.Errorf("panel path %q must not contain ..", panel)
}
if !strings.HasSuffix(strings.ToLower(panel), ".html") {
return "", fmt.Errorf("panel path %q must end with .html", panel)
}
// Resolve and verify the path is within the plugin directory
panelPath := filepath.Join(p.Dir, panel)
absPanel, err := filepath.Abs(panelPath)
if err != nil {
return "", fmt.Errorf("resolve panel path: %w", err)
}
absDir, err := filepath.Abs(p.Dir)
if err != nil {
return "", fmt.Errorf("resolve plugin dir: %w", err)
}
if !strings.HasPrefix(absPanel, absDir+string(filepath.Separator)) {
return "", fmt.Errorf("panel path %q escapes plugin directory", panel)
}
data, err := os.ReadFile(absPanel)
if err != nil {
return "", fmt.Errorf("read panel %s: %w", panel, err)
}
return string(data), nil
}
return "", nil
}
// ListSystemViewsWithPlugins returns system views + plugin sidebar items.
func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
base := a.ListSystemViews()
if a.plugins == nil {
return base
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
enabledSet[name] = true
}
}
for _, p := range a.plugins.Plugins() {
active := enabledSet[p.Meta.Name] || p.Active
if !active {
continue
}
for _, item := range p.Meta.UI.SidebarItems {
pageID := "plugin:" + p.Meta.Name + ":" + item.ID
base = append(base, SystemViewDTO{
ID: pageID,
Label: item.Label,
Icon: item.Icon,
})
}
}
return base
}
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// CallPluginFunction calls a global Lua function on an active plugin.
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
// Only alphanumeric identifiers with underscores are allowed (no Lua injection).
// Returns JSON string or error.
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready")
}
// Validate funcName: only [a-zA-Z0-9_.]+ allowed, each segment must be valid ident
if funcName == "" {
return "", fmt.Errorf("funcName is empty")
}
segments := strings.Split(funcName, ".")
if len(segments) > 3 {
return "", fmt.Errorf("funcName %q too deep (max 2 dots)", funcName)
}
for _, seg := range segments {
if !validLuaIdent.MatchString(seg) {
return "", fmt.Errorf("funcName %q contains invalid segment %q", funcName, seg)
}
}
for _, p := range a.plugins.Plugins() {
if p.Meta.Name != pluginName || !p.Active {
continue
}
vm := p.VM()
if vm == nil {
continue
}
log.Printf("[plugins] CallPluginFunction: %s.%s active=true, calling Lua...", pluginName, funcName)
// Call via fully thread-safe LuaVM.CallFunctionJSON
// (JSON→Lua conversion happens under vm.mu)
result, err := vm.CallFunctionJSON(segments, paramsJSON)
if err != nil {
log.Printf("[plugins] CallPluginFunction: %s.%s ERROR: %v", pluginName, funcName, err)
return "", err
}
log.Printf("[plugins] CallPluginFunction: %s.%s OK (%d bytes)", pluginName, funcName, len(result))
return result, nil
}
log.Printf("[plugins] CallPluginFunction: %s.%s NOT FOUND or inactive", pluginName, funcName)
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
}
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
func (a *App) ReloadPlugins() error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
log.Print("[plugins] reload requested")
// Fully stop runtimes: schedulers first (they depend on VMs), then VMs
a.plugins.StopSchedulers()
a.plugins.CallShutdownHooks()
a.plugins.CloseRuntimes()
a.plugins.Discover()
appCfg, _ := config.LoadAppConfig()
a.plugins.SyncConfig(appCfg)
a.plugins.InitRuntimes()
a.plugins.CallInitHooks()
a.plugins.StartSchedulers()
log.Print("[plugins] reload complete")
return nil
}
// InstallPlugin creates plugin's database tables and defaults via on_install hook.
// Does NOT activate the plugin — use SetPluginEnabled after.
func (a *App) InstallPlugin(name string) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
return a.plugins.Install(name)
}
// UninstallPlugin drops plugin's database tables and cleans data via on_uninstall hook.
// Disables the plugin first if active. Does NOT delete plugin files from disk.
func (a *App) UninstallPlugin(name string) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
return a.plugins.Uninstall(name)
}

View File

@ -1,402 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"verstak/internal/core/config"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/i18n"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// ===== Template management =====
// AllTemplates returns all registered templates with their enabled status.
type TemplateWithStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"`
}
func (a *App) AllTemplates() ([]TemplateWithStatus, error) {
if !a.IsReady() || a.templates == nil {
return nil, fmt.Errorf("vault not ready")
}
appCfg, _ := config.LoadAppConfig()
enabledSet := make(map[string]bool)
if appCfg != nil {
for _, id := range appCfg.EnabledTemplates {
enabledSet[id] = true
}
}
all := a.templates.All()
result := make([]TemplateWithStatus, len(all))
for i, t := range all {
// If config has explicit list, use it; otherwise default to true
enabled := true
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
enabled = enabledSet[t.ID]
}
result[i] = TemplateWithStatus{
ID: t.ID,
Title: t.Title,
Type: t.Type,
Icon: t.Icon,
Enabled: enabled,
}
}
return result, nil
}
func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
if !a.IsReady() || a.templates == nil {
return fmt.Errorf("vault not ready")
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
}
// Update enabled templates list
existing := make(map[string]bool)
for _, id := range appCfg.EnabledTemplates {
existing[id] = true
}
if enabled {
existing[templateID] = true
} else {
delete(existing, templateID)
}
appCfg.EnabledTemplates = make([]string, 0, len(existing))
for id := range existing {
appCfg.EnabledTemplates = append(appCfg.EnabledTemplates, id)
}
if err := config.SaveAppConfig(appCfg); err != nil {
return err
}
// Update in-memory registry
if enabled {
_ = a.templates.Enable(templateID)
} else {
_ = a.templates.Disable(templateID)
}
return nil
}
func (a *App) ListTemplates() []TemplateDTO {
if !a.IsReady() {
return nil
}
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
for _, t := range templates {
out = append(out, TemplateDTO{
ID: t.Name,
Title: t.Name,
Type: t.RootType,
Icon: t.Icon,
})
}
return out
}
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
var tmpl *plugins.TemplateDefinition
for _, t := range a.plugins.Templates() {
if t.Name == template {
tmpl = &t
break
}
}
if tmpl == nil {
return nil, nil
}
root, err := a.nodes.Create(strPtr(parentID), tmpl.RootType, title, 0, "", "")
if err != nil {
return nil, err
}
var createTree func(parentID string, nodes []plugins.TreeNode) error
createTree = func(parentID string, nodes []plugins.TreeNode) error {
for _, tn := range nodes {
child, err := a.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
if err != nil {
return err
}
if len(tn.Children) > 0 {
if err := createTree(child.ID, tn.Children); err != nil {
return err
}
}
}
return nil
}
if err := createTree(root.ID, tmpl.Tree); err != nil {
return nil, err
}
dto := toNodeDTO(root)
return &dto, nil
}
// ===== File picking =====
func (a *App) PickFile() (string, error) {
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: i18n.TF("ru", "file.pickSingle"),
})
}
func (a *App) PickFiles() ([]string, error) {
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: i18n.TF("ru", "file.pickMultiple"),
})
}
func (a *App) PickDirectory() (string, error) {
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: i18n.TF("ru", "file.pickDirectory"),
})
}
func (a *App) OpenFile(fileID string) error {
if err := a.requireVault(); err != nil {
return err
}
return a.files.Open(fileID)
}
func (a *App) ReadFileText(fileID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.files.ReadText(fileID)
}
func (a *App) GetFileBase64(fileID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.files.ReadBase64(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
var fileRecordPath string
if n.Type == nodes.TypeFile && n.FsPath == "" {
records, _ := a.files.ListByNode(nodeID)
if len(records) > 0 {
fileRecordPath = records[0].Path
}
}
target := resolveOpenFolderTarget(a.vault, n, fileRecordPath)
if _, err := os.Stat(target); os.IsNotExist(err) {
target = a.vault
}
cmd := exec.Command("xdg-open", target)
return cmd.Run()
}
func resolveOpenFolderTarget(vault string, n *nodes.Node, fileRecordPath string) string {
if n.Type == nodes.TypeFile && n.FsPath == "" {
if fileRecordPath == "" {
return vault
}
return filepath.Dir(filepath.Join(vault, fileRecordPath))
}
target := filepath.Join(vault, n.FsPath)
if n.Type == nodes.TypeFile {
return filepath.Dir(target)
}
return target
}
func (a *App) OpenVaultFolder() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
cmd := exec.Command("xdg-open", a.vault)
return cmd.Run()
}
func ensurePluginsFolder(vaultPath string) (string, error) {
path := filepath.Join(vaultPath, ".verstak", "plugins")
if err := os.MkdirAll(path, 0o750); err != nil {
return "", err
}
return path, nil
}
func (a *App) OpenPluginsFolder() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
target, err := ensurePluginsFolder(a.vault)
if err != nil {
return err
}
cmd := exec.Command("xdg-open", target)
return cmd.Run()
}
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" {
return []SearchResultDTO{}, nil
}
out := []SearchResultDTO{}
seen := map[string]bool{}
add := func(r SearchResultDTO) {
if r.Title == "" {
return
}
key := r.Type + ":" + r.NodeID + ":" + r.TargetID + ":" + r.Title
if seen[key] {
return
}
seen[key] = true
out = append(out, r)
}
results, err := a.search.Search(query)
if err != nil {
return nil, err
}
for _, r := range results {
path := r.Path
if path == "" && r.NodeID != "" {
path = a.nodes.Path(r.NodeID)
}
add(SearchResultDTO{
NodeID: r.NodeID,
Title: r.Title,
Snippet: r.Snippet,
Type: r.Type,
Path: path,
})
}
if len(out) < 20 {
nodes, err := a.nodes.Search(query, 20-len(out))
if err == nil {
for i := range nodes {
if nodes[i].IsDeleted() {
continue
}
add(SearchResultDTO{
NodeID: nodes[i].ID,
Title: nodes[i].Title,
Type: nodes[i].Type,
Path: a.nodes.Path(nodes[i].ID),
})
}
}
}
if len(out) < 20 {
rows, err := a.db.Query(
`SELECT l.id,l.node_id,l.title,l.url,l.hostname,COALESCE(l.note,''),n.deleted_at
FROM links l
LEFT JOIN nodes n ON n.id = l.node_id
WHERE n.deleted_at IS NULL
AND (l.title_lower LIKE ? OR l.url_lower LIKE ? OR l.hostname_lower LIKE ? OR l.note_lower LIKE ?)
ORDER BY l.created_at DESC
LIMIT ?`,
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
if err == nil {
defer rows.Close()
for rows.Next() {
var id, nodeID, title, url, hostname, note string
var deletedAt interface{}
if err := rows.Scan(&id, &nodeID, &title, &url, &hostname, &note, &deletedAt); err != nil {
return nil, err
}
snippet := url
if note != "" {
snippet = note
}
add(SearchResultDTO{
NodeID: nodeID,
TargetID: id,
Title: title,
Snippet: snippet,
Type: "link",
Path: a.nodes.Path(nodeID),
URL: url,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
}
}
if len(out) < 20 {
rows, err := a.db.Query(
`SELECT ac.id,ac.node_id,ac.title,ac.kind,COALESCE(ac.url,''),COALESCE(ac.command,''),n.deleted_at
FROM actions ac
LEFT JOIN nodes n ON n.id = ac.node_id
WHERE n.deleted_at IS NULL
AND (ac.title_lower LIKE ? OR ac.kind_lower LIKE ? OR ac.url_lower LIKE ? OR ac.command_lower LIKE ?)
ORDER BY ac.created_at DESC
LIMIT ?`,
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
if err == nil {
defer rows.Close()
for rows.Next() {
var id, nodeID, title, kind, url, command string
var deletedAt interface{}
if err := rows.Scan(&id, &nodeID, &title, &kind, &url, &command, &deletedAt); err != nil {
return nil, err
}
snippet := url
if snippet == "" {
snippet = command
}
add(SearchResultDTO{
NodeID: nodeID,
TargetID: id,
Title: title,
Snippet: snippet,
Type: "action",
Path: a.nodes.Path(nodeID),
})
}
if err := rows.Err(); err != nil {
return nil, err
}
}
}
return out, nil
}
func likeQuery(query string) string {
return "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
}

View File

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

View File

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

View File

@ -1,510 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"verstak/internal/core/nodes"
)
type TrashDTO struct {
TrashPath string `json:"trashPath"`
Count int `json:"count"`
Nodes []TrashNodeDTO `json:"nodes"`
Entries []TrashEntryDTO `json:"entries"`
}
type TrashNodeDTO struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Title string `json:"title"`
Type string `json:"type"`
FsPath string `json:"fsPath"`
NodePath string `json:"nodePath"`
TrashFsPath string `json:"trashFsPath"`
DeletedAt string `json:"deletedAt"`
}
type TrashEntryDTO struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
func (a *App) ListTrash() (*TrashDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
deleted, err := a.nodes.ListDeleted()
if err != nil {
return nil, err
}
// Phase 1: build all DTOs and compute direct trash paths for folder-type nodes.
nodeMap := make(map[string]*TrashNodeDTO, len(deleted))
var allDeleted []nodes.Node
for _, n := range deleted {
allDeleted = append(allDeleted, n)
}
for _, n := range allDeleted {
deletedAt := ""
if n.DeletedAt != nil {
deletedAt = n.DeletedAt.Format(time.RFC3339)
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
dto := &TrashNodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
FsPath: n.FsPath,
NodePath: a.nodes.Path(n.ID),
DeletedAt: deletedAt,
}
nodeMap[n.ID] = dto
// Try direct trash entry (for folders that were os.Rename'd).
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
dto.TrashFsPath = p
} else if recs, recErr := a.files.ListTrashedByNode(n.ID); recErr == nil && len(recs) > 0 {
// Try file records (for TypeFile nodes whose files were individually moved).
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
if _, stErr := os.Stat(candidate); stErr == nil {
dto.TrashFsPath = candidate
break
}
}
}
}
// Phase 2: propagate trash paths from parents to children that have no direct entry
// but whose FsPath starts with the parent's FsPath.
changed := true
for changed {
changed = false
for _, n := range allDeleted {
dto := nodeMap[n.ID]
if dto.TrashFsPath != "" {
continue
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
if parentID == "" {
continue
}
parent := nodeMap[parentID]
if parent == nil || parent.TrashFsPath == "" {
continue
}
// Child inherits parent's trash path + relative FsPath fragment.
if n.FsPath != "" && parent.FsPath != "" && strings.HasPrefix(n.FsPath, parent.FsPath) {
rel := strings.TrimPrefix(n.FsPath, parent.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
dto.TrashFsPath = filepath.Join(parent.TrashFsPath, rel)
changed = true
}
}
}
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
for _, dto := range nodeMap {
nodes = append(nodes, *dto)
}
entries, err := listTrashEntries(trashPath)
if err != nil {
return nil, err
}
return &TrashDTO{TrashPath: trashPath, Count: len(nodes), Nodes: nodes, Entries: entries}, nil
}
func (a *App) TrashCount() (int, error) {
trash, err := a.ListTrash()
if err != nil {
return 0, err
}
return trash.Count, nil
}
func (a *App) RestoreTrashNode(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
chain, err := a.deletedAncestorChain(nodeID)
if err != nil {
return err
}
for _, n := range chain {
if err := a.restoreTrashPath(n.ID, n.FsPath); err != nil {
return err
}
if _, err := a.db.Exec(`UPDATE nodes SET deleted_at = NULL, updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), n.ID); err != nil {
return err
}
}
return nil
}
func (a *App) RestoreTrashNodesJSON(nodeIDsJSON string) error {
var ids []string
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
return err
}
for _, id := range ids {
if err := a.RestoreTrashNode(id); err != nil {
return err
}
}
return nil
}
func (a *App) PurgeTrashNodesJSON(nodeIDsJSON string) error {
if err := a.requireVault(); err != nil {
return err
}
var ids []string
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
return err
}
for _, id := range ids {
if err := a.purgeTrashNode(id); err != nil {
return err
}
}
return nil
}
func (a *App) EmptyTrash() error {
if err := a.requireVault(); err != nil {
return err
}
trash, err := a.ListTrash()
if err != nil {
return err
}
ids := make([]string, 0, len(trash.Nodes))
for _, n := range trash.Nodes {
if n.ParentID == "" {
ids = append(ids, n.ID)
}
}
if len(ids) == 0 {
for _, n := range trash.Nodes {
ids = append(ids, n.ID)
}
}
for _, id := range ids {
if err := a.purgeTrashNode(id); err != nil {
return err
}
}
return os.RemoveAll(filepath.Join(a.vault, ".verstak", "trash"))
}
func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
var reversed []TrashNodeDTO
current := nodeID
for current != "" {
n, err := a.nodes.Get(current)
if err != nil {
return nil, err
}
if n.DeletedAt == nil {
break
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
reversed = append(reversed, TrashNodeDTO{ID: n.ID, ParentID: parentID, Title: n.Title, Type: n.Type, FsPath: n.FsPath})
current = parentID
}
if len(reversed) == 0 {
return nil, fmt.Errorf("deleted node not found")
}
chain := make([]TrashNodeDTO, 0, len(reversed))
for i := len(reversed) - 1; i >= 0; i-- {
chain = append(chain, reversed[i])
}
return chain, nil
}
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
if fsPath == "" {
// TypeFile node — restore file records that were marked missing=1.
recs, err := a.files.ListTrashedByNode(nodeID)
if err != nil {
return err
}
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
if _, err := os.Stat(trashPath); os.IsNotExist(err) {
continue // already restored via parent dir
}
dst := filepath.Join(a.vault, r.Path)
rel, rErr := filepath.Rel(a.vault, dst)
if rErr != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("path safety: %s", r.Path)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
if err := os.Rename(trashPath, dst); err != nil {
return fmt.Errorf("restore file %s: %w", r.ID, err)
}
if _, err := a.db.Exec("UPDATE files SET missing=0, updated_at=? WHERE id=?", nowStr(), r.ID); err != nil {
return err
}
}
return nil
}
// Directory-type node — move the whole directory back from trash.
trashEntry, err := a.findTrashEntryForNode(nodeID)
if err != nil {
return nil // parent may have already been restored
}
dst := filepath.Join(a.vault, fsPath)
if _, err := os.Stat(dst); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
return os.Rename(trashEntry, dst)
}
func nowStr() string {
return time.Now().UTC().Format(time.RFC3339)
}
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
trashPath := filepath.Join(a.vault, ".verstak", "trash")
entries, err := os.ReadDir(trashPath)
if err != nil {
return "", err
}
prefix := nodeID + "_"
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
return filepath.Join(trashPath, entry.Name()), nil
}
}
return "", fmt.Errorf("trash entry not found")
}
func (a *App) purgeTrashNode(nodeID string) error {
ids, err := a.deletedSubtreeIDs(nodeID)
if err != nil {
return err
}
for _, id := range ids {
// Try direct trash entry (folder-type nodes: nodeID_title).
if path, err := a.findTrashEntryForNode(id); err == nil {
_ = os.RemoveAll(path)
}
// Try file record trash entries (file/note nodes: fileID_filename).
// These are created by files.trashRecord and not found by findTrashEntryForNode.
if recs, err := a.files.ListTrashedByNode(id); err == nil {
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
_ = os.RemoveAll(trashPath)
}
}
}
tx, err := a.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for i := len(ids) - 1; i >= 0; i-- {
id := ids[i]
_, _ = tx.Exec(`DELETE FROM node_meta WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM notes WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM actions WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM links WHERE node_id = ?`, id)
_, _ = tx.Exec(`DELETE FROM worklog_entry_events WHERE entry_id IN (SELECT id FROM worklog_entries WHERE node_id = ?)`, id)
_, _ = tx.Exec(`DELETE FROM worklog_entries WHERE node_id = ?`, id)
if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil {
return err
}
}
return tx.Commit()
}
func (a *App) deletedSubtreeIDs(nodeID string) ([]string, error) {
rows, err := a.db.Query(
`WITH RECURSIVE subtree(id) AS (
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NOT NULL
UNION ALL
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
WHERE n.deleted_at IS NOT NULL
) SELECT id FROM subtree`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, fmt.Errorf("deleted node not found")
}
return ids, rows.Err()
}
func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return nil, err
}
dirEntries, err := os.ReadDir(trashPath)
if err != nil {
return nil, err
}
out := make([]TrashEntryDTO, 0, len(dirEntries))
for _, entry := range dirEntries {
info, err := entry.Info()
if err != nil {
continue
}
out = append(out, TrashEntryDTO{
Name: entry.Name(),
Path: filepath.Join(trashPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
return out, nil
}
// ReadTrashFile reads a trash file by its absolute filesystem path.
// This is preferred over ReadTrashFileContent (which re-resolves by nodeID)
// because the frontend already has the precomputed trashFsPath from ListTrash.
func (a *App) ReadTrashFile(trashFsPath string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
data, err := os.ReadFile(trashFsPath)
if err != nil {
return "", err
}
return string(data), nil
}
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
path, err := a.resolveTrashPath(nodeID)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// resolveTrashPath finds the physical path of a deleted node's file in the trash.
// For directly-moved entries (directory-type nodes), it looks up <nodeID>_* in
// the trash dir. For file-type nodes it searches by file record. For nested
// files (moved inside a parent folder) it walks up the ancestor chain.
func (a *App) resolveTrashPath(nodeID string) (string, error) {
// 1. Try direct lookup first (for directory-type nodes).
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
return p, nil
}
// 2. Try file records (for TypeFile nodes whose files were individually moved).
recs, err := a.files.ListTrashedByNode(nodeID)
if err == nil {
trashDir := filepath.Join(a.vault, ".verstak", "trash")
for _, r := range recs {
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
if info, stErr := os.Stat(candidate); stErr == nil && !info.IsDir() {
return candidate, nil
}
}
}
// 3. Walk parent chain to find nearest ancestor with a direct trash entry
// (for files nested inside a deleted parent directory).
type step struct {
ID string
FsPath string
Title string
}
var chain []step
current := nodeID
for current != "" {
n, err := a.nodes.Get(current)
if err != nil {
break
}
chain = append(chain, step{ID: n.ID, FsPath: n.FsPath, Title: n.Title})
if n.ParentID != nil {
current = *n.ParentID
} else {
current = ""
}
}
for i := 0; i < len(chain); i++ {
anc := chain[i]
ancPath, err := a.findTrashEntryForNode(anc.ID)
if err != nil {
continue
}
// Path 3a: compute relative path from ancestor's FsPath to target's FsPath.
if chain[0].FsPath != "" && anc.FsPath != "" && strings.HasPrefix(chain[0].FsPath, anc.FsPath) {
rel := strings.TrimPrefix(chain[0].FsPath, anc.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
fullPath := filepath.Join(ancPath, rel)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
return fullPath, nil
}
}
}
// Path 3b: try node title as a direct child inside the ancestor dir.
candidate := filepath.Join(ancPath, chain[0].Title)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("trash file not found for node %s", nodeID)
}
func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil {
return err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return err
}
return exec.Command("xdg-open", trashPath).Run()
}

View File

@ -1,66 +0,0 @@
package main
import (
"fmt"
"verstak/internal/core/config"
"verstak/internal/core/watcher"
)
// WatcherStatus returns whether the real-time file watcher is active.
func (a *App) WatcherStatus() (bool, error) {
if err := a.requireVault(); err != nil {
return false, err
}
return a.fileWatcher.IsWatching(), nil
}
// RunSnapshotScan performs a one-shot scan and returns results.
func (a *App) RunSnapshotScan() (*watcher.SnapshotResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.fileWatcher.RunScanner()
}
// ToggleFileWatcher enables or disables the real-time file watcher.
// Changing this persists to app config (~/.config/verstak/config.json → vault.file_watcher).
//
// При включении: запускает snapshot scan (сверка диска с БД), затем включает real-time watcher.
// При отключении: останавливает fsnotify, watcher_state в БД сохраняется.
//
// Отключить watcher НАВСЕГДА (независимо от галки):
// export VERSTAK_NO_WATCHER=1
//
// Отключить на один запуск:
// verstak-gui --no-watcher
//
// Snapshot scan запускается ВСЕГДА при открытии vault, даже при FileWatcher=false.
func (a *App) ToggleFileWatcher(enable bool) error {
if err := a.requireVault(); err != nil {
return err
}
cfg, err := config.LoadAppConfig()
if err != nil || cfg == nil {
return fmt.Errorf("config: %w", err)
}
cfg.Vault.FileWatcher = config.BoolPtr(enable)
if err := config.SaveAppConfig(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
if enable {
_, err := a.fileWatcher.RunScanner()
if err != nil {
return fmt.Errorf("scan: %w", err)
}
if _, err := a.fileWatcher.Start(true); err != nil {
return fmt.Errorf("start watcher: %w", err)
}
} else {
a.fileWatcher.Stop()
}
return nil
}

View File

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

View File

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

View File

@ -1,122 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,10 +2,7 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<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" />
<link rel="icon" type="image/svg+xml" href="/wails.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Верстак</title>
<style>
@ -19,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-CzfuqGWF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
</head>
<body>
<div id="app"></div>

View File

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

View File

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

View File

@ -3,8 +3,17 @@ package main
import (
"embed"
"log"
"os"
"path/filepath"
"verstak/internal/core/config"
"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"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@ -15,12 +24,44 @@ import (
var assets embed.FS
func main() {
app := &App{}
vaultPath := "."
if len(os.Args) > 1 {
vaultPath = os.Args[1]
}
// Fix WebKit signal handler for Go 1.24+ compatibility
ensureSignalOnStack()
abs, err := filepath.Abs(vaultPath)
if err != nil {
log.Fatal(err)
}
err := wails.Run(&options.App{
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,
@ -40,7 +81,4 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Ensure config dir exists for logging/cli usage
config.EnsureConfigDir()
}

View File

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

View File

@ -1,123 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
)
// TestSetPluginEnabled_BrokenPlugin_Rollback verifies that when activation fails,
// the plugin is fully rolled back: not Active, not Enabled, and not in config.
func TestSetPluginEnabled_BrokenPlugin_Rollback(t *testing.T) {
// Isolate global config to a temp dir so we don't pollute the user's real config
tmpCfgDir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(tmpCfgDir, 0o750); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", tmpCfgDir)
vaultRoot := t.TempDir()
// Create .verstak/plugins/ structure
pluginsDir := filepath.Join(vaultRoot, ".verstak", "plugins")
if err := os.MkdirAll(pluginsDir, 0o750); err != nil {
t.Fatalf("mkdir plugins: %v", err)
}
// Create a broken plugin: valid Lua but invalid background task interval
brokenDir := filepath.Join(pluginsDir, "broken")
if err := os.MkdirAll(brokenDir, 0o750); err != nil {
t.Fatalf("mkdir broken plugin: %v", err)
}
// plugin.json with an invalid background task interval ("not-a-duration")
pluginJSON := `{
"name": "broken",
"version": "0.1.0",
"hooks": {
"on_install": "on_install",
"on_init": "on_init"
},
"background_tasks": [
{"id": "bad-task", "interval": "not-a-duration", "script": "bad.lua"}
]
}`
if err := os.WriteFile(filepath.Join(brokenDir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
t.Fatalf("write plugin.json: %v", err)
}
// main.lua — on_install is a no-op (tables not needed for this test),
// on_init is harmless. Activation will fail on the invalid background task interval.
mainLua := `
function on_install()
-- no-op
end
function on_init()
-- harmless
end
`
if err := os.WriteFile(filepath.Join(brokenDir, "main.lua"), []byte(mainLua), 0o644); err != nil {
t.Fatalf("write main.lua: %v", err)
}
// Create App with real plugin manager
app := &App{
plugins: plugins.NewManager(vaultRoot),
vault: vaultRoot,
vaultOpen: true,
}
// Discover the plugin
app.plugins.Discover()
// Step 1: Install the plugin (creates tables, marks installed in config)
if err := app.plugins.Install("broken"); err != nil {
t.Fatalf("install broken plugin: %v", err)
}
// Verify plugin is installed
if !app.plugins.IsInstalled("broken") {
t.Fatal("expected plugin to be installed")
}
// Step 2: Try to enable — should fail because background task has invalid interval
err := app.SetPluginEnabled("broken", true)
if err == nil {
t.Fatal("expected SetPluginEnabled to fail for broken plugin, got nil")
}
t.Logf("SetPluginEnabled returned expected error: %v", err)
// Step 3: Verify plugin is NOT Active and NOT Enabled (in-memory rollback)
found := false
for _, p := range app.plugins.Plugins() {
if p.Meta.Name == "broken" {
found = true
if p.Active {
t.Error("expected plugin to NOT be Active after failed activation")
}
if p.Enabled {
t.Error("expected plugin to NOT be Enabled after failed activation (in-memory rollback)")
}
}
}
if !found {
t.Fatal("plugin 'broken' not found in manager")
}
// Step 4: Verify plugin is NOT in EnabledPlugins config
appCfg, err := config.LoadAppConfig()
if err != nil {
t.Fatalf("load app config: %v", err)
}
if appCfg != nil {
for _, name := range appCfg.EnabledPlugins {
if name == "broken" {
t.Error("expected 'broken' to NOT be in EnabledPlugins config after failed activation")
}
}
}
}

View File

@ -1,26 +0,0 @@
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)
}
}

View File

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

View File

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

View File

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

View File

@ -1,457 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Trash Me", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
if err := app.DeleteNode(n.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var foundNode bool
for _, node := range trash.Nodes {
if node.ID == n.ID && node.Title == "Trash Me" && node.DeletedAt != "" {
foundNode = true
break
}
}
if !foundNode {
t.Fatalf("deleted node %s missing from trash nodes: %#v", n.ID, trash.Nodes)
}
var foundPhysical bool
for _, entry := range trash.Entries {
if strings.Contains(entry.Name, n.ID) && entry.IsDir {
foundPhysical = true
break
}
}
if !foundPhysical {
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
}
}
func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T) {
app, vault := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
child, err := app.CreateNodeFromTemplate(parent.ID, "Specs", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
other, err := app.CreateNodeFromTemplate(parent.ID, "Drafts", "folder.default")
if err != nil {
t.Fatalf("create other: %v", err)
}
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
if err := app.RestoreTrashNode(child.ID); err != nil {
t.Fatalf("RestoreTrashNode(child): %v", err)
}
for _, id := range []string{parent.ID, child.ID} {
if _, err := app.nodes.GetActive(id); err != nil {
t.Fatalf("node %s should be active after restore: %v", id, err)
}
}
if _, err := app.nodes.GetActive(other.ID); err == nil {
t.Fatalf("unselected sibling should remain deleted")
}
if _, err := os.Stat(filepath.Join(vault, "Documents", "Specs")); err != nil {
t.Fatalf("restored child path missing: %v", err)
}
}
func TestRestoreTrashNodeFromNestedDeletedFolderRestoresFullPath(t *testing.T) {
app, vault := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Verstak", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
templates, err := app.CreateNodeFromTemplate(parent.ID, "templates", "folder.default")
if err != nil {
t.Fatalf("create templates: %v", err)
}
registry, err := app.CreateNodeFromTemplate(templates.ID, "registry.go", "folder.default")
if err != nil {
t.Fatalf("create registry: %v", err)
}
other, err := app.CreateNodeFromTemplate(templates.ID, "other.go", "folder.default")
if err != nil {
t.Fatalf("create other: %v", err)
}
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
if err := app.RestoreTrashNode(registry.ID); err != nil {
t.Fatalf("RestoreTrashNode(registry): %v", err)
}
for _, id := range []string{parent.ID, templates.ID, registry.ID} {
if _, err := app.nodes.GetActive(id); err != nil {
t.Fatalf("node %s should be active after restore: %v", id, err)
}
}
if _, err := app.nodes.GetActive(other.ID); err == nil {
t.Fatalf("unselected nested sibling should remain deleted")
}
if _, err := os.Stat(filepath.Join(vault, "Verstak", "templates", "registry.go")); err != nil {
t.Fatalf("restored nested path missing: %v", err)
}
}
func TestTrashCountPurgeAndEmpty(t *testing.T) {
app, _ := setupTestApp(t)
a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default")
b, _ := app.CreateNodeFromTemplate("", "Trash B", "folder.default")
if err := app.DeleteNode(a.ID); err != nil {
t.Fatalf("delete A: %v", err)
}
if err := app.DeleteNode(b.ID); err != nil {
t.Fatalf("delete B: %v", err)
}
count, err := app.TrashCount()
if err != nil {
t.Fatalf("TrashCount: %v", err)
}
if count != 4 {
t.Fatalf("TrashCount = %d, want 4 (2 folders + 2 Notes children)", count)
}
if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil {
t.Fatalf("PurgeTrashNodesJSON: %v", err)
}
count, _ = app.TrashCount()
if count != 2 {
t.Fatalf("TrashCount after purge = %d, want 2 (1 folder + 1 Notes child)", count)
}
if err := app.EmptyTrash(); err != nil {
t.Fatalf("EmptyTrash: %v", err)
}
count, _ = app.TrashCount()
if count != 0 {
t.Fatalf("TrashCount after empty = %d, want 0", count)
}
}
func TestTrashTypeFilePreviewAndRestore(t *testing.T) {
app, vault := setupTestApp(t)
// Create a folder to hold the file.
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
// Create a TypeFile node with a file record.
fileNode, err := app.CreateEmptyFile(parent.ID, "hello.txt")
if err != nil {
t.Fatalf("CreateEmptyFile: %v", err)
}
// Write some content to the physical file via file records.
recs, err := app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
}
record := recs[0]
absPath := filepath.Join(vault, record.Path)
content := "Hello, World!"
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Delete the entire tree.
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
// Verify trash listing has the file node with trashFsPath set.
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var fileTrashNode *TrashNodeDTO
for i, n := range trash.Nodes {
if n.ID == fileNode.ID {
fileTrashNode = &trash.Nodes[i]
break
}
}
if fileTrashNode == nil {
t.Fatalf("file node not found in trash listing")
}
if fileTrashNode.TrashFsPath == "" {
t.Fatalf("file node missing trashFsPath: %+v", fileTrashNode)
}
// Verify ReadTrashFile works with the precomputed path.
readContent, err := app.ReadTrashFile(fileTrashNode.TrashFsPath)
if err != nil {
t.Fatalf("ReadTrashFile: %v", err)
}
if readContent != content {
t.Fatalf("ReadTrashFile content = %q, want %q", readContent, content)
}
// Verify ReadTrashFileContent also works (fallback using file records).
readContent2, err := app.ReadTrashFileContent(fileNode.ID)
if err != nil {
t.Fatalf("ReadTrashFileContent: %v", err)
}
if readContent2 != content {
t.Fatalf("ReadTrashFileContent = %q, want %q", readContent2, content)
}
// Verify trashed file records are still in DB.
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListTrashedByNode: %v", err)
}
if len(trashedRecs) != 1 {
t.Fatalf("ListTrashedByNode = %d records, want 1", len(trashedRecs))
}
if !trashedRecs[0].Missing {
t.Fatalf("expected trashed record to have Missing=true")
}
if trashedRecs[0].Path == "" {
t.Fatalf("expected trashed record to keep original Path")
}
// Restore the file node.
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
t.Fatalf("RestoreTrashNode: %v", err)
}
// Verify the node is active.
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
t.Fatalf("file node not active after restore: %v", err)
}
// Verify the file record is restored (missing=0).
restoredRecs, err := app.files.ListByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListByNode after restore: %v", err)
}
if len(restoredRecs) != 1 {
t.Fatalf("ListByNode after restore = %d records, want 1", len(restoredRecs))
}
if restoredRecs[0].Missing {
t.Fatalf("file record should have Missing=false after restore")
}
// Verify the physical file content is intact.
absRestored := filepath.Join(vault, restoredRecs[0].Path)
restoredBytes, err := os.ReadFile(absRestored)
if err != nil {
t.Fatalf("read restored file: %v", err)
}
if string(restoredBytes) != content {
t.Fatalf("restored file content = %q, want %q", string(restoredBytes), content)
}
// Verify the trash entry is gone (file was moved back).
if _, err := os.Stat(fileTrashNode.TrashFsPath); !os.IsNotExist(err) {
t.Fatalf("trash entry should be gone after restore, err=%v", err)
}
// Parent is also restored because RestoreTrashNode restores the entire
// ancestor chain from the requested node up to the root deleted node.
if _, err := app.nodes.GetActive(parent.ID); err != nil {
t.Fatalf("parent should be active after child restore (ancestor chain): %v", err)
}
}
func TestTrashTypeFileInsideFolderRestorePreservesContent(t *testing.T) {
app, vault := setupTestApp(t)
// Create: parent folder → child TypeFile.
parent, err := app.CreateNodeFromTemplate("", "ProjectX", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
fileNode, err := app.CreateEmptyFile(parent.ID, "data.csv")
if err != nil {
t.Fatalf("CreateEmptyFile: %v", err)
}
// Write content.
recs, err := app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
}
absPath := filepath.Join(vault, recs[0].Path)
content := "a,b,c\n1,2,3"
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Delete the whole tree.
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
// Restore parent. This moves the directory back from trash but does NOT
// restore child nodes (RestoreTrashNode walks ancestor chain, not children).
if err := app.RestoreTrashNode(parent.ID); err != nil {
t.Fatalf("RestoreTrashNode parent: %v", err)
}
// Only the parent should be active.
if _, err := app.nodes.GetActive(parent.ID); err != nil {
t.Fatalf("parent should be active: %v", err)
}
if _, err := app.nodes.GetActive(fileNode.ID); err == nil {
t.Fatalf("child file node should remain deleted (RestoreTrashNode is ancestor-only)")
}
// Parent directory should exist on disk.
if _, err := os.Stat(filepath.Join(vault, "ProjectX")); err != nil {
t.Fatalf("parent directory should exist: %v", err)
}
// Now restore the child file node specifically.
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
t.Fatalf("RestoreTrashNode file: %v", err)
}
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
t.Fatalf("file node should be active: %v", err)
}
// File record should be restored.
recs, err = app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode after restore: %v (len=%d)", err, len(recs))
}
if recs[0].Missing {
t.Fatalf("file record should not be missing after restore")
}
// Physical file content should be intact.
absRestored := filepath.Join(vault, recs[0].Path)
restoredBytes, err := os.ReadFile(absRestored)
if err != nil {
t.Fatalf("read restored file: %v", err)
}
if string(restoredBytes) != content {
t.Fatalf("content = %q, want %q", string(restoredBytes), content)
}
}
func TestTrashTypeFileMultipleRecords(t *testing.T) {
app, vault := setupTestApp(t)
// Create a TypeFile node with two file records.
fileNode, err := app.CreateEmptyFile("", "report.txt")
if err != nil {
t.Fatalf("CreateEmptyFile: %v", err)
}
// Write content to first record.
recs, err := app.files.ListByNode(fileNode.ID)
if err != nil || len(recs) == 0 {
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
}
absPath1 := filepath.Join(vault, recs[0].Path)
content1 := "version 1"
if err := os.WriteFile(absPath1, []byte(content1), 0o644); err != nil {
t.Fatalf("write file 1: %v", err)
}
// Manually insert a second file record with its own vault file.
now := nowStr()
absPath2 := filepath.Join(vault, "report-v2.txt")
content2 := "version 2"
if err := os.WriteFile(absPath2, []byte(content2), 0o644); err != nil {
t.Fatalf("write file 2: %v", err)
}
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
"second-record-id", fileNode.ID, "report-v2.txt",
"report-v2.txt", "vault", 0, "", "text/plain", now, now)
if err != nil {
t.Fatalf("insert second record: %v", err)
}
// Delete the node.
if err := app.DeleteNode(fileNode.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
// Verify both records are trashed.
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListTrashedByNode: %v", err)
}
if len(trashedRecs) != 2 {
t.Fatalf("expected 2 trashed records, got %d", len(trashedRecs))
}
for _, r := range trashedRecs {
if !r.Missing {
t.Fatalf("record %s should have Missing=true", r.ID)
}
}
// Verify trash listing has trashFsPath set.
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var found bool
for _, n := range trash.Nodes {
if n.ID == fileNode.ID {
found = true
if n.TrashFsPath == "" {
t.Fatalf("trashFsPath should be set for file node with multiple records")
}
break
}
}
if !found {
t.Fatalf("file node not found in trash listing")
}
// Restore.
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
t.Fatalf("RestoreTrashNode: %v", err)
}
// Both records should be restored.
restoredRecs, err := app.files.ListByNode(fileNode.ID)
if err != nil {
t.Fatalf("ListByNode after restore: %v", err)
}
if len(restoredRecs) != 2 {
t.Fatalf("expected 2 restored records, got %d: %+v", len(restoredRecs), restoredRecs)
}
for _, r := range restoredRecs {
if r.Missing {
t.Fatalf("record %s should not be missing", r.ID)
}
}
}

View File

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

View File

@ -1,462 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/nodes"
"verstak/internal/core/notes"
)
// --- Files tab / ListItems tests for Notes folder ---
// TestFileManagerListItemsShowsNotesFolder verifies that ListItems on a
// container returns the Notes folder, matching what the Files tab UI shows.
func TestFileManagerListItemsShowsNotesFolder(t *testing.T) {
app, _ := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// ListItems is what the Files tab actually calls
items, err := app.ListItems(proj.ID)
if err != nil {
t.Fatalf("ListItems: %v", err)
}
var foundNotes bool
for _, item := range items {
if item.Name == notes.NotesFolder && item.Type == "folder" {
foundNotes = true
break
}
}
if !foundNotes {
t.Errorf("ListItems(%q) should contain Notes folder, got %d items", proj.ID, len(items))
for _, item := range items {
t.Logf(" %s (type=%s)", item.Name, item.Type)
}
}
}
// TestFileManagerListItemsInsideNotesShowsOverview verifies that ListItems
// on the Notes folder returns the Overview note.
func TestFileManagerListItemsInsideNotesShowsOverview(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find Notes folder
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
// ListItems inside Notes folder
items, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems(Notes folder): %v", err)
}
var foundOverview bool
for _, item := range items {
if item.Name == "Overview" && item.Type == "note" {
foundOverview = true
if item.FileID == "" {
t.Error("Overview note has empty FileID")
}
if item.Mime == "" {
t.Error("Overview note has empty Mime")
}
break
}
}
if !foundOverview {
t.Errorf("ListItems(Notes) should contain Overview note, got %d items", len(items))
for _, item := range items {
t.Logf(" %s (type=%s)", item.Name, item.Type)
}
}
// Verify no root-level Overview.md on disk
rootPath := filepath.Join(vault, proj.FsPath, "Overview.md")
if _, err := os.Stat(rootPath); err == nil {
t.Error("Overview.md should NOT exist at root level, only in Notes/")
}
// Verify Notes/Overview.md exists on disk
notesPath := filepath.Join(vault, proj.FsPath, notes.NotesFolder, "Overview.md")
if _, err := os.Stat(notesPath); os.IsNotExist(err) {
t.Error("Overview.md should exist at Notes/Overview.md")
}
}
// TestRepairMovesDirectNoteChildrenToNotesFolder verifies that a note
// created as a direct child of a container (old layout) is moved into
// the Notes folder by RepairNotesLayout, and that ListItems then shows
// the note under Notes/ not as a direct child.
func TestRepairMovesDirectNoteChildrenToNotesFolder(t *testing.T) {
app, vault := setupTestApp(t)
// Create a container that supports notes
parent, err := app.CreateNodeFromTemplate("", "TestCase", "project.default")
if err != nil {
t.Fatalf("create container: %v", err)
}
// Simulate old layout: create TypeNote as direct child (not under Notes/)
noteNode, err := app.nodes.Create(&parent.ID, nodes.TypeNote, "LegacyNote", 0, "", "")
if err != nil {
t.Fatalf("create legacy note: %v", err)
}
// Write a physical file so repair has something to fix
noteDir := filepath.Join(vault, parent.FsPath)
if err := os.MkdirAll(noteDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
oldPath := filepath.Join(noteDir, "LegacyNote.md")
if err := os.WriteFile(oldPath, []byte("# Legacy Content\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
relPath, _ := filepath.Rel(vault, oldPath)
fileID := "repair-file-" + noteNode.ID
_, _ = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/plain','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
fileID, noteNode.ID, "LegacyNote.md", relPath)
_, _ = app.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
noteNode.ID, fileID, "markdown")
// Verify the note is a direct child before repair
beforeChildren, err := app.nodes.ListChildren(parent.ID, false)
if err != nil {
t.Fatalf("ListChildren before repair: %v", err)
}
var foundDirect bool
for _, c := range beforeChildren {
if c.ID == noteNode.ID {
foundDirect = true
break
}
}
if !foundDirect {
t.Fatal("legacy note should be a direct child before repair")
}
// Run repair
result, err := app.notes.RepairNotesLayout()
if err != nil {
t.Fatalf("RepairNotesLayout: %v", err)
}
if result.RepairedNotes == 0 {
t.Errorf("expected at least 1 repaired note, got 0")
}
// After repair: note should NOT be a direct child of parent
afterChildren, err := app.nodes.ListChildren(parent.ID, false)
if err != nil {
t.Fatalf("ListChildren after repair: %v", err)
}
for _, c := range afterChildren {
if c.ID == noteNode.ID {
t.Errorf("note should no longer be a direct child after repair")
break
}
}
// After repair: note should be inside Notes folder
// ListItems(parent.ID) should NOT show the note directly
parentItems, err := app.ListItems(parent.ID)
if err != nil {
t.Fatalf("ListItems(parent) after repair: %v", err)
}
for _, item := range parentItems {
if item.ID == noteNode.ID {
t.Errorf("ListItems(parent) should not show note directly after repair")
break
}
}
// But ListItems(Notes) should show it
var notesFolder *nodes.Node
for i := range afterChildren {
if afterChildren[i].Title == notes.NotesFolder && afterChildren[i].Type == "folder" {
notesFolder = &afterChildren[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder should exist after repair")
}
notesItems, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems(Notes) after repair: %v", err)
}
var foundInNotes bool
for _, item := range notesItems {
if item.ID == noteNode.ID && item.Type == "note" {
foundInNotes = true
break
}
}
if !foundInNotes {
t.Errorf("ListItems(Notes) should show the repaired note, got %d items", len(notesItems))
for _, item := range notesItems {
t.Logf(" %s (type=%s)", item.Name, item.Type)
}
}
// Verify node parent was updated
note, err := app.nodes.Get(noteNode.ID)
if err != nil {
t.Fatalf("get note node after repair: %v", err)
}
if note.ParentID == nil || *note.ParentID != notesFolder.ID {
t.Errorf("note.ParentID should be Notes folder (%s), got %v", notesFolder.ID, note.ParentID)
}
// Verify file path was updated
recs, err := app.files.ListByNode(noteNode.ID)
if err != nil {
t.Fatalf("ListByNode after repair: %v", err)
}
if len(recs) == 0 {
t.Fatal("file record should exist after repair")
}
expectedRelPath := filepath.Join(parent.FsPath, notes.NotesFolder, "LegacyNote.md")
if recs[0].Path != expectedRelPath {
t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path)
}
}
// TestCheckFileAction_NoteLinked verifies that CheckFileAction returns
// Action="note" for a .md file linked to a note record.
func TestCheckFileAction_NoteLinked(t *testing.T) {
app, _ := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find the Overview note — it should be inside Notes folder, linked via notes record
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
notesChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
if err != nil {
t.Fatalf("ListChildren(Notes): %v", err)
}
if len(notesChildren) == 0 {
t.Fatal("expected at least one note inside Notes folder")
}
// Get file ID for the Overview note
items, err := app.ListItems(notesFolder.ID)
if err != nil {
t.Fatalf("ListItems: %v", err)
}
var overviewFileID string
for _, item := range items {
if item.Type == "note" && item.Name == "Overview" {
overviewFileID = item.FileID
break
}
}
if overviewFileID == "" {
t.Fatal("Overview note has no FileID")
}
// CheckFileAction should return Action="note"
action, err := app.CheckFileAction(overviewFileID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "note" {
t.Errorf("expected Action=note for linked .md, got %q", action.Action)
}
if action.NoteID == "" {
t.Error("expected non-empty NoteID for linked note")
}
if action.NoteTitle == "" {
t.Error("expected non-empty NoteTitle")
}
if action.FileName == "" {
t.Error("expected non-empty FileName")
}
}
// TestCheckFileAction_ExternalForNonMD verifies that non-.md files return
// Action="external" from CheckFileAction.
func TestCheckFileAction_ExternalForNonMD(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a file node and record for a non-.md file
fileNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "image.png", 0, "", filepath.Join(proj.FsPath, "image.png"))
if err != nil {
t.Fatalf("create file node: %v", err)
}
absPath := filepath.Join(vault, proj.FsPath, "image.png")
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(absPath, []byte("fake-png"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record directly
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','image/png','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-png-"+fileNode.ID, fileNode.ID, "image.png", filepath.Join(proj.FsPath, "image.png"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// CheckFileAction should return Action="external"
action, err := app.CheckFileAction("file-png-" + fileNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "external" {
t.Errorf("expected Action=external for .png, got %q", action.Action)
}
if action.FileName != "image.png" {
t.Errorf("expected FileName=image.png, got %q", action.FileName)
}
}
// TestCheckFileAction_PreviewForMDOutsideNotes verifies that .md files
// outside Notes/ without a note record return Action="preview".
func TestCheckFileAction_PreviewForMDOutsideNotes(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Create a .md file directly under the project (not inside Notes/)
mdNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "readme.md", 0, "", filepath.Join(proj.FsPath, "readme.md"))
if err != nil {
t.Fatalf("create md node: %v", err)
}
absPath := filepath.Join(vault, proj.FsPath, "readme.md")
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(absPath, []byte("# Readme\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record directly
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-md-"+mdNode.ID, mdNode.ID, "readme.md", filepath.Join(proj.FsPath, "readme.md"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// Do NOT create a notes record — this .md is outside Notes/
action, err := app.CheckFileAction("file-md-" + mdNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "preview" {
t.Errorf("expected Action=preview for .md outside Notes/, got %q", action.Action)
}
}
func TestCheckFileAction_AutoLinkInNotes(t *testing.T) {
app, vault := setupTestApp(t)
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
if err != nil {
t.Fatalf("create project: %v", err)
}
// Find Notes folder
children, err := app.nodes.ListChildren(proj.ID, false)
if err != nil {
t.Fatalf("ListChildren: %v", err)
}
var notesFolder *nodes.Node
for i := range children {
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
notesFolder = &children[i]
break
}
}
if notesFolder == nil {
t.Fatal("Notes folder not found")
}
// Create a .md file INSIDE Notes/ but WITHOUT a notes record
mdNode, err := app.nodes.Create(&notesFolder.ID, nodes.TypeFile, "orphan.md", 0, "",
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
if err != nil {
t.Fatalf("create md node: %v", err)
}
notesDir := filepath.Join(vault, proj.FsPath, notes.NotesFolder)
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
absPath := filepath.Join(notesDir, "orphan.md")
if err := os.WriteFile(absPath, []byte("# Orphan Note\n"), 0o640); err != nil {
t.Fatalf("write file: %v", err)
}
// Insert file record
_, err = app.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
"file-orphan-"+mdNode.ID, mdNode.ID, "orphan.md",
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
if err != nil {
t.Fatalf("insert file record: %v", err)
}
// No notes record yet — just file + node
// CheckFileAction should auto-link and return Action="note"
action, err := app.CheckFileAction("file-orphan-" + mdNode.ID)
if err != nil {
t.Fatalf("CheckFileAction: %v", err)
}
if action.Action != "note" {
t.Errorf("expected Action=note for .md inside Notes/, got %q", action.Action)
}
if action.NoteID == "" {
t.Error("expected auto-linked NoteID")
}
if action.NoteTitle == "" {
t.Error("expected NoteTitle for auto-linked note")
}
// Verify notes record was actually created
noteRec, err := app.notes.FindByFileID("file-orphan-" + mdNode.ID)
if err != nil {
t.Fatalf("FindByFileID after auto-link: %v", err)
}
if noteRec == nil {
t.Fatal("expected note record after auto-link")
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,336 +0,0 @@
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,131 +0,0 @@
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)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -8,11 +8,9 @@ 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"
)
@ -40,8 +38,6 @@ 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:
@ -60,7 +56,6 @@ 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")
}
@ -602,170 +597,6 @@ 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) {

View File

@ -18,11 +18,7 @@ func runNodeCreate(vault, parentID, typ, title string) error {
defer db.Close()
repo := nodes.NewRepository(db)
var pid *string
if parentID != "" {
pid = &parentID
}
n, err := repo.Create(pid, typ, title, 0, "", "")
n, err := repo.Create(parentID, typ, title, "")
if err != nil {
return err
}
@ -69,7 +65,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)
}
@ -91,11 +87,7 @@ func runNodeMove(vault, id, parentID string, sortOrder int) error {
defer db.Close()
repo := nodes.NewRepository(db)
var pid *string
if parentID != "" {
pid = &parentID
}
if err := repo.Move(id, pid, sortOrder); err != nil {
if err := repo.Move(id, parentID, sortOrder); err != nil {
return err
}
fmt.Println("moved")

View File

@ -1,781 +0,0 @@
--[[
Calendar plugin for Verstak reference plugin demonstrating the full plugin API.
Covers: verstak.db.* / config.* / state.* / node.* / worklog.* / activity.* / schedule.* / http.* / ui.*
]]
--------------------------------------------------------------------------------
-- Module table — internal
--------------------------------------------------------------------------------
local M = {}
-- Safe wrapper for optional service calls (avoids pcall boilerplate)
local function safe_log(fn, ...)
pcall(fn, ...)
end
-- ID generation
local function uuid()
local f = function() return math.random(0, 16777215) end
local p = string.format
return p("%04x%04x-%04x-%04x-%04x-%06x%06x",
f(), f(), f(), f(), f(), f(), f())
end
math.randomseed(os.time())
-- Current timestamp ISO8601
local function now()
return os.date("%Y-%m-%dT%H:%M:%S")
end
local function today()
return os.date("%Y-%m-%d")
end
--------------------------------------------------------------------------------
-- Default categories
--------------------------------------------------------------------------------
local DEFAULT_CATEGORIES = {
{ name = "Работа", color = "#3b82f6", icon = "💼", sort_order = 1 },
{ name = "Личное", color = "#10b981", icon = "🏠", sort_order = 2 },
{ name = "Встреча", color = "#8b5cf6", icon = "🤝", sort_order = 3 },
{ name = "Дедлайн", color = "#ef4444", icon = "🔥", sort_order = 4 },
{ name = "Здоровье", color = "#f59e0b", icon = "💪", sort_order = 5 },
{ name = "Звонок", color = "#06b6d4", icon = "📞", sort_order = 6 },
}
--------------------------------------------------------------------------------
-- Verstak.config — load/store default categories
--------------------------------------------------------------------------------
function M.ensure_categories()
local rows = verstak.db.query("SELECT COUNT(*) as cnt FROM categories WHERE deleted = 0")
local n = 0
if rows and #rows > 0 then
for _, v in pairs(rows[1]) do
if type(v) == "number" then n = v; break end
end
end
if n > 0 then return end
for _, cat in ipairs(DEFAULT_CATEGORIES) do
local id = uuid()
verstak.db.exec(
"INSERT INTO categories (id, name, color, icon, sort_order) VALUES (?, ?, ?, ?, ?)",
id, cat.name, cat.color, cat.icon, cat.sort_order
)
-- Activity log for each category
safe_log(verstak.activity.log,"category_created", "Категория: " .. cat.name, id, "")
end
-- Save as config so user can restore defaults
local cfg = verstak.config.get("categories") or {}
if next(cfg) == nil then
verstak.config.set("categories", DEFAULT_CATEGORIES)
end
end
--------------------------------------------------------------------------------
-- Categories CRUD (verstak.db.* demo)
--------------------------------------------------------------------------------
-- Get all non-deleted categories
function M.get_categories()
print("get_categories called")
return verstak.db.query(
"SELECT id, name, color, icon, sort_order FROM categories WHERE deleted = 0 ORDER BY sort_order"
)
end
-- Get all categories including deleted
function M.get_categories_all()
return verstak.db.query(
"SELECT id, name, color, icon, sort_order, deleted FROM categories ORDER BY sort_order"
)
end
-- Create a new category
function M.create_category(name, color, icon)
if not name or name == "" then error("category name required") end
local id = uuid()
verstak.db.exec(
"INSERT INTO categories (id, name, color, icon) VALUES (?, ?, ?, ?)",
id, name, color or "#6b7280", icon or "📌"
)
safe_log(verstak.activity.log,"category_created", "Категория: " .. name, id, "")
return id
end
-- Update a category
function M.update_category(id, fields)
if not id then error("category id required") end
local old = verstak.db.query_row("SELECT name FROM categories WHERE id = ?", id)
if not old then error("category not found: " .. id) end
verstak.db.exec(
"UPDATE categories SET name = ?, color = ?, icon = ?, sort_order = ?, updated_at = ? WHERE id = ?",
fields.name or old.name,
fields.color or "#6b7280",
fields.icon or "📌",
fields.sort_order or 0,
now(),
id
)
safe_log(verstak.activity.log,"category_updated", "Категория: " .. (fields.name or old.name), id, "")
return true
end
-- Soft-delete a category (keeps history)
function M.delete_category(id)
if not id then error("category id required") end
verstak.db.exec("UPDATE categories SET deleted = 1, updated_at = ? WHERE id = ?", now(), id)
safe_log(verstak.activity.log,"category_deleted", "Категория удалена: " .. id, id, "")
return true
end
-- Restore default categories
function M.restore_default_categories()
verstak.db.exec("UPDATE categories SET deleted = 1")
M.ensure_categories()
return true
end
--------------------------------------------------------------------------------
-- Events CRUD (verstak.db.* + verstak.state.* demo)
--------------------------------------------------------------------------------
-- Get events within a date range (inclusive)
function M.get_events(params, end_date)
-- Debug: log what we received
print("get_events: params type=" .. type(params) .. ", end_date type=" .. type(end_date))
if type(params) == "table" then
for k, v in pairs(params) do
print(" params[" .. tostring(k) .. "] type=" .. type(v))
end
end
-- Backward compat: support positional (start, end) and table {start=, end=}
if type(params) == "string" then
print("get_events: backward compat path, end_date=" .. tostring(end_date))
local e = end_date
if type(e) ~= "string" then e = nil end
return M.get_events{ start_date = params, ["end"] = e or params }
end
local start_date = params.start_date or params.start
if type(start_date) ~= "string" then print("WARN get_events start_date not string: " .. type(start_date)); start_date = tostring(start_date) end
local end_date = params["end"] or params.end_date or params.end_date
if type(end_date) ~= "string" then print("WARN get_events end_date not string: " .. type(end_date)); end_date = start_date end
if not start_date then error("start_date required") end
if not end_date then end_date = start_date end
return verstak.db.query(
[[SELECT e.id, e.title, e.description,
e.start, e.end, e.all_day,
e.category_id, e.color,
e.node_id, e.link_type,
e.recurring_rule, e.reminder_minutes,
e.completed, e.source_series,
e.created_at, e.updated_at,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM events e
LEFT JOIN categories c ON e.category_id = c.id AND c.deleted = 0
WHERE (e.start >= ? AND e.start <= ?)
OR (e.end >= ? AND e.end <= ?)
OR (e.start <= ? AND e.end >= ?)
ORDER BY e.start
]],
start_date, end_date, start_date, end_date, start_date, end_date
)
end
-- Get events for a specific day
function M.get_events_day(params)
local date_str = params.date or params
if type(date_str) ~= "string" then date_str = tostring(date_str) end
return M.get_events({ start_date = date_str .. "T00:00:00", end_date = date_str .. "T23:59:59" })
end
-- Get event by ID
function M.get_event(params)
local id = params.id or params
if not id then error("event id required") end
return verstak.db.query_row(
"SELECT * FROM events WHERE id = ?", id
)
end
-- Create a single event (base event for recurrences)
-- Already accepts a single table — no change needed
function M.create_event(opts)
opts = opts or {}
if not opts.title or opts.title == "" then error("event title required") end
if not opts.start then error("event start datetime required") end
local id = uuid()
local e_start = opts.start
local e_end = opts["end"] or e_start
local cat_id = opts.category_id or ""
local color = opts.color or "#6b7280"
-- Resolve color from category if not set
if color == "" and cat_id ~= "" then
local cat = verstak.db.query_row("SELECT color FROM categories WHERE id = ?", cat_id)
if cat then color = cat.color end
end
local recurring_json = nil
if opts.recurring then
recurring_json = verstak.state.get("rr:" .. id)
if not recurring_json then
recurring_json = opts.recurring
end
end
local reminder = "[]"
if opts.reminder_minutes then
reminder = "[" .. table.concat(opts.reminder_minutes, ",") .. "]"
end
verstak.db.exec(
[[INSERT INTO events (id, title, description, start, end, all_day,
category_id, color, node_id, link_type, recurring_rule, reminder_minutes,
completed, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)]],
id, opts.title, opts.description or "", e_start, e_end,
opts.all_day and 1 or 0,
cat_id, color,
opts.node_id or "", opts.link_type or "node",
recurring_json, reminder,
now(), now()
)
safe_log(verstak.activity.log,"event_created",
"Событие: " .. opts.title, id, opts.node_id or "")
-- Link to worklog if minutes provided
if opts.minutes and opts.node_id and opts.node_id ~= "" then
local ok, err = pcall(verstak.worklog.add, opts.node_id, opts.title, opts.minutes)
if not ok then
print("Calendar: worklog.add error: " .. tostring(err))
end
end
return id
end
-- Update an event (partial fields)
function M.update_event(params, fields)
-- Backward compat: support positional (id, fields) and table { id = ..., ... }
if type(params) == "string" then
local t = { id = params }
if fields then
for k, v in pairs(fields) do t[k] = v end
end
return M.update_event(t)
end
local id = params.id
if not id then error("event id required") end
local old = verstak.db.query_row("SELECT * FROM events WHERE id = ?", id)
if not old then error("event not found: " .. id) end
local set_clauses = {}
local sql_params = {}
-- Build dynamic update
local mutable = {
title = true, description = true, start = true, ["end"] = true,
all_day = true, category_id = true, color = true,
node_id = true, link_type = true, reminder_minutes = true,
completed = true
}
for k, v in pairs(params) do
if k ~= "id" and mutable[k] then
if k == "all_day" or k == "completed" then
v = v and 1 or 0
end
table.insert(set_clauses, k .. " = ?")
table.insert(sql_params, v)
end
end
table.insert(sql_params, now())
table.insert(sql_params, id)
if #set_clauses > 0 then
verstak.db.exec(
"UPDATE events SET " .. table.concat(set_clauses, ", ") .. ", updated_at = ? WHERE id = ?",
unpack(sql_params)
)
end
safe_log(verstak.activity.log,"event_updated",
"Событие обновлено: " .. (params.title or old.title or id), id, old.node_id or "")
return true
end
-- Delete an event
function M.delete_event(params)
local id = params.id or params
if not id or type(id) ~= "string" then error("event id required") end
local old = verstak.db.query_row("SELECT title, node_id FROM events WHERE id = ?", id)
if not old then return true end
verstak.db.exec("DELETE FROM events WHERE id = ?", id)
safe_log(verstak.activity.log,"event_deleted",
"Событие удалено: " .. (old.title or id), id, old.node_id or "")
return true
end
-- Delete ALL events (for testing/cache clear)
function M.clear_events()
verstak.db.exec("DELETE FROM events")
safe_log(verstak.activity.log,"events_cleared", "Все события удалены", "", "")
return true
end
--------------------------------------------------------------------------------
-- Recurrence (verstak.state.* for ex_dates cache)
--------------------------------------------------------------------------------
-- Parse an ISO date string
local function parse_date(s)
if not s then return nil end
local y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
if not y then return nil end
return { year = tonumber(y), month = tonumber(m), day = tonumber(d) }
end
local function date_to_epoch(t)
return os.time({ year = t.year, month = t.month, day = t.day, hour = 0, sec = 0 })
end
-- day-of-week: Mon=1..Sun=7
local function dow(t)
return os.date("*t", date_to_epoch(t)).wday
-- os.date().wday: Sun=1, Mon=2... → we convert
end
local function to_iso(t)
return string.format("%04d-%02d-%02dT00:00:00", t.year, t.month, t.day)
end
-- Expand a recurring event into concrete dates within a range
local function expand_recurring(base_start, base_end, rule, range_start, range_end)
rule = rule or {}
local freq = rule.freq or "weekly"
local interval = rule.interval or 1
local until_date = rule["until"]
local max_count = rule.count or 52
local by_day = rule.by_day or {}
local by_month_day = rule.by_month_day or {}
local by_month = rule.by_month or {}
local ex_dates_set = {}
if rule.ex_dates then
for _, d in ipairs(rule.ex_dates) do ex_dates_set[d] = true end
end
local start_t = parse_date(base_start)
local range_start_t = parse_date(range_start)
local range_end_t = parse_date(range_end)
if not start_t or not range_start_t or not range_end_t then return {} end
local results = {}
local count = 0
local max_iterations = 365 * 3
local iter = 0
local current = { year = start_t.year, month = start_t.month, day = start_t.day }
local current_epoch = date_to_epoch(current)
local range_start_epoch = date_to_epoch(range_start_t)
local range_end_epoch = date_to_epoch(range_end_t)
local until_epoch
if until_date then
local ut = parse_date(until_date)
if ut then until_epoch = date_to_epoch(ut) end
end
local function check_matches()
if ex_dates_set[to_iso(current)] then
return false
end
if freq == "daily" then return true end
if freq == "weekly" then
if #by_day == 0 then return true end
local wd = os.date("*t", current_epoch).wday
local our_wd = (wd == 1) and 7 or (wd - 1)
for _, d in ipairs(by_day) do
if d == our_wd then return true end
end
return false
end
if freq == "monthly" then
if #by_month_day == 0 then return true end
for _, d in ipairs(by_month_day) do
if d == current.day then return true end
end
return false
end
if freq == "yearly" then
local month_ok = (#by_month == 0)
if not month_ok then
for _, m in ipairs(by_month) do
if m == current.month then month_ok = true; break end
end
end
if not month_ok then return false end
if #by_month_day == 0 then return true end
for _, d in ipairs(by_month_day) do
if d == current.day then return true end
end
return false
end
return false
end
local function advance()
current_epoch = current_epoch + 86400 * interval
current = parse_date(os.date("%Y-%m-%d", current_epoch))
end
while count < max_count and iter < max_iterations do
iter = iter + 1
if check_matches() then
count = count + 1
local iso = to_iso(current)
if current_epoch >= range_start_epoch and current_epoch <= range_end_epoch then
table.insert(results, iso)
end
end
advance()
if until_epoch and current_epoch > until_epoch then break end
if current_epoch > range_end_epoch and iter > 7 then break end
end
return results
end
M.expand_recurring = expand_recurring
-- Get all events (flat + expanded) for a range — used by the panel
function M.get_expanded_events(params)
local start_date = params.start_date or params.start
local end_date = params.end_date or params["end"]
local base_events = verstak.db.query(
[[SELECT * FROM events WHERE recurring_rule IS NOT NULL AND recurring_rule != ''
AND completed = 0]]
)
local expanded = {}
for _, ev in ipairs(base_events) do
local rule
if type(ev.recurring_rule) == "string" then
-- Try to load as JSON (table) — for Lua demo we store as JSON string
-- In our case it's already a table since we stored via verstak.state
rule = verstak.state.get("rr:" .. ev.id)
end
if not rule then rule = {} end -- fallback: no rule, just use as-is
local dates = M.expand_recurring(ev.start, ev["end"], rule, start_date, end_date)
for _, d in ipairs(dates) do
-- Create instance copy
local instance = {}
for k, v in pairs(ev) do instance[k] = v end
instance.id = ev.id .. "_" .. d:gsub("-", "")
instance.start = d
instance["end"] = d
instance.is_recurring = true
instance.base_id = ev.id
table.insert(expanded, instance)
end
end
return expanded
end
-- Get all events (flat + expanded) for a range — used by the panel
function M.get_calendar_events(params)
-- 1. Normal events
local normal = M.get_events(params)
-- 2. Expanded recurring
local recur = M.get_expanded_events(params)
-- Merge
local all = {}
for _, e in ipairs(normal) do table.insert(all, e) end
for _, e in ipairs(recur) do table.insert(all, e) end
return all
end
--------------------------------------------------------------------------------
-- Node integration (verstak.node.* + verstak.worklog.* demo)
--------------------------------------------------------------------------------
-- Create an event linked to a Verstak node
function M.create_event_from_node(node_id, date_str, fields)
if not node_id then error("node_id required") end
local node = verstak.node.get(node_id)
if not node then error("node not found: " .. node_id) end
fields = fields or {}
if not fields.title then fields.title = "📎 " .. (node.title or "Без названия") end
if not fields.start then fields.start = date_str or today() .. "T09:00:00" end
fields.node_id = node_id
fields.link_type = "node"
local new_id = M.create_event(fields)
safe_log(verstak.activity.log,"event_from_node",
"Событие из узла: " .. fields.title, new_id, node_id)
return new_id
end
-- Open linked node from event (called when user clicks on event with node_id)
function M.open_event_node(event_id)
local ev = M.get_event(event_id)
if not ev or not ev.node_id or ev.node_id == "" then
return nil, "no linked node"
end
local node = verstak.node.get(ev.node_id)
if not node then return nil, "node not found" end
-- Navigate in Verstak
pcall(verstak.ui.navigate_to, "node:" .. ev.node_id)
return true
end
-- Log work for an event and link to node
function M.log_work_for_event(event_id, minutes)
local ev = M.get_event(event_id)
if not ev then error("event not found") end
if not ev.node_id or ev.node_id == "" then
error("event has no linked node — create link first")
end
local ok, result = pcall(verstak.worklog.add, ev.node_id, ev.title, minutes)
if not ok then error("worklog.add failed: " .. tostring(result)) end
safe_log(verstak.activity.log,"worklog_from_event",
"Worklog: " .. ev.title .. " (" .. minutes .. "м)", event_id, ev.node_id)
if ev.all_day == 1 then
-- Mark as completed if all-day
M.update_event(event_id, { completed = true })
end
return true
end
--------------------------------------------------------------------------------
-- Reminders (verstak.schedule.* + verstak.ui.* demo)
--------------------------------------------------------------------------------
local function parse_time(s)
if not s then return nil end
local y, m, d, h, min, sec = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)")
if not y then
y, m, d, h, min = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)")
end
if not y then
y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
if y then return { year = tonumber(y), month = tonumber(m), day = tonumber(d) } end
return nil
end
return {
year = tonumber(y), month = tonumber(m), day = tonumber(d),
hour = tonumber(h or 0), min = tonumber(min or 0), sec = tonumber(sec or 0)
}
end
local function iso_to_epoch(s)
local t = parse_time(s)
if not t then return nil end
return os.time({
year = t.year, month = t.month, day = t.day,
hour = t.hour or 0, min = t.min or 0, sec = t.sec or 0
})
end
function M.check_reminders()
local upcoming = verstak.db.query(
[[SELECT id, title, start, reminder_minutes, node_id
FROM events WHERE reminder_minutes != '[]' AND reminder_minutes != ''
AND completed = 0 AND datetime(start) > datetime('now')]]
)
local now_epoch = os.time()
local reminded = {}
for _, ev in ipairs(upcoming) do
local mins = {}
-- Parse reminder_minutes JSON array like [10, 60]
for m in string.gmatch(ev.reminder_minutes or "", "(-?%d+)") do
table.insert(mins, tonumber(m))
end
local ev_epoch = iso_to_epoch(ev.start)
if ev_epoch then
local key = "reminded:" .. ev.id
local already = verstak.state.get(key) or {}
for _, min_before in ipairs(mins) do
local notify_at = ev_epoch - min_before * 60
local diff = notify_at - now_epoch
if diff >= -30 and diff <= 60 and not already[tostring(min_before)] then
-- Fire reminder
local msg = "🔔 " .. ev.title
if min_before > 0 then
msg = msg .. " (через " .. min_before .. " мин)"
end
pcall(verstak.ui.toast, msg, "reminder")
print("Calendar reminder: " .. msg)
already[tostring(min_before)] = true
table.insert(reminded, ev.id)
end
end
verstak.state.set(key, already)
end
end
return #reminded
end
--------------------------------------------------------------------------------
-- Holidays via HTTP (verstak.http.* demo)
--------------------------------------------------------------------------------
function M.fetch_holidays(year)
if not year then year = tonumber(os.date("%Y")) end
local ok_http, resp = pcall(verstak.http.get, "https://date.nager.at/api/v3/PublicHolidays/" .. year .. "/RU")
if not ok_http then
print("Calendar: HTTP call failed: " .. tostring(resp))
return {}
end
if resp.status ~= 200 then
print("Calendar: HTTP status " .. tostring(resp.status))
return {}
end
local body = resp.body or "[]"
-- Cache in DB
verstak.db.exec("DELETE FROM events WHERE source_series = 'holiday_" .. year .. "'")
for _, h in ipairs(body) do
local date_str = h.date or (year .. "-01-01")
local title = (h.localName or "Праздник") .. " 🎉"
local cat_id
local cat = verstak.db.query_row("SELECT id FROM categories WHERE name = 'Личное' AND deleted = 0")
if cat then cat_id = cat.id end
M.create_event{
title = title,
start = date_str .. "T00:00:00",
all_day = true,
category_id = cat_id,
color = "#f59e0b",
node_id = "",
source_series = "holiday_" .. year,
}
end
print("Calendar: imported " .. #body .. " holidays for " .. year)
return true
end
--------------------------------------------------------------------------------
-- Hooks
--------------------------------------------------------------------------------
function on_install()
print("Calendar: on_install — creating tables")
local ok, err = pcall(function()
verstak.db.exec([[
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
icon TEXT NOT NULL DEFAULT '📌',
sort_order INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
]])
verstak.db.exec([[
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL,
end TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
category_id TEXT REFERENCES categories(id),
color TEXT NOT NULL DEFAULT '#6b7280',
node_id TEXT,
link_type TEXT DEFAULT 'node',
recurring_rule TEXT,
reminder_minutes TEXT DEFAULT '[]',
completed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
source_series TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
]])
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_start ON events(start)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_end ON events(end)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id)")
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted)")
end)
if not ok then
print("Calendar: migration error: " .. tostring(err))
error(err)
else
print("Calendar: migration complete")
end
-- Insert default categories
M.ensure_categories()
print("Calendar: install complete")
end
function on_uninstall()
print("Calendar: on_uninstall — dropping tables")
local ok, err = pcall(function()
verstak.db.exec("DROP TABLE IF EXISTS events")
verstak.db.exec("DROP TABLE IF EXISTS categories")
end)
if not ok then
print("Calendar: uninstall error: " .. tostring(err))
else
print("Calendar: tables dropped")
end
-- Clean up config
pcall(verstak.config.set, "categories", nil)
print("Calendar: uninstall complete")
end
function on_init()
print("Calendar: on_init — registering API")
-- Register global API for panel access
_G.calendar = M
-- Set initial state (current month)
verstak.state.set("calendar_month", os.date("%Y-%m"))
verstak.state.set("calendar_view", "month")
print("Calendar: init complete — " .. #M.get_categories() .. " categories, API ready")
end
function on_shutdown()
print("Calendar: shutdown")
end
print("Calendar: module loaded")

View File

@ -1,10 +0,0 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT,
location TEXT,
node_id TEXT,
color TEXT DEFAULT 'blue',
created_at TEXT DEFAULT (datetime('now'))
);

View File

@ -1,36 +0,0 @@
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
icon TEXT NOT NULL DEFAULT '📌',
sort_order INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL,
end TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
category_id TEXT REFERENCES categories(id),
color TEXT NOT NULL DEFAULT '#6b7280',
node_id TEXT,
link_type TEXT DEFAULT 'node',
recurring_rule TEXT,
reminder_minutes TEXT DEFAULT '[]',
completed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
source_series TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);
CREATE INDEX IF NOT EXISTS idx_events_end ON events(end);
CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id);
CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id);
CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted);

View File

@ -1,996 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--bg2: #22223a;
--bg3: #2a2a44;
--text: #e0e0e0;
--text-dim: #888;
--text-bright: #fff;
--border: #333;
--accent: #6366f1;
--accent-hover: #818cf8;
--danger: #ef4444;
--success: #22c55e;
--warn: #f59e0b;
--radius: 8px;
--radius-sm: 4px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
overflow-x: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
gap: 8px;
flex-wrap: wrap;
}
.header-left { display: flex; align-items: center; gap: 8px; }
.header-title { font-size: 1.1rem; font-weight: 600; color: var(--text-bright); }
.nav-btn {
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.nav-btn:hover { background: var(--accent); color: #fff; }
.nav-btn.today-btn { font-weight: 600; }
.view-tabs { display: flex; gap: 2px; }
.view-tab {
padding: 6px 14px;
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.view-tab:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
.view-tab:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
.view-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.view-tab:hover:not(.active) { background: var(--bg3); color: var(--text); }
/* Month grid */
.month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--border);
margin: 0;
}
.day-header {
background: var(--bg3);
padding: 8px 4px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
}
.day-cell {
background: var(--bg2);
min-height: 90px;
padding: 4px 6px;
cursor: pointer;
transition: background 0.1s;
position: relative;
}
.day-cell:hover { background: var(--bg3); }
.day-cell.other-month { opacity: 0.35; }
.day-cell.today { background: rgba(99, 102, 241, 0.12); }
.day-cell.selected { background: rgba(99, 102, 241, 0.2); box-shadow: inset 0 0 0 1px var(--accent); }
.day-cell.drop-target { background: rgba(34, 197, 94, 0.15); box-shadow: inset 0 0 0 2px var(--success); }
.day-number {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
color: var(--text);
}
.day-cell.today .day-number {
color: var(--accent);
}
/* Events in month grid */
.month-events {
display: flex;
flex-direction: column;
gap: 1px;
}
.month-event {
font-size: 11px;
padding: 1px 4px;
border-radius: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: opacity 0.1s;
color: #fff;
}
.month-event:hover { opacity: 0.85; }
.month-event.more-link {
background: transparent;
color: var(--text-dim);
font-size: 10px;
font-style: italic;
}
/* Week view */
.week-view {
display: grid;
grid-template-columns: 60px repeat(7, 1fr);
gap: 1px;
background: var(--border);
overflow-x: auto;
}
.hour-label {
background: var(--bg3);
padding: 4px;
text-align: right;
font-size: 11px;
color: var(--text-dim);
min-height: 40px;
border-bottom: 1px solid var(--border);
}
.hour-slot {
background: var(--bg2);
min-height: 40px;
border-bottom: 1px solid var(--border);
cursor: pointer;
position: relative;
}
.hour-slot:hover { background: var(--bg3); }
.week-event {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
font-size: 11px;
border-radius: 3px;
cursor: pointer;
overflow: hidden;
color: #fff;
}
/* Day view */
.day-view {
padding: 12px 16px;
}
.day-events-list { display: flex; flex-direction: column; gap: 8px; }
.day-event-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s;
border-left: 4px solid var(--accent);
}
.day-event-card:hover { background: var(--bg3); }
.day-event-time { font-size: 12px; color: var(--text-dim); }
.day-event-title { font-weight: 600; margin-top: 2px; }
.day-event-desc { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
.day-event-category { font-size: 11px; margin-top: 4px; display: inline-block; padding: 1px 6px; border-radius: 3px; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
max-width: 480px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
padding: 24px;
}
.modal h3 { margin-bottom: 16px; color: var(--text-bright); }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
}
.form-group textarea { resize: vertical; min-height: 60px; }
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none; border-color: var(--accent);
}
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.btn {
padding: 8px 18px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--bg3); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { opacity: 0.85; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; }
.checkbox-row input[type="checkbox"] { width: auto; }
/* Categories legend */
.categories-bar {
display: flex;
gap: 8px;
padding: 8px 16px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
align-items: center;
}
.cat-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
cursor: pointer;
transition: opacity 0.15s;
color: #fff;
}
.cat-tag:hover { opacity: 0.8; }
.cat-tag.active { box-shadow: 0 0 0 2px var(--text-bright); }
.cat-filter-all {
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
}
/* Toast */
.toast {
position: fixed;
bottom: 16px;
right: 16px;
background: var(--bg3);
border: 1px solid var(--border);
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-bright);
z-index: 2000;
animation: slideIn 0.25s ease;
}
@keyframes slideIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Loading */
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-dim);
font-size: 16px;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* Empty state */
.empty-day { color: var(--text-dim); padding: 20px; text-align: center; }
/* Responsive */
@media (max-width: 600px) {
.header { flex-direction: column; align-items: stretch; }
.view-tabs { justify-content: center; }
.day-cell { min-height: 60px; }
}
</style>
</head>
<body>
<div id="app">
<div class="loading-screen">⏳ Загрузка календаря...</div>
</div>
<script>
(function() {
'use strict';
// ─── State ────────────────────────────────────────────────────────
const state = {
view: 'month', // month | week | day
currentDate: new Date(), // the "focus" date (what month/week/day we're looking at)
selectedDate: null, // clicked day (for modal)
events: [],
categories: [],
filterCatId: null, // null = all
eventsLoaded: false,
dropDate: null, // date string being dragged onto
panelReady: false,
};
// Cache DOM refs
let appEl;
// ─── Helpers ───────────────────────────────────────────────────────
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()); }
function fmtISO(d) { return fmtDate(d) + 'T00:00:00'; }
function fmtMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1); }
function dayOfWeek(d) { const w = d.getDay(); return w === 0 ? 7 : w; } // Mon=1..Sun=7
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
function parseDate(s) { const d = new Date(s); if (isNaN(d.getTime())) return null; return d; }
function shortTime(s) {
if (!s) return '';
const d = parseDate(s);
if (!d) return s;
return pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function shortDate(s) {
if (!s) return '';
const d = parseDate(s);
if (!d) return s;
return pad(d.getDate()) + '.' + pad(d.getMonth()+1);
}
function fromNow(d) {
const now = new Date();
const diff = d.getTime() - now.getTime();
if (diff < 0) return 'прошло';
const mins = Math.round(diff / 60000);
if (mins < 60) return 'через ' + mins + ' мин';
const hours = Math.round(mins / 60);
if (hours < 24) return 'через ' + hours + ' ч';
const days = Math.round(hours / 24);
return 'через ' + days + ' дн';
}
// ─── Communication ─────────────────────────────────────────────────
function sendToParent(action, data) {
const msg = { source: 'calendar-plugin', action: action, data: data || {} };
if (window.parent && window.parent !== window) {
window.parent.postMessage(msg, '*');
}
}
// Request events from parent
function requestEvents() {
const start = getViewStart();
const end = getViewEnd();
sendToParent('get-events', { start: fmtISO(start), end: fmtISO(end) });
}
function getViewStart() {
const d = state.currentDate;
if (state.view === 'month') {
const first = new Date(d.getFullYear(), d.getMonth(), 1);
// Go back to Monday
const wd = dayOfWeek(first);
first.setDate(first.getDate() - (wd - 1));
return first;
}
if (state.view === 'week') {
const wd = dayOfWeek(d);
const monday = new Date(d);
monday.setDate(monday.getDate() - (wd - 1));
return monday;
}
return new Date(d);
}
function getViewEnd() {
const start = getViewStart();
const end = new Date(start);
if (state.view === 'month') end.setDate(end.getDate() + 42); // 6 weeks
else if (state.view === 'week') end.setDate(end.getDate() + 7);
else end.setDate(end.getDate() + 1);
return end;
}
// ─── Listen for parent messages ──────────────────────────────────────
window.addEventListener('message', function(e) {
const msg = e.data;
if (!msg || msg.source !== 'verstak') return;
switch (msg.type) {
case 'calendar-data':
try { window.parent.go.main.App.WriteDebugLog('[iframe] received calendar-data, events=' + (msg.events ? msg.events.length : 0) + ', categories=' + (msg.categories ? msg.categories.length : 0)); } catch(e) { try { console.log('iframe debug error: ' + e); } catch(e2) {} }
if (msg.events) state.events = msg.events;
if (msg.categories) state.categories = msg.categories;
state.eventsLoaded = true;
state.panelReady = true;
try { render(); } catch(e) { try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e) + ' ' + JSON.stringify({message: e.message, stack: e.stack?.substring(0,200)})); } catch(e2) {} }
break;
case 'drop':
// Dragged from another section
if (msg.date) {
state.dropDate = msg.date;
openCreateModal(msg.date, {
node_id: msg.data?.node_id || '',
link_type: msg.data?.link_type || 'node',
title: msg.data?.title || '',
});
}
break;
case 'event-created':
case 'event-updated':
case 'event-deleted':
requestEvents();
break;
}
});
// ─── Tell parent we're ready ─────────────────────────────────────────
function init() {
appEl = document.getElementById('app');
sendToParent('ready', { version: '1.0' });
// Request initial data
setTimeout(requestEvents, 100);
// Render immediately with empty state
render();
}
// ─── Render ──────────────────────────────────────────────────────────
function render() {
if (!appEl) {
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: appEl is null!'); } catch(e) {}
return;
}
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: eventsLoaded=' + state.eventsLoaded + ', events=' + (state.events ? state.events.length : 'no-state') + ', cats=' + (state.categories ? state.categories.length : 'no-state')); } catch(e) {}
if (!state.eventsLoaded) {
appEl.innerHTML = '<div class="loading-screen">⏳ Загрузка календаря...</div>';
return;
}
try {
appEl.innerHTML = renderHeader() + renderCategories() + renderView();
try { window.parent.go.main.App.WriteDebugLog('[iframe] render OK, view=' + state.view + ', events=' + state.events.length + ', cats=' + state.categories.length); } catch(e) {}
} catch(e) {
appEl.innerHTML = '<div class="loading-screen">⚠ Ошибка: ' + escapeHtml(String(e)) + '</div>';
try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e)); } catch(e2) {}
}
}
function renderHeader() {
const d = state.currentDate;
const monthNames = ['Январь','Февраль','Март','Апрель','Май','Июнь',
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
let title = '';
if (state.view === 'month') title = monthNames[d.getMonth()] + ' ' + d.getFullYear();
else if (state.view === 'week') title = 'Неделя ' + fmtDate(getViewStart());
else title = fmtDate(d);
return `<div class="header">
<div class="header-left">
<button class="nav-btn" onclick="calendar.prevPeriod()"></button>
<button class="nav-btn today-btn" onclick="calendar.goToday()">Сегодня</button>
<button class="nav-btn" onclick="calendar.nextPeriod()"></button>
<span class="header-title">${title}</span>
</div>
<div class="view-tabs">
<button class="view-tab${state.view==='month'?' active':''}" onclick="calendar.setView('month')">Месяц</button>
<button class="view-tab${state.view==='week'?' active':''}" onclick="calendar.setView('week')">Неделя</button>
<button class="view-tab${state.view==='day'?' active':''}" onclick="calendar.setView('day')">День</button>
</div>
</div>`;
}
function renderCategories() {
const cats = state.categories;
if (!cats || cats.length === 0) return '';
let html = '<div class="categories-bar">';
html += `<span class="cat-filter-all" onclick="calendar.setFilter(null)">${state.filterCatId === null ? '●' : '○'} Все</span>`;
for (const c of cats) {
const active = c.id === state.filterCatId;
html += `<span class="cat-tag${active ? ' active' : ''}" style="background:${c.color}" onclick="calendar.setFilter('${c.id}')">${c.icon || ''} ${c.name}</span>`;
}
html += '</div>';
return html;
}
function getEventsForDate(dateStr) {
const filtered = state.filterCatId
? state.events.filter(e => e.category_id === state.filterCatId)
: state.events;
return filtered.filter(e => {
const eStart = (e.start || '').substring(0, 10);
const eEnd = (e.end || e.start || '').substring(0, 10);
return dateStr >= eStart && dateStr <= eEnd;
});
}
function renderView() {
if (state.view === 'month') return renderMonth();
if (state.view === 'week') return renderWeek();
return renderDay();
}
function renderMonth() {
const d = state.currentDate;
const y = d.getFullYear(), m = d.getMonth();
const firstDay = new Date(y, m, 1);
const startDay = dayOfWeek(firstDay); // Mon=1..Sun=7
const totalDays = daysInMonth(y, m);
const todayStr = fmtDate(new Date());
const selectedStr = state.selectedDate ? fmtDate(state.selectedDate) : null;
// Previous month padding
const prevMonthDays = daysInMonth(y, m - 1);
let cells = [];
const startOffset = startDay - 1; // how many prev-month cells
// Day headers
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
let html = '<div class="month-grid">';
for (const n of dayNames) {
html += `<div class="day-header">${n}</div>`;
}
// Fill cells
const totalCells = Math.ceil((startOffset + totalDays) / 7) * 7;
for (let i = 0; i < totalCells; i++) {
let dayNum, cellDate, isOther = false;
if (i < startOffset) {
dayNum = prevMonthDays - startOffset + i + 1;
cellDate = new Date(y, m - 1, dayNum);
isOther = true;
} else if (i >= startOffset + totalDays) {
dayNum = i - startOffset - totalDays + 1;
cellDate = new Date(y, m + 1, dayNum);
isOther = true;
} else {
dayNum = i - startOffset + 1;
cellDate = new Date(y, m, dayNum);
}
const dateStr = fmtDate(cellDate);
const isToday = dateStr === todayStr;
const isSelected = selectedStr === dateStr;
const isDrop = state.dropDate === dateStr;
const events = getEventsForDate(dateStr);
const isWeekend = dayOfWeek(cellDate) >= 6;
let cls = 'day-cell';
if (isOther) cls += ' other-month';
if (isToday) cls += ' today';
if (isSelected) cls += ' selected';
if (isDrop) cls += ' drop-target';
html += `<div class="${cls}" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
html += `<div class="day-number">${dayNum}</div>`;
if (events.length > 0) {
html += '<div class="month-events">';
const maxShow = 3;
for (let j = 0; j < Math.min(events.length, maxShow); j++) {
const ev = events[j];
const color = ev.color || '#6366f1';
html += `<div class="month-event" style="background:${color}" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${ev.title}</div>`;
}
if (events.length > maxShow) {
html += `<div class="month-event more-link" onclick="event.stopPropagation(); calendar.setView('day'); state.currentDate = new Date('${dateStr}'); render();">+${events.length - maxShow} ещё</div>`;
}
html += '</div>';
}
html += '</div>';
}
html += '</div>';
return html;
}
function renderWeek() {
const start = getViewStart();
const todayStr = fmtDate(new Date());
let html = '<div class="week-view">';
// Corner label
html += '<div class="hour-label"></div>';
for (let d = 0; d < 7; d++) {
const day = new Date(start);
day.setDate(day.getDate() + d);
const dateStr = fmtDate(day);
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
html += `<div class="day-header" style="${dayOfWeek(day) >= 6 ? 'color:var(--danger)' : ''}">${dayNames[d]} ${day.getDate()}</div>`;
}
// Hours
for (let h = 0; h < 24; h++) {
html += `<div class="hour-label">${pad(h)}:00</div>`;
for (let d = 0; d < 7; d++) {
const day = new Date(start);
day.setDate(day.getDate() + d);
const dateStr = fmtDate(day);
const eventsOnHour = getEventsForDate(dateStr).filter(ev => {
const evH = parseInt((ev.start || '').substring(11, 13));
return evH === h;
});
let cellHtml = `<div class="hour-slot" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
for (const ev of eventsOnHour) {
const color = ev.color || '#6366f1';
cellHtml += `<div class="week-event" style="background:${color}; top:2px" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${shortTime(ev.start)} ${ev.title}</div>`;
}
cellHtml += '</div>';
html += cellHtml;
}
}
html += '</div>';
return html;
}
function renderDay() {
const d = state.currentDate;
const dateStr = fmtDate(d);
const events = getEventsForDate(dateStr);
const todayStr = fmtDate(new Date());
const isToday = dateStr === todayStr;
let html = `<div class="day-view">`;
html += `<h3 style="margin-bottom:12px">${fmtDate(d)}${isToday ? ' — Сегодня' : ''}</h3>`;
html += `<button class="btn btn-primary btn-sm" onclick="calendar.selectDay('${dateStr}')" style="margin-bottom:12px">+ Добавить событие</button>`;
if (events.length === 0) {
html += '<div class="empty-day">Нет событий на этот день</div>';
} else {
html += '<div class="day-events-list">';
for (const ev of events) {
const color = ev.category_color || ev.color || '#6366f1';
const catName = ev.category_name || '';
html += `<div class="day-event-card" style="border-left-color:${color}" onclick="calendar.openEvent('${ev.id}')">`;
if (ev.all_day == 1) {
html += `<div class="day-event-time">Весь день</div>`;
} else {
html += `<div class="day-event-time">${shortTime(ev.start)} — ${shortTime(ev.end)}</div>`;
}
html += `<div class="day-event-title">${ev.title}</div>`;
if (ev.description) {
html += `<div class="day-event-desc">${ev.description}</div>`;
}
if (catName) {
html += `<span class="day-event-category" style="background:${color}20; color:${color}">${catName}</span>`;
}
html += `</div>`;
}
html += '</div>';
}
html += '</div>';
return html;
}
// ─── Event Modal ─────────────────────────────────────────────────────
function openCreateModal(dateStr, prefill) {
prefill = prefill || {};
const cats = state.categories;
const todayStr = fmtDate(new Date());
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
let catOptions = '<option value="">Без категории</option>';
for (const c of cats) {
catOptions += `<option value="${c.id}" style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
}
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
<h3>${prefill.node_id ? '📎 Событие из узла' : '📅 Новое событие'}</h3>
<div class="form-group">
<label>Название</label>
<input id="ev-title" value="${escapeHtml(prefill.title || '')}" placeholder="Введите название события">
</div>
<div class="form-row">
<div class="form-group">
<label>Дата начала</label>
<input id="ev-start" type="date" value="${dateStr || todayStr}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-start-time" type="time" value="09:00">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дата окончания</label>
<input id="ev-end" type="date" value="${dateStr || todayStr}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-end-time" type="time" value="10:00">
</div>
</div>
<div class="checkbox-row">
<input id="ev-allday" type="checkbox">
<label for="ev-allday">Весь день</label>
</div>
<div class="form-group">
<label>Описание</label>
<textarea id="ev-desc" placeholder="Описание события (необязательно)"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Категория</label>
<select id="ev-category">${catOptions}</select>
</div>
</div>
${prefill.node_id ? `<input type="hidden" id="ev-node-id" value="${escapeHtml(prefill.node_id)}">
<input type="hidden" id="ev-link-type" value="${escapeHtml(prefill.link_type || 'node')}">` : ''}
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="submitCreate()">Создать</button>
</div>
</div>`;
document.body.appendChild(modal);
// Toggle time fields when "all day" is checked
document.getElementById('ev-allday').addEventListener('change', function() {
document.getElementById('ev-start-time').disabled = this.checked;
document.getElementById('ev-end-time').disabled = this.checked;
});
}
// Expose to global for onclick handlers
window.closeModal = function() {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(m => m.remove());
};
window.submitCreate = function() {
const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast('Название обязательно'); return; }
const startDate = document.getElementById('ev-start').value;
const endDate = document.getElementById('ev-end').value;
const allDay = document.getElementById('ev-allday').checked;
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
const description = document.getElementById('ev-desc').value.trim();
const categoryId = document.getElementById('ev-category').value;
const nodeId = document.getElementById('ev-node-id')?.value || '';
const linkType = document.getElementById('ev-link-type')?.value || 'node';
sendToParent('create-event', {
title: title,
start: startDate + 'T' + startTime + ':00',
end: endDate + 'T' + endTime + ':00',
all_day: allDay,
description: description,
category_id: categoryId,
node_id: nodeId,
link_type: linkType,
});
closeModal();
showToast('✅ Событие создаётся...');
};
function openEditModal(eventId) {
const ev = state.events.find(e => e.id === eventId);
if (!ev) { showToast('Событие не найдено'); return; }
const cats = state.categories;
const startDate = (ev.start || '').substring(0, 10);
const startTime = (ev.start || '').substring(11, 16);
const endDate = (ev.end || ev.start || '').substring(0, 10);
const endTime = (ev.end || ev.start || '').substring(11, 16);
const allDay = ev.all_day == 1;
const completed = ev.completed == 1;
let catOptions = '<option value="">Без категории</option>';
for (const c of cats) {
catOptions += `<option value="${c.id}" ${c.id === ev.category_id ? 'selected' : ''} style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
}
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
<h3>✏️ Редактировать событие</h3>
<div class="form-group">
<label>Название</label>
<input id="ev-title" value="${escapeHtml(ev.title)}">
</div>
<div class="form-row">
<div class="form-group">
<label>Дата начала</label>
<input id="ev-start" type="date" value="${startDate}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-start-time" type="time" value="${startTime}" ${allDay ? 'disabled' : ''}>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дата окончания</label>
<input id="ev-end" type="date" value="${endDate}">
</div>
<div class="form-group">
<label>Время</label>
<input id="ev-end-time" type="time" value="${endTime}" ${allDay ? 'disabled' : ''}>
</div>
</div>
<div class="checkbox-row">
<input id="ev-allday" type="checkbox" ${allDay ? 'checked' : ''}>
<label for="ev-allday">Весь день</label>
</div>
<div class="form-group">
<label>Описание</label>
<textarea id="ev-desc">${escapeHtml(ev.description || '')}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Категория</label>
<select id="ev-category">${catOptions}</select>
</div>
</div>
<div class="checkbox-row">
<input id="ev-completed" type="checkbox" ${completed ? 'checked' : ''}>
<label for="ev-completed">Выполнено</label>
</div>
${ev.node_id ? `<div style="margin-top:8px;font-size:12px;color:var(--text-dim)">📎 Связано с узлом: ${escapeHtml(ev.node_id)}</div>` : ''}
<div class="modal-actions">
<button class="btn btn-danger" onclick="submitDelete('${ev.id}')">Удалить</button>
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="submitUpdate('${ev.id}')">Сохранить</button>
</div>
</div>`;
document.body.appendChild(modal);
document.getElementById('ev-allday').addEventListener('change', function() {
document.getElementById('ev-start-time').disabled = this.checked;
document.getElementById('ev-end-time').disabled = this.checked;
});
}
window.submitUpdate = function(id) {
const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast('Название обязательно'); return; }
const allDay = document.getElementById('ev-allday').checked;
const startDate = document.getElementById('ev-start').value;
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
const endDate = document.getElementById('ev-end').value;
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
const completed = document.getElementById('ev-completed')?.checked || false;
sendToParent('update-event', {
id: id,
title: title,
start: startDate + 'T' + startTime + ':00',
end: endDate + 'T' + endTime + ':00',
all_day: allDay,
description: document.getElementById('ev-desc').value.trim(),
category_id: document.getElementById('ev-category').value,
completed: completed,
});
closeModal();
showToast('✅ Сохраняю...');
};
window.submitDelete = function(id) {
if (!confirm('Удалить это событие?')) return;
sendToParent('delete-event', { id: id });
closeModal();
showToast('🗑️ Удаляю...');
};
// ─── Navigation functions ───────────────────────────────────────────
window.calendar = {
prevPeriod: function() {
const d = state.currentDate;
if (state.view === 'month') d.setMonth(d.getMonth() - 1);
else if (state.view === 'week') d.setDate(d.getDate() - 7);
else d.setDate(d.getDate() - 1);
state.currentDate = d;
requestEvents();
render();
},
nextPeriod: function() {
const d = state.currentDate;
if (state.view === 'month') d.setMonth(d.getMonth() + 1);
else if (state.view === 'week') d.setDate(d.getDate() + 7);
else d.setDate(d.getDate() + 1);
state.currentDate = d;
requestEvents();
render();
},
goToday: function() {
state.currentDate = new Date();
requestEvents();
render();
},
setView: function(view) {
state.view = view;
requestEvents();
render();
},
setFilter: function(catId) {
state.filterCatId = catId;
render();
},
selectDay: function(dateStr) {
state.selectedDate = parseDate(dateStr);
openCreateModal(dateStr, {});
},
openEvent: function(eventId) {
openEditModal(eventId);
},
};
// ─── Toast ──────────────────────────────────────────────────────────
function showToast(msg) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => { el.remove(); }, 3000);
}
// ─── Escape HTML ────────────────────────────────────────────────────
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// ─── Init ───────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>

View File

@ -1,40 +0,0 @@
{
"name": "calendar",
"version": "1.0.0",
"author": "Verstak contributors",
"description": "Календарь событий с категориями, рекарренсом, напоминаниями, связью с деревом Верстака",
"license": "MIT",
"hooks": {
"on_init": "on_init",
"on_shutdown": "on_shutdown",
"on_install": "on_install",
"on_uninstall": "on_uninstall"
},
"ui": {
"sidebar_items": [
{
"id": "calendar",
"label": "Календарь",
"icon": "calendar",
"page": "plugin:calendar:main"
}
],
"create_dialog_categories": ["event"]
},
"background_tasks": [
{
"id": "check_reminders",
"interval": "1m",
"script": "scripts/check_reminders.lua"
}
],
"panel": "panels/calendar.html",
"migrations": [
"migrations/001_create_tables.sql"
]
}

View File

@ -1,8 +0,0 @@
-- Background task: check for upcoming reminders
-- Runs every 1 minute (defined in plugin.json)
-- Calls calendar.check_reminders() from main.lua
local n = calendar.check_reminders()
if n > 0 then
print("Calendar: " .. n .. " reminders fired")
end

View File

@ -1,205 +0,0 @@
-- Calendar plugin test
-- Exercises: verstak.db.* / verstak.config.* / verstak.state.* / verstak.activity.*
-- Runs as part of the LuaVM test suite (no node/worklog/http — those are service-dependent)
local function assert(cond, msg)
if not cond then
error("ASSERT FAIL: " .. tostring(msg), 2)
end
print("" .. tostring(msg))
end
local test_count = 0
local function test(name, fn)
test_count = test_count + 1
print("\n[" .. test_count .. "] " .. name)
local ok, err = pcall(fn)
if not ok then
print(" ✗ FAIL: " .. tostring(err))
error("Test " .. test_count .. " failed: " .. tostring(err), 2)
end
end
----------------------------------------------------------------------
-- Test suite
----------------------------------------------------------------------
test("calendar module exists", function()
assert(calendar ~= nil, "calendar global exists")
assert(type(calendar.get_categories) == "function", "calendar.get_categories exists")
assert(type(calendar.create_event) == "function", "calendar.create_event exists")
end)
test("categories have defaults", function()
local cats = calendar.get_categories()
assert(#cats >= 6, "at least 6 default categories, got " .. #cats)
print(" Categories: " .. #cats)
for _, c in ipairs(cats) do
print(" " .. c.icon .. " " .. c.name .. " (" .. c.color .. ")")
end
end)
test("create custom category", function()
local id = calendar.create_category("Тестовая", "#ff0000", "🧪")
assert(id ~= nil, "got category id: " .. tostring(id))
local cats = calendar.get_categories()
local found = false
for _, c in ipairs(cats) do
if c.id == id then found = true end
end
assert(found, "category found in list")
end)
test("update category", function()
local cats = calendar.get_categories()
if #cats == 0 then return end
local ok = calendar.update_category(cats[1].id, { name = "Обновлённая" })
assert(ok, "category updated")
cats = calendar.get_categories()
-- Don't assert the name because the DB might not persist across LuaVM sessions
assert(#cats > 0, "still have categories")
end)
test("soft delete category", function()
local cats = calendar.get_categories()
if #cats < 2 then return end
calendar.delete_category(cats[2].id)
-- Verify it's gone from active list
local active = calendar.get_categories()
for _, c in ipairs(active) do
assert(c.id ~= cats[2].id, "deleted category not in active list")
end
end)
test("restore default categories", function()
local ok = calendar.restore_default_categories()
assert(ok, "defaults restored")
local cats = calendar.get_categories()
assert(#cats >= 6, "at least 6 default categories after restore")
end)
test("create event (all-day)", function()
local cats = calendar.get_categories()
local cat_id = ""
if #cats > 0 then cat_id = cats[1].id end
local id = calendar.create_event{
title = "Тестовое событие",
start = "2026-07-01T00:00:00",
all_day = true,
category_id = cat_id,
color = "#10b981",
}
assert(id ~= nil, "event created: " .. tostring(id))
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
local found = false
for _, e in ipairs(events) do
if e.id == id then found = true; break end
end
assert(found, "event found in date query")
end)
test("create event with time", function()
local id = calendar.create_event{
title = "Встреча в полдень",
start = "2026-07-02T12:00:00",
["end"] = "2026-07-02T13:00:00",
all_day = false,
color = "#8b5cf6",
}
assert(id ~= nil, "timed event created")
end)
test("create event with reminder", function()
local id = calendar.create_event{
title = "С напоминанием",
start = os.date("%Y-%m-%dT23:00:00"),
reminder_minutes = {10, 60},
}
assert(id ~= nil, "event with reminders created")
local ev = calendar.get_event(id)
assert(ev ~= nil, "event readable")
-- reminder_minutes should be stored — we can check if it has content
assert(ev.reminder_minutes ~= nil and ev.reminder_minutes ~= "", "reminder stored")
end)
test("update event partial", function()
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
if #events == 0 then return end
calendar.update_event(events[1].id, { title = "Обновлённое событие" })
print(" updated: " .. events[1].id)
end)
test("delete event", function()
local events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
if #events == 0 then return end
local ev_id = events[1].id
calendar.delete_event(ev_id)
events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
for _, e in ipairs(events) do
assert(e.id ~= ev_id, "deleted event not found")
end
print(" deleted: " .. ev_id)
end)
test("expand daily recurrence", function()
local dates = calendar.expand_recurring(
"2026-07-01T00:00:00",
"2026-07-01T01:00:00",
{ freq = "daily", interval = 1, count = 5, ["until"] = "2026-07-15" },
"2026-07-01T00:00:00",
"2026-07-10T00:00:00"
)
assert(#dates > 0, "got " .. #dates .. " daily instances")
print(" daily instances: " .. #dates)
for _, d in ipairs(dates) do
print(" " .. d)
end
end)
test("expand weekly recurrence (Mon/Wed/Fri)", function()
local dates = calendar.expand_recurring(
"2026-07-06T00:00:00", -- Monday
"2026-07-06T01:00:00",
{ freq = "weekly", interval = 1, by_day = {1, 3, 5}, count = 9 },
"2026-07-06T00:00:00",
"2026-07-20T00:00:00"
)
assert(#dates > 0, "got " .. #dates .. " weekday instances")
print(" weekday instances: " .. #dates)
for _, d in ipairs(dates) do
print(" " .. d)
end
end)
test("expand monthly recurrence", function()
local dates = calendar.expand_recurring(
"2026-07-15T00:00:00",
"2026-07-15T00:00:00",
{ freq = "monthly", interval = 1, by_month_day = {15}, count = 6 },
"2026-07-01T00:00:00",
"2026-12-31T00:00:00"
)
assert(#dates >= 5, "got " .. #dates .. " monthly instances (>= 5)")
print(" monthly instances: " .. #dates)
end)
test("check reminders (no crash)", function()
local n = calendar.check_reminders()
assert(type(n) == "number", "check_reminders returned a number: " .. tostring(n))
print(" reminders to fire: " .. tostring(n))
end)
-- Clear test data
calendar.clear_events()
print("\n========================")
print("All " .. test_count .. " tests passed!")
print("========================")

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