Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceee03959b | ||
|
|
2ed2ecf77a | ||
|
|
c8aaf36533 | ||
|
|
1fa009b1e2 | ||
|
|
10b287de7b | ||
|
|
23f517dee3 | ||
|
|
6d15639b41 | ||
|
|
56ef211418 | ||
|
|
22b05f57b4 | ||
|
|
a8df9d118c | ||
|
|
91b5629e01 | ||
|
|
db47d31183 | ||
|
|
4755d3199d | ||
|
|
0e5d13ff01 | ||
|
|
f112e9a2d0 | ||
|
|
c1dfc456ec | ||
|
|
9e70e36f7f | ||
|
|
bcb093d453 | ||
|
|
336037d887 | ||
|
|
6eaa4cda49 | ||
|
|
a96a316883 | ||
|
|
326f6f283d | ||
|
|
44d0be2649 | ||
|
|
d6ef3a973a | ||
|
|
2e86229350 | ||
|
|
58a74acbf6 | ||
|
|
cc83cd3476 | ||
|
|
035f877280 | ||
|
|
02d68ca3f4 | ||
|
|
eb6a861310 | ||
|
|
272a7f870b | ||
|
|
644ec0ed00 | ||
|
|
cc59f928a8 | ||
|
|
2284f893f8 | ||
|
|
cb6c06fdc5 | ||
|
|
767c03ba8c | ||
|
|
20e605bab7 | ||
|
|
cc157a2d36 | ||
|
|
c40d8c9dd3 | ||
|
|
7641a311cc | ||
|
|
7e709e140d | ||
|
|
3a20e1b093 | ||
|
|
bb0bb608e3 | ||
|
|
3c6bc097e1 | ||
|
|
e2aad19cc4 | ||
|
|
0c0b0d98c7 | ||
|
|
a526661825 | ||
|
|
58795b66b2 | ||
|
|
9d14ba50af | ||
|
|
a69dc845e6 | ||
|
|
f92394e3d7 | ||
|
|
e30a75c5a0 | ||
|
|
4ec03c849f | ||
|
|
0bebcdce8c | ||
|
|
3e55b08e6f | ||
|
|
9338b0a851 | ||
|
|
db869a7c97 | ||
|
|
b42aa35ee8 | ||
|
|
21a595c3ce | ||
|
|
7076980954 | ||
|
|
fd99dd4f5c | ||
|
|
1472bb3e6f | ||
|
|
d34100e2ed | ||
|
|
5732264fc5 | ||
|
|
c25e75f839 | ||
|
|
57d13c9506 | ||
|
|
ca280a59c0 | ||
|
|
7d81250ebd | ||
|
|
23b3d07071 | ||
|
|
8cbf23a74d | ||
|
|
b6a3a2238d | ||
|
|
105657400b | ||
|
|
cc3500c14f | ||
|
|
3c9b9edf8c | ||
|
|
81405ed61b | ||
|
|
baf57e993d | ||
|
|
c941f05dab | ||
|
|
9260582072 | ||
|
|
b2dcb116c9 | ||
|
|
f022f46909 | ||
|
|
a6b0f9d7e6 | ||
|
|
b26b757d80 | ||
|
|
d285f9ad8b | ||
|
|
7e38ffed7b | ||
|
|
a31f5fd702 | ||
|
|
49c0fda61c | ||
|
|
7b2a1da529 | ||
|
|
20a05569ac | ||
|
|
66c5c81f39 | ||
|
|
4f01f2de2e | ||
|
|
0b26f7e5b3 | ||
|
|
12f2916a24 | ||
|
|
7091397649 | ||
|
|
2fa583d157 | ||
|
|
3089d777a8 | ||
|
|
390d451977 | ||
|
|
50e7e95844 | ||
|
|
4a96aa3468 | ||
|
|
f8f9510e2a | ||
|
|
852d6d373c | ||
|
|
3c7e9d1d56 | ||
|
|
87c8dfcbea | ||
|
|
7fe02fc8df | ||
|
|
b0d992b0d6 | ||
|
|
e5860ca076 | ||
|
|
daed8e0aba | ||
|
|
fa6f988368 | ||
|
|
c8cdb089a6 | ||
|
|
4afcc0e135 | ||
|
|
61928cf28e | ||
|
|
04af88940b | ||
|
|
015c8fdec7 | ||
|
|
0f5c584c50 | ||
|
|
99e47fcb17 | ||
|
|
0ef54c31f8 | ||
|
|
b3662d4876 | ||
|
|
f8dc436709 | ||
|
|
241a9d8c06 | ||
|
|
5db3da3618 | ||
|
|
e828ebd44e | ||
|
|
84c0bcbcab | ||
|
|
a1a50863c5 | ||
|
|
1abe8c4fa0 | ||
|
|
5b2cec5bcc | ||
|
|
1a20edac44 | ||
|
|
ad684eb118 | ||
|
|
10c6d06e38 | ||
|
|
ec928e3be6 | ||
|
|
c5e0060fee | ||
|
|
834b5ef0d4 | ||
|
|
4145b4d74a | ||
|
|
edc708a106 | ||
|
|
ee708d30bb | ||
|
|
305158ecc6 | ||
|
|
996322f3a9 | ||
|
|
a098cf721c | ||
|
|
3672e3133b | ||
|
|
5a1c4c6d7f | ||
|
|
08c9d5dbea | ||
|
|
c74fa3ad43 | ||
|
|
69891e395c | ||
|
|
a4ae22c445 |
@@ -20,10 +20,17 @@ go.work
|
||||
|
||||
# Wails
|
||||
frontend/dist/
|
||||
frontend/frontend-dist/
|
||||
frontend/node_modules/
|
||||
frontend/bindings/
|
||||
verstak-gui
|
||||
verstak-cli
|
||||
/verstak-gui
|
||||
/verstak-cli
|
||||
/verstak-server
|
||||
/verstak
|
||||
|
||||
# Vault data
|
||||
.verstak/
|
||||
spaces/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
@@ -35,3 +42,10 @@ Thumbs.db
|
||||
|
||||
# Vault test data
|
||||
test-vault/
|
||||
server-data/
|
||||
Ромашка/
|
||||
Тестовая папка/
|
||||
|
||||
# Build output
|
||||
build/
|
||||
build.log
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Session summary
|
||||
|
||||
## Bugs fixed (this session)
|
||||
1. **webkit2_41 build tag** — binary wouldn't start without it. Added to build instructions.
|
||||
2. **Sidebar refresh** — `reloadTreePreservingExpanded` patches children in-place so expand/collapse state stays intact.
|
||||
3. **Context menu off-screen** — changed to `position: fixed` with cursor coordinates.
|
||||
4. **"Show in explorer" only for folder types** — `OpenFolder` in backend falls back to file record path for `TypeFile` nodes.
|
||||
5. **Context menu not closing on action** — `handleShowInFolder` calls `closeMenu()`.
|
||||
6. **Wrong folder when opening file's parent folder** — `OpenFolder` checks `n.FsPath == ""` for TypeFile and uses first file record path.
|
||||
7. **Tab highlight not updating visually** — was using `class={tabClass(tab.id)}` which didn't trigger reactive class updates in Svelte. Switched to `class="tab" class:active={activeTab === tab.id}`.
|
||||
8. **Journal table expand/collapse** — added explicit ▸/▾ toggle column so it's clear rows are expandable.
|
||||
9. **Per-node worklog entries** — made entries expandable with ▸/▾, showing details + billable/approximate tags.
|
||||
10. **Manual worklog entry form** — converted inline form to modal dialog ("+ Добавить запись") with all fields: date, summary, minutes, details, billable, approximate.
|
||||
11. **"С подзадачами" → "Учитывать вложенные дела"** — renamed, now hidden when no node selected.
|
||||
12. **Filter/export layout** — split into separate "Фильтры" and "Экспорт отчёта" sections with headings.
|
||||
13. **Suggestion events** — added "Показать в проводнике" button for file-type events in suggestion detail.
|
||||
14. **Removed duplicate i18n keys** in `ru.js` (worklog.suggestions, worklog.apply).
|
||||
15. **Removed unused CSS** (`.journal-filters`, `.wl-meta`, `.worklog-form`).
|
||||
16. **Added `openNodeFolder(nodeOrId)`** — accepts both string ID and node object.
|
||||
17. **Added `resetJournalFilters()`** — resets all filters and reloads.
|
||||
18. **Source field** — added `worklog_entries.source` column (migration 014). Values: manual, suggestion. Old entries default to 'unknown'.
|
||||
19. **Suggestions now use worklog_entry_events** instead of `HasTodayEntries` — only events already linked to worklog entries are excluded. Repeated activity on the same node today now produces new suggestions.
|
||||
20. **Activity target navigation** — clicking activity events for notes opens the note tab and loads the specific note. File events open the files tab.
|
||||
21. **Source display** — detail sections now show accurate source: "Ручная запись", "Из предложения", "Из предложения, но связанные события отсутствуют", or "Источник неизвестен".
|
||||
22. **Wails `[]string` marshalling bug** — Wails v2.12.0 silently drops `[]string` positional args from JS→Go. **Fix**: pass all string arrays as `JSON.stringify()` → `string` → `json.Unmarshal` on Go side.
|
||||
23. **Event link validation** — `AcceptSuggestionWith` pre-checks each eventID against `activity_events`, uses plain `INSERT` (not `INSERT OR IGNORE`), and verifies with JOIN `COUNT(*)` after commit.
|
||||
24. **GetWorklogEntryEvents column fix** — query used `e.details_json` but the column is `e.metadata`. Fixed to `COALESCE(e.metadata,'')`.
|
||||
25. **"Посмотреть" button** — `openActivityTarget(ev)` navigates to the specific target: note tab + open note for `targetType=note`, files tab + `OpenFolder(targetPath)` for `file/folder`.
|
||||
26. **End-to-end test** — `TestAcceptSuggestionWithEndToEnd` creates node, 3 activity events, accepts suggestion, verifies all 3 linked via `worklog_entry_events` + JOIN.
|
||||
27. **WriteDebugLog binding** — `bindings_debug.go` writes frontend logs to `<vault>/.verstak/debug.log` for production GUI debugging.
|
||||
28. **Journal regression tests** — `TestJournalFullRegression`, `TestSuggestionOnRepeatedActivity`, `TestManualWorklogEntry`.
|
||||
29. **resolveActivityTarget helper** — pure function returning `{ nodeId, tab, noteId/fileId/targetPath }`, used by `openActivityTarget`.
|
||||
30. **First-run flow** — no auto-vault creation. New `GetStartupStatus` binding returns `first_run`/`recovery`/`ready`. Frontend shows FirstRun.svelte or VaultRecovery.svelte accordingly.
|
||||
31. **Global config.json** — moved vault path, sync settings, templates, theme, language from implicit CLI args to `~/.config/verstak/config.json` (`AppConfig` struct).
|
||||
32. **Sync settings in Settings** — extracted sync modal into Settings → Sync section. Removed all inline sync form fields from `App.svelte`. Added `SyncStatus.svelte` widget replacing navbar sync button.
|
||||
33. **Settings window** — modal with sidebar (8 sections: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup). ESC to close. Lazy-loaded content panels.
|
||||
34. **Template enable/disable** — `AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
|
||||
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
|
||||
|
||||
## Key patterns
|
||||
- Always use explicit toggle icons (▸/▾) on expandable rows.
|
||||
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
|
||||
- `openNodeFolder(id)` accepts a string ID or a node object.
|
||||
- `GetSuggestions` filters out only events already in `worklog_entry_events`, not entire nodes.
|
||||
- New worklog entries get `source=manual` via `Add`/`AddWithDate`; suggestion entries get `source=suggestion` via `AcceptSuggestionWith`.
|
||||
- **NEVER pass `[]string` through Wails v2 bindings** — always JSON-serialize to `string` first. Wails v2.12.0 silently drops slice arguments.
|
||||
- **Always wrap create-entry + link-events in a transaction** with pre-validation and post-commit verification to prevent orphan entries.
|
||||
- Frontend debug logs in production: use `wailsCall('WriteDebugLog', msg)` → writes to `<vault>/.verstak/debug.log`.
|
||||
- `AppConfig` stores all global settings in `~/.config/verstak/config.json`. Vault-specific config stays in `.verstak/config.yml`.
|
||||
- Use `GetStartupStatus` to determine first-run vs recovery vs normal startup.
|
||||
- Settings window uses a sidebar with 8 sections; each section is a separate Svelte component imported lazily.
|
||||
- Template enable/disable state is stored in `appCfg.EnabledTemplates` and applied to the registry during `initVault`.
|
||||
|
||||
# Build instructions
|
||||
|
||||
## GUI binary (Wails v2)
|
||||
|
||||
```bash
|
||||
# From project root:
|
||||
cp -r frontend/dist/* cmd/verstak-gui/frontend-dist/
|
||||
go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak-gui-linux-amd64 ./cmd/verstak-gui/
|
||||
```
|
||||
|
||||
## Server binary
|
||||
|
||||
```bash
|
||||
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
|
||||
```
|
||||
@@ -1,46 +1,163 @@
|
||||
# Верстак
|
||||
|
||||
**Верстак** — локальная программа, где по каждому клиенту или проекту
|
||||
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
|
||||
**Верстак** — local-first рабочий vault. Всё организовано вокруг **дел**, а не задач.
|
||||
|
||||
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди,
|
||||
у которых работа организована через дела, а не через задачи:
|
||||
|
||||
```
|
||||
дело → файлы → заметки → документы → действия → история → вернуться через месяц
|
||||
```
|
||||
|
||||
## Для кого
|
||||
|
||||
Один продукт — разные входные двери:
|
||||
|
||||
| Кто | Как видит Верстак |
|
||||
|-----|-------------------|
|
||||
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
|
||||
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
|
||||
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
|
||||
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
|
||||
|
||||
## Универсальные сущности
|
||||
|
||||
Базовая модель предельно проста — плагины добавляют функционал:
|
||||
|
||||
- **Дело** — контекст для всего остального
|
||||
- **Заметка** — Markdown внутри vault
|
||||
- **Файл / Документ** — любой файл, привязанный к делу
|
||||
- **Действие** — кнопка запуска: URL, файл, папка, команда
|
||||
- **Журнал** — записи о затраченном времени
|
||||
|
||||
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
|
||||
эти сущности без перекомпиляции программы.
|
||||
Дело может быть: клиентом, проектом, набором документов, рецептом, архивом, разовой работой.
|
||||
Внутри дела: вложенные папки, Markdown-заметки, файлы, действия (URL/файл/папка/команда), журнал работ, история активности.
|
||||
|
||||
## Стек
|
||||
|
||||
Go + SQLite + Lua (плагины) + Wails + Bubble Tea.
|
||||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| GUI | Wails v2 + Svelte 4 |
|
||||
| CLI/TUI | Go (bubbletea-like) |
|
||||
| Backend | Go |
|
||||
| Хранилище | SQLite (индекс) + файловая система (vault) |
|
||||
| Плагины | Lua |
|
||||
| Синхронизация | HTTP API (опциональный сервер) |
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ GUI (Wails v2) │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────▼────────┐ ┌─────────────┐
|
||||
│ Core Library │◄──│ CLI Commands │
|
||||
└─────────┬────────┘ └─────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Local Vault+SQLite │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Sync Client │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Sync Server │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- libwebkit2gtk-4.1-dev, libgtk-3-dev и другие Wails-зависимости (см. [wails.io/docs/desktop/linux](https://wails.io/docs/desktop/linux))
|
||||
- npm
|
||||
|
||||
### Сборка
|
||||
|
||||
```bash
|
||||
# Всё сразу (GUI + сервер)
|
||||
./scripts/build.sh
|
||||
|
||||
# Или по отдельности
|
||||
./scripts/build.sh gui # только GUI
|
||||
./scripts/build.sh server # только сервер
|
||||
```
|
||||
|
||||
Проверка GUI перед коммитом:
|
||||
|
||||
```bash
|
||||
./scripts/check-gui.sh
|
||||
```
|
||||
|
||||
Она проверяет локали, production-сборку фронтенда, актуальность embedded Wails assets и компиляцию GUI-бинаря.
|
||||
Дополнительно запускается headless Chromium smoke через Wails-mock: проверяются first-run, recovery, основное окно, Settings, workspace, вкладки дела, файлы, журнал, активность и мобильный viewport. Smoke выполняет реальные UI-действия: создание заметки, запись worklog, создание узла, вход в папку и возврат назад, а также Sync Now с предупреждениями о conflicts/applyErrors. Скриншоты пишутся в `/tmp/verstak-gui-smoke`.
|
||||
|
||||
Бинарники попадают в `build/`:
|
||||
- `verstak-gui-linux-amd64` — GUI-приложение
|
||||
- `verstak-server-linux-amd64` — опциональный сервер синхронизации
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
# GUI (после сборки)
|
||||
./build/verstak-gui-linux-amd64
|
||||
|
||||
# Сервер (после сборки)
|
||||
./build/verstak-server-linux-amd64 --help
|
||||
|
||||
# CLI
|
||||
go run ./cmd/verstak/ --help
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/ # Точки входа
|
||||
│ ├── verstak/ # CLI/TUI
|
||||
│ ├── verstak-gui/ # Wails GUI
|
||||
│ └── verstak-server/ # Sync server
|
||||
├── internal/
|
||||
│ ├── core/ # Бизнес-логика
|
||||
│ │ ├── actions/ # Действия (URL, папка, команда)
|
||||
│ │ ├── config/ # Конфигурация
|
||||
│ │ ├── files/ # Файлы и импорт
|
||||
│ │ ├── i18n/ # Интернационализация (Go)
|
||||
│ │ ├── nodes/ # Дела/узлы дерева
|
||||
│ │ ├── plugins/ # Lua-плагины
|
||||
│ │ ├── search/ # Поиск
|
||||
│ │ ├── storage/ # SQLite + миграции
|
||||
│ │ ├── sync/ # Синхронизация
|
||||
│ │ ├── templates/ # Шаблоны дел
|
||||
│ │ ├── vault/ # Vault layout
|
||||
│ │ └── worklog/ # Журнал работ
|
||||
│ └── gui/ # Wails bridge (embedded HTML)
|
||||
├── frontend/ # Svelte-приложение
|
||||
│ └── src/
|
||||
│ ├── lib/
|
||||
│ │ └── i18n/ # Локали (JS)
|
||||
│ └── ...svelte # Компоненты
|
||||
├── migrations/ # SQL-миграции
|
||||
├── docs/ # Документация
|
||||
└── scripts/ # Вспомогательные скрипты и сборка
|
||||
```
|
||||
|
||||
## CLI команды
|
||||
|
||||
```bash
|
||||
go run ./cmd/verstak/ sync # Синхронизация с сервером
|
||||
go run ./cmd/verstak/ sync configure # Настройка сервера
|
||||
go run ./cmd/verstak/ sync status # Статус синхронизации
|
||||
```
|
||||
|
||||
## Vault layout
|
||||
|
||||
Данные хранятся в локальной папке (vault). Структура на диске:
|
||||
|
||||
```
|
||||
vault/
|
||||
.verstak/ # Служебные данные: index.db, config.yml, trash, blobs
|
||||
Проекты/ # Пользовательские папки-дела
|
||||
Клиенты/
|
||||
Рабочие/
|
||||
Archive/
|
||||
```
|
||||
|
||||
Внутри папок-дел: `Notes/`, `Files/`, `Documents/`, `Overview.md`.
|
||||
Vault открывается в любом файловом менеджере без специальных инструментов.
|
||||
|
||||
## Документация
|
||||
|
||||
- Описание продукта: [docs/01_Product_Spec.md](docs/01_Product_Spec.md)
|
||||
- Архитектура: [docs/02_Architecture.md](docs/02_Architecture.md)
|
||||
- Плагины: [docs/09_Extensibility.md](docs/09_Extensibility.md)
|
||||
- План разработки: [docs/PLAN.md](docs/PLAN.md)
|
||||
| Раздел | Описание |
|
||||
|--------|----------|
|
||||
| [Описание продукта](docs/01_Product_Spec.md) | Аудитория, сценарии, фичи |
|
||||
| [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync |
|
||||
| [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы |
|
||||
| [Синхронизация](docs/04_Sync_Backup_Activity.md) | Sync, backup, activity |
|
||||
| [UI/UX](docs/05_UI_UX.md) | Экраны GUI/TUI |
|
||||
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
|
||||
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
|
||||
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
|
||||
| [План](docs/PLAN.md) | Дорожная карта |
|
||||
| [Шаблоны](docs/TEMPLATES.md) | Шаблоны дел |
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd frontend && npm run build && cd ..
|
||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
||||
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
@@ -1,355 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
go:mod:tidy:
|
||||
summary: Runs `go mod tidy`
|
||||
internal: true
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
install:frontend:deps:
|
||||
summary: Install frontend dependencies
|
||||
cmds:
|
||||
- task: install:frontend:deps:{{.PACKAGE_MANAGER}}
|
||||
|
||||
install:frontend:deps:npm:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: npm version
|
||||
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
|
||||
cmds:
|
||||
- npm install
|
||||
|
||||
install:frontend:deps:bun:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- bun.lock
|
||||
- bun.lockb
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: bun --version
|
||||
msg: "bun not found"
|
||||
cmds:
|
||||
- bun install
|
||||
|
||||
install:frontend:deps:pnpm:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: pnpm --version
|
||||
msg: "pnpm not found"
|
||||
cmds:
|
||||
- pnpm install
|
||||
|
||||
install:frontend:deps:yarn:
|
||||
dir: frontend
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
status:
|
||||
- test -d node_modules || test -f .pnp.cjs
|
||||
preconditions:
|
||||
- sh: yarn --version
|
||||
msg: "yarn not found"
|
||||
cmds:
|
||||
- yarn install
|
||||
|
||||
build:frontend:
|
||||
label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
|
||||
summary: Build the frontend project
|
||||
dir: frontend
|
||||
sources:
|
||||
- "**/*"
|
||||
- exclude: node_modules/**/*
|
||||
generates:
|
||||
- dist/**/*
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
- task: generate:bindings
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
cmds:
|
||||
- task: frontend:run
|
||||
vars:
|
||||
SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||
env:
|
||||
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||
|
||||
frontend:run:
|
||||
summary: Run a frontend script with selected runner
|
||||
cmds:
|
||||
- task: frontend:run:{{.PACKAGE_MANAGER}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:npm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- npm run {{.SCRIPT}} -q
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:yarn:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn {{.SCRIPT}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:pnpm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- pnpm run {{.SCRIPT}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:run:bun:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- bun run {{.SCRIPT}}
|
||||
vars:
|
||||
SCRIPT: "{{.SCRIPT}}"
|
||||
|
||||
frontend:vendor:puppertino:
|
||||
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
|
||||
sources:
|
||||
- frontend/public/puppertino/puppertino.css
|
||||
generates:
|
||||
- frontend/public/puppertino/puppertino.css
|
||||
cmds:
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p frontend/public/puppertino
|
||||
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
|
||||
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
|
||||
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
|
||||
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
|
||||
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
|
||||
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
|
||||
else
|
||||
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
|
||||
fi
|
||||
else
|
||||
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
|
||||
fi
|
||||
# Ensure index.html includes Puppertino CSS and button classes
|
||||
INDEX_HTML=frontend/index.html
|
||||
if [ -f "$INDEX_HTML" ]; then
|
||||
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
|
||||
# Insert Puppertino link tag after style.css link
|
||||
awk '
|
||||
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
|
||||
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
|
||||
fi
|
||||
# Replace default .btn with Puppertino primary button classes if present
|
||||
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
|
||||
fi
|
||||
|
||||
|
||||
|
||||
generate:bindings:
|
||||
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
|
||||
summary: Generates bindings for the frontend
|
||||
deps:
|
||||
- task: go:mod:tidy
|
||||
sources:
|
||||
- "**/*.[jt]s"
|
||||
- exclude: frontend/**/*
|
||||
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
generates:
|
||||
- frontend/bindings/**/*
|
||||
cmds:
|
||||
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true{{if eq .OBFUSCATED "true"}} -obfuscated{{end}}
|
||||
|
||||
generate:icons:
|
||||
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
|
||||
dir: build
|
||||
sources:
|
||||
- "appicon.png"
|
||||
- "appicon.icon"
|
||||
generates:
|
||||
- "darwin/icons.icns"
|
||||
- "windows/icon.ico"
|
||||
cmds:
|
||||
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
|
||||
|
||||
dev:frontend:
|
||||
summary: Runs the frontend in development mode
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
cmds:
|
||||
- task: frontend:dev:{{.PACKAGE_MANAGER}}
|
||||
|
||||
frontend:dev:npm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- npm run dev -- --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
frontend:dev:yarn:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- yarn dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
frontend:dev:pnpm:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- pnpm dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
frontend:dev:bun:
|
||||
dir: frontend
|
||||
cmds:
|
||||
- bun run dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
update:build-assets:
|
||||
summary: Updates the build assets
|
||||
dir: build
|
||||
cmds:
|
||||
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||
|
||||
build:server:
|
||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||
desc: |
|
||||
Builds the application with the server build tag enabled.
|
||||
Server mode runs as a pure HTTP server without native GUI dependencies.
|
||||
Usage: task build:server
|
||||
deps:
|
||||
- task: build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
cmds:
|
||||
- go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||
vars:
|
||||
BUILD_FLAGS: "{{.BUILD_FLAGS}}"
|
||||
|
||||
run:server:
|
||||
summary: Builds and runs the application in server mode
|
||||
deps:
|
||||
- task: build:server
|
||||
cmds:
|
||||
- ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||
|
||||
build:docker:
|
||||
summary: Builds a Docker image for server mode deployment
|
||||
desc: |
|
||||
Creates a minimal Docker image containing the server mode binary.
|
||||
The image is based on distroless for security and small size.
|
||||
Usage: task build:docker [TAG=myapp:latest]
|
||||
cmds:
|
||||
- docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
|
||||
vars:
|
||||
TAG: "{{.TAG}}"
|
||||
preconditions:
|
||||
- sh: docker info > /dev/null 2>&1
|
||||
msg: "Docker is required. Please install Docker first."
|
||||
- sh: test -f build/docker/Dockerfile.server
|
||||
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
|
||||
|
||||
run:docker:
|
||||
summary: Builds and runs the Docker image
|
||||
desc: |
|
||||
Builds the Docker image and runs it, exposing port 8080.
|
||||
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
|
||||
Note: The internal container port is always 8080. The PORT variable
|
||||
only changes the host port mapping. Ensure your app uses port 8080
|
||||
or modify the Dockerfile to match your ServerOptions.Port setting.
|
||||
deps:
|
||||
- task: build:docker
|
||||
vars:
|
||||
TAG:
|
||||
ref: .TAG
|
||||
cmds:
|
||||
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
|
||||
vars:
|
||||
TAG: "{{.TAG}}"
|
||||
PORT: "{{.PORT}}"
|
||||
|
||||
setup:docker:
|
||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||
desc: |
|
||||
Builds the Docker image needed for cross-compiling to any platform.
|
||||
Run this once to enable cross-platform builds from any OS.
|
||||
cmds:
|
||||
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
|
||||
preconditions:
|
||||
- sh: docker info > /dev/null 2>&1
|
||||
msg: "Docker is required. Please install Docker first."
|
||||
|
||||
ios:device:list:
|
||||
summary: Lists connected iOS devices (UDIDs)
|
||||
cmds:
|
||||
- xcrun xcdevice list
|
||||
|
||||
ios:run:device:
|
||||
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
|
||||
vars:
|
||||
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
|
||||
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
|
||||
CONFIG: '{{.CONFIG | default "Debug"}}'
|
||||
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
|
||||
UDID: '{{.UDID}}' # from `task ios:device:list`
|
||||
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
|
||||
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
|
||||
preconditions:
|
||||
- sh: xcrun -f xcodebuild
|
||||
msg: "xcodebuild not found. Please install Xcode."
|
||||
- sh: xcrun -f devicectl
|
||||
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
|
||||
- sh: test -n '{{.PROJECT}}'
|
||||
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
|
||||
- sh: test -n '{{.SCHEME}}'
|
||||
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
|
||||
- sh: test -n '{{.UDID}}'
|
||||
msg: "Set UDID to your device UDID (see: task ios:device:list)."
|
||||
- sh: test -n '{{.BUNDLE_ID}}'
|
||||
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
|
||||
cmds:
|
||||
- |
|
||||
set -euo pipefail
|
||||
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
|
||||
XCB_ARGS=(
|
||||
-project "{{.PROJECT}}"
|
||||
-scheme "{{.SCHEME}}"
|
||||
-configuration "{{.CONFIG}}"
|
||||
-destination "id={{.UDID}}"
|
||||
-derivedDataPath "{{.DERIVED}}"
|
||||
-allowProvisioningUpdates
|
||||
-allowProvisioningDeviceRegistration
|
||||
)
|
||||
# Optionally inject signing identifiers if provided
|
||||
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
|
||||
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
|
||||
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
|
||||
# If xcpretty isn't installed, run without it
|
||||
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||
xcodebuild "${XCB_ARGS[@]}" build
|
||||
fi
|
||||
# Find built .app
|
||||
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing: $APP_PATH"
|
||||
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
|
||||
echo "Launching: {{.BUNDLE_ID}}"
|
||||
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-246,-251)">
|
||||
<g id="Ebene1">
|
||||
<path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 698 B |
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "extended-gray:1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"image-name" : "wails_icon_vector.svg",
|
||||
"name" : "wails_icon_vector",
|
||||
"position" : {
|
||||
"scale" : 1.25,
|
||||
"translation-in-points" : [
|
||||
36.890625,
|
||||
4.96875
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 130 KiB |
@@ -1,79 +0,0 @@
|
||||
# This file contains the configuration for this project.
|
||||
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
|
||||
# Note that this will overwrite any changes you have made to the assets.
|
||||
version: '3'
|
||||
|
||||
# This information is used to generate the build assets.
|
||||
info:
|
||||
companyName: "My Company" # The name of the company
|
||||
productName: "My Product" # The name of the application
|
||||
productIdentifier: "com.mycompany.myproduct" # The unique product identifier
|
||||
description: "A program that does X" # The application description
|
||||
copyright: "(c) 2025, My Company" # Copyright text
|
||||
comments: "Some Product Comments" # Comments
|
||||
version: "0.0.1" # The application version
|
||||
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
|
||||
# # Should match the name of your .icon file without the extension
|
||||
# # If not set and Assets.car exists, defaults to "appicon"
|
||||
|
||||
# iOS build configuration (uncomment to customise iOS project generation)
|
||||
# Note: Keys under `ios` OVERRIDE values under `info` when set.
|
||||
# ios:
|
||||
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
|
||||
# bundleID: "com.mycompany.myproduct"
|
||||
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
|
||||
# displayName: "My Product"
|
||||
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
|
||||
# version: "0.0.1"
|
||||
# # The company/organisation name for templates and project settings
|
||||
# company: "My Company"
|
||||
# # Additional comments to embed in Info.plist metadata
|
||||
# comments: "Some Product Comments"
|
||||
|
||||
# Dev mode configuration
|
||||
dev_mode:
|
||||
root_path: .
|
||||
log_level: warn
|
||||
debounce: 1000
|
||||
ignore:
|
||||
dir:
|
||||
- .git
|
||||
- node_modules
|
||||
- frontend
|
||||
- bin
|
||||
file:
|
||||
- .DS_Store
|
||||
- .gitignore
|
||||
- .gitkeep
|
||||
- "*_test.go"
|
||||
watched_extension:
|
||||
- "*.go"
|
||||
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
|
||||
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
|
||||
git_ignore: true
|
||||
executes:
|
||||
- cmd: wails3 build DEV=true
|
||||
type: blocking
|
||||
- cmd: wails3 task common:dev:frontend
|
||||
type: background
|
||||
- cmd: wails3 task run
|
||||
type: primary
|
||||
|
||||
# File Associations
|
||||
# More information at: https://v3.wails.io/noit/done/yet
|
||||
fileAssociations:
|
||||
# - ext: wails
|
||||
# name: Wails
|
||||
# description: Wails Application File
|
||||
# iconName: wailsFileIcon
|
||||
# role: Editor
|
||||
# - ext: jpg
|
||||
# name: JPEG
|
||||
# description: Image File
|
||||
# iconName: jpegFileIcon
|
||||
# role: Editor
|
||||
# mimeType: image/jpeg # (optional)
|
||||
|
||||
# Other data
|
||||
other:
|
||||
- name: My Other Data
|
||||
@@ -1,212 +0,0 @@
|
||||
# Cross-compile Wails v3 apps to any platform
|
||||
#
|
||||
# Darwin: Zig + macOS SDK
|
||||
# Linux: Native GCC when host matches target, Zig for cross-arch
|
||||
# Windows: Zig + bundled mingw
|
||||
#
|
||||
# Usage:
|
||||
# docker build -t wails-cross -f Dockerfile.cross .
|
||||
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
|
||||
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
|
||||
# docker run --rm -v $(pwd):/app wails-cross linux amd64
|
||||
# docker run --rm -v $(pwd):/app wails-cross linux arm64
|
||||
# docker run --rm -v $(pwd):/app wails-cross windows amd64
|
||||
# docker run --rm -v $(pwd):/app wails-cross windows arm64
|
||||
|
||||
FROM golang:1.26-bookworm
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG GARBLE_VERSION=v0.16.0
|
||||
|
||||
# Install base tools, GCC, and GTK/WebKit dev packages
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl xz-utils nodejs npm pkg-config gcc libc6-dev \
|
||||
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||
libgtk-4-dev libwebkitgtk-6.0-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN go install mvdan.cc/garble@${GARBLE_VERSION}
|
||||
|
||||
# Install Zig - automatically selects correct binary for host architecture
|
||||
ARG ZIG_VERSION=0.14.0
|
||||
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \
|
||||
| tar -xJ -C /opt \
|
||||
&& ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
|
||||
|
||||
# Download macOS SDK (required for darwin targets)
|
||||
ARG MACOS_SDK_VERSION=14.5
|
||||
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
|
||||
| tar -xJ -C /opt \
|
||||
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
|
||||
|
||||
ENV MACOS_SDK_PATH=/opt/macos-sdk
|
||||
|
||||
# Create Zig CC wrappers for cross-compilation targets
|
||||
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
|
||||
|
||||
# Darwin arm64
|
||||
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
|
||||
#!/bin/sh
|
||||
ARGS=""
|
||||
SKIP_NEXT=0
|
||||
for arg in "$@"; do
|
||||
if [ $SKIP_NEXT -eq 1 ]; then
|
||||
SKIP_NEXT=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
-target) SKIP_NEXT=1 ;;
|
||||
-mmacosx-version-min=*) ;;
|
||||
*) ARGS="$ARGS $arg" ;;
|
||||
esac
|
||||
done
|
||||
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
|
||||
ZIGWRAP
|
||||
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
|
||||
|
||||
# Darwin amd64
|
||||
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
|
||||
#!/bin/sh
|
||||
ARGS=""
|
||||
SKIP_NEXT=0
|
||||
for arg in "$@"; do
|
||||
if [ $SKIP_NEXT -eq 1 ]; then
|
||||
SKIP_NEXT=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
-target) SKIP_NEXT=1 ;;
|
||||
-mmacosx-version-min=*) ;;
|
||||
*) ARGS="$ARGS $arg" ;;
|
||||
esac
|
||||
done
|
||||
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
|
||||
ZIGWRAP
|
||||
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
|
||||
|
||||
# Windows amd64 - uses Zig's bundled mingw
|
||||
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
|
||||
#!/bin/sh
|
||||
ARGS=""
|
||||
SKIP_NEXT=0
|
||||
for arg in "$@"; do
|
||||
if [ $SKIP_NEXT -eq 1 ]; then
|
||||
SKIP_NEXT=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
-target) SKIP_NEXT=1 ;;
|
||||
-Wl,*) ;;
|
||||
*) ARGS="$ARGS $arg" ;;
|
||||
esac
|
||||
done
|
||||
exec zig cc -target x86_64-windows-gnu $ARGS
|
||||
ZIGWRAP
|
||||
RUN chmod +x /usr/local/bin/zcc-windows-amd64
|
||||
|
||||
# Windows arm64 - uses Zig's bundled mingw
|
||||
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
|
||||
#!/bin/sh
|
||||
ARGS=""
|
||||
SKIP_NEXT=0
|
||||
for arg in "$@"; do
|
||||
if [ $SKIP_NEXT -eq 1 ]; then
|
||||
SKIP_NEXT=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
-target) SKIP_NEXT=1 ;;
|
||||
-Wl,*) ;;
|
||||
*) ARGS="$ARGS $arg" ;;
|
||||
esac
|
||||
done
|
||||
exec zig cc -target aarch64-windows-gnu $ARGS
|
||||
ZIGWRAP
|
||||
RUN chmod +x /usr/local/bin/zcc-windows-arm64
|
||||
|
||||
# Build script
|
||||
COPY <<'SCRIPT' /usr/local/bin/build.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
OS=${1:-darwin}
|
||||
ARCH=${2:-arm64}
|
||||
|
||||
case "${OS}-${ARCH}" in
|
||||
darwin-arm64|darwin-aarch64)
|
||||
export CC=zcc-darwin-arm64
|
||||
export GOARCH=arm64
|
||||
export GOOS=darwin
|
||||
;;
|
||||
darwin-amd64|darwin-x86_64)
|
||||
export CC=zcc-darwin-amd64
|
||||
export GOARCH=amd64
|
||||
export GOOS=darwin
|
||||
;;
|
||||
linux-arm64|linux-aarch64)
|
||||
export CC=gcc
|
||||
export GOARCH=arm64
|
||||
export GOOS=linux
|
||||
;;
|
||||
linux-amd64|linux-x86_64)
|
||||
export CC=gcc
|
||||
export GOARCH=amd64
|
||||
export GOOS=linux
|
||||
;;
|
||||
windows-arm64|windows-aarch64)
|
||||
export CC=zcc-windows-arm64
|
||||
export GOARCH=arm64
|
||||
export GOOS=windows
|
||||
;;
|
||||
windows-amd64|windows-x86_64)
|
||||
export CC=zcc-windows-amd64
|
||||
export GOARCH=amd64
|
||||
export GOOS=windows
|
||||
;;
|
||||
*)
|
||||
echo "Usage: <os> <arch>"
|
||||
echo " os: darwin, linux, windows"
|
||||
echo " arch: amd64, arm64"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS="-w"
|
||||
|
||||
# Build frontend if exists and not already built (host may have built it)
|
||||
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
|
||||
(cd frontend && npm install --silent && npm run build --silent)
|
||||
fi
|
||||
|
||||
# Build
|
||||
APP=${APP_NAME:-$(basename $(pwd))}
|
||||
mkdir -p bin
|
||||
|
||||
EXT=""
|
||||
LDFLAGS="-s -w"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
EXT=".exe"
|
||||
LDFLAGS="-s -w -H windowsgui"
|
||||
fi
|
||||
|
||||
TAGS="production"
|
||||
if [ -n "$EXTRA_TAGS" ]; then
|
||||
TAGS="${TAGS},${EXTRA_TAGS}"
|
||||
fi
|
||||
|
||||
COMPILER="go build"
|
||||
if [ "$OBFUSCATED" = "true" ]; then
|
||||
COMPILER="garble ${GARBLE_ARGS} build"
|
||||
TAGS="${TAGS},wails_obfuscated"
|
||||
fi
|
||||
|
||||
${COMPILER} -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
|
||||
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
|
||||
SCRIPT
|
||||
RUN chmod +x /usr/local/bin/build.sh
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["/usr/local/bin/build.sh"]
|
||||
CMD ["darwin", "arm64"]
|
||||
@@ -1,41 +0,0 @@
|
||||
# Wails Server Mode Dockerfile
|
||||
# Multi-stage build for minimal image size
|
||||
|
||||
# Build stage
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Remove local replace directive if present (for production builds)
|
||||
RUN sed -i '/^replace/d' go.mod || true
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod tidy
|
||||
|
||||
# Build the server binary
|
||||
RUN go build -tags server -ldflags="-s -w" -o server .
|
||||
|
||||
# Runtime stage - minimal image
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /app/server /server
|
||||
|
||||
# Copy frontend assets
|
||||
COPY --from=builder /app/frontend/dist /frontend/dist
|
||||
|
||||
# Expose the default port
|
||||
EXPOSE 8080
|
||||
|
||||
# Bind to all interfaces (required for Docker)
|
||||
# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
|
||||
ENV WAILS_SERVER_HOST=0.0.0.0
|
||||
|
||||
# Run the server
|
||||
ENTRYPOINT ["/server"]
|
||||
@@ -1,224 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
vars:
|
||||
# Signing configuration - edit these values for your project
|
||||
# PGP_KEY: "path/to/signing-key.asc"
|
||||
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
|
||||
#
|
||||
# Password is stored securely in system keychain. Run: wails3 setup signing
|
||||
|
||||
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
|
||||
CROSS_IMAGE: wails-cross
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application for Linux
|
||||
cmds:
|
||||
# Linux requires CGO - use Docker when:
|
||||
# 1. Cross-compiling from non-Linux, OR
|
||||
# 2. No C compiler is available, OR
|
||||
# 3. Target architecture differs from host architecture (cross-arch compilation)
|
||||
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
|
||||
vars:
|
||||
ARCH: '{{.ARCH}}'
|
||||
DEV: '{{.DEV}}'
|
||||
OUTPUT: '{{.OUTPUT}}'
|
||||
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||
OBFUSCATED: '{{.OBFUSCATED}}'
|
||||
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
|
||||
vars:
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
# Determine target architecture (defaults to host ARCH if not specified)
|
||||
TARGET_ARCH: '{{.ARCH | default ARCH}}'
|
||||
# Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
|
||||
HAS_CC:
|
||||
sh: 'wails3 tool has-cc'
|
||||
|
||||
build:native:
|
||||
summary: Builds the application natively on Linux
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
DEV:
|
||||
ref: .DEV
|
||||
- task: common:generate:icons
|
||||
- task: generate:dotdesktop
|
||||
preconditions:
|
||||
- sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
|
||||
msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
|
||||
cmds:
|
||||
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
build:docker:
|
||||
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
OBFUSCATED:
|
||||
ref: .OBFUSCATED
|
||||
- task: common:generate:icons
|
||||
- task: generate:dotdesktop
|
||||
preconditions:
|
||||
- sh: docker info > /dev/null 2>&1
|
||||
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
|
||||
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||
msg: |
|
||||
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||
Build it first: wails3 task setup:docker
|
||||
cmds:
|
||||
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
|
||||
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||
platforms: [linux, darwin]
|
||||
- mkdir -p {{.BIN_DIR}}
|
||||
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||
vars:
|
||||
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
# Generate Docker volume mounts: Go module cache + go.mod replace directives
|
||||
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
|
||||
DOCKER_MOUNTS:
|
||||
sh: 'wails3 tool docker-mounts'
|
||||
|
||||
package:
|
||||
summary: Packages the application for Linux
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: create:appimage
|
||||
- task: create:deb
|
||||
- task: create:rpm
|
||||
- task: create:aur
|
||||
|
||||
create:appimage:
|
||||
summary: Creates an AppImage
|
||||
dir: build/linux/appimage
|
||||
deps:
|
||||
- task: build
|
||||
- task: generate:dotdesktop
|
||||
cmds:
|
||||
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
|
||||
- cp ../../appicon.png "{{.APP_NAME}}.png"
|
||||
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
|
||||
vars:
|
||||
APP_NAME: '{{.APP_NAME}}'
|
||||
APP_BINARY: '../../../bin/{{.APP_NAME}}'
|
||||
ICON: '{{.APP_NAME}}.png'
|
||||
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
|
||||
OUTPUT_DIR: '../../../bin'
|
||||
|
||||
create:deb:
|
||||
summary: Creates a deb package
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: generate:dotdesktop
|
||||
- task: generate:deb
|
||||
|
||||
create:rpm:
|
||||
summary: Creates a rpm package
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: generate:dotdesktop
|
||||
- task: generate:rpm
|
||||
|
||||
create:aur:
|
||||
summary: Creates a arch linux packager package
|
||||
deps:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: generate:dotdesktop
|
||||
- task: generate:aur
|
||||
|
||||
generate:deb:
|
||||
summary: Creates a deb package
|
||||
cmds:
|
||||
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||
|
||||
generate:rpm:
|
||||
summary: Creates a rpm package
|
||||
cmds:
|
||||
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||
|
||||
generate:aur:
|
||||
summary: Creates a arch linux packager package
|
||||
cmds:
|
||||
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||
|
||||
generate:dotdesktop:
|
||||
summary: Generates a `.desktop` file
|
||||
dir: build
|
||||
cmds:
|
||||
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
|
||||
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
|
||||
vars:
|
||||
APP_NAME: '{{.APP_NAME}}'
|
||||
EXEC: '{{.APP_NAME}}'
|
||||
ICON: '{{.APP_NAME}}'
|
||||
CATEGORIES: 'Development;'
|
||||
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
|
||||
|
||||
run:
|
||||
cmds:
|
||||
- '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
|
||||
sign:deb:
|
||||
summary: Signs the DEB package
|
||||
desc: |
|
||||
Signs the .deb package with a PGP key.
|
||||
Configure PGP_KEY in the vars section at the top of this file.
|
||||
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||
deps:
|
||||
- task: create:deb
|
||||
cmds:
|
||||
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
|
||||
preconditions:
|
||||
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||
|
||||
sign:rpm:
|
||||
summary: Signs the RPM package
|
||||
desc: |
|
||||
Signs the .rpm package with a PGP key.
|
||||
Configure PGP_KEY in the vars section at the top of this file.
|
||||
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||
deps:
|
||||
- task: create:rpm
|
||||
cmds:
|
||||
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
|
||||
preconditions:
|
||||
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||
|
||||
sign:packages:
|
||||
summary: Signs all Linux packages (DEB and RPM)
|
||||
desc: |
|
||||
Signs both .deb and .rpm packages with a PGP key.
|
||||
Configure PGP_KEY in the vars section at the top of this file.
|
||||
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||
cmds:
|
||||
- task: sign:deb
|
||||
- task: sign:rpm
|
||||
preconditions:
|
||||
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||
@@ -2,38 +2,58 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// App is the Wails v2 application adapter. It wraps core services.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
mu sync.RWMutex
|
||||
vaultOpen bool
|
||||
|
||||
db *storage.DB
|
||||
nodes *nodes.Repository
|
||||
templates *templates.Registry
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
activity *activity.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
plugins *plugins.Manager
|
||||
sync *syncsvc.Service
|
||||
vault string
|
||||
}
|
||||
|
||||
// requireVault returns an error if no vault is open and services are not initialized.
|
||||
// All binding methods that access vault services MUST call this first.
|
||||
func (a *App) requireVault() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startup is called when the app starts. Store context and wire drag-and-drop.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
@@ -44,18 +64,91 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) autoSyncLoop() {
|
||||
// Wait for vault to be ready
|
||||
time.Sleep(5 * time.Second)
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
|
||||
const checkInterval = 60 * time.Second
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("[autosync] started")
|
||||
var lastSync time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
a.mu.RLock()
|
||||
vaultPath := a.vault
|
||||
a.mu.RUnlock()
|
||||
|
||||
serverURL, _, _, _, _ := a.sync.GetState()
|
||||
if serverURL == "" {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil && appCfg.Vault.Sync.ServerURL != "" {
|
||||
serverURL = appCfg.Vault.Sync.ServerURL
|
||||
}
|
||||
}
|
||||
if serverURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
interval := 0
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil {
|
||||
interval = appCfg.Vault.Sync.SyncInterval
|
||||
}
|
||||
if interval <= 0 {
|
||||
continue
|
||||
}
|
||||
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceToken := config.LoadDeviceToken(vaultPath)
|
||||
if deviceToken == "" {
|
||||
continue
|
||||
}
|
||||
log.Printf("[autosync] running SyncNow...")
|
||||
if _, err := a.SyncNow(); err != nil {
|
||||
log.Printf("[autosync] SyncNow error: %v", err)
|
||||
} else {
|
||||
lastSync = time.Now()
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
log.Printf("[autosync] stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DTOs
|
||||
// ============================================================
|
||||
|
||||
type NodeDTO struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
TemplateID string `json:"template_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Archived bool `json:"archived"`
|
||||
HasChildren bool `json:"has_children"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TemplateDTO struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Section string `json:"section"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type SectionDTO struct {
|
||||
@@ -85,7 +178,7 @@ type FileDTO struct {
|
||||
type FileTreeItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"fileId,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
@@ -103,8 +196,15 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -115,367 +215,46 @@ type SearchResultDTO struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sections
|
||||
// ============================================================
|
||||
|
||||
func (a *App) ListSections() []SectionDTO {
|
||||
return []SectionDTO{
|
||||
{ID: "today", Label: "Сегодня"},
|
||||
{ID: "inbox", Label: "Неразобранное"},
|
||||
{ID: "clients", Label: "Клиенты"},
|
||||
{ID: "projects", Label: "Проекты"},
|
||||
{ID: "recipes", Label: "Рецепты"},
|
||||
{ID: "documents", Label: "Документы"},
|
||||
{ID: "archive", Label: "Архив"},
|
||||
}
|
||||
type EventDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
EventType string `json:"eventType"`
|
||||
TargetType string `json:"targetType"`
|
||||
TargetID string `json:"targetId"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
Title string `json:"title"`
|
||||
DetailsJSON string `json:"detailsJson"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Nodes
|
||||
// ============================================================
|
||||
|
||||
func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
||||
list, err := a.nodes.ListRoots(false, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(list), nil
|
||||
type CaseActivityDTO struct {
|
||||
Node NodeDTO `json:"node"`
|
||||
Events []EventDTO `json:"events"`
|
||||
}
|
||||
|
||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||
list, err := a.nodes.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(list), nil
|
||||
type SummaryDTO struct {
|
||||
ChangedCases int `json:"changedCases"`
|
||||
Notes int `json:"notes"`
|
||||
Files int `json:"files"`
|
||||
Actions int `json:"actions"`
|
||||
TimeEntries int `json:"timeEntries"`
|
||||
}
|
||||
|
||||
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
type TodayGroupDTO struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle"`
|
||||
NodeKind string `json:"nodeKind"`
|
||||
Section string `json:"section"`
|
||||
LastActivityAt string `json:"lastActivityAt"`
|
||||
Events []EventDTO `json:"events"`
|
||||
}
|
||||
|
||||
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
||||
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteNode(id string) error {
|
||||
return a.nodes.SoftDelete(id)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Notes
|
||||
// ============================================================
|
||||
|
||||
// ListNotes returns note-type children of a node.
|
||||
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []NodeDTO
|
||||
for i := range children {
|
||||
if children[i].Type == nodes.TypeNote {
|
||||
result = append(result, toNodeDTO(&children[i]))
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateNote creates a note under a parent node.
|
||||
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
node, _, err := a.notes.Create(parentID, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// ReadNote reads note content.
|
||||
func (a *App) ReadNote(noteID string) (string, error) {
|
||||
return a.notes.Read(noteID)
|
||||
}
|
||||
|
||||
// SaveNote saves note content.
|
||||
func (a *App) SaveNote(noteID, content string) error {
|
||||
return a.notes.Save(noteID, content)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Files
|
||||
// ============================================================
|
||||
|
||||
// ListFiles returns file records directly linked to a node (non-recursive).
|
||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
records, err := a.files.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileDTO, len(records))
|
||||
for i := range records {
|
||||
rec := &records[i]
|
||||
result[i] = FileDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Name: rec.Filename,
|
||||
Path: rec.Path,
|
||||
Size: rec.Size,
|
||||
Mime: rec.MIME,
|
||||
IsDir: rec.MIME == "inode/directory",
|
||||
Missing: rec.Missing,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListItems returns children of a node for the file tree view.
|
||||
// Folders can be expanded; files include their file record info.
|
||||
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
|
||||
continue
|
||||
}
|
||||
item := FileTreeItemDTO{
|
||||
ID: children[i].ID,
|
||||
Name: children[i].Title,
|
||||
Type: children[i].Type,
|
||||
}
|
||||
if children[i].Type == nodes.TypeFolder {
|
||||
// Check if this folder has children
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
} else if children[i].Type == nodes.TypeFile {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = records[0].MIME
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
node, err := a.files.CreateEmptyFile(parentID, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
node, err := a.files.Duplicate(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
return a.nodes.Move(nodeID, newParentID, 0)
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
return a.files.PreviewImport(sourcePath)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Actions
|
||||
// ============================================================
|
||||
|
||||
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
|
||||
list, err := a.actions.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ActionDTO, len(list))
|
||||
for i := range list {
|
||||
data := list[i].Command
|
||||
if list[i].URL != "" {
|
||||
data = list[i].URL
|
||||
}
|
||||
result[i] = ActionDTO{
|
||||
ID: list[i].ID,
|
||||
NodeID: list[i].NodeID,
|
||||
Title: list[i].Title,
|
||||
Type: list[i].Kind,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) RunAction(id string) error {
|
||||
_, err := a.actions.Run(id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Worklog
|
||||
// ============================================================
|
||||
|
||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||
list, err := a.worklog.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]WorklogDTO, len(list))
|
||||
for i := range list {
|
||||
mins := 0
|
||||
if list[i].Minutes != nil {
|
||||
mins = *list[i].Minutes
|
||||
}
|
||||
result[i] = WorklogDTO{
|
||||
ID: list[i].ID,
|
||||
NodeID: list[i].NodeID,
|
||||
Summary: list[i].Summary,
|
||||
Minutes: mins,
|
||||
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
||||
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
}
|
||||
dto := &WorklogDTO{
|
||||
ID: entry.ID,
|
||||
NodeID: entry.NodeID,
|
||||
Summary: entry.Summary,
|
||||
Minutes: mins,
|
||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// File Dialogs (Wails v2 Runtime)
|
||||
// ============================================================
|
||||
|
||||
func (a *App) PickFile() (string, error) {
|
||||
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: "Выберите файл",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickFiles() ([]string, error) {
|
||||
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: "Выберите файлы",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickDirectory() (string, error) {
|
||||
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: "Выберите папку",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// System helpers
|
||||
// ============================================================
|
||||
|
||||
func (a *App) OpenFile(fileID string) error {
|
||||
return a.files.Open(fileID)
|
||||
}
|
||||
|
||||
func (a *App) ReadFileText(fileID string) (string, error) {
|
||||
return a.files.ReadText(fileID)
|
||||
}
|
||||
|
||||
func (a *App) GetFileBase64(fileID string) (string, error) {
|
||||
return a.files.ReadBase64(fileID)
|
||||
}
|
||||
|
||||
func (a *App) OpenFolder(nodeID string) error {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get node: %w", err)
|
||||
}
|
||||
dir := filepath.Join(a.vault, "spaces", n.Slug)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
dir = a.vault
|
||||
}
|
||||
cmd := exec.Command("xdg-open", dir)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (a *App) VerstakVersion() string {
|
||||
return "verstak-gui/v2"
|
||||
type TodayDashboardDTO struct {
|
||||
Date string `json:"date"`
|
||||
Summary SummaryDTO `json:"summary"`
|
||||
Groups []TodayGroupDTO `json:"groups"`
|
||||
Events []EventDTO `json:"events"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -483,22 +262,17 @@ func (a *App) VerstakVersion() string {
|
||||
// ============================================================
|
||||
|
||||
func toNodeDTO(n *nodes.Node) NodeDTO {
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
path := ""
|
||||
if n.Path != nil {
|
||||
path = *n.Path
|
||||
}
|
||||
return NodeDTO{
|
||||
ID: n.ID,
|
||||
ParentID: parentID,
|
||||
Title: n.Title,
|
||||
ParentID: n.ParentID,
|
||||
Type: n.Type,
|
||||
Section: n.Section,
|
||||
Path: path,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
Title: n.Title,
|
||||
TemplateID: n.TemplateID,
|
||||
FsPath: n.FsPath,
|
||||
SortOrder: n.SortOrder,
|
||||
Archived: n.Archived,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,4 +284,158 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
|
||||
return result
|
||||
}
|
||||
|
||||
func toEventDTO(e activity.Event) EventDTO {
|
||||
return EventDTO{
|
||||
ID: e.ID,
|
||||
NodeID: e.NodeID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
TargetPath: e.TargetPath,
|
||||
Title: e.Title,
|
||||
DetailsJSON: e.DetailsJSON,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func nodePayload(n *nodes.Node) map[string]interface{} {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": n.ID,
|
||||
"parent_id": pid,
|
||||
"type": n.Type,
|
||||
"title": n.Title,
|
||||
"slug": n.Slug,
|
||||
"template_id": n.TemplateID,
|
||||
"fs_path": n.FsPath,
|
||||
"section": n.Section,
|
||||
"sort_order": n.SortOrder,
|
||||
"archived": n.Archived,
|
||||
"created_at": n.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": n.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) filePayload(n *nodes.Node) map[string]interface{} {
|
||||
p := map[string]interface{}{
|
||||
"node_id": n.ID,
|
||||
"type": n.Type,
|
||||
"title": n.Title,
|
||||
"slug": n.Slug,
|
||||
"created_at": n.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": n.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if n.ParentID != nil {
|
||||
p["parent_id"] = *n.ParentID
|
||||
}
|
||||
if recs, err := a.files.ListByNode(n.ID); err == nil && len(recs) > 0 {
|
||||
rec := recs[0]
|
||||
p["filename"] = rec.Filename
|
||||
p["path"] = rec.Path
|
||||
p["storage_mode"] = rec.StorageMode
|
||||
p["size"] = rec.Size
|
||||
p["sha256"] = rec.SHA256
|
||||
p["mime"] = rec.MIME
|
||||
p["file_id"] = rec.ID
|
||||
if rec.StorageMode == "vault" {
|
||||
if rec.SHA256 != "" {
|
||||
p["blob_sha256"] = rec.SHA256
|
||||
} else {
|
||||
absPath := filepath.Join(a.vault, rec.Path)
|
||||
if hash, err := syncsvc.HashFile(absPath); err == nil {
|
||||
p["blob_sha256"] = hash
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p["filename"] = n.Title
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} {
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"node_id": node.ID,
|
||||
"parent_id": pid,
|
||||
"title": node.Title,
|
||||
"file_id": fileRec.ID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": fileRec.Filename,
|
||||
"path": fileRec.Path,
|
||||
"created_at": node.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": node.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func actionPayload(rec *actions.Record) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": rec.ID,
|
||||
"node_id": rec.NodeID,
|
||||
"title": rec.Title,
|
||||
"kind": rec.Kind,
|
||||
"command": rec.Command,
|
||||
"args": rec.Args,
|
||||
"working_dir": rec.WorkingDir,
|
||||
"url": rec.URL,
|
||||
"confirm_required": rec.ConfirmRequired,
|
||||
"capture_output": rec.CaptureOutput,
|
||||
"created_at": rec.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": rec.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func worklogPayload(entry *worklog.Entry) map[string]interface{} {
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
}
|
||||
p := map[string]interface{}{
|
||||
"id": entry.ID,
|
||||
"node_id": entry.NodeID,
|
||||
"summary": entry.Summary,
|
||||
"details": entry.Details,
|
||||
"minutes": mins,
|
||||
"date": entry.Date,
|
||||
"approximate": entry.Approximate,
|
||||
"billable": entry.Billable,
|
||||
"created_at": entry.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": entry.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if entry.StartedAt != nil {
|
||||
p["started_at"] = entry.StartedAt.Format(time.RFC3339)
|
||||
}
|
||||
if entry.EndedAt != nil {
|
||||
p["ended_at"] = entry.EndedAt.Format(time.RFC3339)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func jsonArgs(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(args)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.actions.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ActionDTO, len(list))
|
||||
for i := range list {
|
||||
data := list[i].Command
|
||||
if list[i].URL != "" {
|
||||
data = list[i].URL
|
||||
}
|
||||
result[i] = ActionDTO{
|
||||
ID: list[i].ID,
|
||||
NodeID: list[i].NodeID,
|
||||
Title: list[i].Title,
|
||||
Type: list[i].Kind,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec))
|
||||
return &ActionDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Title: rec.Title,
|
||||
Type: rec.Kind,
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteAction(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
|
||||
return a.actions.Delete(id)
|
||||
}
|
||||
|
||||
func (a *App) RunAction(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.actions.Run(id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/i18n"
|
||||
)
|
||||
|
||||
type SystemViewDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func (a *App) ListSystemViews() []SystemViewDTO {
|
||||
return []SystemViewDTO{
|
||||
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
||||
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
||||
{ID: "trash", Label: i18n.TF("ru", "nav.trash")},
|
||||
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
|
||||
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aeByParent, err := a.activity.ListTodayEventsByParent()
|
||||
if err != nil {
|
||||
aeByParent = nil
|
||||
}
|
||||
todayNodes, _ := a.nodes.ListTodayNodes()
|
||||
|
||||
type rawEvent struct {
|
||||
NodeID string
|
||||
EventType string
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetPath string
|
||||
Title string
|
||||
CreatedAt string
|
||||
}
|
||||
type caseInfo struct {
|
||||
Node nodes.Node
|
||||
Events []rawEvent
|
||||
}
|
||||
caseMap := make(map[string]*caseInfo)
|
||||
|
||||
ensureCase := func(caseID string) *caseInfo {
|
||||
if ci, ok := caseMap[caseID]; ok {
|
||||
return ci
|
||||
}
|
||||
ci := &caseInfo{Events: nil}
|
||||
if n, err := a.nodes.GetActive(caseID); err == nil {
|
||||
ci.Node = *n
|
||||
}
|
||||
caseMap[caseID] = ci
|
||||
return ci
|
||||
}
|
||||
|
||||
for pid, events := range aeByParent {
|
||||
ci := ensureCase(pid)
|
||||
for _, e := range events {
|
||||
ci.Events = append(ci.Events, rawEvent{
|
||||
NodeID: e.NodeID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
TargetPath: e.TargetPath,
|
||||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range todayNodes {
|
||||
_ = ensureCase(n.ID)
|
||||
if ci := caseMap[n.ID]; ci.Node.ID == "" {
|
||||
ci.Node = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []TodayGroupDTO
|
||||
var flatEvents []EventDTO
|
||||
summary := SummaryDTO{}
|
||||
|
||||
for _, ci := range caseMap {
|
||||
if ci.Node.ID == "" {
|
||||
continue
|
||||
}
|
||||
summary.ChangedCases++
|
||||
|
||||
dtoEvents := make([]EventDTO, 0, len(ci.Events))
|
||||
for _, re := range ci.Events {
|
||||
dtoEvents = append(dtoEvents, EventDTO{
|
||||
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
|
||||
NodeID: re.NodeID,
|
||||
EventType: re.EventType,
|
||||
TargetType: re.TargetType,
|
||||
TargetID: re.TargetID,
|
||||
TargetPath: re.TargetPath,
|
||||
Title: re.Title,
|
||||
CreatedAt: re.CreatedAt,
|
||||
})
|
||||
switch re.EventType {
|
||||
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
|
||||
summary.Notes++
|
||||
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
|
||||
summary.Files++
|
||||
}
|
||||
}
|
||||
|
||||
last := ci.Node.UpdatedAt.Format(time.RFC3339)
|
||||
for _, e := range dtoEvents {
|
||||
if e.CreatedAt > last {
|
||||
last = e.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
groups = append(groups, TodayGroupDTO{
|
||||
NodeID: ci.Node.ID,
|
||||
NodeTitle: ci.Node.Title,
|
||||
NodeKind: ci.Node.Type,
|
||||
Section: ci.Node.Section,
|
||||
LastActivityAt: last,
|
||||
Events: dtoEvents,
|
||||
})
|
||||
flatEvents = append(flatEvents, dtoEvents...)
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].LastActivityAt > groups[j].LastActivityAt
|
||||
})
|
||||
sort.Slice(flatEvents, func(i, j int) bool {
|
||||
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
|
||||
})
|
||||
|
||||
return &TodayDashboardDTO{
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Summary: summary,
|
||||
Groups: groups,
|
||||
Events: flatEvents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListRecent(limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return a.activity.CountByNode(nodeID)
|
||||
}
|
||||
|
||||
var _ = syncsvc.EntityNode
|
||||
|
||||
func (a *App) listActivityByNodeSubtree(nodeID string, limit, offset int) ([]activity.Event, error) {
|
||||
rows, err := a.db.Query(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT e.id, e.node_id, e.event_type, COALESCE(e.target_type,''), COALESCE(e.target_id,''), COALESCE(e.target_path,''),
|
||||
e.title, COALESCE(e.metadata,'{}'), e.created_at
|
||||
FROM activity_events e
|
||||
JOIN subtree s ON s.id = e.node_id
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []activity.Event
|
||||
for rows.Next() {
|
||||
var e activity.Event
|
||||
if err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, &e.TargetType, &e.TargetID, &e.TargetPath, &e.Title, &e.DetailsJSON, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
|
||||
dto := toEventDTO(e)
|
||||
dto.NodePath = a.nodes.Path(e.NodeID)
|
||||
return dto
|
||||
}
|
||||
|
||||
// ListTodayInProgress returns today's modification events — items the user worked on.
|
||||
func (a *App) ListTodayInProgress() ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListTodayEvents()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modTypes := map[string]bool{
|
||||
activity.TypeNoteCreated: true,
|
||||
activity.TypeNoteUpdated: true,
|
||||
activity.TypeNoteDeleted: true,
|
||||
activity.TypeFileAdded: true,
|
||||
activity.TypeFileDeleted: true,
|
||||
activity.TypeFileRenamed: true,
|
||||
activity.TypeFileCopied: true,
|
||||
activity.TypeFileMoved: true,
|
||||
activity.TypeFolderAdded: true,
|
||||
activity.TypeFolderDeleted: true,
|
||||
activity.TypeFolderRenamed: true,
|
||||
activity.TypeFolderMoved: true,
|
||||
activity.TypeNodeCreated: true,
|
||||
activity.TypeNodeUpdated: true,
|
||||
activity.TypeNodeDeleted: true,
|
||||
activity.TypeActionCreated: true,
|
||||
activity.TypeActionDone: true,
|
||||
}
|
||||
result := make([]EventDTO, 0, len(events))
|
||||
for _, e := range events {
|
||||
if modTypes[e.EventType] {
|
||||
result = append(result, a.eventDTOWithPath(e))
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].CreatedAt > result[j].CreatedAt
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
type CaptureContextDTO struct {
|
||||
ContextType string `json:"contextType"`
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
Section string `json:"section,omitempty"`
|
||||
SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) CaptureText(text string) (*InboxNodeDTO, error) {
|
||||
return a.CaptureTextWithContext(text, "clipboard", "")
|
||||
}
|
||||
|
||||
func (a *App) CaptureTextWithContext(text, source, contextJSON string) (*InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("text required")
|
||||
}
|
||||
title := firstLineTitle(text, "Captured text")
|
||||
content := "# " + title + "\n\n" + text + "\n"
|
||||
ctx := parseCaptureContext(contextJSON)
|
||||
return a.createCaptureNote(title, content, "text", source, ctx)
|
||||
}
|
||||
|
||||
func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) {
|
||||
return a.CaptureURLWithContext(rawURL, title, "clipboard", "")
|
||||
}
|
||||
|
||||
func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (*InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("url required")
|
||||
}
|
||||
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid url")
|
||||
}
|
||||
rawURL = normalizedURL
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = linkTitle(rawURL, "")
|
||||
}
|
||||
ctx := parseCaptureContext(contextJSON)
|
||||
node, err := a.nodes.Create(nil, nodes.TypeLink, firstLineTitle(title, rawURL), 0, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create capture link: %w", err)
|
||||
}
|
||||
if err := a.setCaptureMeta(node.ID, "url", source, ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.nodes.MetaSet(node.ID, "capture.url", rawURL)
|
||||
_ = a.nodes.MetaSet(node.ID, "capture.title", title)
|
||||
_ = a.nodes.MetaSet(node.ID, "capture.hostname", hostnameForURL(rawURL))
|
||||
_ = a.activity.Record("", activity.TargetNode, node.ID, "", activity.TypeNodeCreated, title, `{"capture":true,"kind":"url"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNode, node.ID, syncsvc.OpCreate, nodePayload(node))
|
||||
return a.inboxNodeDTO(node)
|
||||
}
|
||||
|
||||
func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) {
|
||||
return a.CapturePathWithContext(sourcePath, "drop", "")
|
||||
}
|
||||
|
||||
func (a *App) CapturePathWithContext(sourcePath, source, contextJSON string) (*InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourcePath = strings.TrimSpace(sourcePath)
|
||||
if sourcePath == "" {
|
||||
return nil, fmt.Errorf("path required")
|
||||
}
|
||||
absPath, err := filepath.Abs(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs path: %w", err)
|
||||
}
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
|
||||
nodeType := nodes.TypeFile
|
||||
kind := captureKindForFilename(absPath)
|
||||
if info.IsDir() {
|
||||
nodeType = nodes.TypeFolder
|
||||
kind = "folder"
|
||||
}
|
||||
node, err := a.nodes.Create(nil, nodeType, filepath.Base(absPath), 0, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create capture node: %w", err)
|
||||
}
|
||||
stagingRel, _, err := a.captureStagingDir(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := a.nodes.UpdateFsPath(node.ID, stagingRel); err != nil {
|
||||
return nil, fmt.Errorf("set capture folder path: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source dir: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if _, err := a.files.AddPathCopy(node.ID, filepath.Join(absPath, entry.Name())); err != nil {
|
||||
return nil, fmt.Errorf("copy capture child %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err := a.files.CopyIntoVault(node.ID, absPath, stagingRel); err != nil {
|
||||
return nil, fmt.Errorf("copy capture file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := parseCaptureContext(contextJSON)
|
||||
if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := activity.TargetFile
|
||||
evType := activity.TypeFileAdded
|
||||
entity := syncsvc.EntityFile
|
||||
if info.IsDir() {
|
||||
target = activity.TargetFolder
|
||||
evType = activity.TypeFolderAdded
|
||||
entity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.activity.Record("", target, node.ID, "", evType, node.Title, `{"capture":true}`)
|
||||
_ = a.sync.RecordOp(entity, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
return a.inboxNodeDTO(node)
|
||||
}
|
||||
|
||||
func (a *App) CaptureFileData(filename, dataBase64 string) (*InboxNodeDTO, error) {
|
||||
return a.CaptureFileDataWithContext(filename, dataBase64, "clipboard", "")
|
||||
}
|
||||
|
||||
func (a *App) CaptureFileDataWithContext(filename, dataBase64, source, contextJSON string) (*InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename = filepath.Base(strings.TrimSpace(filename))
|
||||
if filename == "." || filename == "" {
|
||||
filename = "clipboard.bin"
|
||||
}
|
||||
if err := files.ValidateName(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if comma := strings.Index(dataBase64, ","); comma >= 0 {
|
||||
dataBase64 = dataBase64[comma+1:]
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataBase64))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode file data: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("file data required")
|
||||
}
|
||||
|
||||
node, err := a.nodes.Create(nil, nodes.TypeFile, filename, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create capture file node: %w", err)
|
||||
}
|
||||
stagingRel, stagingAbs, err := a.captureStagingDir(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
absPath := filepath.Join(stagingAbs, filename)
|
||||
if err := os.WriteFile(absPath, data, 0o640); err != nil {
|
||||
return nil, fmt.Errorf("write capture data: %w", err)
|
||||
}
|
||||
relPath := filepath.Join(stagingRel, filename)
|
||||
fileRec := &files.Record{
|
||||
ID: util.UUID7(),
|
||||
NodeID: node.ID,
|
||||
Filename: filename,
|
||||
Path: relPath,
|
||||
StorageMode: "vault",
|
||||
Size: int64(len(data)),
|
||||
MIME: mimeForFilename(filename),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,0)`,
|
||||
fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode,
|
||||
fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil {
|
||||
return nil, fmt.Errorf("insert capture file data: %w", err)
|
||||
}
|
||||
ctx := parseCaptureContext(contextJSON)
|
||||
if err := a.setCaptureMeta(node.ID, captureKindForFilename(filename), source, ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record("", activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, `{"capture":true}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
return a.inboxNodeDTO(node)
|
||||
}
|
||||
|
||||
func (a *App) createCaptureNote(title, content, kind, source string, ctx CaptureContextDTO) (*InboxNodeDTO, error) {
|
||||
node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
inboxDir := filepath.Join(a.vault, ".verstak", "inbox")
|
||||
if err := os.MkdirAll(inboxDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create inbox dir: %w", err)
|
||||
}
|
||||
filename := node.ID + ".md"
|
||||
absPath := filepath.Join(inboxDir, filename)
|
||||
if err := os.WriteFile(absPath, []byte(content), 0o640); err != nil {
|
||||
return nil, fmt.Errorf("write capture note: %w", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, absPath)
|
||||
now := time.Now().UTC()
|
||||
fileRec := &files.Record{
|
||||
ID: util.UUID7(),
|
||||
NodeID: node.ID,
|
||||
Filename: filename,
|
||||
Path: relPath,
|
||||
StorageMode: "vault",
|
||||
Size: int64(len(content)),
|
||||
MIME: "text/markdown",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,0)`,
|
||||
fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode,
|
||||
fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil {
|
||||
return nil, fmt.Errorf("insert capture file: %w", err)
|
||||
}
|
||||
if _, err := a.db.Exec(`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown"); err != nil {
|
||||
return nil, fmt.Errorf("insert capture note: %w", err)
|
||||
}
|
||||
if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = a.activity.Record("", activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, `{"capture":true}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content))
|
||||
|
||||
return a.inboxNodeDTO(node)
|
||||
}
|
||||
|
||||
func (a *App) captureStagingDir(node *nodes.Node) (string, string, error) {
|
||||
segment := node.ID + "_" + templates.SafeDisplayNameToPathSegment(node.Title)
|
||||
rel := filepath.Join(".verstak", "inbox", segment)
|
||||
abs := filepath.Join(a.vault, rel)
|
||||
if err := os.MkdirAll(abs, 0o750); err != nil {
|
||||
return "", "", fmt.Errorf("create inbox staging dir: %w", err)
|
||||
}
|
||||
return rel, abs, nil
|
||||
}
|
||||
|
||||
func (a *App) setCaptureMeta(nodeID, kind, source string, ctx CaptureContextDTO) error {
|
||||
if source == "" {
|
||||
source = "clipboard"
|
||||
}
|
||||
ctx = normalizeCaptureContext(ctx)
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.inbox", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.status", "unresolved"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.kind", kind); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.source_kind", kind); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.source", source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.context_type", ctx.ContextType); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.NodeID != "" {
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.context_node_id", ctx.NodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ctx.Section != "" {
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.context_section", ctx.Section); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ctx.SuggestedTargetNodeID != "" {
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.suggested_target_node_id", ctx.SuggestedTargetNodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return a.nodes.MetaSet(nodeID, "capture.created_at", time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) {
|
||||
dto := &InboxNodeDTO{NodeDTO: toNodeDTO(n)}
|
||||
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.kind"); err == nil && ok {
|
||||
dto.CaptureKind = kind
|
||||
}
|
||||
if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok {
|
||||
dto.CaptureSource = source
|
||||
}
|
||||
if status, ok, err := a.nodes.MetaGet(n.ID, "capture.status"); err == nil && ok {
|
||||
dto.CaptureStatus = status
|
||||
}
|
||||
if dto.CaptureStatus == "" {
|
||||
dto.CaptureStatus = "unresolved"
|
||||
}
|
||||
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.source_kind"); err == nil && ok {
|
||||
dto.SourceKind = kind
|
||||
}
|
||||
if dto.SourceKind == "" {
|
||||
dto.SourceKind = dto.CaptureKind
|
||||
}
|
||||
if contextType, ok, err := a.nodes.MetaGet(n.ID, "capture.context_type"); err == nil && ok {
|
||||
dto.CaptureContextType = contextType
|
||||
}
|
||||
if dto.CaptureContextType == "" {
|
||||
dto.CaptureContextType = "global"
|
||||
}
|
||||
if nodeID, ok, err := a.nodes.MetaGet(n.ID, "capture.context_node_id"); err == nil && ok {
|
||||
dto.CaptureContextNodeID = nodeID
|
||||
dto.CaptureContextLabel = a.captureNodeLabel(nodeID)
|
||||
}
|
||||
if section, ok, err := a.nodes.MetaGet(n.ID, "capture.context_section"); err == nil && ok {
|
||||
dto.CaptureContextSection = section
|
||||
if dto.CaptureContextLabel == "" {
|
||||
dto.CaptureContextLabel = section
|
||||
}
|
||||
}
|
||||
if targetID, ok, err := a.nodes.MetaGet(n.ID, "capture.suggested_target_node_id"); err == nil && ok {
|
||||
dto.SuggestedTargetNodeID = targetID
|
||||
dto.SuggestedTargetLabel = a.captureNodeLabel(targetID)
|
||||
}
|
||||
if capturedAt, ok, err := a.nodes.MetaGet(n.ID, "capture.created_at"); err == nil && ok {
|
||||
dto.CapturedAt = capturedAt
|
||||
}
|
||||
if rawURL, ok, err := a.nodes.MetaGet(n.ID, "capture.url"); err == nil && ok {
|
||||
dto.URL = rawURL
|
||||
}
|
||||
if hostname, ok, err := a.nodes.MetaGet(n.ID, "capture.hostname"); err == nil && ok {
|
||||
dto.Hostname = hostname
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func parseCaptureContext(contextJSON string) CaptureContextDTO {
|
||||
var ctx CaptureContextDTO
|
||||
if strings.TrimSpace(contextJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(contextJSON), &ctx)
|
||||
}
|
||||
return normalizeCaptureContext(ctx)
|
||||
}
|
||||
|
||||
func normalizeCaptureContext(ctx CaptureContextDTO) CaptureContextDTO {
|
||||
ctx.ContextType = strings.TrimSpace(ctx.ContextType)
|
||||
ctx.NodeID = strings.TrimSpace(ctx.NodeID)
|
||||
ctx.Section = strings.TrimSpace(ctx.Section)
|
||||
ctx.SuggestedTargetNodeID = strings.TrimSpace(ctx.SuggestedTargetNodeID)
|
||||
switch ctx.ContextType {
|
||||
case "node":
|
||||
if ctx.NodeID == "" {
|
||||
ctx.ContextType = "global"
|
||||
break
|
||||
}
|
||||
if ctx.SuggestedTargetNodeID == "" {
|
||||
ctx.SuggestedTargetNodeID = ctx.NodeID
|
||||
}
|
||||
case "section":
|
||||
if ctx.Section == "" {
|
||||
ctx.Section = "root"
|
||||
}
|
||||
case "global":
|
||||
default:
|
||||
if ctx.NodeID != "" {
|
||||
ctx.ContextType = "node"
|
||||
if ctx.SuggestedTargetNodeID == "" {
|
||||
ctx.SuggestedTargetNodeID = ctx.NodeID
|
||||
}
|
||||
} else if ctx.Section != "" {
|
||||
ctx.ContextType = "section"
|
||||
} else {
|
||||
ctx.ContextType = "global"
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (a *App) captureNodeLabel(nodeID string) string {
|
||||
if nodeID == "" {
|
||||
return ""
|
||||
}
|
||||
if p := a.nodes.Path(nodeID); p != "" {
|
||||
return p
|
||||
}
|
||||
if n, err := a.nodes.GetActive(nodeID); err == nil {
|
||||
return n.Title
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hostnameForURL(rawURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func captureKindForFilename(filename string) string {
|
||||
if strings.HasPrefix(mimeForFilename(filename), "image/") {
|
||||
return "image"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
func mimeForFilename(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
}
|
||||
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
|
||||
if semi := strings.Index(mimeType, ";"); semi >= 0 {
|
||||
return mimeType[:semi]
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func firstLineTitle(text, fallback string) string {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
if len(line) > 80 {
|
||||
return line[:80]
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func (a *App) ReadClipboardText() (string, error) {
|
||||
text, err := wailsruntime.ClipboardGetText(a.ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clipboard text is unavailable")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (a *App) CaptureClipboardTextWithContext(contextJSON string) (*InboxNodeDTO, error) {
|
||||
text, err := a.ReadClipboardText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, value := classifyClipboardText(text)
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("clipboard is empty")
|
||||
}
|
||||
if kind == "url" {
|
||||
return a.CaptureURLWithContext(value, "", "clipboard_button", contextJSON)
|
||||
}
|
||||
return a.CaptureTextWithContext(value, "clipboard_button", contextJSON)
|
||||
}
|
||||
|
||||
func classifyClipboardText(text string) (string, string) {
|
||||
value := strings.TrimSpace(text)
|
||||
if value == "" {
|
||||
return "text", ""
|
||||
}
|
||||
if normalized, ok := normalizeHTTPURL(value); ok {
|
||||
return "url", normalized
|
||||
}
|
||||
return "text", value
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/vault"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// StartupStatus describes the application startup state.
|
||||
type StartupStatus struct {
|
||||
Status string `json:"status"` // "first_run", "recovery", "ready"
|
||||
VaultPath string `json:"vaultPath"` // configured or default vault path
|
||||
VaultExists bool `json:"vaultExists"` // whether index.db exists at the path
|
||||
DefaultPath string `json:"defaultPath"` // default vault path suggestion
|
||||
Error string `json:"error,omitempty"`
|
||||
AppConfig *config.AppConfig `json:"appConfig,omitempty"`
|
||||
}
|
||||
|
||||
// GetStartupStatus checks the global config and vault state.
|
||||
func (a *App) GetStartupStatus() (*StartupStatus, error) {
|
||||
defaultPath, _ := config.DefaultVaultPath()
|
||||
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
DefaultPath: defaultPath,
|
||||
Error: fmt.Sprintf("config load error: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No config at all → first run
|
||||
if appCfg == nil {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
DefaultPath: defaultPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config says not completed → first run
|
||||
if !appCfg.FirstRunCompleted {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config has no vault path → first run
|
||||
if appCfg.VaultPath == "" {
|
||||
appCfg.VaultPath = defaultPath
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// Check if vault exists
|
||||
vaultExists := vaultExistsAt(appCfg.VaultPath)
|
||||
if !vaultExists {
|
||||
return &StartupStatus{
|
||||
Status: "recovery",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Initialize services so that the vault is ready for use
|
||||
if err := a.initVault(appCfg.VaultPath); err != nil {
|
||||
return &StartupStatus{
|
||||
Status: "recovery",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
Error: fmt.Sprintf("init vault: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vaultExistsAt(vaultPath string) bool {
|
||||
dbPath := filepath.Join(vaultPath, ".verstak", "index.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault at the given path and initializes all services.
|
||||
func (a *App) CreateVault(vaultPath string) (*StartupStatus, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("vault path is empty")
|
||||
}
|
||||
|
||||
// Create vault directories and database
|
||||
if err := vault.Init(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("create vault: %w", err)
|
||||
}
|
||||
|
||||
// Initialize services for this vault
|
||||
if err := a.initVault(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("init vault services: %w", err)
|
||||
}
|
||||
|
||||
// Save global config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.VaultPath = vaultPath
|
||||
appCfg.FirstRunCompleted = true
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[startup] vault created at %s", vaultPath)
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: vaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenVault opens an existing vault and initializes services.
|
||||
func (a *App) OpenVault(vaultPath string) (*StartupStatus, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("vault path is empty")
|
||||
}
|
||||
if !vaultExistsAt(vaultPath) {
|
||||
return nil, fmt.Errorf("vault not found at %s", vaultPath)
|
||||
}
|
||||
|
||||
if err := a.initVault(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("init vault: %w", err)
|
||||
}
|
||||
|
||||
// Update config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.VaultPath = vaultPath
|
||||
appCfg.FirstRunCompleted = true
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[startup] vault opened at %s", vaultPath)
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: vaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initVault opens the vault DB and initializes all core services.
|
||||
func (a *App) initVault(vaultPath string) error {
|
||||
// Close previous vault if any
|
||||
a.closeVault()
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
activitySvc := activity.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Discover()
|
||||
|
||||
templatesReg := templates.NewRegistry()
|
||||
if err := templatesReg.LoadSystem(); err != nil {
|
||||
log.Printf("warning: failed to load system templates: %v", err)
|
||||
}
|
||||
|
||||
// Apply enabled templates from config
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
for _, t := range templatesReg.All() {
|
||||
if !enabledSet[t.ID] {
|
||||
_ = templatesReg.Disable(t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync service
|
||||
deviceID := ""
|
||||
_ = appCfg // will store sync settings
|
||||
if appCfg != nil && appCfg.Vault.Sync.DeviceID != "" {
|
||||
deviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
if deviceID == "" {
|
||||
deviceID = "gui-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
|
||||
a.mu.Lock()
|
||||
a.db = db
|
||||
a.nodes = nodeRepo
|
||||
a.files = fileSvc
|
||||
a.notes = noteSvc
|
||||
a.activity = activitySvc
|
||||
a.actions = actionSvc
|
||||
a.worklog = worklogSvc
|
||||
a.search = searchSvc
|
||||
a.plugins = pm
|
||||
a.templates = templatesReg
|
||||
a.sync = syncSvc
|
||||
a.vault = abs
|
||||
a.vaultOpen = true
|
||||
a.mu.Unlock()
|
||||
|
||||
// Start auto-sync loop
|
||||
go a.autoSyncLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeVault shuts down current vault services if any.
|
||||
func (a *App) closeVault() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.vaultOpen {
|
||||
return
|
||||
}
|
||||
if a.db != nil {
|
||||
a.db.Close()
|
||||
}
|
||||
a.db = nil
|
||||
a.nodes = nil
|
||||
a.files = nil
|
||||
a.notes = nil
|
||||
a.activity = nil
|
||||
a.actions = nil
|
||||
a.worklog = nil
|
||||
a.search = nil
|
||||
a.plugins = nil
|
||||
a.templates = nil
|
||||
a.sync = nil
|
||||
a.vault = ""
|
||||
a.vaultOpen = false
|
||||
}
|
||||
|
||||
// IsReady returns true if a vault is open and services are initialized.
|
||||
func (a *App) IsReady() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.vaultOpen
|
||||
}
|
||||
|
||||
// GetAppConfig returns the current global app config.
|
||||
func (a *App) GetAppConfig() (*config.AppConfig, error) {
|
||||
cfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
return config.DefaultAppConfig(), nil
|
||||
}
|
||||
if cfg == nil {
|
||||
return config.DefaultAppConfig(), nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveAppConfig saves the global app config.
|
||||
func (a *App) SaveAppConfig(cfg *config.AppConfig) error {
|
||||
return config.SaveAppConfig(cfg)
|
||||
}
|
||||
|
||||
// GetDefaultVaultPath returns the default vault path.
|
||||
func (a *App) GetDefaultVaultPath() (string, error) {
|
||||
return config.DefaultVaultPath()
|
||||
}
|
||||
|
||||
// CheckVaultPath checks whether a given path is usable as a vault.
|
||||
type CheckVaultPathResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
HasVault bool `json:"hasVault"`
|
||||
Writable bool `json:"writable"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) CheckVaultPath(vaultPath string) (*CheckVaultPathResult, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("path is empty")
|
||||
}
|
||||
info, err := os.Stat(vaultPath)
|
||||
exists := err == nil
|
||||
hasVault := false
|
||||
writable := false
|
||||
|
||||
if exists {
|
||||
if info.IsDir() {
|
||||
writable = checkDirWritable(vaultPath)
|
||||
hasVault = vaultExistsAt(vaultPath)
|
||||
}
|
||||
} else {
|
||||
// Path doesn't exist - check if parent is writable
|
||||
parent := filepath.Dir(vaultPath)
|
||||
parentInfo, pErr := os.Stat(parent)
|
||||
if pErr == nil && parentInfo.IsDir() {
|
||||
writable = checkDirWritable(parent)
|
||||
}
|
||||
}
|
||||
|
||||
desc := describeVaultPath(exists, hasVault)
|
||||
|
||||
return &CheckVaultPathResult{
|
||||
Exists: exists,
|
||||
HasVault: hasVault,
|
||||
Writable: writable,
|
||||
Description: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkDirWritable(dir string) bool {
|
||||
testFile := filepath.Join(dir, ".verstak-write-test")
|
||||
if err := os.WriteFile(testFile, []byte{}, 0o640); err != nil {
|
||||
return false
|
||||
}
|
||||
os.Remove(testFile)
|
||||
return true
|
||||
}
|
||||
|
||||
func describeVaultPath(exists, hasVault bool) string {
|
||||
if !exists {
|
||||
return "Путь не существует. Будет создан новый vault."
|
||||
}
|
||||
if hasVault {
|
||||
return "Найден существующий vault. Можно подключиться."
|
||||
}
|
||||
return "Папка существует, но vault не найден. Можно создать новый vault."
|
||||
}
|
||||
|
||||
// VaultInfo returns information about the currently open vault.
|
||||
type VaultInfo struct {
|
||||
Path string `json:"path"`
|
||||
DBPath string `json:"dbPath"`
|
||||
FilesPath string `json:"filesPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
Healthy bool `json:"healthy"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
FileCount int `json:"fileCount"`
|
||||
}
|
||||
|
||||
func (a *App) GetVaultInfo() (*VaultInfo, error) {
|
||||
if !a.IsReady() {
|
||||
return nil, fmt.Errorf("vault not open")
|
||||
}
|
||||
a.mu.RLock()
|
||||
vp := a.vault
|
||||
nodesCount := 0
|
||||
if a.nodes != nil {
|
||||
roots, _ := a.nodes.ListRoots(true)
|
||||
nodesCount = len(roots)
|
||||
}
|
||||
fileCount := 0
|
||||
_ = a.db.QueryRow("SELECT COUNT(*) FROM files").Scan(&fileCount)
|
||||
a.mu.RUnlock()
|
||||
|
||||
return &VaultInfo{
|
||||
Path: vp,
|
||||
DBPath: filepath.Join(vp, ".verstak", "index.db"),
|
||||
FilesPath: filepath.Join(vp, "spaces"),
|
||||
TrashPath: filepath.Join(vp, ".verstak", "trash"),
|
||||
Healthy: true,
|
||||
NodeCount: nodesCount,
|
||||
FileCount: fileCount,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WriteDebugLog appends a line to <vault>/.verstak/debug.log.
|
||||
// Called from frontend to log JS-side diagnostics in production GUI builds.
|
||||
func (a *App) WriteDebugLog(msg string) {
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
logPath := filepath.Join(a.vault, ".verstak", "debug.log")
|
||||
line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), msg)
|
||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
f.WriteString(line)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, err := a.files.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileDTO, len(records))
|
||||
for i := range records {
|
||||
rec := &records[i]
|
||||
result[i] = FileDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Name: rec.Filename,
|
||||
Path: rec.Path,
|
||||
Size: rec.Size,
|
||||
Mime: rec.MIME,
|
||||
IsDir: rec.MIME == "inode/directory",
|
||||
Missing: rec.Missing,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
|
||||
continue
|
||||
}
|
||||
item := FileTreeItemDTO{
|
||||
ID: children[i].ID,
|
||||
Name: children[i].Title,
|
||||
Type: children[i].Type,
|
||||
}
|
||||
if children[i].Type == nodes.TypeFolder {
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
} else if children[i].Type == nodes.TypeFile {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = records[0].MIME
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
evType := activity.TypeFileDeleted
|
||||
targetType := activity.TargetFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
evType = activity.TypeFolderDeleted
|
||||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
||||
syncEntity := syncsvc.EntityFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
|
||||
}
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := a.files.CreateEmptyFile(parentID, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := a.files.Duplicate(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err2 := a.nodes.GetActive(nodeID)
|
||||
pid := ""
|
||||
if err2 == nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) ValidateName(name string) error {
|
||||
return files.ValidateName(name)
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.files.PreviewImport(sourcePath)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
)
|
||||
|
||||
type InboxNodeDTO struct {
|
||||
NodeDTO
|
||||
CaptureKind string `json:"captureKind"`
|
||||
CaptureSource string `json:"captureSource"`
|
||||
CaptureStatus string `json:"captureStatus"`
|
||||
CaptureContextType string `json:"captureContextType"`
|
||||
CaptureContextNodeID string `json:"captureContextNodeId"`
|
||||
CaptureContextSection string `json:"captureContextSection"`
|
||||
SuggestedTargetNodeID string `json:"suggestedTargetNodeId"`
|
||||
CaptureContextLabel string `json:"captureContextLabel"`
|
||||
SuggestedTargetLabel string `json:"suggestedTargetLabel"`
|
||||
CapturedAt string `json:"capturedAt"`
|
||||
SourceKind string `json:"sourceKind"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.nodes.ListInboxRoots(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos := make([]InboxNodeDTO, 0, len(list))
|
||||
for _, n := range list {
|
||||
dto, err := a.inboxNodeDTO(&n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos = append(dtos, *dto)
|
||||
}
|
||||
for i := range dtos {
|
||||
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos[i].HasChildren = n > 0
|
||||
}
|
||||
return dtos, nil
|
||||
}
|
||||
|
||||
func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nodeID == "" {
|
||||
return []InboxNodeDTO{}, nil
|
||||
}
|
||||
list, err := a.ListInboxNodes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]InboxNodeDTO, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.CaptureContextNodeID == nodeID || item.SuggestedTargetNodeID == nodeID {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
|
||||
return a.ResolveInboxNode(nodeID, targetParentID)
|
||||
}
|
||||
|
||||
func (a *App) ResolveInboxNodeHere(nodeID string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto, err := a.inboxNodeByID(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dto.SuggestedTargetNodeID == "" {
|
||||
return nil, fmt.Errorf("suggested target is required")
|
||||
}
|
||||
return a.ResolveInboxNode(nodeID, dto.SuggestedTargetNodeID)
|
||||
}
|
||||
|
||||
func (a *App) ResolveInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !a.isInboxCaptureNode(nodeID) {
|
||||
return nil, fmt.Errorf("node is not an inbox artifact")
|
||||
}
|
||||
if targetParentID == "" {
|
||||
return nil, fmt.Errorf("target parent is required")
|
||||
}
|
||||
sourceKind := a.captureMeta(nodeID, "capture.source_kind")
|
||||
if sourceKind == "" {
|
||||
sourceKind = a.captureMeta(nodeID, "capture.kind")
|
||||
}
|
||||
if sourceKind == "url" {
|
||||
if err := a.resolveURLInboxNode(nodeID, targetParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.GetNodeDetail(targetParentID)
|
||||
}
|
||||
if err := a.MoveNode(nodeID, targetParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.clearCaptureMeta(nodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto, err := a.GetNodeDetail(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (a *App) inboxNodeByID(nodeID string) (*InboxNodeDTO, error) {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.inboxNodeDTO(n)
|
||||
}
|
||||
|
||||
func (a *App) resolveURLInboxNode(nodeID, targetParentID string) error {
|
||||
if _, err := a.nodes.GetActive(targetParentID); err != nil {
|
||||
return fmt.Errorf("target parent not found: %w", err)
|
||||
}
|
||||
rawURL := a.captureMeta(nodeID, "capture.url")
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("captured url is missing")
|
||||
}
|
||||
title := a.captureMeta(nodeID, "capture.title")
|
||||
if title == "" {
|
||||
if n, err := a.nodes.GetActive(nodeID); err == nil {
|
||||
title = n.Title
|
||||
}
|
||||
}
|
||||
source := a.captureMeta(nodeID, "capture.source")
|
||||
capturedAt := a.captureMeta(nodeID, "capture.created_at")
|
||||
if _, err := a.createResolvedLink(targetParentID, rawURL, title, "", source, capturedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.DeleteNode(nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.clearCaptureMeta(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) DeleteInboxNode(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !a.isInboxCaptureNode(nodeID) {
|
||||
return fmt.Errorf("node is not an inbox artifact")
|
||||
}
|
||||
if err := a.DeleteNode(nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.clearCaptureMeta(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) filterInboxCaptureNodes(list []NodeDTO) []NodeDTO {
|
||||
out := make([]NodeDTO, 0, len(list))
|
||||
for _, item := range list {
|
||||
if !a.isInboxCaptureNode(item.ID) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) isInboxCaptureNode(nodeID string) bool {
|
||||
v, ok, err := a.nodes.MetaGet(nodeID, "capture.inbox")
|
||||
if err != nil || !ok || v != "true" {
|
||||
return false
|
||||
}
|
||||
status, ok, err := a.nodes.MetaGet(nodeID, "capture.status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !ok || status == "" || status == "unresolved"
|
||||
}
|
||||
|
||||
func (a *App) clearCaptureMeta(nodeID string) error {
|
||||
_, err := a.db.Exec(`DELETE FROM node_meta WHERE node_id = ? AND key LIKE 'capture.%'`, nodeID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListTodayCaptures returns inbox nodes captured today.
|
||||
func (a *App) ListTodayCaptures() ([]InboxNodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := a.ListInboxNodes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end := activity.TodayBoundaries()
|
||||
startTime, _ := time.Parse(time.RFC3339, start)
|
||||
endTime, _ := time.Parse(time.RFC3339, end)
|
||||
result := make([]InboxNodeDTO, 0, len(all))
|
||||
for _, item := range all {
|
||||
if item.CapturedAt == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, item.CapturedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if (t.Equal(startTime) || t.After(startTime)) && t.Before(endTime) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) captureMeta(nodeID, key string) string {
|
||||
v, ok, err := a.nodes.MetaGet(nodeID, key)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
type LinkDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Hostname string `json:"hostname"`
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
CapturedAt string `json:"capturedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (a *App) ListLinks(nodeID string) ([]LinkDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nodeID == "" {
|
||||
return []LinkDTO{}, nil
|
||||
}
|
||||
rows, err := a.db.Query(
|
||||
`SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at
|
||||
FROM links
|
||||
WHERE node_id = ?
|
||||
ORDER BY created_at DESC, title`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LinkDTO
|
||||
for rows.Next() {
|
||||
var l LinkDTO
|
||||
if err := rows.Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) UpdateLink(id, title, rawURL, note string) (*LinkDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
title = strings.TrimSpace(title)
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("link id required")
|
||||
}
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("url required")
|
||||
}
|
||||
if title == "" {
|
||||
title = linkTitle(rawURL, "")
|
||||
}
|
||||
updatedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := a.db.Exec(
|
||||
`UPDATE links SET title=?, url=?, hostname=?, note=?, updated_at=? WHERE id=?`,
|
||||
title, rawURL, hostnameForURL(rawURL), note, updatedAt, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, fmt.Errorf("link not found")
|
||||
}
|
||||
return a.getLink(id)
|
||||
}
|
||||
|
||||
func (a *App) DeleteLink(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := a.db.Exec(`DELETE FROM links WHERE id = ?`, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("link not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) OpenLink(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := a.getLink(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return openExternalURL(l.URL)
|
||||
}
|
||||
|
||||
func (a *App) OpenURL(rawURL string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
return openExternalURL(rawURL)
|
||||
}
|
||||
|
||||
func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt string) (*LinkDTO, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("url required")
|
||||
}
|
||||
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid url")
|
||||
}
|
||||
rawURL = normalizedURL
|
||||
title = linkTitle(rawURL, title)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := util.UUID7()
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.getLink(id)
|
||||
}
|
||||
|
||||
func (a *App) getLink(id string) (*LinkDTO, error) {
|
||||
var l LinkDTO
|
||||
err := a.db.QueryRow(
|
||||
`SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at
|
||||
FROM links WHERE id = ?`, strings.TrimSpace(id)).
|
||||
Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func linkTitle(rawURL, title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title != "" {
|
||||
return firstLineTitle(title, title)
|
||||
}
|
||||
if h := hostnameForURL(rawURL); h != "" {
|
||||
return h
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func isURLLike(text string) bool {
|
||||
_, ok := normalizeHTTPURL(text)
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeHTTPURL(text string) (string, bool) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(text, " \t\r\n") || strings.Contains(text, "@") {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(text)
|
||||
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
|
||||
return text, true
|
||||
}
|
||||
if u != nil && u.Scheme != "" {
|
||||
return "", false
|
||||
}
|
||||
withScheme := "https://" + text
|
||||
u, err = url.Parse(withScheme)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" || !strings.Contains(host, ".") {
|
||||
return "", false
|
||||
}
|
||||
return withScheme, true
|
||||
}
|
||||
|
||||
func openExternalURL(rawURL string) error {
|
||||
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid url")
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", normalizedURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", normalizedURL)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", normalizedURL)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.nodes.ListRoots(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos := filterContainers(a.filterInboxCaptureNodes(toNodeDTOs(list)))
|
||||
for i := range dtos {
|
||||
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos[i].HasChildren = n > 0
|
||||
}
|
||||
return dtos, nil
|
||||
}
|
||||
|
||||
func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.nodes.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos := filterContainers(a.filterInboxCaptureNodes(toNodeDTOs(list)))
|
||||
for i := range dtos {
|
||||
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtos[i].HasChildren = n > 0
|
||||
}
|
||||
return dtos, nil
|
||||
}
|
||||
|
||||
func filterContainers(dtos []NodeDTO) []NodeDTO {
|
||||
out := make([]NodeDTO, 0, len(dtos))
|
||||
for _, d := range dtos {
|
||||
if isContainerType(d.Type) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isContainerType(typ string) bool {
|
||||
switch typ {
|
||||
case "case", "client", "project", "folder", "document", "recipe":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.nodes.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(list), nil
|
||||
}
|
||||
|
||||
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) GetNodeTitle(nodeID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return n.Title, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, ok := a.templates.Get(templateID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("template %q not found", templateID)
|
||||
}
|
||||
|
||||
seg := templates.SafeDisplayNameToPathSegment(title)
|
||||
if seg == "" {
|
||||
seg = "node"
|
||||
}
|
||||
|
||||
var parent *nodes.Node
|
||||
var parentFsPath string
|
||||
if parentID != "" {
|
||||
var err error
|
||||
parent, err = a.nodes.GetActive(parentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent not found: %w", err)
|
||||
}
|
||||
parentFsPath = parent.FsPath
|
||||
}
|
||||
|
||||
fsPath := seg
|
||||
if parentFsPath != "" {
|
||||
fsPath = filepath.Join(parentFsPath, seg)
|
||||
}
|
||||
|
||||
physPath := filepath.Join(a.vault, fsPath)
|
||||
physPath = templates.UniquePath(physPath)
|
||||
rel, _ := filepath.Rel(a.vault, physPath)
|
||||
fsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, fsPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
var pID *string
|
||||
if parentID != "" {
|
||||
pID = &parentID
|
||||
}
|
||||
|
||||
sortOrder := 0
|
||||
n, err := a.nodes.Create(pID, tmpl.Type, title, sortOrder, tmpl.ID, fsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||
_ = a.nodes.SoftDelete(n.ID)
|
||||
return nil, fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
|
||||
// Create child nodes for default files and folders with rollback on failure.
|
||||
nowRFC := time.Now().UTC().Format(time.RFC3339)
|
||||
type childInfo struct {
|
||||
id string
|
||||
file bool
|
||||
}
|
||||
var created []childInfo
|
||||
rollbackChildren := func() {
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
_ = a.nodes.SoftDelete(created[i].id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fpath := filepath.Join(physPath, df.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create directory for %s: %w", df.Path, err)
|
||||
}
|
||||
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child node for %s: %w", df.Path, childErr)
|
||||
}
|
||||
created = append(created, childInfo{id: childNode.ID, file: true})
|
||||
|
||||
content := fmt.Sprintf("# %s\n\n", title)
|
||||
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("write file %s: %w", df.Path, err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, fpath)
|
||||
fi, _ := os.Stat(fpath)
|
||||
size := int64(0)
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fileID := util.UUID7()
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
|
||||
"node_id": childNode.ID,
|
||||
"parent_id": n.ID,
|
||||
"title": fileTitle,
|
||||
"file_id": fileID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": filepath.Base(fpath),
|
||||
"path": relPath,
|
||||
"created_at": nowRFC,
|
||||
"updated_at": nowRFC,
|
||||
})
|
||||
}
|
||||
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr)
|
||||
}
|
||||
created = append(created, childInfo{id: childNode.ID, file: false})
|
||||
|
||||
childFsPath := folderSeg
|
||||
if fsPath != "" {
|
||||
childFsPath = filepath.Join(fsPath, folderSeg)
|
||||
}
|
||||
childPhysPath := filepath.Join(a.vault, childFsPath)
|
||||
childPhysPath = templates.UniquePath(childPhysPath)
|
||||
childRel, _ := filepath.Rel(a.vault, childPhysPath)
|
||||
childFsPath = childRel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err)
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
pid := ""
|
||||
if parentID != "" {
|
||||
pid = parentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, `{"template":"`+templateID+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
|
||||
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteNode(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := a.nodes.GetActive(id)
|
||||
if err != nil {
|
||||
return a.nodes.SoftDelete(id)
|
||||
}
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
var entity string
|
||||
var targetType string
|
||||
var evType string
|
||||
switch n.Type {
|
||||
case nodes.TypeNote:
|
||||
entity = syncsvc.EntityNote
|
||||
targetType = activity.TargetNote
|
||||
evType = activity.TypeNoteDeleted
|
||||
case nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient, nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace:
|
||||
entity = syncsvc.EntityFolder
|
||||
targetType = activity.TargetFolder
|
||||
evType = activity.TypeFolderDeleted
|
||||
case nodes.TypeFile:
|
||||
entity = syncsvc.EntityFile
|
||||
targetType = activity.TargetFile
|
||||
evType = activity.TypeFileDeleted
|
||||
default:
|
||||
entity = syncsvc.EntityNode
|
||||
targetType = activity.TargetNode
|
||||
evType = activity.TypeNodeDeleted
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "")
|
||||
_ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil)
|
||||
|
||||
return a.files.DeleteNodeAndChildren(id)
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seg := templates.SafeDisplayNameToPathSegment(newTitle)
|
||||
if seg == "" {
|
||||
seg = "node"
|
||||
}
|
||||
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
if isFolderLike {
|
||||
return a.renameFolderNode(nodeID, n, newTitle, seg)
|
||||
}
|
||||
return a.renameNoteFileNode(nodeID, n, newTitle, seg)
|
||||
}
|
||||
|
||||
// renameFolderNode renames a folder-like node atomically: FS first, then DB transaction.
|
||||
func (a *App) renameFolderNode(nodeID string, n *nodes.Node, newTitle, seg string) error {
|
||||
if n.FsPath == "" {
|
||||
return fmt.Errorf("cannot rename node %s (%s): fs_path is empty", n.ID, n.Title)
|
||||
}
|
||||
|
||||
oldFsPath := n.FsPath
|
||||
oldPhysPath := filepath.Join(a.vault, oldFsPath)
|
||||
oldTitle := n.Title
|
||||
|
||||
parentFsPath := ""
|
||||
if n.ParentID != nil {
|
||||
if p, err := a.nodes.GetActive(*n.ParentID); err == nil {
|
||||
parentFsPath = p.FsPath
|
||||
}
|
||||
}
|
||||
newFsPath := seg
|
||||
if parentFsPath != "" {
|
||||
newFsPath = filepath.Join(parentFsPath, seg)
|
||||
}
|
||||
newPhysPath := filepath.Join(a.vault, newFsPath)
|
||||
newPhysPath = templates.UniquePath(newPhysPath)
|
||||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
|
||||
return fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
// Pre-collect all descendant fs_path updates (before transaction, to avoid deadlock).
|
||||
type pathUp struct{ id, path string }
|
||||
var updates []pathUp
|
||||
var walk func(string, string)
|
||||
walk = func(id, p string) {
|
||||
updates = append(updates, pathUp{id, p})
|
||||
children, _ := a.nodes.ListChildren(id, false)
|
||||
for _, c := range children {
|
||||
cseg := templates.SafeDisplayNameToPathSegment(c.Title)
|
||||
walk(c.ID, filepath.Join(p, cseg))
|
||||
}
|
||||
}
|
||||
walk(nodeID, newFsPath)
|
||||
|
||||
// Check source exists before filesystem rename
|
||||
if _, err := os.Stat(oldPhysPath); err != nil {
|
||||
return fmt.Errorf("source folder not found: %w", err)
|
||||
}
|
||||
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||
return fmt.Errorf("rename folder: %w", err)
|
||||
}
|
||||
|
||||
// Atomic DB transaction: title + own fs_path + all descendant fs_paths
|
||||
nowT := time.Now().UTC().Format(time.RFC3339)
|
||||
slug := nodes.Slugify(newTitle)
|
||||
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
newTitle, slug, nowT, nodeID); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
for _, u := range updates {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
u.path, nowT, u.id); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderRenamed, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"fs_path": newFsPath,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// renameNoteFileNode renames a note or file node atomically: FS first, then DB transaction.
|
||||
func (a *App) renameNoteFileNode(nodeID string, n *nodes.Node, newTitle, seg string) error {
|
||||
// Collect file records before any mutations (avoid deadlock with SetMaxOpenConns=1).
|
||||
type fileRec struct {
|
||||
id, path, filename string
|
||||
}
|
||||
var records []fileRec
|
||||
rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var r fileRec
|
||||
if err := rows.Scan(&r.id, &r.path, &r.filename); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
// Compute new filenames and paths (no FS modifications yet).
|
||||
type renameOp struct {
|
||||
id string
|
||||
oldAbs string
|
||||
newAbs string
|
||||
newFilename string
|
||||
newPath string
|
||||
}
|
||||
var renameOps []renameOp
|
||||
for _, r := range records {
|
||||
if r.path == "" {
|
||||
continue
|
||||
}
|
||||
oldAbs := filepath.Join(a.vault, r.path)
|
||||
dir := filepath.Dir(r.path)
|
||||
var newFilename string
|
||||
if n.Type == nodes.TypeNote {
|
||||
ext := filepath.Ext(r.filename)
|
||||
if ext == "" {
|
||||
ext = ".md"
|
||||
}
|
||||
newFilename = seg + ext
|
||||
} else {
|
||||
newFilename = newTitle
|
||||
}
|
||||
newPath := filepath.Join(dir, newFilename)
|
||||
newAbs := filepath.Join(a.vault, newPath)
|
||||
|
||||
// Check for collision
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
ext := filepath.Ext(newFilename)
|
||||
base := strings.TrimSuffix(newFilename, ext)
|
||||
baseSeg := templates.SafeDisplayNameToPathSegment(base)
|
||||
if baseSeg == "" {
|
||||
baseSeg = "renamed"
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s (%d)%s", baseSeg, i, ext)
|
||||
candidatePath := filepath.Join(dir, candidate)
|
||||
if _, err := os.Stat(filepath.Join(a.vault, candidatePath)); os.IsNotExist(err) {
|
||||
newFilename = candidate
|
||||
newPath = candidatePath
|
||||
newAbs = filepath.Join(a.vault, newPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldAbs); err != nil {
|
||||
return fmt.Errorf("source file not found for rename: %w", err)
|
||||
}
|
||||
renameOps = append(renameOps, renameOp{
|
||||
id: r.id, oldAbs: oldAbs, newAbs: newAbs,
|
||||
newFilename: newFilename, newPath: newPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Perform all physical renames first
|
||||
successCount := 0
|
||||
for i, rop := range renameOps {
|
||||
if err := os.Rename(rop.oldAbs, rop.newAbs); err != nil {
|
||||
// Rollback completed FS renames
|
||||
for j := 0; j < i; j++ {
|
||||
_ = os.Rename(renameOps[j].newAbs, renameOps[j].oldAbs)
|
||||
}
|
||||
return fmt.Errorf("rename file: %w", err)
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
// Atomic DB transaction: title + all file record updates
|
||||
nowT := time.Now().UTC().Format(time.RFC3339)
|
||||
slug := nodes.Slugify(newTitle)
|
||||
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
for _, rop := range renameOps[:successCount] {
|
||||
_ = os.Rename(rop.newAbs, rop.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
newTitle, slug, nowT, nodeID); err != nil {
|
||||
for _, rop := range renameOps[:successCount] {
|
||||
_ = os.Rename(rop.newAbs, rop.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, rop := range renameOps {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE files SET filename=?, path=? WHERE id=?`,
|
||||
rop.newFilename, rop.newPath, rop.id); err != nil {
|
||||
for _, rop2 := range renameOps[:successCount] {
|
||||
_ = os.Rename(rop2.newAbs, rop2.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
for _, rop := range renameOps[:successCount] {
|
||||
_ = os.Rename(rop.newAbs, rop.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
var evType string
|
||||
var targetType string
|
||||
var syncEntity string
|
||||
if n.Type == nodes.TypeNote {
|
||||
evType = activity.TypeNoteUpdated
|
||||
targetType = activity.TargetNote
|
||||
syncEntity = syncsvc.EntityNote
|
||||
} else {
|
||||
evType = activity.TypeFileRenamed
|
||||
targetType = activity.TargetFile
|
||||
syncEntity = syncsvc.EntityFile
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "")
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"updated_at": nowT,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// wouldCreateCycle checks that moving nodeID into newParentID does not create a cycle.
|
||||
// It walks from newParentID up toward root: if nodeID is encountered, then newParentID
|
||||
// is inside nodeID's subtree, and the move would create a cycle.
|
||||
func (a *App) wouldCreateCycle(nodeID, newParentID string) error {
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("node ID is empty")
|
||||
}
|
||||
if newParentID == "" {
|
||||
return nil
|
||||
}
|
||||
current := newParentID
|
||||
depth := 0
|
||||
for current != "" && depth < 1000 {
|
||||
if current == nodeID {
|
||||
return fmt.Errorf("cannot move a node into itself or its descendant")
|
||||
}
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil || n.ParentID == nil {
|
||||
return nil
|
||||
}
|
||||
current = *n.ParentID
|
||||
depth++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("node ID is required")
|
||||
}
|
||||
|
||||
node, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("node not found: %w", err)
|
||||
}
|
||||
|
||||
// Cannot move into itself
|
||||
if nodeID == newParentID {
|
||||
return fmt.Errorf("cannot move a node into itself")
|
||||
}
|
||||
|
||||
// Cannot move into descendant (would create cycle)
|
||||
if err := a.wouldCreateCycle(nodeID, newParentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile
|
||||
|
||||
// Resolve new parent
|
||||
var parent *nodes.Node
|
||||
if newParentID != "" {
|
||||
parent, err = a.nodes.GetActive(newParentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new parent not found: %w", err)
|
||||
}
|
||||
if !isContainerType(parent.Type) {
|
||||
return fmt.Errorf("target %q is not a container (type: %s)", parent.Title, parent.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute new title (name conflict resolution) but don't commit yet.
|
||||
nodeTitle := node.Title
|
||||
titleChanged := false
|
||||
if parent != nil {
|
||||
destChildren, _ := a.nodes.ListChildren(newParentID, false)
|
||||
for i := range destChildren {
|
||||
if destChildren[i].Title == nodeTitle && destChildren[i].ID != nodeID {
|
||||
nodeTitle = fmt.Sprintf("%s (%d)", nodeTitle, 2)
|
||||
titleChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isFolderLike {
|
||||
return a.moveFolderNode(nodeID, node, parent, newParentID, nodeTitle, titleChanged)
|
||||
}
|
||||
return a.moveNoteFileNode(nodeID, node, parent, newParentID, nodeTitle, titleChanged)
|
||||
}
|
||||
|
||||
// moveFolderNode moves a folder-like node atomically: FS first, then DB transaction.
|
||||
func (a *App) moveFolderNode(nodeID string, node *nodes.Node, parent *nodes.Node, newParentID, nodeTitle string, titleChanged bool) error {
|
||||
if node.FsPath == "" {
|
||||
return fmt.Errorf("cannot move node %s (%s): fs_path is empty", node.ID, node.Title)
|
||||
}
|
||||
|
||||
oldFsPath := node.FsPath
|
||||
oldPhysPath := filepath.Join(a.vault, oldFsPath)
|
||||
|
||||
seg := templates.SafeDisplayNameToPathSegment(nodeTitle)
|
||||
newFsPath := seg
|
||||
if parent != nil && parent.FsPath != "" {
|
||||
newFsPath = filepath.Join(parent.FsPath, seg)
|
||||
}
|
||||
newPhysPath := filepath.Join(a.vault, newFsPath)
|
||||
newPhysPath = templates.UniquePath(newPhysPath)
|
||||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
|
||||
return fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
// Pre-collect all descendant fs_path updates (before transaction).
|
||||
type pathUp struct{ id, path string }
|
||||
var updates []pathUp
|
||||
var walk func(string, string)
|
||||
walk = func(id, p string) {
|
||||
updates = append(updates, pathUp{id, p})
|
||||
children, _ := a.nodes.ListChildren(id, false)
|
||||
for _, c := range children {
|
||||
cseg := templates.SafeDisplayNameToPathSegment(c.Title)
|
||||
walk(c.ID, filepath.Join(p, cseg))
|
||||
}
|
||||
}
|
||||
walk(nodeID, newFsPath)
|
||||
|
||||
// Check source exists and do filesystem rename first
|
||||
if _, err := os.Stat(oldPhysPath); err != nil {
|
||||
return fmt.Errorf("source folder not found: %w", err)
|
||||
}
|
||||
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||
return fmt.Errorf("move folder: %w", err)
|
||||
}
|
||||
|
||||
// Atomic DB transaction: title + parent_id + all fs_paths
|
||||
nowT := time.Now().UTC().Format(time.RFC3339)
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if titleChanged {
|
||||
slug := nodes.Slugify(nodeTitle)
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
nodeTitle, slug, nowT, nodeID); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var parentVal interface{}
|
||||
if newParentID != "" {
|
||||
parentVal = newParentID
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
parentVal, nowT, nodeID); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Touch new parent
|
||||
if newParentID != "" {
|
||||
_, _ = tx.Exec(`UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nowT, newParentID)
|
||||
}
|
||||
|
||||
// Touch old parent if different
|
||||
if node.ParentID != nil && *node.ParentID != newParentID {
|
||||
_, _ = tx.Exec(`UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, nowT, *node.ParentID)
|
||||
}
|
||||
|
||||
for _, u := range updates {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
u.path, nowT, u.id); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
_ = os.Rename(newPhysPath, oldPhysPath)
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
// Update node.ParentID for activity/sync below
|
||||
if newParentID == "" {
|
||||
node.ParentID = nil
|
||||
} else {
|
||||
node.ParentID = &newParentID
|
||||
}
|
||||
node.FsPath = newFsPath
|
||||
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFolder, nodeID, "", activity.TypeFolderMoved, nodeTitle, `{"to":"`+newParentID+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"fs_path": newFsPath,
|
||||
"updated_at": nowT,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveNoteFileNode moves a note/file atomically: FS first, then DB transaction.
|
||||
func (a *App) moveNoteFileNode(nodeID string, node *nodes.Node, parent *nodes.Node, newParentID, nodeTitle string, titleChanged bool) error {
|
||||
// Collect file records before any mutations.
|
||||
type fileMove struct {
|
||||
id string
|
||||
oldPath string
|
||||
oldAbs string
|
||||
newRelPath string
|
||||
newAbs string
|
||||
}
|
||||
var fileMoves []fileMove
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var fm fileMove
|
||||
if err := frows.Scan(&fm.id, &fm.oldPath); err != nil {
|
||||
continue
|
||||
}
|
||||
if fm.oldPath == "" {
|
||||
continue
|
||||
}
|
||||
fm.oldAbs = filepath.Join(a.vault, fm.oldPath)
|
||||
filename := filepath.Base(fm.oldPath)
|
||||
fm.newRelPath = filename
|
||||
if parent != nil && parent.FsPath != "" {
|
||||
fm.newRelPath = filepath.Join(parent.FsPath, filename)
|
||||
}
|
||||
fm.newAbs = filepath.Join(a.vault, fm.newRelPath)
|
||||
fileMoves = append(fileMoves, fm)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
// Perform filesystem moves first (with rollback on partial failure).
|
||||
for i, fm := range fileMoves {
|
||||
if _, err := os.Stat(fm.oldAbs); err != nil {
|
||||
for j := 0; j < i; j++ {
|
||||
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
|
||||
}
|
||||
return fmt.Errorf("source file not found for move: %w", err)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(fm.newAbs), 0o750)
|
||||
if err := os.Rename(fm.oldAbs, fm.newAbs); err != nil {
|
||||
for j := 0; j < i; j++ {
|
||||
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
|
||||
}
|
||||
return fmt.Errorf("move file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic DB transaction: title + parent_id + file paths
|
||||
nowT := time.Now().UTC().Format(time.RFC3339)
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if titleChanged {
|
||||
slug := nodes.Slugify(nodeTitle)
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
nodeTitle, slug, nowT, nodeID); err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var parentVal interface{}
|
||||
if newParentID != "" {
|
||||
parentVal = newParentID
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`,
|
||||
parentVal, nowT, nodeID); err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fm := range fileMoves {
|
||||
if _, err := tx.Exec(`UPDATE files SET path=? WHERE id=?`,
|
||||
fm.newRelPath, fm.id); err != nil {
|
||||
for _, fm2 := range fileMoves {
|
||||
_ = os.Rename(fm2.newAbs, fm2.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
if newParentID == "" {
|
||||
node.ParentID = nil
|
||||
} else {
|
||||
node.ParentID = &newParentID
|
||||
}
|
||||
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
var targetType string
|
||||
var evType string
|
||||
var syncEntity string
|
||||
if node.Type == nodes.TypeNote {
|
||||
targetType = activity.TargetNote
|
||||
evType = activity.TypeNoteUpdated
|
||||
syncEntity = syncsvc.EntityNote
|
||||
} else {
|
||||
targetType = activity.TargetFile
|
||||
evType = activity.TypeFileMoved
|
||||
syncEntity = syncsvc.EntityFile
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, nodeTitle, `{"to":"`+newParentID+`"}`)
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"updated_at": nowT,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return []TemplateDTO{}, nil
|
||||
}
|
||||
list := a.templates.Enabled()
|
||||
result := make([]TemplateDTO, len(list))
|
||||
for i, t := range list {
|
||||
result[i] = TemplateDTO{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Type: t.Type,
|
||||
Icon: t.Icon,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type fileMoveInfo struct {
|
||||
id, oldPath, newRelPath string
|
||||
}
|
||||
|
||||
func (a *App) rollbackFileMoves(moves []fileMoveInfo) error {
|
||||
for _, fm := range moves {
|
||||
oldAbs := filepath.Join(a.vault, fm.oldPath)
|
||||
newAbs := filepath.Join(a.vault, fm.newRelPath)
|
||||
_ = os.Rename(newAbs, oldAbs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SearchNodeResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (a *App) SearchNodes(query string) ([]SearchNodeResult, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := a.nodes.Search(query, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]SearchNodeResult, 0, len(nodes))
|
||||
for i := range nodes {
|
||||
if nodes[i].IsDeleted() {
|
||||
continue
|
||||
}
|
||||
path := a.nodes.Path(nodes[i].ID)
|
||||
results = append(results, SearchNodeResult{
|
||||
ID: nodes[i].ID,
|
||||
Title: nodes[i].Title,
|
||||
Path: path,
|
||||
Type: nodes[i].Type,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.FsPath == "" {
|
||||
return "", fmt.Errorf("node %s has no physical path", nodeID)
|
||||
}
|
||||
physPath := filepath.Join(a.vault, n.FsPath)
|
||||
return physPath, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []NodeDTO
|
||||
for i := range children {
|
||||
if children[i].Type == nodes.TypeNote {
|
||||
result = append(result, toNodeDTO(&children[i]))
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, fileRec, err := a.notes.Create(parentID, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, ""))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) ReadNote(noteID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.notes.Read(noteID)
|
||||
}
|
||||
|
||||
func (a *App) SaveNote(noteID, content string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.notes.Save(noteID, content); err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := a.nodes.GetActive(noteID); err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"node_id": noteID,
|
||||
"content": content,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/i18n"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ===== Template management =====
|
||||
|
||||
// AllTemplates returns all registered templates with their enabled status.
|
||||
type TemplateWithStatus struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (a *App) AllTemplates() ([]TemplateWithStatus, error) {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return nil, fmt.Errorf("vault not ready")
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
all := a.templates.All()
|
||||
result := make([]TemplateWithStatus, len(all))
|
||||
for i, t := range all {
|
||||
// If config has explicit list, use it; otherwise default to true
|
||||
enabled := true
|
||||
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
|
||||
enabled = enabledSet[t.ID]
|
||||
}
|
||||
result[i] = TemplateWithStatus{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Type: t.Type,
|
||||
Icon: t.Icon,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return fmt.Errorf("vault not ready")
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
// Update enabled templates list
|
||||
existing := make(map[string]bool)
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
existing[id] = true
|
||||
}
|
||||
if enabled {
|
||||
existing[templateID] = true
|
||||
} else {
|
||||
delete(existing, templateID)
|
||||
}
|
||||
|
||||
appCfg.EnabledTemplates = make([]string, 0, len(existing))
|
||||
for id := range existing {
|
||||
appCfg.EnabledTemplates = append(appCfg.EnabledTemplates, id)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update in-memory registry
|
||||
if enabled {
|
||||
_ = a.templates.Enable(templateID)
|
||||
} else {
|
||||
_ = a.templates.Disable(templateID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListTemplates() []TemplateDTO {
|
||||
if !a.IsReady() {
|
||||
return nil
|
||||
}
|
||||
templates := a.plugins.Templates()
|
||||
out := make([]TemplateDTO, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
out = append(out, TemplateDTO{
|
||||
ID: t.Name,
|
||||
Title: t.Name,
|
||||
Type: t.RootType,
|
||||
Icon: t.Icon,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tmpl *plugins.TemplateDefinition
|
||||
for _, t := range a.plugins.Templates() {
|
||||
if t.Name == template {
|
||||
tmpl = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmpl == nil {
|
||||
return nil, nil
|
||||
}
|
||||
root, err := a.nodes.Create(strPtr(parentID), tmpl.RootType, title, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||
for _, tn := range nodes {
|
||||
child, err := a.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tn.Children) > 0 {
|
||||
if err := createTree(child.ID, tn.Children); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := createTree(root.ID, tmpl.Tree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(root)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// ===== File picking =====
|
||||
|
||||
func (a *App) PickFile() (string, error) {
|
||||
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickSingle"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickFiles() ([]string, error) {
|
||||
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickMultiple"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickDirectory() (string, error) {
|
||||
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickDirectory"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) OpenFile(fileID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.files.Open(fileID)
|
||||
}
|
||||
|
||||
func (a *App) ReadFileText(fileID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.files.ReadText(fileID)
|
||||
}
|
||||
|
||||
func (a *App) GetFileBase64(fileID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.files.ReadBase64(fileID)
|
||||
}
|
||||
|
||||
func (a *App) OpenFolder(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileRecordPath string
|
||||
if n.Type == nodes.TypeFile && n.FsPath == "" {
|
||||
records, _ := a.files.ListByNode(nodeID)
|
||||
if len(records) > 0 {
|
||||
fileRecordPath = records[0].Path
|
||||
}
|
||||
}
|
||||
target := resolveOpenFolderTarget(a.vault, n, fileRecordPath)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
target = a.vault
|
||||
}
|
||||
cmd := exec.Command("xdg-open", target)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func resolveOpenFolderTarget(vault string, n *nodes.Node, fileRecordPath string) string {
|
||||
if n.Type == nodes.TypeFile && n.FsPath == "" {
|
||||
if fileRecordPath == "" {
|
||||
return vault
|
||||
}
|
||||
return filepath.Dir(filepath.Join(vault, fileRecordPath))
|
||||
}
|
||||
target := filepath.Join(vault, n.FsPath)
|
||||
if n.Type == nodes.TypeFile {
|
||||
return filepath.Dir(target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func (a *App) OpenVaultFolder() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
cmd := exec.Command("xdg-open", a.vault)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensurePluginsFolder(vaultPath string) (string, error) {
|
||||
path := filepath.Join(vaultPath, ".verstak", "plugins")
|
||||
if err := os.MkdirAll(path, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenPluginsFolder() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
target, err := ensurePluginsFolder(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("xdg-open", target)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) VerstakVersion() string {
|
||||
return "verstak-gui/v2"
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// GetSuggestions analyzes today's activity and returns conservative suggestions.
|
||||
// Only events not already linked in worklog_entry_events are considered.
|
||||
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListTodayEvents()
|
||||
if err != nil || len(events) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine which event IDs are already accounted for in any worklog entry.
|
||||
accounted := make(map[string]bool)
|
||||
rows, err := a.db.Query(`SELECT DISTINCT event_id FROM worklog_entry_events`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var eid string
|
||||
if rows.Scan(&eid) == nil {
|
||||
accounted[eid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
rows, err = a.db.Query(`SELECT DISTINCT event_id FROM worklog_dismissed_events`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var eid string
|
||||
if rows.Scan(&eid) == nil {
|
||||
accounted[eid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type acc struct {
|
||||
title string
|
||||
kind string
|
||||
events []activity.Event
|
||||
}
|
||||
grouped := make(map[string]*acc)
|
||||
for _, e := range events {
|
||||
if accounted[e.ID] {
|
||||
continue
|
||||
}
|
||||
grp, ok := grouped[e.NodeID]
|
||||
if !ok {
|
||||
n, err := a.nodes.GetActive(e.NodeID)
|
||||
title := ""
|
||||
kind := ""
|
||||
if err == nil && n != nil {
|
||||
title = n.Title
|
||||
kind = n.Type
|
||||
}
|
||||
grp = &acc{title: title, kind: kind}
|
||||
grouped[e.NodeID] = grp
|
||||
}
|
||||
grp.events = append(grp.events, e)
|
||||
}
|
||||
|
||||
var suggestions []activity.Suggestion
|
||||
for nodeID, grp := range grouped {
|
||||
if grp.title == "" || len(grp.events) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
notes, files, actions, other := countByType(grp.events)
|
||||
summary := buildSuggestionSummary(notes, files, actions, other)
|
||||
if summary == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
spread := timeSpread(grp.events)
|
||||
bursts := countBursts(grp.events, 10)
|
||||
min := estimateMinutes(bursts, spread, len(grp.events))
|
||||
conf, reason := confidence(bursts, spread, len(grp.events))
|
||||
|
||||
eventIDs := make([]string, 0, len(grp.events))
|
||||
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
|
||||
for _, e := range grp.events {
|
||||
eventIDs = append(eventIDs, e.ID)
|
||||
evDetails = append(evDetails, activity.SuggestionDetail{
|
||||
ID: e.ID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
NodeID: e.NodeID,
|
||||
NodePath: a.nodes.Path(e.NodeID),
|
||||
})
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, activity.Suggestion{
|
||||
NodeID: nodeID,
|
||||
NodeTitle: grp.title,
|
||||
Summary: summary,
|
||||
SuggestedMin: min,
|
||||
EventCount: len(grp.events),
|
||||
NodeKind: grp.kind,
|
||||
Confidence: conf,
|
||||
ConfidenceReason: reason,
|
||||
TimeSpreadMin: spread,
|
||||
EventIDs: eventIDs,
|
||||
Events: evDetails,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(suggestions, func(i, j int) bool {
|
||||
return suggestions[i].EventCount > suggestions[j].EventCount
|
||||
})
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func (a *App) DismissSuggestion(nodeID, eventIDsJSON string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
var eventIDs []string
|
||||
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
|
||||
return fmt.Errorf("unmarshal eventIDs: %w", err)
|
||||
}
|
||||
if len(eventIDs) == 0 {
|
||||
return fmt.Errorf("eventIDs required")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, eventID := range eventIDs {
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, nodeID).Scan(&n); err != nil {
|
||||
return fmt.Errorf("check event %s: %w", eventID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("event %s not found for node", eventID)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO worklog_dismissed_events(event_id,node_id,created_at) VALUES(?,?,?)`, eventID, nodeID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
|
||||
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDsJSON)
|
||||
}
|
||||
|
||||
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
|
||||
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
|
||||
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
return a.AcceptSuggestionFull(nodeID, summary, "", date, minutes, true, false, eventIDsJSON)
|
||||
}
|
||||
|
||||
// AcceptSuggestionFull creates a worklog entry from an edited suggestion and links events in a single transaction.
|
||||
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
|
||||
func (a *App) AcceptSuggestionFull(nodeID, summary, details, date string, minutes int, approximate, billable bool, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := date
|
||||
if d == "" {
|
||||
d = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
var eventIDs []string
|
||||
if eventIDsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal eventIDs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that every eventID actually exists in activity_events.
|
||||
for _, eid := range eventIDs {
|
||||
var n int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eid).Scan(&n); err != nil {
|
||||
return nil, fmt.Errorf("check event %s: %w", eid, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("event %s not found in activity_events", eid)
|
||||
}
|
||||
}
|
||||
|
||||
// Use a transaction to atomically create entry + link events
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, details, d, minutes, approximate, billable, worklog.SourceSuggestion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create entry: %w", err)
|
||||
}
|
||||
|
||||
for _, eid := range eventIDs {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
|
||||
entry.ID, eid); err != nil {
|
||||
return nil, fmt.Errorf("link event %s: %w", eid, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(eventIDs) > 0 {
|
||||
var linked int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM worklog_entry_events wle
|
||||
JOIN activity_events ae ON ae.id = wle.event_id
|
||||
WHERE wle.entry_id = ?`, entry.ID).Scan(&linked); err != nil {
|
||||
return nil, fmt.Errorf("verify links: %w", err)
|
||||
}
|
||||
if linked != len(eventIDs) {
|
||||
return nil, fmt.Errorf("expected %d linked events, got %d", len(eventIDs), linked)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
|
||||
// HideSuggestion marks a suggestion as hidden for the session.
|
||||
// The frontend tracks visibility; this is a no-op on the backend.
|
||||
func (a *App) HideSuggestion(_ activity.Suggestion) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- event analysis ---
|
||||
|
||||
func countByType(events []activity.Event) (notes, files, actions, other int) {
|
||||
for _, e := range events {
|
||||
switch e.EventType {
|
||||
case activity.TypeNoteCreated, activity.TypeNoteUpdated, activity.TypeNoteDeleted:
|
||||
notes++
|
||||
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed,
|
||||
activity.TypeFileCopied, activity.TypeFileMoved,
|
||||
activity.TypeFolderAdded, activity.TypeFolderDeleted, activity.TypeFolderRenamed:
|
||||
files++
|
||||
case activity.TypeActionCreated, activity.TypeActionDone:
|
||||
actions++
|
||||
default:
|
||||
other++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// timeSpread returns minutes between first and last event.
|
||||
func timeSpread(events []activity.Event) int {
|
||||
if len(events) < 2 {
|
||||
return 0
|
||||
}
|
||||
minTime := events[0].CreatedAt
|
||||
maxTime := events[0].CreatedAt
|
||||
for _, e := range events {
|
||||
if e.CreatedAt < minTime {
|
||||
minTime = e.CreatedAt
|
||||
}
|
||||
if e.CreatedAt > maxTime {
|
||||
maxTime = e.CreatedAt
|
||||
}
|
||||
}
|
||||
t1, err1 := time.Parse(time.RFC3339, minTime)
|
||||
t2, err2 := time.Parse(time.RFC3339, maxTime)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0
|
||||
}
|
||||
diff := t2.Sub(t1)
|
||||
return int(diff.Minutes())
|
||||
}
|
||||
|
||||
// countBursts groups events into bursts where consecutive events
|
||||
// are within `windowMin` minutes of each other.
|
||||
func countBursts(events []activity.Event, windowMin int) int {
|
||||
if len(events) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
times := make([]time.Time, 0, len(events))
|
||||
for _, e := range events {
|
||||
t, err := time.Parse(time.RFC3339, e.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
times = append(times, t)
|
||||
}
|
||||
if len(times) == 0 {
|
||||
return 1
|
||||
}
|
||||
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
|
||||
|
||||
bursts := 1
|
||||
last := times[0]
|
||||
for _, t := range times[1:] {
|
||||
if t.Sub(last) > time.Duration(windowMin)*time.Minute {
|
||||
bursts++
|
||||
}
|
||||
last = t
|
||||
}
|
||||
return bursts
|
||||
}
|
||||
|
||||
// estimateMinutes conservatively estimates suggested minutes.
|
||||
func estimateMinutes(bursts, spread, totalEvents int) int {
|
||||
if totalEvents <= 1 {
|
||||
return 5
|
||||
}
|
||||
switch {
|
||||
case spread >= 60 && bursts >= 3 && totalEvents >= 8:
|
||||
return 30
|
||||
case spread >= 30 && bursts >= 2 && totalEvents >= 5:
|
||||
return 20
|
||||
case spread >= 15 && bursts >= 2 && totalEvents >= 3:
|
||||
return 15
|
||||
case totalEvents >= 3:
|
||||
return 10
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
// confidence returns a label and reason string for the estimate.
|
||||
func confidence(bursts, spread, totalEvents int) (string, string) {
|
||||
if spread >= 60 && totalEvents >= 10 {
|
||||
return activity.ConfidenceHigh, fmt.Sprintf("активность растянута на %d минут, %d всплесков", spread, bursts)
|
||||
}
|
||||
if spread >= 30 && totalEvents >= 5 && bursts >= 2 {
|
||||
return activity.ConfidenceMedium, fmt.Sprintf("несколько всплесков активности за %d минут", spread)
|
||||
}
|
||||
return activity.ConfidenceLow, fmt.Sprintf("%d событий за %d минут, %d всплесков", totalEvents, spread, bursts)
|
||||
}
|
||||
|
||||
func buildSuggestionSummary(notes, files, actions, other int) string {
|
||||
var parts []string
|
||||
if notes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
|
||||
}
|
||||
if files > 0 {
|
||||
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
|
||||
}
|
||||
if actions > 0 {
|
||||
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
|
||||
}
|
||||
if other > 0 {
|
||||
parts = append(parts, fmt.Sprintf("события (%d)", other))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
type SyncStatusDTO struct {
|
||||
Configured bool `json:"configured"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Connected bool `json:"connected"`
|
||||
Revoked bool `json:"revoked"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
LastError string `json:"lastError"`
|
||||
StatusLabel string `json:"statusLabel"` // human-readable status
|
||||
}
|
||||
|
||||
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
if !a.IsReady() {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
|
||||
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||
ServerURL: serverURL,
|
||||
LastSyncAt: lastSyncAt,
|
||||
UnpushedOps: 0,
|
||||
TokenStored: deviceToken != "",
|
||||
}
|
||||
if appCfg != nil {
|
||||
dto.DeviceID = appCfg.Vault.Sync.DeviceID
|
||||
dto.SyncInterval = appCfg.Vault.Sync.SyncInterval
|
||||
dto.LastError = appCfg.Vault.Sync.LastError
|
||||
}
|
||||
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
dto.UnpushedOps = len(unpushed)
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
if appCfg != nil {
|
||||
client.DeviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
if info, err := client.GetMe(); err == nil {
|
||||
dto.DeviceName = info.DeviceName
|
||||
dto.DeviceID = info.DeviceID
|
||||
dto.Connected = true
|
||||
if info.RevokedAt != "" {
|
||||
dto.Revoked = true
|
||||
dto.Connected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build status label
|
||||
switch {
|
||||
case dto.Revoked:
|
||||
dto.StatusLabel = "revoked"
|
||||
case dto.Connected:
|
||||
dto.StatusLabel = "connected"
|
||||
case dto.Configured:
|
||||
dto.StatusLabel = "disconnected"
|
||||
default:
|
||||
dto.StatusLabel = "disabled"
|
||||
}
|
||||
|
||||
// Update config with latest status
|
||||
if appCfg != nil {
|
||||
changed := false
|
||||
if dto.LastSyncAt != "" && appCfg.Vault.Sync.LastSyncAt != dto.LastSyncAt {
|
||||
appCfg.Vault.Sync.LastSyncAt = dto.LastSyncAt
|
||||
changed = true
|
||||
}
|
||||
if appCfg.Vault.Sync.LastStatus != dto.StatusLabel {
|
||||
appCfg.Vault.Sync.LastStatus = dto.StatusLabel
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
}
|
||||
}
|
||||
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
type SyncSettingsDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
LastError string `json:"lastError"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
}
|
||||
|
||||
func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
return &SyncSettingsDTO{
|
||||
Enabled: appCfg.Vault.Sync.Enabled,
|
||||
ServerURL: appCfg.Vault.Sync.ServerURL,
|
||||
DeviceID: appCfg.Vault.Sync.DeviceID,
|
||||
DeviceName: appCfg.Vault.Sync.DeviceName,
|
||||
SyncInterval: appCfg.Vault.Sync.SyncInterval,
|
||||
LastStatus: appCfg.Vault.Sync.LastStatus,
|
||||
LastSyncAt: appCfg.Vault.Sync.LastSyncAt,
|
||||
LastError: appCfg.Vault.Sync.LastError,
|
||||
TokenStored: deviceToken != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pair: %w", err)
|
||||
}
|
||||
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
|
||||
return fmt.Errorf("save token: %w", err)
|
||||
}
|
||||
if err := a.sync.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update global config
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.Vault.Sync.Enabled = true
|
||||
appCfg.Vault.Sync.ServerURL = serverURL
|
||||
appCfg.Vault.Sync.DeviceID = deviceID
|
||||
appCfg.Vault.Sync.DeviceName = hostname
|
||||
appCfg.Vault.Sync.LastStatus = "connected"
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SyncDisconnect() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
config.RemoveDeviceToken(a.vault)
|
||||
|
||||
appCfg.Vault.Sync.Enabled = false
|
||||
appCfg.Vault.Sync.ServerURL = ""
|
||||
appCfg.Vault.Sync.DeviceID = ""
|
||||
appCfg.Vault.Sync.DeviceName = ""
|
||||
appCfg.Vault.Sync.LastStatus = "disabled"
|
||||
appCfg.Vault.Sync.LastError = ""
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.sync.SetState("", "")
|
||||
}
|
||||
|
||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
return client.TestAuth(serverURL, username, password)
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.Vault.Sync.SyncInterval = minutes
|
||||
if appCfg.Vault.Sync.DeviceID == "" && a.sync != nil {
|
||||
appCfg.Vault.Sync.DeviceID = a.sync.GetDeviceID()
|
||||
}
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||
return nil, fmt.Errorf("sync not configured")
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil {
|
||||
deviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
|
||||
unpushed, err := a.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ops: %w", err)
|
||||
}
|
||||
for i := range unpushed {
|
||||
unpushed[i].LastSeenServerSeq = lastPullSeq
|
||||
}
|
||||
pushResult := &syncsvc.PushResponse{}
|
||||
if len(unpushed) > 0 {
|
||||
pushResult, err = client.Push(unpushed)
|
||||
if err != nil {
|
||||
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
|
||||
return nil, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
|
||||
return nil, fmt.Errorf("mark pushed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pullResult, err := client.Pull(lastPullSeq)
|
||||
if err != nil {
|
||||
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
|
||||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
var applyErrors []string
|
||||
for _, op := range pullResult.Ops {
|
||||
if err := a.applyRemoteOp(op); err != nil {
|
||||
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
|
||||
}
|
||||
_ = a.sync.RecordRemoteOp(op)
|
||||
}
|
||||
if len(pullResult.Ops) > 0 {
|
||||
opIDs := make([]string, len(pullResult.Ops))
|
||||
for i, op := range pullResult.Ops {
|
||||
opIDs[i] = op.OpID
|
||||
}
|
||||
_ = a.sync.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
|
||||
for _, c := range pushResult.Conflicts {
|
||||
log.Printf("[sync] conflict: op=%v entity=%v/%v",
|
||||
c["op_id"], c["entity_type"], c["entity_id"])
|
||||
}
|
||||
}
|
||||
|
||||
if pullResult.ServerSequence > lastPullSeq {
|
||||
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
|
||||
}
|
||||
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Update config with success
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
a.updateSyncSuccess(now)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverSequence": pullResult.ServerSequence,
|
||||
}
|
||||
if len(applyErrors) > 0 {
|
||||
result["applyErrors"] = applyErrors
|
||||
}
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
result["conflicts"] = pushResult.Conflicts
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) updateSyncError(errMsg string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil
|
||||
}
|
||||
appCfg.Vault.Sync.LastError = errMsg
|
||||
appCfg.Vault.Sync.LastStatus = "error"
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
func (a *App) updateSyncSuccess(lastSyncAt string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil
|
||||
}
|
||||
appCfg.Vault.Sync.LastError = ""
|
||||
appCfg.Vault.Sync.LastStatus = "connected"
|
||||
appCfg.Vault.Sync.LastSyncAt = lastSyncAt
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// CheckSyncConnection tests the current sync connection.
|
||||
func (a *App) CheckSyncConnection() (bool, string) {
|
||||
if !a.IsReady() {
|
||||
return false, "vault not open"
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil || !appCfg.Vault.Sync.Enabled {
|
||||
return false, "sync not configured"
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if deviceToken == "" {
|
||||
return false, "no device token"
|
||||
}
|
||||
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", appCfg.Vault.Sync.DeviceID, a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
info, err := client.GetMe()
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
if info.RevokedAt != "" {
|
||||
return false, "device revoked"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// ResetSyncKey clears the device token and resets sync state.
|
||||
func (a *App) ResetSyncKey() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
config.RemoveDeviceToken(a.vault)
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil
|
||||
}
|
||||
appCfg.Vault.Sync.LastStatus = "disabled"
|
||||
appCfg.Vault.Sync.LastError = ""
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrashDTO struct {
|
||||
TrashPath string `json:"trashPath"`
|
||||
Count int `json:"count"`
|
||||
Nodes []TrashNodeDTO `json:"nodes"`
|
||||
Entries []TrashEntryDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type TrashNodeDTO struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
FsPath string `json:"fsPath"`
|
||||
NodePath string `json:"nodePath"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
|
||||
type TrashEntryDTO struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
|
||||
deleted, err := a.nodes.ListDeleted()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes := make([]TrashNodeDTO, 0, len(deleted))
|
||||
for _, n := range deleted {
|
||||
deletedAt := ""
|
||||
if n.DeletedAt != nil {
|
||||
deletedAt = n.DeletedAt.Format(time.RFC3339)
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
nodes = append(nodes, TrashNodeDTO{
|
||||
ID: n.ID,
|
||||
ParentID: parentID,
|
||||
Title: n.Title,
|
||||
Type: n.Type,
|
||||
FsPath: n.FsPath,
|
||||
NodePath: a.nodes.Path(n.ID),
|
||||
DeletedAt: deletedAt,
|
||||
})
|
||||
}
|
||||
|
||||
entries, err := listTrashEntries(trashPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TrashDTO{TrashPath: trashPath, Count: len(nodes), Nodes: nodes, Entries: entries}, nil
|
||||
}
|
||||
|
||||
func (a *App) TrashCount() (int, error) {
|
||||
trash, err := a.ListTrash()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return trash.Count, nil
|
||||
}
|
||||
|
||||
func (a *App) RestoreTrashNode(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
chain, err := a.deletedAncestorChain(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range chain {
|
||||
if err := a.restoreTrashPath(n.ID, n.FsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE nodes SET deleted_at = NULL, updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), n.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RestoreTrashNodesJSON(nodeIDsJSON string) error {
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.RestoreTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PurgeTrashNodesJSON(nodeIDsJSON string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.purgeTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) EmptyTrash() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
trash, err := a.ListTrash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, 0, len(trash.Nodes))
|
||||
for _, n := range trash.Nodes {
|
||||
if n.ParentID == "" {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
for _, n := range trash.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.purgeTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(a.vault, ".verstak", "trash"))
|
||||
}
|
||||
|
||||
func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
|
||||
var reversed []TrashNodeDTO
|
||||
current := nodeID
|
||||
for current != "" {
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.DeletedAt == nil {
|
||||
break
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
reversed = append(reversed, TrashNodeDTO{ID: n.ID, ParentID: parentID, Title: n.Title, Type: n.Type, FsPath: n.FsPath})
|
||||
current = parentID
|
||||
}
|
||||
if len(reversed) == 0 {
|
||||
return nil, fmt.Errorf("deleted node not found")
|
||||
}
|
||||
chain := make([]TrashNodeDTO, 0, len(reversed))
|
||||
for i := len(reversed) - 1; i >= 0; i-- {
|
||||
chain = append(chain, reversed[i])
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
||||
if fsPath == "" {
|
||||
return nil
|
||||
}
|
||||
trashEntry, err := a.findTrashEntryForNode(nodeID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dst := filepath.Join(a.vault, fsPath)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(trashEntry, dst)
|
||||
}
|
||||
|
||||
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
entries, err := os.ReadDir(trashPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := nodeID + "_"
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), prefix) {
|
||||
return filepath.Join(trashPath, entry.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("trash entry not found")
|
||||
}
|
||||
|
||||
func (a *App) purgeTrashNode(nodeID string) error {
|
||||
ids, err := a.deletedSubtreeIDs(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if path, err := a.findTrashEntryForNode(id); err == nil {
|
||||
_ = os.RemoveAll(path)
|
||||
}
|
||||
}
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
id := ids[i]
|
||||
_, _ = tx.Exec(`DELETE FROM node_meta WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM notes WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM actions WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM links WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM worklog_entry_events WHERE entry_id IN (SELECT id FROM worklog_entries WHERE node_id = ?)`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM worklog_entries WHERE node_id = ?`, id)
|
||||
if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) deletedSubtreeIDs(nodeID string) ([]string, error) {
|
||||
rows, err := a.db.Query(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
WHERE n.deleted_at IS NOT NULL
|
||||
) SELECT id FROM subtree`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("deleted node not found")
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
||||
if err := os.MkdirAll(trashPath, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirEntries, err := os.ReadDir(trashPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TrashEntryDTO, 0, len(dirEntries))
|
||||
for _, entry := range dirEntries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, TrashEntryDTO{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(trashPath, entry.Name()),
|
||||
IsDir: entry.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenTrashFolder() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashPath, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("xdg-open", trashPath).Run()
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := a.worklog.ListReport(worklog.ReportFilter{NodeID: nodeID, IncludeChildren: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]WorklogDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, reportRowToWorklogDTO(row))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
||||
return a.CreateWorklogFull(nodeID, summary, "", "", minutes, false, false)
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if date == "" {
|
||||
entry, err := a.worklog.Add(nodeID, summary, details, minutes, approximate, billable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
entry, err := a.worklog.AddWithDate(nodeID, summary, details, date, minutes, approximate, billable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateWorklogEntry(id, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.worklog.UpdateWithDate(id, summary, details, date, minutes, approximate, billable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := a.worklog.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpUpdate, worklogPayload(entry))
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteWorklogEntry(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.worklog.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, id, syncsvc.OpDelete, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- report bindings ---
|
||||
|
||||
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
rows, err := a.worklog.ListReport(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.worklog.BuildReportPaths(rows)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (*worklog.ReportSummary, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
return a.worklog.Summary(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
return a.worklog.ExportCSV(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
return a.worklog.ExportMarkdown(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogPDF(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]byte, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
return a.worklog.ExportPDF(f)
|
||||
}
|
||||
|
||||
func boolPtr(s string) *bool {
|
||||
switch s {
|
||||
case "yes":
|
||||
v := true
|
||||
return &v
|
||||
case "no":
|
||||
v := false
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) worklog.ReportFilter {
|
||||
return worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
Billable: boolPtr(billableFilter),
|
||||
Approximate: boolPtr(approxFilter),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveWorklogReport generates a worklog report and opens a SaveFileDialog.
|
||||
func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
|
||||
var data []byte
|
||||
var ext string
|
||||
switch format {
|
||||
case "csv":
|
||||
s, err := a.worklog.ExportCSV(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data = []byte(s)
|
||||
ext = ".csv"
|
||||
case "markdown":
|
||||
s, err := a.worklog.ExportMarkdown(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data = []byte(s)
|
||||
ext = ".md"
|
||||
case "pdf":
|
||||
var err error
|
||||
data, err = a.worklog.ExportPDF(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext = ".pdf"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown format: %s", format)
|
||||
}
|
||||
|
||||
from := dateFrom
|
||||
if from == "" {
|
||||
from = "all"
|
||||
}
|
||||
to := dateTo
|
||||
if to == "" {
|
||||
to = "all"
|
||||
}
|
||||
defaultName := fmt.Sprintf("verstak-worklog-%s--%s%s", from, to, ext)
|
||||
|
||||
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
|
||||
DefaultFilename: defaultName,
|
||||
Filters: []wailsruntime.FileFilter{
|
||||
{DisplayName: format, Pattern: "*" + ext},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("отменено пользователем")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("не удалось сохранить файл: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Отчёт сохранён: %s", path), nil
|
||||
}
|
||||
|
||||
// GetWorklogEntryEvents returns activity events linked to a worklog entry.
|
||||
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := a.db.Query(
|
||||
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
|
||||
e.title, COALESCE(e.metadata,''), e.created_at
|
||||
FROM activity_events e
|
||||
JOIN worklog_entry_events wle ON wle.event_id = e.id
|
||||
WHERE wle.entry_id = ?
|
||||
ORDER BY e.created_at ASC`, entryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []EventDTO
|
||||
for rows.Next() {
|
||||
var d EventDTO
|
||||
if err := rows.Scan(&d.ID, &d.NodeID, &d.EventType, &d.TargetType,
|
||||
&d.TargetID, &d.TargetPath, &d.Title, &d.DetailsJSON, &d.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
|
||||
result := make([]WorklogDTO, len(list))
|
||||
for i := range list {
|
||||
result[i] = *entryToDTO(&list[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func entryToDTO(e *worklog.Entry) *WorklogDTO {
|
||||
mins := 0
|
||||
if e.Minutes != nil {
|
||||
mins = *e.Minutes
|
||||
}
|
||||
return &WorklogDTO{
|
||||
ID: e.ID,
|
||||
NodeID: e.NodeID,
|
||||
Summary: e.Summary,
|
||||
Minutes: mins,
|
||||
Date: e.Date,
|
||||
Details: e.Details,
|
||||
Approximate: e.Approximate,
|
||||
Billable: e.Billable,
|
||||
Source: e.Source,
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func reportRowToWorklogDTO(r worklog.ReportRow) WorklogDTO {
|
||||
return WorklogDTO{
|
||||
ID: r.ID,
|
||||
NodeID: r.NodeID,
|
||||
NodeTitle: r.NodeTitle,
|
||||
NodePath: r.NodePath,
|
||||
Summary: r.Summary,
|
||||
Minutes: r.Minutes,
|
||||
Date: r.Date,
|
||||
Details: r.Details,
|
||||
Approximate: r.Approximate,
|
||||
Billable: r.Billable,
|
||||
Source: r.Source,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCaptureTextCreatesInboxArtifact(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
dto, err := app.CaptureText("Нужно разобрать этот текст")
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureText: %v", err)
|
||||
}
|
||||
if dto.ID == "" {
|
||||
t.Fatal("empty captured node id")
|
||||
}
|
||||
if dto.CaptureKind != "text" {
|
||||
t.Fatalf("CaptureKind = %q, want text", dto.CaptureKind)
|
||||
}
|
||||
if dto.CaptureSource != "clipboard" {
|
||||
t.Fatalf("CaptureSource = %q, want clipboard", dto.CaptureSource)
|
||||
}
|
||||
|
||||
content, err := app.ReadNote(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNote: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "Нужно разобрать этот текст") {
|
||||
t.Fatalf("captured content missing: %q", content)
|
||||
}
|
||||
var path string
|
||||
if err := app.db.QueryRow(`SELECT f.path FROM notes n JOIN files f ON f.id = n.file_id WHERE n.node_id = ?`, dto.ID).Scan(&path); err != nil {
|
||||
t.Fatalf("query note file path: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(path, ".verstak/inbox/") {
|
||||
t.Fatalf("path = %q, want .verstak/inbox prefix", path)
|
||||
}
|
||||
|
||||
inbox, err := app.ListInboxNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodes: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, item := range inbox {
|
||||
if item.ID == dto.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("captured text missing from inbox")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureURLCreatesInboxArtifact(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
dto, err := app.CaptureURL("https://example.test/page", "Example Page")
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureURL: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "url" {
|
||||
t.Fatalf("CaptureKind = %q, want url", dto.CaptureKind)
|
||||
}
|
||||
if dto.Title != "Example Page" {
|
||||
t.Fatalf("Title = %q, want Example Page", dto.Title)
|
||||
}
|
||||
if dto.Type != "link" {
|
||||
t.Fatalf("Type = %q, want link", dto.Type)
|
||||
}
|
||||
if dto.SourceKind != "url" {
|
||||
t.Fatalf("SourceKind = %q, want url", dto.SourceKind)
|
||||
}
|
||||
if dto.URL != "https://example.test/page" {
|
||||
t.Fatalf("URL = %q, want captured URL", dto.URL)
|
||||
}
|
||||
if dto.Hostname != "example.test" {
|
||||
t.Fatalf("Hostname = %q, want example.test", dto.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePathCopiesFileIntoInbox(t *testing.T) {
|
||||
app, vaultRoot := setupTestApp(t)
|
||||
sourceDir := t.TempDir()
|
||||
source := filepath.Join(sourceDir, "brief.pdf")
|
||||
if err := os.WriteFile(source, []byte("pdf content"), 0o640); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
dto, err := app.CapturePath(source)
|
||||
if err != nil {
|
||||
t.Fatalf("CapturePath: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "file" {
|
||||
t.Fatalf("CaptureKind = %q, want file", dto.CaptureKind)
|
||||
}
|
||||
|
||||
records, err := app.files.ListByNode(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %d, want 1", len(records))
|
||||
}
|
||||
if !strings.HasPrefix(records[0].Path, ".verstak/inbox/") {
|
||||
t.Fatalf("path = %q, want .verstak/inbox prefix", records[0].Path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultRoot, records[0].Path)); err != nil {
|
||||
t.Fatalf("captured file missing in vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePathCopiesDirectoryIntoInbox(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
source := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o750); err != nil {
|
||||
t.Fatalf("mkdir nested: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "nested", "note.txt"), []byte("nested"), 0o640); err != nil {
|
||||
t.Fatalf("write nested file: %v", err)
|
||||
}
|
||||
|
||||
dto, err := app.CapturePath(source)
|
||||
if err != nil {
|
||||
t.Fatalf("CapturePath: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "folder" {
|
||||
t.Fatalf("CaptureKind = %q, want folder", dto.CaptureKind)
|
||||
}
|
||||
|
||||
items, err := app.ListItems(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems: %v", err)
|
||||
}
|
||||
var foundNested bool
|
||||
for _, item := range items {
|
||||
if item.Name == "nested" && item.Type == "folder" {
|
||||
foundNested = true
|
||||
}
|
||||
}
|
||||
if !foundNested {
|
||||
t.Fatalf("captured folder children missing: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFileDataCreatesImageInboxArtifact(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
data := base64.StdEncoding.EncodeToString([]byte("fake image bytes"))
|
||||
|
||||
dto, err := app.CaptureFileData("pasted.png", data)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureFileData: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "image" {
|
||||
t.Fatalf("CaptureKind = %q, want image", dto.CaptureKind)
|
||||
}
|
||||
records, err := app.files.ListByNode(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].MIME != "image/png" {
|
||||
t.Fatalf("records = %+v, want one png image", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTextWithSectionContextCreatesUnresolvedArtifact(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
dto, err := app.CaptureTextWithContext("Dropped on today", "paste", `{"contextType":"section","section":"today"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureTextWithContext: %v", err)
|
||||
}
|
||||
|
||||
if dto.CaptureStatus != "unresolved" {
|
||||
t.Fatalf("CaptureStatus = %q, want unresolved", dto.CaptureStatus)
|
||||
}
|
||||
if dto.SourceKind != "text" {
|
||||
t.Fatalf("SourceKind = %q, want text", dto.SourceKind)
|
||||
}
|
||||
if dto.CaptureSource != "paste" {
|
||||
t.Fatalf("CaptureSource = %q, want paste", dto.CaptureSource)
|
||||
}
|
||||
if dto.CaptureContextType != "section" {
|
||||
t.Fatalf("CaptureContextType = %q, want section", dto.CaptureContextType)
|
||||
}
|
||||
if dto.CaptureContextSection != "today" {
|
||||
t.Fatalf("CaptureContextSection = %q, want today", dto.CaptureContextSection)
|
||||
}
|
||||
if dto.CaptureContextNodeID != "" {
|
||||
t.Fatalf("CaptureContextNodeID = %q, want empty", dto.CaptureContextNodeID)
|
||||
}
|
||||
if dto.SuggestedTargetNodeID != "" {
|
||||
t.Fatalf("SuggestedTargetNodeID = %q, want empty", dto.SuggestedTargetNodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePathWithNodeContextUsesNodeIDForLocalInbox(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
sourceDir := t.TempDir()
|
||||
source := filepath.Join(sourceDir, "brief.pdf")
|
||||
if err := os.WriteFile(source, []byte("pdf"), 0o640); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
projectA, err := app.CreateNodeFromTemplate("", "DuckLM", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A: %v", err)
|
||||
}
|
||||
projectB, err := app.CreateNodeFromTemplate("", "DuckLM", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project B: %v", err)
|
||||
}
|
||||
|
||||
ctx := `{"contextType":"node","nodeId":"` + projectA.ID + `","suggestedTargetNodeId":"` + projectA.ID + `"}`
|
||||
dto, err := app.CapturePathWithContext(source, "drop", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CapturePathWithContext: %v", err)
|
||||
}
|
||||
|
||||
if dto.CaptureContextType != "node" {
|
||||
t.Fatalf("CaptureContextType = %q, want node", dto.CaptureContextType)
|
||||
}
|
||||
if dto.CaptureContextNodeID != projectA.ID {
|
||||
t.Fatalf("CaptureContextNodeID = %q, want %q", dto.CaptureContextNodeID, projectA.ID)
|
||||
}
|
||||
if dto.SuggestedTargetNodeID != projectA.ID {
|
||||
t.Fatalf("SuggestedTargetNodeID = %q, want %q", dto.SuggestedTargetNodeID, projectA.ID)
|
||||
}
|
||||
|
||||
localA, err := app.ListInboxNodesForTarget(projectA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodesForTarget(A): %v", err)
|
||||
}
|
||||
if len(localA) != 1 || localA[0].ID != dto.ID {
|
||||
t.Fatalf("local inbox A = %+v, want captured artifact", localA)
|
||||
}
|
||||
|
||||
localB, err := app.ListInboxNodesForTarget(projectB.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodesForTarget(B): %v", err)
|
||||
}
|
||||
if len(localB) != 0 {
|
||||
t.Fatalf("local inbox B = %+v, want empty for same title different node", localB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyClipboardTextRoutesURLBeforePlainText(t *testing.T) {
|
||||
kind, value := classifyClipboardText(" https://example.test/page ")
|
||||
if kind != "url" {
|
||||
t.Fatalf("kind = %q, want url", kind)
|
||||
}
|
||||
if value != "https://example.test/page" {
|
||||
t.Fatalf("value = %q, want trimmed URL", value)
|
||||
}
|
||||
|
||||
kind, value = classifyClipboardText("not a url\nwith more text")
|
||||
if kind != "text" {
|
||||
t.Fatalf("kind = %q, want text", kind)
|
||||
}
|
||||
if value != "not a url\nwith more text" {
|
||||
t.Fatalf("value = %q, want trimmed text", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyClipboardTextTreatsBareDomainsAsURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "apex domain", input: " mirv.top ", want: "https://mirv.top"},
|
||||
{name: "www domain", input: "www.example.com", want: "https://www.example.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
kind, value := classifyClipboardText(tt.input)
|
||||
if kind != "url" {
|
||||
t.Fatalf("kind = %q, want url", kind)
|
||||
}
|
||||
if value != tt.want {
|
||||
t.Fatalf("value = %q, want %q", value, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureURLNormalizesBareDomain(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
dto, err := app.CaptureURL("mirv.top", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureURL: %v", err)
|
||||
}
|
||||
|
||||
if dto.URL != "https://mirv.top" {
|
||||
t.Fatalf("URL = %q, want https://mirv.top", dto.URL)
|
||||
}
|
||||
if dto.Hostname != "mirv.top" {
|
||||
t.Fatalf("Hostname = %q, want mirv.top", dto.Hostname)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
func TestResolveOpenFolderTarget_FileNodeUsesRecordPath(t *testing.T) {
|
||||
vault := t.TempDir()
|
||||
|
||||
got := resolveOpenFolderTarget(vault, &nodes.Node{Type: nodes.TypeFile}, filepath.Join("Parent", "Nested", "file.txt"))
|
||||
want := filepath.Join(vault, "Parent", "Nested")
|
||||
if got != want {
|
||||
t.Fatalf("target = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = resolveOpenFolderTarget(vault, &nodes.Node{Type: nodes.TypeFile}, "")
|
||||
if got != vault {
|
||||
t.Fatalf("target without file record = %q, want vault %q", got, vault)
|
||||
}
|
||||
|
||||
got = resolveOpenFolderTarget(vault, &nodes.Node{Type: nodes.TypeFile, FsPath: filepath.Join("Parent", "file.txt")}, "")
|
||||
want = filepath.Join(vault, "Parent")
|
||||
if got != want {
|
||||
t.Fatalf("target with fs_path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerRecursiveImportListItemsIsFlat(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Files Parent", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
sourceRoot := filepath.Join(t.TempDir(), "drop")
|
||||
if err := os.MkdirAll(filepath.Join(sourceRoot, "nested"), 0o750); err != nil {
|
||||
t.Fatalf("mkdir source: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "root.txt"), []byte("root"), 0o640); err != nil {
|
||||
t.Fatalf("write root file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "nested", "deep.txt"), []byte("deep"), 0o640); err != nil {
|
||||
t.Fatalf("write nested file: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.AddPathCopy(parent.ID, sourceRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathCopy: %v", err)
|
||||
}
|
||||
if len(imported) < 4 {
|
||||
t.Fatalf("imported %d nodes, want folder + nested folder + files", len(imported))
|
||||
}
|
||||
|
||||
rootItems, err := app.ListItems(parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems(parent): %v", err)
|
||||
}
|
||||
if hasItemNamed(rootItems, "deep.txt") {
|
||||
t.Fatal("parent file view includes nested file deep.txt")
|
||||
}
|
||||
drop := findItem(rootItems, "drop", nodes.TypeFolder)
|
||||
if drop == nil {
|
||||
t.Fatalf("parent file view missing imported root folder: %#v", rootItems)
|
||||
}
|
||||
|
||||
dropItems, err := app.ListItems(drop.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems(drop): %v", err)
|
||||
}
|
||||
if !hasItemNamed(dropItems, "root.txt") {
|
||||
t.Fatal("imported root folder missing root.txt")
|
||||
}
|
||||
nested := findItem(dropItems, "nested", nodes.TypeFolder)
|
||||
if nested == nil {
|
||||
t.Fatalf("imported root folder missing nested folder: %#v", dropItems)
|
||||
}
|
||||
|
||||
nestedItems, err := app.ListItems(nested.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems(nested): %v", err)
|
||||
}
|
||||
if !hasItemNamed(nestedItems, "deep.txt") {
|
||||
t.Fatal("nested folder missing deep.txt")
|
||||
}
|
||||
|
||||
seen := map[string]string{}
|
||||
for level, items := range map[string][]FileTreeItemDTO{
|
||||
"parent": rootItems,
|
||||
"drop": dropItems,
|
||||
"nested": nestedItems,
|
||||
} {
|
||||
for _, item := range items {
|
||||
if prev, ok := seen[item.ID]; ok {
|
||||
t.Fatalf("file manager listed ID %s in both %s and %s", item.ID, prev, level)
|
||||
}
|
||||
seen[item.ID] = level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findItem(items []FileTreeItemDTO, name, typ string) *FileTreeItemDTO {
|
||||
for i := range items {
|
||||
if items[i].Name == name && items[i].Type == typ {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasItemNamed(items []FileTreeItemDTO, name string) bool {
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 765 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -2,7 +2,10 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/wails.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/app-icons/icon_16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/app-icons/icon_32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/assets/app-icons/icon_64x64.png" />
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/assets/app-icons/icon_128x128.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Верстак</title>
|
||||
<style>
|
||||
@@ -16,8 +19,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
<script type="module" crossorigin src="/assets/main-DNvOo-CH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CXWRKHDk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
:root {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(""),
|
||||
url("./Inter-Medium.ttf") format("truetype");
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 3em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
margin: 0 0 0 20px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #e80000aa);
|
||||
}
|
||||
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #f7df1eaa);
|
||||
}
|
||||
|
||||
.result {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin: 1.5rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1rem;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.input-box .btn:hover {
|
||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.input-box .input {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
padding: 0 10px;
|
||||
color: black;
|
||||
background-color: rgba(240, 240, 240, 1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.input-box .input:hover {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.input-box .input:focus {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
func TestListInboxNodesReturnsOnlyCapturedArtifacts(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
manual, err := app.CreateNodeFromTemplate("", "Manual Root", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create manual root: %v", err)
|
||||
}
|
||||
legacyInbox, err := app.CreateNodeFromTemplate("", "Legacy Inbox Root", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy inbox root: %v", err)
|
||||
}
|
||||
captured, err := app.CreateNodeFromTemplate("", "Captured Artifact", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create captured artifact: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(captured.ID, "Nested Child", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
if _, err := app.db.Exec(`UPDATE nodes SET section = 'inbox' WHERE id = ?`, legacyInbox.ID); err != nil {
|
||||
t.Fatalf("mark legacy inbox: %v", err)
|
||||
}
|
||||
if err := app.nodes.MetaSet(captured.ID, "capture.inbox", "true"); err != nil {
|
||||
t.Fatalf("mark captured: %v", err)
|
||||
}
|
||||
if err := app.nodes.MetaSet(captured.ID, "capture.kind", "text"); err != nil {
|
||||
t.Fatalf("mark capture kind: %v", err)
|
||||
}
|
||||
if err := app.nodes.MetaSet(captured.ID, "capture.source", "clipboard"); err != nil {
|
||||
t.Fatalf("mark capture source: %v", err)
|
||||
}
|
||||
|
||||
list, err := app.ListInboxNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodes: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, item := range list {
|
||||
got[item.ID] = true
|
||||
}
|
||||
if !got[captured.ID] {
|
||||
t.Fatal("captured artifact missing from inbox")
|
||||
}
|
||||
for _, item := range list {
|
||||
if item.ID == captured.ID {
|
||||
if item.CaptureKind != "text" {
|
||||
t.Fatalf("CaptureKind = %q, want text", item.CaptureKind)
|
||||
}
|
||||
if item.CaptureSource != "clipboard" {
|
||||
t.Fatalf("CaptureSource = %q, want clipboard", item.CaptureSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got[manual.ID] {
|
||||
t.Fatal("manual root should not be in inbox")
|
||||
}
|
||||
if got[legacyInbox.ID] {
|
||||
t.Fatal("section=inbox root without capture metadata should not be in inbox")
|
||||
}
|
||||
if got[child.ID] {
|
||||
t.Fatal("nested child should not be in inbox")
|
||||
}
|
||||
|
||||
workspace, err := app.ListWorkspaceTree()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaceTree: %v", err)
|
||||
}
|
||||
for _, item := range workspace {
|
||||
if item.ID == captured.ID {
|
||||
t.Fatal("captured artifact should not be shown in workspace tree")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignInboxNodeMovesArtifactIntoCase(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Target Case", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
captured, err := app.CaptureText("Captured task material")
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureText: %v", err)
|
||||
}
|
||||
|
||||
moved, err := app.AssignInboxNode(captured.ID, parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("AssignInboxNode: %v", err)
|
||||
}
|
||||
if moved.ParentID == nil || *moved.ParentID != parent.ID {
|
||||
t.Fatalf("ParentID = %v, want %q", moved.ParentID, parent.ID)
|
||||
}
|
||||
|
||||
inbox, err := app.ListInboxNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodes: %v", err)
|
||||
}
|
||||
for _, item := range inbox {
|
||||
if item.ID == captured.ID {
|
||||
t.Fatal("assigned artifact should leave inbox")
|
||||
}
|
||||
}
|
||||
notes, err := app.ListNotes(parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListNotes: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, note := range notes {
|
||||
if note.ID == captured.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("assigned text artifact missing from target case notes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteInboxNodeRemovesArtifactFromInbox(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
captured, err := app.CaptureText("Delete this captured material")
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureText: %v", err)
|
||||
}
|
||||
if err := app.DeleteInboxNode(captured.ID); err != nil {
|
||||
t.Fatalf("DeleteInboxNode: %v", err)
|
||||
}
|
||||
|
||||
inbox, err := app.ListInboxNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodes: %v", err)
|
||||
}
|
||||
for _, item := range inbox {
|
||||
if item.ID == captured.ID {
|
||||
t.Fatal("deleted artifact should leave inbox")
|
||||
}
|
||||
}
|
||||
if _, err := app.nodes.GetActive(captured.ID); !errors.Is(err, nodes.ErrNotFound) {
|
||||
t.Fatalf("GetActive err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCapturedURLCreatesLinkForTargetOnly(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
projectA, err := app.CreateNodeFromTemplate("", "Project A", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A: %v", err)
|
||||
}
|
||||
projectB, err := app.CreateNodeFromTemplate("", "Project B", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project B: %v", err)
|
||||
}
|
||||
ctx := `{"contextType":"node","nodeId":"` + projectA.ID + `","suggestedTargetNodeId":"` + projectA.ID + `"}`
|
||||
captured, err := app.CaptureURLWithContext("https://example.test/article", "Example Article", "drop", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureURLWithContext: %v", err)
|
||||
}
|
||||
|
||||
localBefore, err := app.ListInboxNodesForTarget(projectA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodesForTarget before: %v", err)
|
||||
}
|
||||
if len(localBefore) != 1 || localBefore[0].ID != captured.ID {
|
||||
t.Fatalf("local inbox before = %+v, want captured URL", localBefore)
|
||||
}
|
||||
|
||||
if _, err := app.ResolveInboxNode(captured.ID, projectA.ID); err != nil {
|
||||
t.Fatalf("ResolveInboxNode: %v", err)
|
||||
}
|
||||
|
||||
globalAfter, err := app.ListInboxNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodes after: %v", err)
|
||||
}
|
||||
for _, item := range globalAfter {
|
||||
if item.ID == captured.ID {
|
||||
t.Fatal("resolved URL should leave global inbox")
|
||||
}
|
||||
}
|
||||
|
||||
localAfter, err := app.ListInboxNodesForTarget(projectA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInboxNodesForTarget after: %v", err)
|
||||
}
|
||||
if len(localAfter) != 0 {
|
||||
t.Fatalf("local inbox after = %+v, want empty", localAfter)
|
||||
}
|
||||
|
||||
linksA, err := app.ListLinks(projectA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLinks(A): %v", err)
|
||||
}
|
||||
if len(linksA) != 1 {
|
||||
t.Fatalf("links A = %+v, want one link", linksA)
|
||||
}
|
||||
if linksA[0].Title != "Example Article" || linksA[0].URL != "https://example.test/article" || linksA[0].Hostname != "example.test" {
|
||||
t.Fatalf("link A = %+v, want captured URL data", linksA[0])
|
||||
}
|
||||
|
||||
linksB, err := app.ListLinks(projectB.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLinks(B): %v", err)
|
||||
}
|
||||
if len(linksB) != 0 {
|
||||
t.Fatalf("links B = %+v, want empty", linksB)
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,8 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/worklog"
|
||||
"verstak/internal/core/config"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
@@ -24,44 +15,12 @@ import (
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
vaultPath := "."
|
||||
if len(os.Args) > 1 {
|
||||
vaultPath = os.Args[1]
|
||||
}
|
||||
app := &App{}
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Fix WebKit signal handler for Go 1.24+ compatibility
|
||||
ensureSignalOnStack()
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Open vault: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Init core services
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
plugins.NewManager(abs).Discover()
|
||||
|
||||
app := &App{
|
||||
db: db,
|
||||
nodes: nodeRepo,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
err = wails.Run(&options.App{
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Верстак",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
@@ -81,4 +40,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Ensure config dir exists for logging/cli usage
|
||||
config.EnsureConfigDir()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
// setupTree creates a simple tree: A -> B -> C for MoveNode testing.
|
||||
func setupTree(t *testing.T) (*App, *NodeDTO, *NodeDTO, *NodeDTO) {
|
||||
t.Helper()
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
a, err := app.CreateNodeFromTemplate("", "A", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create A: %v", err)
|
||||
}
|
||||
|
||||
b, err := app.CreateNodeFromTemplate(a.ID, "B", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create B: %v", err)
|
||||
}
|
||||
|
||||
c, err := app.CreateNodeFromTemplate(b.ID, "C", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create C: %v", err)
|
||||
}
|
||||
|
||||
return app, a, b, c
|
||||
}
|
||||
|
||||
// listTree returns a flat mapping of all visible node IDs in the workspace.
|
||||
func listTreeIDs(t *testing.T, app *App) map[string]int {
|
||||
t.Helper()
|
||||
roots, err := app.ListWorkspaceTree()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaceTree: %v", err)
|
||||
}
|
||||
ids := make(map[string]int)
|
||||
var walk func(parentID string)
|
||||
walk = func(parentID string) {
|
||||
var nodes []NodeDTO
|
||||
var err error
|
||||
if parentID == "" {
|
||||
nodes = roots
|
||||
} else {
|
||||
nodes, err = app.ListWorkspaceChildren(parentID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, n := range nodes {
|
||||
ids[n.ID]++
|
||||
if n.HasChildren {
|
||||
walk(n.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("")
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestMoveNode_DescendantToAncestor(t *testing.T) {
|
||||
app, a, b, c := setupTree(t)
|
||||
|
||||
// Move C into A — must succeed
|
||||
err := app.MoveNode(c.ID, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveNode(C, A): %v", err)
|
||||
}
|
||||
|
||||
// Verify C's parent is A
|
||||
nc, err := app.nodes.GetActive(c.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActive(C): %v", err)
|
||||
}
|
||||
if nc.ParentID == nil || *nc.ParentID != a.ID {
|
||||
t.Errorf("C parent_id = %v, want %s", nc.ParentID, a.ID)
|
||||
}
|
||||
|
||||
// C should no longer be a child of B
|
||||
bKids, _ := app.nodes.ListChildren(b.ID, false)
|
||||
for _, child := range bKids {
|
||||
if child.ID == c.ID {
|
||||
t.Error("C still appears under B after move")
|
||||
}
|
||||
}
|
||||
|
||||
// C should be a child of A
|
||||
aKids, _ := app.nodes.ListChildren(a.ID, false)
|
||||
found := false
|
||||
for _, child := range aKids {
|
||||
if child.ID == c.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("C not found under A after move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_ChildToRoot(t *testing.T) {
|
||||
app, a, b, _ := setupTree(t)
|
||||
|
||||
// Move B to root — must succeed
|
||||
err := app.MoveNode(b.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("MoveNode(B, root): %v", err)
|
||||
}
|
||||
|
||||
// Verify B has no parent
|
||||
nb, err := app.nodes.GetActive(b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActive(B): %v", err)
|
||||
}
|
||||
if nb.ParentID != nil {
|
||||
t.Errorf("B parent_id = %v, want nil", nb.ParentID)
|
||||
}
|
||||
|
||||
// B should NOT be under A
|
||||
aKids, _ := app.nodes.ListChildren(a.ID, false)
|
||||
for _, child := range aKids {
|
||||
if child.ID == b.ID {
|
||||
t.Error("B still appears under A after move to root")
|
||||
}
|
||||
}
|
||||
|
||||
// B should appear in roots
|
||||
roots, _ := app.nodes.ListRoots(false)
|
||||
found := false
|
||||
for _, root := range roots {
|
||||
if root.ID == b.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("B not found in roots after move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_ParentIntoChild_Rejected(t *testing.T) {
|
||||
app, a, b, _ := setupTree(t)
|
||||
|
||||
err := app.MoveNode(a.ID, b.ID)
|
||||
if err == nil {
|
||||
t.Fatal("MoveNode(A, B): expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "descendant") && !strings.Contains(err.Error(), "cycle") {
|
||||
t.Errorf("MoveNode(A, B): unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify A's parent unchanged
|
||||
na, _ := app.nodes.GetActive(a.ID)
|
||||
if na.ParentID != nil {
|
||||
t.Errorf("A parent_id changed to %v after rejected move", *na.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_ParentIntoDeepDescendant_Rejected(t *testing.T) {
|
||||
app, a, _, c := setupTree(t)
|
||||
|
||||
err := app.MoveNode(a.ID, c.ID)
|
||||
if err == nil {
|
||||
t.Fatal("MoveNode(A, C): expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "descendant") && !strings.Contains(err.Error(), "cycle") {
|
||||
t.Errorf("MoveNode(A, C): unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_IntoSelf_Rejected(t *testing.T) {
|
||||
app, a, _, _ := setupTree(t)
|
||||
|
||||
err := app.MoveNode(a.ID, a.ID)
|
||||
if err == nil {
|
||||
t.Fatal("MoveNode(A, A): expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "into itself") {
|
||||
t.Errorf("MoveNode(A, A): unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_NoDuplicateIDs(t *testing.T) {
|
||||
app, a, b, c := setupTree(t)
|
||||
|
||||
// Initial check: no duplicates
|
||||
ids := listTreeIDs(t, app)
|
||||
for id, count := range ids {
|
||||
if count > 1 {
|
||||
t.Errorf("Duplicate ID %q found %d times before move", id, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Move C into A
|
||||
if err := app.MoveNode(c.ID, a.ID); err != nil {
|
||||
t.Fatalf("MoveNode(C, A): %v", err)
|
||||
}
|
||||
|
||||
ids = listTreeIDs(t, app)
|
||||
for id, count := range ids {
|
||||
if count > 1 {
|
||||
t.Errorf("Duplicate ID %q found %d times after C→A move", id, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Move B to root
|
||||
if err := app.MoveNode(b.ID, ""); err != nil {
|
||||
t.Fatalf("MoveNode(B, root): %v", err)
|
||||
}
|
||||
|
||||
ids = listTreeIDs(t, app)
|
||||
for id, count := range ids {
|
||||
if count > 1 {
|
||||
t.Errorf("Duplicate ID %q found %d times after B→root move", id, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_FsPathUpdated(t *testing.T) {
|
||||
app, _, _, c := setupTree(t)
|
||||
|
||||
ncBefore, _ := app.nodes.GetActive(c.ID)
|
||||
origPath := ncBefore.FsPath
|
||||
|
||||
// Move C to root — fs_path should change
|
||||
if err := app.MoveNode(c.ID, ""); err != nil {
|
||||
t.Fatalf("MoveNode(C, root): %v", err)
|
||||
}
|
||||
|
||||
nc, _ := app.nodes.GetActive(c.ID)
|
||||
if nc.FsPath == origPath {
|
||||
t.Errorf("C fs_path unchanged after move to root: %q", nc.FsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNode_NonContainerRejected(t *testing.T) {
|
||||
app, a, _, _ := setupTree(t)
|
||||
|
||||
// Create a root-level note (not inside A's subtree)
|
||||
note, err := app.nodes.Create(nil, nodes.TypeNote, "Test Note", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
err = app.MoveNode(a.ID, note.ID)
|
||||
if err == nil {
|
||||
t.Fatal("MoveNode(A, note): expected error for non-container, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a container") {
|
||||
t.Errorf("MoveNode(A, note): unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsurePluginsFolder(t *testing.T) {
|
||||
vault := t.TempDir()
|
||||
|
||||
path, err := ensurePluginsFolder(vault)
|
||||
if err != nil {
|
||||
t.Fatalf("ensurePluginsFolder: %v", err)
|
||||
}
|
||||
|
||||
want := filepath.Join(vault, ".verstak", "plugins")
|
||||
if path != want {
|
||||
t.Fatalf("plugins path = %q, want %q", path, want)
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("stat plugins dir: %v", err)
|
||||
} else if !info.IsDir() {
|
||||
t.Fatalf("plugins path is not a directory: %s", path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#define _GNU_SOURCE
|
||||
#include <signal.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
// fixSigsegvOnStack adds SA_ONSTACK to the current SIGSEGV handler.
|
||||
// Go 1.24+ requires all signal handlers to have SA_ONSTACK set,
|
||||
// but WebKit/JavaScriptCore installs a SIGSEGV handler without it.
|
||||
void fixSigsegvOnStack(void) {
|
||||
struct sigaction act;
|
||||
if (sigaction(SIGSEGV, NULL, &act) == 0) {
|
||||
if (!(act.sa_flags & SA_ONSTACK)) {
|
||||
act.sa_flags |= SA_ONSTACK;
|
||||
sigaction(SIGSEGV, &act, NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "time"
|
||||
|
||||
// ensureSignalOnStack periodically ensures SIGSEGV handler has SA_ONSTACK.
|
||||
// This is needed because WebKit/JavaScriptCore installs a SIGSEGV handler
|
||||
// without SA_ONSTACK, which causes Go 1.24+ to crash with:
|
||||
// "non-Go code set up signal handler without SA_ONSTACK flag"
|
||||
func ensureSignalOnStack() {
|
||||
// Apply once after a short delay to let WebKit initialize
|
||||
go func() {
|
||||
// Retry a few times since WebKit may re-install its handler
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
C.fixSigsegvOnStack()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/util"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// nowISO returns the current time in RFC3339, usable as an activity_events.created_at.
|
||||
func nowISO() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// insertTestEvent inserts an activity event at the given created_at and returns its ID.
|
||||
func insertTestEventAt(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title, createdAt string) string {
|
||||
t.Helper()
|
||||
id := util.UUID7()
|
||||
_, err := app.db.Exec(
|
||||
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
id, nodeID, eventType, targetType, targetID, "", title, "{}", createdAt)
|
||||
if err != nil {
|
||||
t.Fatalf("insert event: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// insertTestEvent creates a "now" event.
|
||||
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string {
|
||||
return insertTestEventAt(t, app, nodeID, eventType, targetType, targetID, title, nowISO())
|
||||
}
|
||||
|
||||
// countLinked returns the number of worklog_entry_events for an entry.
|
||||
func countLinked(t *testing.T, app *App, entryID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, entryID).Scan(&n); err != nil {
|
||||
t.Fatalf("count worklog_entry_events: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// countJoined returns the number of events in worklog_entry_events that successfully
|
||||
// join to activity_events for a given entry.
|
||||
func countJoined(t *testing.T, app *App, entryID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := app.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM worklog_entry_events wle
|
||||
JOIN activity_events ae ON ae.id = wle.event_id
|
||||
WHERE wle.entry_id = ?`, entryID).Scan(&n); err != nil {
|
||||
t.Fatalf("join count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: Full journal regression — GetSuggestions + Accept + verify
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestJournalFullRegression(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
// Create a node
|
||||
n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Create activity events (use now() so GetSuggestions picks them up)
|
||||
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Создана заметка")
|
||||
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
|
||||
eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "f1", "Добавлен файл")
|
||||
|
||||
// Call GetSuggestions
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
|
||||
// Must find at least one suggestion for our node
|
||||
if len(suggestions) == 0 {
|
||||
t.Fatal("GetSuggestions returned 0 suggestions, expected at least 1")
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
found = true
|
||||
// Verify events match what we inserted
|
||||
if len(s.Events) != 3 {
|
||||
t.Fatalf("suggestion has %d events, want 3", len(s.Events))
|
||||
}
|
||||
if len(s.EventIDs) != len(s.Events) {
|
||||
t.Fatalf("suggestion eventIds (%d) != events (%d)", len(s.EventIDs), len(s.Events))
|
||||
}
|
||||
// Each eventId must be in the events list
|
||||
idSet := make(map[string]bool, len(s.Events))
|
||||
for _, ev := range s.Events {
|
||||
idSet[ev.ID] = true
|
||||
}
|
||||
for _, eid := range s.EventIDs {
|
||||
if !idSet[eid] {
|
||||
t.Errorf("eventId %s not found in events list", eid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no suggestion found for node %s", n.ID)
|
||||
}
|
||||
|
||||
// Accept the suggestion via AcceptSuggestionWith (JSON-serialised eventIDs)
|
||||
eventIDs := []string{eid1, eid2, eid3}
|
||||
eventIDsJSON, _ := json.Marshal(eventIDs)
|
||||
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
// Check worklog_entry_events count
|
||||
if n := countLinked(t, app, dto.ID); n != 3 {
|
||||
t.Errorf("worklog_entry_events count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// Check JOIN with activity_events
|
||||
if n := countJoined(t, app, dto.ID); n != 3 {
|
||||
t.Errorf("JOIN count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// Check GetWorklogEntryEvents returns 3 events
|
||||
events, err := app.GetWorklogEntryEvents(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorklogEntryEvents: %v", err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events))
|
||||
}
|
||||
|
||||
// All three event IDs must be present
|
||||
returnedIDs := make(map[string]bool, len(events))
|
||||
for _, ev := range events {
|
||||
returnedIDs[ev.ID] = true
|
||||
}
|
||||
for _, eid := range eventIDs {
|
||||
if !returnedIDs[eid] {
|
||||
t.Errorf("event %s missing from GetWorklogEntryEvents", eid)
|
||||
}
|
||||
}
|
||||
|
||||
// Source must be suggestion
|
||||
var src string
|
||||
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
|
||||
if src != worklog.SourceSuggestion {
|
||||
t.Errorf("source = %q, want %q", src, worklog.SourceSuggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: Repeated activity on same node — suggestion must still appear
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSuggestionOnRepeatedActivity(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Repeat Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Create a first event and accept it
|
||||
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Первая заметка")
|
||||
eid1JSON, _ := json.Marshal([]string{eid1})
|
||||
_, err = app.AcceptSuggestionWith(n.ID, "Создание заметки", 5, "", string(eid1JSON))
|
||||
if err != nil {
|
||||
t.Fatalf("first AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
// Now create a brand new event on the same node
|
||||
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
|
||||
|
||||
// GetSuggestions must still return a suggestion for this node (new event is unaccounted)
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
found = true
|
||||
// Only the second event should be suggested
|
||||
if len(s.Events) != 1 {
|
||||
t.Fatalf("expected 1 new event, got %d", len(s.Events))
|
||||
}
|
||||
if s.Events[0].ID != eid2 {
|
||||
t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID)
|
||||
}
|
||||
if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 {
|
||||
t.Errorf("eventIds does not contain the new event")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("GetSuggestions did not return a suggestion for the new event — it should")
|
||||
}
|
||||
|
||||
// Accept the new suggestion
|
||||
eid2JSON, _ := json.Marshal([]string{eid2})
|
||||
dto2, err := app.AcceptSuggestionWith(n.ID, "Обновление заметки", 5, "", string(eid2JSON))
|
||||
if err != nil {
|
||||
t.Fatalf("second AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
// Each entry must have exactly 1 linked event
|
||||
if n := countLinked(t, app, dto2.ID); n != 1 {
|
||||
t.Errorf("second entry: worklog_entry_events count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: Manual worklog entry — source, billable, details, empty events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestManualWorklogEntry(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Manual Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Create a manual entry
|
||||
dto, err := app.CreateWorklogFull(n.ID, "Ручная работа", "Подробное описание", "", 30, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
|
||||
// Source must be manual
|
||||
var src string
|
||||
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
|
||||
if src != worklog.SourceManual {
|
||||
t.Errorf("source = %q, want %q", src, worklog.SourceManual)
|
||||
}
|
||||
|
||||
// Billable, approximate, details must be correct
|
||||
var details string
|
||||
var billable, approximate int
|
||||
app.db.QueryRow(
|
||||
`SELECT details, billable, approximate FROM worklog_entries WHERE id = ?`, dto.ID,
|
||||
).Scan(&details, &billable, &approximate)
|
||||
if details != "Подробное описание" {
|
||||
t.Errorf("details = %q, want %q", details, "Подробное описание")
|
||||
}
|
||||
if billable != 1 {
|
||||
t.Errorf("billable = %d, want 1", billable)
|
||||
}
|
||||
if approximate != 1 {
|
||||
t.Errorf("approximate = %d, want 1", approximate)
|
||||
}
|
||||
|
||||
// Minutes must be 30
|
||||
if dto.Minutes != 30 {
|
||||
t.Errorf("minutes = %d, want 30", dto.Minutes)
|
||||
}
|
||||
|
||||
// GetWorklogEntryEvents must be empty for a manual entry
|
||||
events, err := app.GetWorklogEntryEvents(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorklogEntryEvents: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("manual entry has %d linked events, want 0", len(events))
|
||||
}
|
||||
|
||||
// DTO should distinguish manual from suggestion
|
||||
if dto.Source != worklog.SourceManual {
|
||||
t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual)
|
||||
}
|
||||
}
|
||||
|
||||
func hasWorklogSyncOp(t *testing.T, app *App, entryID, opType string) bool {
|
||||
t.Helper()
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnpushedOps: %v", err)
|
||||
}
|
||||
for _, op := range ops {
|
||||
if op.EntityType == syncsvc.EntityWorklog && op.EntityID == entryID && op.OpType == opType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Editable Worklog Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
dto, err := app.CreateWorklogFull(n.ID, "Old summary", "Old details", "2026-01-01", 30, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Связанное событие")
|
||||
if _, err := app.db.Exec(`INSERT INTO worklog_entry_events(entry_id,event_id) VALUES(?,?)`, dto.ID, eventID); err != nil {
|
||||
t.Fatalf("insert worklog event link: %v", err)
|
||||
}
|
||||
|
||||
updated, err := app.UpdateWorklogEntry(dto.ID, "New summary", "New details", "2026-01-02", 45, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWorklogEntry: %v", err)
|
||||
}
|
||||
if updated.Summary != "New summary" || updated.Details != "New details" || updated.Date != "2026-01-02" {
|
||||
t.Fatalf("updated DTO = %#v", updated)
|
||||
}
|
||||
if updated.Minutes != 45 || !updated.Approximate || !updated.Billable {
|
||||
t.Fatalf("updated flags/minutes = %#v", updated)
|
||||
}
|
||||
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpUpdate) {
|
||||
t.Fatal("missing worklog update sync op")
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 1 {
|
||||
t.Fatalf("event links after update = %d, want 1", n)
|
||||
}
|
||||
|
||||
if err := app.DeleteWorklogEntry(dto.ID); err != nil {
|
||||
t.Fatalf("DeleteWorklogEntry: %v", err)
|
||||
}
|
||||
if _, err := app.worklog.Get(dto.ID); err == nil {
|
||||
t.Fatal("expected deleted worklog entry to be gone")
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 0 {
|
||||
t.Fatalf("event links after delete = %d, want 0", n)
|
||||
}
|
||||
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpDelete) {
|
||||
t.Fatal("missing worklog delete sync op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||
|
||||
parentLog, err := app.ListWorklog(parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorklog(parent): %v", err)
|
||||
}
|
||||
if len(parentLog) != 1 || parentLog[0].ID != entry.ID {
|
||||
t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID)
|
||||
}
|
||||
if parentLog[0].NodeID != child.ID {
|
||||
t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID)
|
||||
}
|
||||
if parentLog[0].NodePath != "Parent Project > Documents" {
|
||||
t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath)
|
||||
}
|
||||
|
||||
parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivityByNode(parent): %v", err)
|
||||
}
|
||||
var foundEvent *EventDTO
|
||||
for i := range parentActivity {
|
||||
if parentActivity[i].ID == eventID {
|
||||
foundEvent = &parentActivity[i]
|
||||
}
|
||||
}
|
||||
if foundEvent == nil {
|
||||
t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID)
|
||||
}
|
||||
if foundEvent.NodePath != "Parent Project > Documents" {
|
||||
t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath)
|
||||
}
|
||||
|
||||
var physicalEvents int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil {
|
||||
t.Fatalf("count physical event: %v", err)
|
||||
}
|
||||
if physicalEvents != 1 {
|
||||
t.Fatalf("physical child event count = %d, want 1", physicalEvents)
|
||||
}
|
||||
var copiedToParent int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil {
|
||||
t.Fatalf("count copied parent event: %v", err)
|
||||
}
|
||||
if copiedToParent != 0 {
|
||||
t.Fatalf("copied parent events = %d, want 0", copiedToParent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки")
|
||||
|
||||
if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil {
|
||||
t.Fatalf("DismissSuggestion: %v", err)
|
||||
}
|
||||
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
t.Fatalf("dismissed suggestion still visible: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
var eventCount int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil {
|
||||
t.Fatalf("count event: %v", err)
|
||||
}
|
||||
if eventCount != 1 {
|
||||
t.Fatalf("activity event count = %d, want 1", eventCount)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Remote Worklog Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
dto, err := app.CreateWorklogFull(n.ID, "Before remote", "", "2026-01-03", 15, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"id": dto.ID,
|
||||
"node_id": n.ID,
|
||||
"summary": "After remote",
|
||||
"details": "Remote details",
|
||||
"minutes": 75,
|
||||
"date": "2026-01-04",
|
||||
"approximate": true,
|
||||
"billable": true,
|
||||
"updated_at": nowISO(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
if err := app.applyRemoteWorklogOp(syncsvc.Op{
|
||||
EntityType: syncsvc.EntityWorklog,
|
||||
EntityID: dto.ID,
|
||||
OpType: syncsvc.OpUpdate,
|
||||
PayloadJSON: string(payload),
|
||||
}); err != nil {
|
||||
t.Fatalf("applyRemoteWorklogOp update: %v", err)
|
||||
}
|
||||
|
||||
got, err := app.worklog.Get(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated entry: %v", err)
|
||||
}
|
||||
if got.Summary != "After remote" || got.Details != "Remote details" || got.Date != "2026-01-04" {
|
||||
t.Fatalf("remote updated entry = %#v", got)
|
||||
}
|
||||
if got.Minutes == nil || *got.Minutes != 75 || !got.Approximate || !got.Billable {
|
||||
t.Fatalf("remote updated minutes/flags = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptSuggestionFullUsesEditedFields(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Edited Suggestion Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||
eventIDsJSON, _ := json.Marshal([]string{eventID})
|
||||
|
||||
dto, err := app.AcceptSuggestionFull(n.ID, "Edited summary", "Edited details", "2026-01-05", 55, false, true, string(eventIDsJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptSuggestionFull: %v", err)
|
||||
}
|
||||
if dto.Summary != "Edited summary" || dto.Details != "Edited details" || dto.Date != "2026-01-05" {
|
||||
t.Fatalf("dto = %#v", dto)
|
||||
}
|
||||
if dto.Minutes != 55 || dto.Approximate || !dto.Billable {
|
||||
t.Fatalf("dto minutes/flags = %#v", dto)
|
||||
}
|
||||
if dto.Source != worklog.SourceSuggestion {
|
||||
t.Fatalf("dto.Source = %q, want %q", dto.Source, worklog.SourceSuggestion)
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 1 {
|
||||
t.Fatalf("linked events = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// applyRemoteOp dispatches a remote sync operation to the correct entity handler.
|
||||
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||
switch op.EntityType {
|
||||
case syncsvc.EntityNode:
|
||||
return a.applyRemoteNodeOp(op)
|
||||
case syncsvc.EntityNote:
|
||||
return a.applyRemoteNoteOp(op)
|
||||
case syncsvc.EntityFile, syncsvc.EntityFolder:
|
||||
return a.applyRemoteFileOrFolderOp(op)
|
||||
case syncsvc.EntityAction:
|
||||
return a.applyRemoteActionOp(op)
|
||||
case syncsvc.EntityWorklog:
|
||||
return a.applyRemoteWorklogOp(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- apply helpers ---
|
||||
|
||||
func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteNodeCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNodeUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNodeMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
TemplateID string `json:"template_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
Section string `json:"section"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.Type == "" || payload.Title == "" {
|
||||
return fmt.Errorf("incomplete node payload")
|
||||
}
|
||||
|
||||
if _, err := a.nodes.Get(payload.ID); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.CreatedAt == "" {
|
||||
payload.CreatedAt = now
|
||||
}
|
||||
if payload.UpdatedAt == "" {
|
||||
payload.UpdatedAt = now
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
var section interface{}
|
||||
if payload.Section != "" {
|
||||
section = payload.Section
|
||||
}
|
||||
slug := payload.Slug
|
||||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
|
||||
// Determine fs_path for folder-like nodes
|
||||
fsPath := payload.FsPath
|
||||
if fsPath == "" {
|
||||
isFolderLike := payload.Type != "note" && payload.Type != "file"
|
||||
if isFolderLike {
|
||||
seg := templates.SafeDisplayNameToPathSegment(payload.Title)
|
||||
if seg == "" {
|
||||
seg = "node"
|
||||
}
|
||||
if payload.ParentID != "" {
|
||||
if parent, err := a.nodes.Get(payload.ParentID); err == nil && parent.FsPath != "" {
|
||||
fsPath = filepath.Join(parent.FsPath, seg)
|
||||
}
|
||||
}
|
||||
if fsPath == "" {
|
||||
fsPath = filepath.Join(".verstak", "remote-inbox")
|
||||
}
|
||||
// Ensure unique path
|
||||
fullPath := filepath.Join(a.vault, fsPath)
|
||||
fullPath = templates.UniquePath(fullPath)
|
||||
rel, _ := filepath.Rel(a.vault, fullPath)
|
||||
fsPath = rel
|
||||
}
|
||||
}
|
||||
|
||||
archived := 0
|
||||
if payload.Archived {
|
||||
archived = 1
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,NULL)`,
|
||||
payload.ID, parent, payload.Type, payload.Title, slug,
|
||||
payload.TemplateID, fsPath, section, payload.SortOrder, archived,
|
||||
payload.CreatedAt, payload.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create physical folder for folder-like nodes
|
||||
if fsPath != "" {
|
||||
isFolderLike := payload.Type != "note" && payload.Type != "file"
|
||||
if isFolderLike {
|
||||
physPath := filepath.Join(a.vault, fsPath)
|
||||
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||
log.Printf("[sync] create folder for remote node %s: %v", payload.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the node was created from a template, also create child nodes
|
||||
// for any default_files and default_folders that were not already synced
|
||||
// as individual ops (backward compatibility with devices that do not
|
||||
// sync template children).
|
||||
_ = a.ensureTemplateChildren(payload.ID, payload.TemplateID, fsPath, payload.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTemplateChildren creates child nodes for a template's default files
|
||||
// and folders if they don't already exist. This handles backward compatibility
|
||||
// with devices that do not sync template children as individual ops.
|
||||
func (a *App) ensureTemplateChildren(nodeID, templateID, parentFsPath, title string) error {
|
||||
if templateID == "" {
|
||||
return nil
|
||||
}
|
||||
tmpl, ok := a.templates.Get(templateID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
nowRFC := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if len(tmpl.DefaultFolders) == 0 && len(tmpl.DefaultFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check existing children to avoid duplicates.
|
||||
existing, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists := make(map[string]bool, len(existing))
|
||||
for i := range existing {
|
||||
exists[existing[i].Title] = true
|
||||
}
|
||||
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
if exists[folderName] {
|
||||
continue
|
||||
}
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
childFsPath := folderSeg
|
||||
if parentFsPath != "" {
|
||||
childFsPath = filepath.Join(parentFsPath, folderSeg)
|
||||
}
|
||||
fullPath := filepath.Join(a.vault, childFsPath)
|
||||
fullPath = templates.UniquePath(fullPath)
|
||||
rel, _ := filepath.Rel(a.vault, fullPath)
|
||||
childFsPath = rel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
_ = os.MkdirAll(fullPath, 0o755)
|
||||
|
||||
_ = a.activity.Record(nodeID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
}
|
||||
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
if exists[fileTitle] {
|
||||
continue
|
||||
}
|
||||
|
||||
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
content := fmt.Sprintf("# %s\n\n", title)
|
||||
fpath := filepath.Join(a.vault, parentFsPath, df.Path)
|
||||
_ = os.MkdirAll(filepath.Dir(fpath), 0o750)
|
||||
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, fpath)
|
||||
fi, _ := os.Stat(fpath)
|
||||
size := int64(0)
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fileID := util.UUID7()
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(nodeID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
FsPath string `json:"fs_path"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node update: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
|
||||
n, err := a.nodes.Get(op.EntityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
// FS-first: rename folder on disk before touching DB
|
||||
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
|
||||
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.FsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unsafe fs_path in node update: %w", err)
|
||||
}
|
||||
payload.FsPath = cleanPath
|
||||
|
||||
oldPhys := filepath.Join(a.vault, n.FsPath)
|
||||
newPhys := filepath.Join(a.vault, payload.FsPath)
|
||||
if _, err := os.Stat(oldPhys); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
||||
if err := os.Rename(oldPhys, newPhys); err != nil {
|
||||
return fmt.Errorf("rename folder for update %s -> %s: %w", oldPhys, newPhys, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any title/fs_path/template_id changes? Then do atomic DB transaction.
|
||||
if payload.Title != "" || payload.FsPath != "" || payload.TemplateID != "" {
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if payload.Title != "" {
|
||||
slug := nodes.Slugify(payload.Title)
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
|
||||
payload.Title, slug, now, op.EntityID); err != nil {
|
||||
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if payload.FsPath != "" && isFolderLike {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||
payload.FsPath, now, op.EntityID); err != nil {
|
||||
if n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if payload.TemplateID != "" {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`,
|
||||
payload.TemplateID, now, op.EntityID); err != nil {
|
||||
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if payload.Archived != nil {
|
||||
v := 0
|
||||
if *payload.Archived {
|
||||
v = 1
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET archived=?, updated_at=? WHERE id=?`,
|
||||
v, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node move: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
|
||||
n, err := a.nodes.Get(op.EntityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
if isFolderLike {
|
||||
// Folder-like: FS-first rename, then DB transaction
|
||||
if payload.FsPath != "" && n.FsPath != "" {
|
||||
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.FsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unsafe fs_path in node move: %w", err)
|
||||
}
|
||||
payload.FsPath = cleanPath
|
||||
|
||||
oldPhys := filepath.Join(a.vault, n.FsPath)
|
||||
newPhys := filepath.Join(a.vault, payload.FsPath)
|
||||
if _, err := os.Stat(oldPhys); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
||||
if err := os.Rename(oldPhys, newPhys); err != nil {
|
||||
return fmt.Errorf("move folder %s -> %s: %w", oldPhys, newPhys, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
if payload.FsPath != "" && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
||||
parent, now, op.EntityID); err != nil {
|
||||
if payload.FsPath != "" && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if payload.FsPath != "" && n.FsPath != "" {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||
payload.FsPath, now, op.EntityID); err != nil {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
if payload.FsPath != "" && n.FsPath != "" {
|
||||
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
|
||||
}
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note/file: FS-first move, then DB transaction
|
||||
return a.moveNodeFiles(n, payload.ParentID, now)
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
now, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteNoteCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNoteUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNoteMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Title string `json:"title"`
|
||||
FileID string `json:"file_id"`
|
||||
Format string `json:"format"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note create: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
title := payload.Title
|
||||
if title == "" {
|
||||
title = "remote-note"
|
||||
}
|
||||
slug := nodes.Slugify(title)
|
||||
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
|
||||
VALUES (?,?,'note',?,?,'','',?,?,1)`,
|
||||
payload.NodeID, parent, title, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
} else if payload.ParentID != "" {
|
||||
// Update parent_id on existing node (e.g., created by old version without parent_id).
|
||||
_, _ = a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=? AND (parent_id IS NULL OR parent_id='')`,
|
||||
payload.ParentID, now, payload.NodeID)
|
||||
}
|
||||
|
||||
var dest string
|
||||
if payload.Path == "" {
|
||||
filename := payload.Filename
|
||||
if filename == "" {
|
||||
filename = payload.NodeID[:8] + ".md"
|
||||
}
|
||||
parentFsPath := ""
|
||||
if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil {
|
||||
if parent, err := a.nodes.GetActive(*noteNode.ParentID); err == nil {
|
||||
parentFsPath = parent.FsPath
|
||||
}
|
||||
}
|
||||
if parentFsPath == "" {
|
||||
parentFsPath = filepath.Join(".verstak", "remote-inbox")
|
||||
}
|
||||
dest = filepath.Join(a.vault, parentFsPath, filename)
|
||||
payload.Path, _ = filepath.Rel(a.vault, dest)
|
||||
} else {
|
||||
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err)
|
||||
}
|
||||
dest = filepath.Join(a.vault, cleanPath)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(dest)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
fileID := payload.FileID
|
||||
if fileID == "" {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
format := payload.Format
|
||||
if format == "" {
|
||||
format = "markdown"
|
||||
}
|
||||
_, err = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
payload.NodeID, fileID, format)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Content string `json:"content"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note update: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var filePath, storageMode string
|
||||
err := a.db.QueryRow(
|
||||
`SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`,
|
||||
payload.NodeID).Scan(&filePath, &storageMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("note record not found: %w", err)
|
||||
}
|
||||
|
||||
if storageMode == "vault" {
|
||||
clean, err := syncsvc.SafeVaultPath(a.vault, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unsafe vault path in note update: %w", err)
|
||||
}
|
||||
abs := filepath.Join(a.vault, clean)
|
||||
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(abs)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, e := a.db.Exec(
|
||||
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
|
||||
size, now, filePath, storageMode)
|
||||
return e
|
||||
}
|
||||
log.Printf("applyRemoteNoteUpdate: skipping non-vault note update for node %s (mode=%s, path=%s)",
|
||||
payload.NodeID, storageMode, filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) moveNodeFiles(n *nodes.Node, newParentID, now string) error {
|
||||
var parentFsPath string
|
||||
if newParentID != "" {
|
||||
parent, err := a.nodes.GetActive(newParentID)
|
||||
if err == nil && parent.FsPath != "" {
|
||||
parentFsPath = parent.FsPath
|
||||
}
|
||||
}
|
||||
|
||||
type fileMove struct {
|
||||
id string
|
||||
oldPath string
|
||||
oldAbs string
|
||||
newRelPath string
|
||||
newAbs string
|
||||
}
|
||||
var fileMoves []fileMove
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, n.ID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var fm fileMove
|
||||
if err := frows.Scan(&fm.id, &fm.oldPath); err != nil {
|
||||
continue
|
||||
}
|
||||
if fm.oldPath == "" {
|
||||
continue
|
||||
}
|
||||
fm.oldAbs = filepath.Join(a.vault, fm.oldPath)
|
||||
filename := filepath.Base(fm.oldPath)
|
||||
fm.newRelPath = filename
|
||||
if parentFsPath != "" {
|
||||
fm.newRelPath = filepath.Join(parentFsPath, filename)
|
||||
}
|
||||
fm.newAbs = filepath.Join(a.vault, fm.newRelPath)
|
||||
fileMoves = append(fileMoves, fm)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
if len(fileMoves) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FS-first: move all files (with rollback on partial failure)
|
||||
for i, fm := range fileMoves {
|
||||
if _, err := os.Stat(fm.oldAbs); err != nil {
|
||||
for j := 0; j < i; j++ {
|
||||
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
|
||||
}
|
||||
return fmt.Errorf("source file not found for move: %w", err)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(fm.newAbs), 0o750)
|
||||
if err := os.Rename(fm.oldAbs, fm.newAbs); err != nil {
|
||||
for j := 0; j < i; j++ {
|
||||
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
|
||||
}
|
||||
return fmt.Errorf("move file %s -> %s: %w", fm.oldAbs, fm.newAbs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic DB transaction: parent_id + file paths
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var parent interface{}
|
||||
if newParentID != "" {
|
||||
parent = newParentID
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
||||
parent, now, n.ID); err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, fm := range fileMoves {
|
||||
if _, err := tx.Exec(`UPDATE files SET path=? WHERE id=?`,
|
||||
fm.newRelPath, fm.id); err != nil {
|
||||
for _, fm2 := range fileMoves {
|
||||
_ = os.Rename(fm2.newAbs, fm2.oldAbs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
for _, fm := range fileMoves {
|
||||
_ = os.Rename(fm.newAbs, fm.oldAbs)
|
||||
}
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteMove(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note move: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
|
||||
n, err := a.nodes.Get(op.EntityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FS-first move, then DB transaction (handled inside moveNodeFiles)
|
||||
return a.moveNodeFiles(n, payload.ParentID, now)
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteFileCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNodeUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNodeMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
StorageMode string `json:"storage_mode"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
MIME string `json:"mime"`
|
||||
FileID string `json:"file_id"`
|
||||
BlobSHA256 string `json:"blob_sha256"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal file create: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := payload.Slug
|
||||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
ntype := payload.Type
|
||||
if ntype == "" {
|
||||
ntype = "file"
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision)
|
||||
VALUES (?,?,?,?,?,?,?,1)`,
|
||||
payload.NodeID, parent, ntype, payload.Title, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
if payload.BlobSHA256 != "" && payload.StorageMode == "vault" {
|
||||
blobsDir := syncsvc.BlobDir(a.vault)
|
||||
blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256)
|
||||
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
||||
serverURL, apiKey, _, _, _ := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault)
|
||||
cli.DeviceToken = deviceToken
|
||||
if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil {
|
||||
log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err)
|
||||
}
|
||||
}
|
||||
|
||||
cleanPath, pathErr := syncsvc.SafeVaultPath(a.vault, payload.Path)
|
||||
if pathErr != nil {
|
||||
return fmt.Errorf("unsafe path in file: %w", pathErr)
|
||||
}
|
||||
dest := filepath.Join(a.vault, cleanPath)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil {
|
||||
input, rErr := os.ReadFile(blobPath)
|
||||
if rErr == nil {
|
||||
_ = os.WriteFile(dest, input, 0o640)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileID := payload.FileID
|
||||
if fileID == "" {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
storageMode := payload.StorageMode
|
||||
if storageMode == "" {
|
||||
storageMode = "vault"
|
||||
}
|
||||
mime := payload.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
fileID, payload.NodeID, payload.Filename, payload.Path, storageMode,
|
||||
payload.Size, payload.SHA256, mime, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteActionOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteActionCreate(op)
|
||||
case syncsvc.OpDelete:
|
||||
_, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteActionCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
URL string `json:"url"`
|
||||
ConfirmRequired bool `json:"confirm_required"`
|
||||
CaptureOutput bool `json:"capture_output"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal action create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
payload.ID, payload.NodeID, payload.Title, payload.Kind,
|
||||
payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL,
|
||||
boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput),
|
||||
payload.CreatedAt, payload.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteWorklogCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteWorklogUpdate(op)
|
||||
case syncsvc.OpDelete:
|
||||
if _, err := a.db.Exec(`DELETE FROM worklog_entry_events WHERE entry_id=?`, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date"`
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal worklog create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt),
|
||||
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
|
||||
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal worklog update: %w", err)
|
||||
}
|
||||
id := payload.ID
|
||||
if id == "" {
|
||||
id = op.EntityID
|
||||
}
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
updatedAt := payload.UpdatedAt
|
||||
if updatedAt == "" {
|
||||
updatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE worklog_entries SET date=?, minutes=?, approximate=?, billable=?,
|
||||
summary=?, details=?, updated_at=? WHERE id=?`,
|
||||
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
|
||||
payload.Summary, payload.Details, updatedAt, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Trash Me", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
if err := app.DeleteNode(n.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
|
||||
trash, err := app.ListTrash()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrash: %v", err)
|
||||
}
|
||||
|
||||
var foundNode bool
|
||||
for _, node := range trash.Nodes {
|
||||
if node.ID == n.ID && node.Title == "Trash Me" && node.DeletedAt != "" {
|
||||
foundNode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNode {
|
||||
t.Fatalf("deleted node %s missing from trash nodes: %#v", n.ID, trash.Nodes)
|
||||
}
|
||||
|
||||
var foundPhysical bool
|
||||
for _, entry := range trash.Entries {
|
||||
if strings.Contains(entry.Name, n.ID) && entry.IsDir {
|
||||
foundPhysical = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundPhysical {
|
||||
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "Specs", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
other, err := app.CreateNodeFromTemplate(parent.ID, "Drafts", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
if err := app.RestoreTrashNode(child.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode(child): %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{parent.ID, child.ID} {
|
||||
if _, err := app.nodes.GetActive(id); err != nil {
|
||||
t.Fatalf("node %s should be active after restore: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := app.nodes.GetActive(other.ID); err == nil {
|
||||
t.Fatalf("unselected sibling should remain deleted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vault, "Documents", "Specs")); err != nil {
|
||||
t.Fatalf("restored child path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashNodeFromNestedDeletedFolderRestoresFullPath(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Verstak", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
templates, err := app.CreateNodeFromTemplate(parent.ID, "templates", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create templates: %v", err)
|
||||
}
|
||||
registry, err := app.CreateNodeFromTemplate(templates.ID, "registry.go", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create registry: %v", err)
|
||||
}
|
||||
other, err := app.CreateNodeFromTemplate(templates.ID, "other.go", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
if err := app.RestoreTrashNode(registry.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode(registry): %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{parent.ID, templates.ID, registry.ID} {
|
||||
if _, err := app.nodes.GetActive(id); err != nil {
|
||||
t.Fatalf("node %s should be active after restore: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := app.nodes.GetActive(other.ID); err == nil {
|
||||
t.Fatalf("unselected nested sibling should remain deleted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vault, "Verstak", "templates", "registry.go")); err != nil {
|
||||
t.Fatalf("restored nested path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashCountPurgeAndEmpty(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default")
|
||||
b, _ := app.CreateNodeFromTemplate("", "Trash B", "folder.default")
|
||||
if err := app.DeleteNode(a.ID); err != nil {
|
||||
t.Fatalf("delete A: %v", err)
|
||||
}
|
||||
if err := app.DeleteNode(b.ID); err != nil {
|
||||
t.Fatalf("delete B: %v", err)
|
||||
}
|
||||
count, err := app.TrashCount()
|
||||
if err != nil {
|
||||
t.Fatalf("TrashCount: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("TrashCount = %d, want 2", count)
|
||||
}
|
||||
if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil {
|
||||
t.Fatalf("PurgeTrashNodesJSON: %v", err)
|
||||
}
|
||||
count, _ = app.TrashCount()
|
||||
if count != 1 {
|
||||
t.Fatalf("TrashCount after purge = %d, want 1", count)
|
||||
}
|
||||
if err := app.EmptyTrash(); err != nil {
|
||||
t.Fatalf("EmptyTrash: %v", err)
|
||||
}
|
||||
count, _ = app.TrashCount()
|
||||
if count != 0 {
|
||||
t.Fatalf("TrashCount after empty = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
type VaultCheckResult struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
NodesWithFsPath int `json:"nodes_with_fs_path"`
|
||||
FoldersOnDisk int `json:"folders_on_disk"`
|
||||
FilesOnDisk int `json:"files_on_disk"`
|
||||
FilesMissing int `json:"files_missing"`
|
||||
PathEscapeCount int `json:"path_escape_count"`
|
||||
ParentIDEmptyCount int `json:"parent_id_empty_count"`
|
||||
OrphanDescendantCount int `json:"orphan_descendant_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
func (a *App) VaultCheck() (*VaultCheckResult, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &VaultCheckResult{Healthy: true}
|
||||
|
||||
// Build a set of all node IDs for ancestor check
|
||||
allNodes := make(map[string]*nodes.Node)
|
||||
roots, err := a.nodes.ListRoots(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list roots: %w", err)
|
||||
}
|
||||
var collectDescendants func(id string)
|
||||
collectDescendants = func(id string) {
|
||||
n, err := a.nodes.Get(id)
|
||||
if err == nil {
|
||||
allNodes[n.ID] = n
|
||||
}
|
||||
children, _ := a.nodes.ListChildren(id, true)
|
||||
for _, c := range children {
|
||||
allNodes[c.ID] = &c
|
||||
collectDescendants(c.ID)
|
||||
}
|
||||
}
|
||||
for _, n := range roots {
|
||||
allNodes[n.ID] = &n
|
||||
collectDescendants(n.ID)
|
||||
}
|
||||
|
||||
// Check parent_id consistency
|
||||
for id, n := range allNodes {
|
||||
if !n.IsRoot() && n.ParentID != nil && *n.ParentID == "" {
|
||||
result.ParentIDEmptyCount++
|
||||
result.Errors = append(result.Errors,
|
||||
fmt.Sprintf("node %s (%s): parent_id is empty string, should be nil", id, n.Title))
|
||||
result.Healthy = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check each node
|
||||
for _, n := range allNodes {
|
||||
if n.IsDeleted() {
|
||||
continue
|
||||
}
|
||||
result.TotalNodes++
|
||||
|
||||
// Check if ancestor is deleted
|
||||
if n.ParentID != nil && *n.ParentID != "" {
|
||||
if parent, ok := allNodes[*n.ParentID]; ok && parent.IsDeleted() {
|
||||
result.OrphanDescendantCount++
|
||||
result.Errors = append(result.Errors,
|
||||
fmt.Sprintf("node %s (%s) is active but parent %s is deleted", n.ID, n.Title, *n.ParentID))
|
||||
result.Healthy = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check fs_path
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
if isFolderLike && n.FsPath != "" {
|
||||
result.NodesWithFsPath++
|
||||
physPath := filepath.Join(a.vault, n.FsPath)
|
||||
rel, err := filepath.Rel(a.vault, physPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
result.PathEscapeCount++
|
||||
result.Errors = append(result.Errors,
|
||||
fmt.Sprintf("node %s (%s): fs_path escapes vault: %s", n.ID, n.Title, n.FsPath))
|
||||
result.Healthy = false
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(physPath); err == nil {
|
||||
if info.IsDir() {
|
||||
result.FoldersOnDisk++
|
||||
}
|
||||
} else {
|
||||
result.FilesMissing++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("node %s (%s): folder missing on disk: %s", n.ID, n.Title, physPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check file records
|
||||
rows, err := a.db.Query(`SELECT f.id, f.node_id, f.path, f.storage_mode FROM files f LEFT JOIN nodes n ON f.node_id = n.id WHERE n.deleted_at IS NULL`)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err))
|
||||
result.Healthy = false
|
||||
return result, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id, nodeID, path, mode string
|
||||
if err := rows.Scan(&id, &nodeID, &path, &mode); err != nil {
|
||||
continue
|
||||
}
|
||||
result.TotalFiles++
|
||||
|
||||
if mode == "vault" {
|
||||
absPath := filepath.Join(a.vault, path)
|
||||
rel, err := filepath.Rel(a.vault, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
result.PathEscapeCount++
|
||||
result.Errors = append(result.Errors,
|
||||
fmt.Sprintf("file %s: path escapes vault: %s", id, path))
|
||||
result.Healthy = false
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(absPath); err == nil {
|
||||
result.FilesOnDisk++
|
||||
} else {
|
||||
result.FilesMissing++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("file %s (%s): missing on disk: %s", id, path, absPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.FilesMissing > 0 || result.PathEscapeCount > 0 ||
|
||||
result.ParentIDEmptyCount > 0 || result.OrphanDescendantCount > 0 {
|
||||
result.Healthy = false
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/templates"
|
||||
)
|
||||
|
||||
// MigrateVaultLayout rebuilds fs_path for all existing nodes based on
|
||||
// parent-child relationships and creates human-readable folders in the vault.
|
||||
// It performs a dry-run if dryRun is true.
|
||||
func (a *App) MigrateVaultLayout(dryRun bool) (*MigrationReport, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
report := &MigrationReport{}
|
||||
|
||||
// Load all nodes
|
||||
allNodes, err := a.nodes.ListRoots(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list roots: %w", err)
|
||||
}
|
||||
|
||||
// Build a map for quick lookup
|
||||
nodeMap := make(map[string]*nodes.Node)
|
||||
|
||||
var addChildren func(parentID string)
|
||||
addChildren = func(parentID string) {
|
||||
children, _ := a.nodes.ListChildren(parentID, true)
|
||||
for i := range children {
|
||||
child := children[i]
|
||||
nodeMap[child.ID] = &child
|
||||
addChildren(child.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range allNodes {
|
||||
n := allNodes[i]
|
||||
nodeMap[n.ID] = &n
|
||||
addChildren(n.ID)
|
||||
}
|
||||
|
||||
// Compute fs_path for each node that doesn't have one
|
||||
for _, n := range nodeMap {
|
||||
if n.FsPath != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
seg := templates.SafeDisplayNameToPathSegment(n.Title)
|
||||
fsPath := seg
|
||||
|
||||
if n.ParentID != nil {
|
||||
if parent, ok := nodeMap[*n.ParentID]; ok {
|
||||
parentSeg := templates.SafeDisplayNameToPathSegment(parent.Title)
|
||||
if parent.FsPath != "" {
|
||||
fsPath = filepath.Join(parent.FsPath, seg)
|
||||
} else {
|
||||
fsPath = filepath.Join(parentSeg, seg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for uniqueness
|
||||
for _, other := range nodeMap {
|
||||
if other.ID != n.ID && other.FsPath == fsPath {
|
||||
fsPath = templates.UniquePath(filepath.Join(a.vault, fsPath))
|
||||
rel, _ := filepath.Rel(a.vault, fsPath)
|
||||
fsPath = rel
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
physPath := filepath.Join(a.vault, fsPath)
|
||||
|
||||
if dryRun {
|
||||
report.DryRun = true
|
||||
report.Actions = append(report.Actions, fmt.Sprintf("WOULD create folder: %s (node: %s)", physPath, n.Title))
|
||||
} else {
|
||||
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||
report.Errors = append(report.Errors, fmt.Sprintf("mkdir %s: %v", physPath, err))
|
||||
continue
|
||||
}
|
||||
if err := a.nodes.UpdateFsPath(n.ID, fsPath); err != nil {
|
||||
report.Errors = append(report.Errors, fmt.Sprintf("update fs_path %s: %v", n.ID, err))
|
||||
continue
|
||||
}
|
||||
report.FoldersCreated++
|
||||
}
|
||||
|
||||
// Also set template_id based on type if not set
|
||||
if n.TemplateID == "" {
|
||||
tmplID := typeToTemplateID(n.Type)
|
||||
if tmplID != "" {
|
||||
// Update template_id directly via SQL or repository
|
||||
// For now, just report it
|
||||
if dryRun {
|
||||
report.Actions = append(report.Actions, fmt.Sprintf("WOULD set template_id=%s for node %s", tmplID, n.Title))
|
||||
} else {
|
||||
report.TemplatesSet++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// MigrationReport contains results of vault migration.
|
||||
type MigrationReport struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
FoldersCreated int `json:"folders_created"`
|
||||
TemplatesSet int `json:"templates_set"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func typeToTemplateID(typ string) string {
|
||||
switch typ {
|
||||
case "folder":
|
||||
return "folder.default"
|
||||
case "project":
|
||||
return "project.default"
|
||||
case "client":
|
||||
return "client.default"
|
||||
case "document":
|
||||
return "document.default"
|
||||
case "recipe":
|
||||
return "recipe.default"
|
||||
case "space", "case":
|
||||
return "folder.default"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "verstak-gui",
|
||||
"assetdir": "frontend/dist",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||
"wailsjsdir": "frontend/wailsjs",
|
||||
"version": "2",
|
||||
"outputfilename": "verstak-gui",
|
||||
"projectdir": "cmd/verstak-gui"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
Username string `yaml:"username"`
|
||||
PasswordHash string `yaml:"password_hash"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Port int `yaml:"port"`
|
||||
Admin []AdminUser `yaml:"admin"`
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
func LoadConfig(dataDir string) (*Config, error) {
|
||||
path := filepath.Join(dataDir, "config.yml")
|
||||
cfg := &Config{
|
||||
Port: 47732,
|
||||
Admin: nil,
|
||||
path: path,
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0640)
|
||||
}
|
||||
|
||||
func (c *Config) SetAdmin(username, password string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := AdminUser{Username: username, PasswordHash: string(hash)}
|
||||
// Replace existing or append.
|
||||
for i, u := range c.Admin {
|
||||
if u.Username == username {
|
||||
c.Admin[i] = user
|
||||
return c.saveLocked()
|
||||
}
|
||||
}
|
||||
c.Admin = append(c.Admin, user)
|
||||
return c.saveLocked()
|
||||
}
|
||||
|
||||
func (c *Config) CheckAdmin(username, password string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, u := range c.Admin {
|
||||
if u.Username == username {
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) saveLocked() error {
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0640)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(adminLoginHTML(s.locale())))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
if !s.cfg.CheckAdmin(user, pass) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/admin/login")))
|
||||
return
|
||||
}
|
||||
tok := s.tokens.Create()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session", Value: tok, Path: "/admin",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
var deviceCount, opsCount int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
||||
|
||||
smtpHost := s.smtpGet("smtp_host")
|
||||
smtpPort := s.smtpGet("smtp_port")
|
||||
smtpUser := s.smtpGet("smtp_user")
|
||||
smtpFrom := s.smtpGet("smtp_from")
|
||||
smtpSecurity := s.smtpGet("smtp_security")
|
||||
srvURL := s.smtpGet("server_url")
|
||||
|
||||
w.Write([]byte(adminDashboardHTML(s.locale(), deviceCount, opsCount, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL)))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(adminUsersHTML(s.locale())))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var opsCount int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
||||
jsonOK(w, map[string]int{"ops": opsCount})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Host string `json:"smtp_host"`
|
||||
Port string `json:"smtp_port"`
|
||||
User string `json:"smtp_user"`
|
||||
Pass string `json:"smtp_pass"`
|
||||
Security string `json:"smtp_security"`
|
||||
From string `json:"smtp_from"`
|
||||
To string `json:"test_to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
host := req.Host
|
||||
port := req.Port
|
||||
user := req.User
|
||||
pass := req.Pass
|
||||
security := req.Security
|
||||
from := req.From
|
||||
to := req.To
|
||||
if to == "" {
|
||||
to = from
|
||||
}
|
||||
if host == "" || port == "" || from == "" {
|
||||
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
|
||||
return
|
||||
}
|
||||
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
|
||||
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
|
||||
switch {
|
||||
case path == "/api/devices" && r.Method == "GET":
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
|
||||
COALESCE(u.username,'')
|
||||
FROM server_devices d
|
||||
LEFT JOIN server_users u ON u.id = d.user_id
|
||||
ORDER BY d.created_at DESC`)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type devDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
RevokedAt string `json:"revoked_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
var out []devDTO
|
||||
for rows.Next() {
|
||||
var d devDTO
|
||||
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
|
||||
out = append(out, d)
|
||||
}
|
||||
jsonOK(w, out)
|
||||
|
||||
case path == "/api/keys" && r.Method == "GET":
|
||||
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []map[string]string
|
||||
for rows.Next() {
|
||||
var id, name, key string
|
||||
rows.Scan(&id, &name, &key)
|
||||
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
|
||||
}
|
||||
jsonOK(w, out)
|
||||
|
||||
case path == "/api/keys" && r.Method == "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
jsonErr(w, 400, "name required")
|
||||
return
|
||||
}
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b)
|
||||
apiKey := hex.EncodeToString(b)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
apiKey[:12], name, apiKey, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
|
||||
case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE":
|
||||
id := strings.TrimPrefix(path, "/api/keys/")
|
||||
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
|
||||
case path == "/api/smtp" && r.Method == "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
|
||||
val := r.FormValue(key)
|
||||
if val != "" {
|
||||
s.smtpSet(key, val)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
|
||||
case path == "/api/users" && r.Method == "GET":
|
||||
filter := r.URL.Query().Get("filter")
|
||||
sort := r.URL.Query().Get("sort")
|
||||
order := r.URL.Query().Get("order")
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
where := ""
|
||||
var args []interface{}
|
||||
if filter != "" {
|
||||
where = " WHERE u.username LIKE ?"
|
||||
args = append(args, "%"+filter+"%")
|
||||
}
|
||||
validSorts := map[string]string{
|
||||
"username": "u.username",
|
||||
"email": "u.email",
|
||||
"confirmed": "u.confirmed",
|
||||
"blocked": "u.blocked",
|
||||
"created_at": "u.created_at",
|
||||
"last_seen": "u.last_seen",
|
||||
"devices": "devices",
|
||||
}
|
||||
orderClause := "u.created_at DESC"
|
||||
if col, ok := validSorts[sort]; ok {
|
||||
if order != "asc" {
|
||||
order = "desc"
|
||||
}
|
||||
orderClause = col + " " + order
|
||||
}
|
||||
// Count total.
|
||||
var total int
|
||||
countSQL := "SELECT COUNT(*) FROM server_users u" + where
|
||||
s.db.QueryRow(countSQL, args...).Scan(&total)
|
||||
// Fetch page.
|
||||
offset := (page - 1) * perPage
|
||||
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
|
||||
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
|
||||
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
|
||||
args = append(args, perPage, offset)
|
||||
rows, err := s.db.Query(sql, args...)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type userRow struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
Blocked int `json:"blocked"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Devices int `json:"devices"`
|
||||
}
|
||||
var users []userRow
|
||||
for rows.Next() {
|
||||
var u userRow
|
||||
var lastSeen *string
|
||||
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
|
||||
if lastSeen != nil {
|
||||
u.LastSeen = *lastSeen
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"users": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
|
||||
case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
|
||||
sub := strings.TrimPrefix(path, "/api/users/")
|
||||
if strings.HasSuffix(sub, "/block") {
|
||||
id := strings.TrimSuffix(sub, "/block")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
var blocked int
|
||||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
|
||||
newVal := 1
|
||||
if blocked != 0 {
|
||||
newVal = 0
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
|
||||
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(sub, "/reset-password") {
|
||||
id := strings.TrimSuffix(sub, "/reset-password")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
b := make([]byte, 12)
|
||||
rand.Read(b)
|
||||
newPass := hex.EncodeToString(b)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||||
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(sub, "/edit") {
|
||||
id := strings.TrimSuffix(sub, "/edit")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Email == "" {
|
||||
jsonErr(w, 400, "username and email required")
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"status": "ok"})
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "unknown action")
|
||||
|
||||
case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
|
||||
id := strings.TrimPrefix(path, "/api/users/")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
// Get user devices to delete.
|
||||
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
|
||||
var deviceIDs []string
|
||||
for rows.Next() {
|
||||
var did string
|
||||
rows.Scan(&did)
|
||||
deviceIDs = append(deviceIDs, did)
|
||||
}
|
||||
rows.Close()
|
||||
for _, did := range deviceIDs {
|
||||
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
|
||||
}
|
||||
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
|
||||
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
|
||||
jsonOK(w, map[string]interface{}{"status": "deleted"})
|
||||
|
||||
default:
|
||||
jsonErr(w, 404, "not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte("Verstak Sync Server\n"))
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "not found")
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"version": "verstak-server/v1",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
ip := r.RemoteAddr
|
||||
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
if !s.pairLimit.allow(ip) {
|
||||
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
|
||||
jsonErr(w, 429, "too many attempts")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Login == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "login and password required")
|
||||
return
|
||||
}
|
||||
if req.DeviceName == "" {
|
||||
req.DeviceName = "unknown"
|
||||
}
|
||||
// Look up user.
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
// Generate device.
|
||||
devID := make([]byte, 12)
|
||||
rand.Read(devID)
|
||||
deviceID := "dev_" + hex.EncodeToString(devID)
|
||||
token, prefix, suffix := genDeviceToken()
|
||||
tokenHash := sha256Hex(token)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
apiKey := make([]byte, 20)
|
||||
rand.Read(apiKey)
|
||||
_, err = s.db.Exec(`INSERT INTO server_devices
|
||||
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
|
||||
userID, req.ClientVersion, ip, now, now)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
|
||||
s.pairLimit.reset(ip)
|
||||
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"device_id": deviceID,
|
||||
"device_token": token,
|
||||
"server_time": now,
|
||||
"initial_sync_cursor": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username and password required")
|
||||
return
|
||||
}
|
||||
var hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" {
|
||||
jsonErr(w, 401, "token required")
|
||||
return
|
||||
}
|
||||
hash := sha256Hex(tok)
|
||||
var deviceID, userID string
|
||||
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid token")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
|
||||
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
|
||||
jsonOK(w, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
userID, ok := s.requireUserWeb(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.DeviceID == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "device_id and password required")
|
||||
return
|
||||
}
|
||||
// Verify password.
|
||||
var pwHash string
|
||||
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash)
|
||||
if err != nil {
|
||||
jsonErr(w, 403, "access denied")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 403, "wrong password")
|
||||
return
|
||||
}
|
||||
// Verify device belongs to user.
|
||||
var devUserID string
|
||||
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "device not found")
|
||||
return
|
||||
}
|
||||
if devUserID != userID {
|
||||
jsonErr(w, 403, "device does not belong to you")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
|
||||
s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
|
||||
jsonOK(w, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" {
|
||||
jsonErr(w, 401, "token required")
|
||||
return
|
||||
}
|
||||
hash := sha256Hex(tok)
|
||||
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
|
||||
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
||||
FROM server_devices d WHERE d.token_hash=?`, hash).
|
||||
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid token")
|
||||
return
|
||||
}
|
||||
var username string
|
||||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"device_id": deviceID,
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"device_name": name,
|
||||
"client_version": clientVer,
|
||||
"last_seen": lastSeen,
|
||||
"revoked_at": revokedAt,
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
jsonErr(w, 400, "name required")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 401, "username and password required")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up user by username or email.
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b)
|
||||
apiKey := hex.EncodeToString(b)
|
||||
deviceID := apiKey[:12]
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
deviceID, req.Name, apiKey, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Link device to user.
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
||||
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"device_id": deviceID,
|
||||
"api_key": apiKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/i18n"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username, email and password required")
|
||||
return
|
||||
}
|
||||
if err := validatePassword(req.Password); err != "" {
|
||||
jsonErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
jsonErr(w, 400, "invalid email")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := make([]byte, 12)
|
||||
rand.Read(id)
|
||||
userID := hex.EncodeToString(id)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
||||
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
jsonErr(w, 409, "username or email already taken")
|
||||
return
|
||||
}
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Confirmation token.
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
// Try to send email.
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
var confirmURL string
|
||||
if srvURL != "" {
|
||||
confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr)
|
||||
} else {
|
||||
confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailConfirmBody"), confirmURL)
|
||||
if err := s.smtpSend(req.Email, i18n.T(s.locale(), "server.emailConfirmSubject"), body); err != nil {
|
||||
log.Printf("register: failed to send confirm email: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("register: SMTP not configured, confirmation token=%s for user %s", tokenStr, req.Username)
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "confirmation_sent"})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
jsonErr(w, 405, "GET required")
|
||||
return
|
||||
}
|
||||
tokenStr := r.URL.Query().Get("token")
|
||||
if tokenStr == "" {
|
||||
jsonErr(w, 400, "token required")
|
||||
return
|
||||
}
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
|
||||
tokenStr).Scan(&userID, &expiresAt)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
jsonErr(w, 400, "token expired")
|
||||
return
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
|
||||
log.Printf("confirm: user %s confirmed email", userID)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(confirmedHTML(s.locale())))
|
||||
}
|
||||
|
||||
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username and password required")
|
||||
return
|
||||
}
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
|
||||
tok := s.userTokens.Create(userID)
|
||||
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
|
||||
}
|
||||
|
||||
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Email == "" {
|
||||
jsonErr(w, 400, "email required")
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
|
||||
if err != nil {
|
||||
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
|
||||
if srvURL != "" {
|
||||
resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailResetBody"), resetURL)
|
||||
s.smtpSend(req.Email, i18n.T(s.locale(), "server.emailResetSubject"), body)
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
||||
}
|
||||
|
||||
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Token == "" || req.NewPassword == "" {
|
||||
jsonErr(w, 400, "token and new_password required")
|
||||
return
|
||||
}
|
||||
if err := validatePassword(req.NewPassword); err != "" {
|
||||
jsonErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
||||
req.Token).Scan(&userID, &expiresAt)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
jsonErr(w, 400, "token expired")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
|
||||
jsonOK(w, map[string]string{"status": "password reset"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := s.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
jsonErr(w, 405, "GET required")
|
||||
return
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.last_seen, d.created_at
|
||||
FROM server_devices d
|
||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||
WHERE ud.user_id = ?
|
||||
ORDER BY d.created_at`, userID)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type deviceDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var devices []deviceDTO
|
||||
for rows.Next() {
|
||||
var d deviceDTO
|
||||
var lastSeen sql.NullString
|
||||
if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
d.LastSeen = lastSeen.String
|
||||
devices = append(devices, d)
|
||||
}
|
||||
if devices == nil {
|
||||
devices = []deviceDTO{}
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"devices": devices})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAPIKey(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Ops []struct {
|
||||
OpID string `json:"op_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
ClientSequence int `json:"client_sequence"`
|
||||
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
} `json:"ops"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotency: if request-level key provided, check for cached response.
|
||||
if req.IdempotencyKey != "" {
|
||||
var cachedJSON string
|
||||
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON)
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(cachedJSON))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
var accepted []string
|
||||
var conflicts []map[string]interface{}
|
||||
|
||||
for _, op := range req.Ops {
|
||||
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
|
||||
continue
|
||||
}
|
||||
// Conflict detection: check if another device already created ops for this entity
|
||||
// with a server_sequence higher than what this client last saw.
|
||||
if op.LastSeenServerSeq > 0 {
|
||||
conflictRows, err := s.db.Query(`
|
||||
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
|
||||
WHERE entity_type=? AND entity_id=? AND device_id!=?
|
||||
AND server_sequence > ? AND op_type != 'delete'
|
||||
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq)
|
||||
if err == nil {
|
||||
for conflictRows.Next() {
|
||||
var cOpID, cDevID, cOpType string
|
||||
var cSeq int
|
||||
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
|
||||
conflicts = append(conflicts, map[string]interface{}{
|
||||
"op_id": cOpID,
|
||||
"device_id": cDevID,
|
||||
"op_type": cOpType,
|
||||
"server_sequence": cSeq,
|
||||
"entity_type": op.EntityType,
|
||||
"entity_id": op.EntityID,
|
||||
})
|
||||
}
|
||||
conflictRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
res, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
|
||||
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
continue // duplicate op_id
|
||||
}
|
||||
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seq, _ := seqRes.LastInsertId()
|
||||
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
|
||||
|
||||
if op.OpType == "delete" {
|
||||
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
|
||||
op.EntityType, op.EntityID, op.OpID, now)
|
||||
}
|
||||
|
||||
accepted = append(accepted, op.OpID)
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"accepted": accepted,
|
||||
"count": len(accepted),
|
||||
"conflicts": conflicts,
|
||||
}
|
||||
|
||||
// Cache response for idempotency.
|
||||
if req.IdempotencyKey != "" {
|
||||
if respJSON, err := json.Marshal(resp); err == nil {
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
|
||||
req.IdempotencyKey, string(respJSON), now)
|
||||
}
|
||||
}
|
||||
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAPIKey(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SinceSequence int `json:"since_sequence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
var serverSeq int
|
||||
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
|
||||
FROM server_ops
|
||||
WHERE server_sequence > ? AND server_sequence IS NOT NULL
|
||||
ORDER BY server_sequence`, req.SinceSequence)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type opDTO struct {
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
DeviceID string `json:"device_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var ops []opDTO
|
||||
for rows.Next() {
|
||||
var o opDTO
|
||||
if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, o)
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"server_sequence": serverSeq,
|
||||
"ops": ops,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAPIKey(w, r) {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
// Upload: accept multipart file, store by SHA-256.
|
||||
if err := r.ParseMultipartForm(200 << 20); err != nil {
|
||||
jsonErr(w, 400, "multipart error: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "file field required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read content and compute SHA-256.
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "read error")
|
||||
return
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
shaHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Store at blobs/ab/cd/sha256.
|
||||
blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4])
|
||||
if err := os.MkdirAll(blobDir, 0750); err != nil {
|
||||
jsonErr(w, 500, "mkdir error")
|
||||
return
|
||||
}
|
||||
blobPath := filepath.Join(blobDir, shaHex)
|
||||
if err := os.WriteFile(blobPath, data, 0640); err != nil {
|
||||
jsonErr(w, 500, "write error")
|
||||
return
|
||||
}
|
||||
_ = header
|
||||
|
||||
// Record in blobs table.
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
|
||||
shaHex, len(data), now)
|
||||
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"sha256": shaHex,
|
||||
"size": len(data),
|
||||
})
|
||||
|
||||
case "GET":
|
||||
// Download: GET /api/v1/blobs/{sha256}
|
||||
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
|
||||
if len(shaHex) != 64 {
|
||||
jsonErr(w, 400, "invalid SHA-256")
|
||||
return
|
||||
}
|
||||
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
|
||||
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
||||
jsonErr(w, 404, "blob not found")
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "read error")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
|
||||
w.Write(data)
|
||||
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/i18n"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
cookie, err := r.Cookie("user_session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return "", false
|
||||
}
|
||||
userID, ok := s.userTokens.Check(cookie.Value)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(userRegisterHTML(s.locale())))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), "400 Bad request", "400 Bad request", "/register")))
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if username == "" || email == "" || password == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.allFieldsRequired"), "/register")))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(password); err != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err, "/register")))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "error.generic"), "/register")))
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := make([]byte, 12)
|
||||
rand.Read(id)
|
||||
userID := hex.EncodeToString(id)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
||||
userID, username, strings.ToLower(email), string(hash), now,
|
||||
)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
w.WriteHeader(409)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), "Username or email already taken", "/register")))
|
||||
} else {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err.Error(), "/register")))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Confirmation token.
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
// Try to send email.
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
var confirmURL string
|
||||
if srvURL != "" {
|
||||
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
|
||||
} else {
|
||||
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailConfirmBody"), confirmURL)
|
||||
if err := s.smtpSend(email, i18n.T(s.locale(), "server.emailConfirmSubject"), body); err != nil {
|
||||
log.Printf("register web: failed to send confirm email: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
regMsg := registrationOKHTML(s.locale())
|
||||
if host == "" {
|
||||
regMsg = registrationAutoHTML(s.locale())
|
||||
}
|
||||
w.Write([]byte(regMsg))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotPasswordHTML(s.locale())))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(r.FormValue("email"))
|
||||
if email == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.needEmail"), "/forgot")))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotSentHTML(s.locale())))
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
|
||||
if srvURL != "" {
|
||||
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf(i18n.T(s.locale(), "server.emailResetBody"), resetURL)
|
||||
if err := s.smtpSend(email, i18n.T(s.locale(), "server.emailResetSubject"), body); err != nil {
|
||||
log.Printf("forgot web: failed to send reset email: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotSentHTML(s.locale())))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Validate token exists and not expired before showing form.
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
||||
token).Scan(&userID, &expiresAt)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := strings.ReplaceAll(resetPasswordHTML(s.locale()), "{TOKEN}", token)
|
||||
w.Write([]byte(html))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
token := r.FormValue("token")
|
||||
newPass := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
if token == "" || newPass == "" || confirm == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.allFieldsRequired"), "/forgot")))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(newPass); err != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err, "/reset?token="+token)))
|
||||
return
|
||||
}
|
||||
if newPass != confirm {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.passwordsDoNotMatch"), "/reset?token="+token)))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
|
||||
log.Printf("reset: user %s reset password", userID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(resetDoneHTML(s.locale())))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(userLoginHTML(s.locale())))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/login")))
|
||||
return
|
||||
}
|
||||
tok := s.userTokens.Create(userID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "user_session", Value: tok, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := s.requireUserWeb(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var username string
|
||||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||||
|
||||
// Get devices with status info.
|
||||
type dev struct {
|
||||
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
|
||||
}
|
||||
var devices []dev
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
|
||||
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
|
||||
FROM server_devices d
|
||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||
WHERE ud.user_id = ?
|
||||
ORDER BY d.created_at DESC`, userID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d dev
|
||||
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
deviceRows := ""
|
||||
if len(devices) == 0 {
|
||||
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>" + i18n.T(s.locale(), "userDashboard.noDevices") + "</td></tr>"
|
||||
} else {
|
||||
for _, d := range devices {
|
||||
ls := d.LastSeen
|
||||
if ls == "" {
|
||||
ls = "—"
|
||||
}
|
||||
created := d.CreatedAt
|
||||
if len(created) > 10 {
|
||||
created = created[:10]
|
||||
}
|
||||
status := "<span style='color:#34d399'>" + i18n.T(s.locale(), "userDashboard.active") + "</span>"
|
||||
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">%s</button>`, d.ID, i18n.T(s.locale(), "userDashboard.revoke"))
|
||||
if d.RevokedAt != "" {
|
||||
status = "<span style='color:#ff6b6b'>" + i18n.T(s.locale(), "userDashboard.revoked") + "</span>"
|
||||
revokeBtn = ""
|
||||
}
|
||||
deviceRows += fmt.Sprintf(`<tr>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s %s</td>
|
||||
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
|
||||
}
|
||||
}
|
||||
|
||||
w.Write([]byte(userDashboardHTML(s.locale(), username, deviceRows)))
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "user_session", Value: "", Path: "/",
|
||||
HttpOnly: true, MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# install.sh — установка Verstak Sync Server
|
||||
#
|
||||
# Использование:
|
||||
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
|
||||
#
|
||||
# Флаги:
|
||||
# --port Порт сервера (по умолчанию: 47732)
|
||||
# --user Системный пользователь (по умолчанию: verstak)
|
||||
# --admin-user Логин администратора (обязательный)
|
||||
# --admin-pass Пароль администратора (обязательный)
|
||||
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Defaults
|
||||
PORT="${VERSTAK_PORT:-47732}"
|
||||
USER="verstak"
|
||||
ADMIN_USER=""
|
||||
ADMIN_PASS=""
|
||||
BIN="./verstak-server"
|
||||
|
||||
# Parse flags
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--user) USER="$2"; shift 2 ;;
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||
--bin) BIN="$2"; shift 2 ;;
|
||||
*) echo "Unknown: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
|
||||
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This script must be run as root (sudo)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Verstak Sync Server Installation ==="
|
||||
echo "Port: $PORT"
|
||||
echo "User: $USER"
|
||||
echo "Admin: $ADMIN_USER"
|
||||
echo "Binary: $BIN"
|
||||
echo ""
|
||||
|
||||
# 1. Create system user if not exists.
|
||||
if ! id -u "$USER" >/dev/null 2>&1; then
|
||||
echo "Creating user: $USER"
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
|
||||
fi
|
||||
|
||||
# 2. Install binary.
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing binary to /usr/local/bin/verstak-server"
|
||||
cp "$BIN" /usr/local/bin/verstak-server
|
||||
chmod 755 /usr/local/bin/verstak-server
|
||||
|
||||
# 3. Create data directory.
|
||||
echo "Creating /var/lib/verstak-server"
|
||||
mkdir -p /var/lib/verstak-server
|
||||
chown "$USER:$USER" /var/lib/verstak-server
|
||||
chmod 750 /var/lib/verstak-server
|
||||
|
||||
# 4. Set up admin user (first run).
|
||||
echo "Setting up admin user"
|
||||
/usr/local/bin/verstak-server \
|
||||
--port "$PORT" \
|
||||
--data /var/lib/verstak-server \
|
||||
--admin-user "$ADMIN_USER" \
|
||||
--admin-pass "$ADMIN_PASS" &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
|
||||
# 5. Install systemd unit.
|
||||
echo "Installing systemd unit"
|
||||
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
|
||||
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
|
||||
# Set port in environment file.
|
||||
mkdir -p /etc/verstak-server
|
||||
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
|
||||
|
||||
# 6. Enable and start.
|
||||
echo "Enabling and starting service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable verstak-server
|
||||
systemctl start verstak-server
|
||||
|
||||
echo ""
|
||||
echo "=== Installation complete ==="
|
||||
echo "Service: verstak-server"
|
||||
echo "Port: $PORT"
|
||||
echo "Admin: http://localhost:$PORT/admin/login"
|
||||
echo ""
|
||||
echo "Check status: systemctl status verstak-server"
|
||||
echo "View logs: journalctl -u verstak-server -f"
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 47732, "HTTP port")
|
||||
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
|
||||
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
|
||||
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
|
||||
flag.Parse()
|
||||
|
||||
absData, err := filepath.Abs(*dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("data dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absData, 0750); err != nil {
|
||||
log.Fatalf("create data dir: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(absData)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
// First-run admin setup.
|
||||
if *adminUser != "" && *adminPass != "" {
|
||||
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
|
||||
log.Fatalf("set admin: %v", err)
|
||||
}
|
||||
fmt.Printf("Admin user %q created.\n", *adminUser)
|
||||
}
|
||||
|
||||
// Open server DB.
|
||||
dbPath := filepath.Join(absData, "server.db")
|
||||
srv, err := NewServer(dbPath, absData, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
|
||||
if err := srv.ListenAndServe(addr); err != nil {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool {
|
||||
key := r.Header.Get("Authorization")
|
||||
key = strings.TrimPrefix(key, "Bearer ")
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if key == "" {
|
||||
jsonErr(w, 401, "API key required")
|
||||
return false
|
||||
}
|
||||
// First try device token (hashed).
|
||||
hash := sha256Hex(key)
|
||||
var deviceID, userID, revokedAt sql.NullString
|
||||
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID, &revokedAt)
|
||||
if err == nil {
|
||||
if revokedAt.Valid && revokedAt.String != "" {
|
||||
jsonErr(w, 401, "device revoked")
|
||||
return false
|
||||
}
|
||||
// Check user not blocked.
|
||||
var blocked int
|
||||
if userID.Valid && userID.String != "" {
|
||||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userID.String).Scan(&blocked)
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "user blocked")
|
||||
return false
|
||||
}
|
||||
}
|
||||
r.Header.Set("X-Device-ID", deviceID.String)
|
||||
r.Header.Set("X-User-ID", userID.String)
|
||||
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceID.String)
|
||||
return true
|
||||
}
|
||||
// Fallback to plain api_key (legacy).
|
||||
var count int
|
||||
err = s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
jsonErr(w, 401, "invalid API key")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil || !s.tokens.Check(cookie.Value) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type PasswordError string
|
||||
|
||||
const (
|
||||
ErrPasswordTooShort PasswordError = "PASSWORD_TOO_SHORT"
|
||||
ErrPasswordTooLong PasswordError = "PASSWORD_TOO_LONG"
|
||||
)
|
||||
|
||||
func validatePassword(password string) string {
|
||||
if len(password) < 8 {
|
||||
return string(ErrPasswordTooShort)
|
||||
}
|
||||
if len(password) > 256 {
|
||||
return string(ErrPasswordTooLong)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
key := r.Header.Get("Authorization")
|
||||
key = strings.TrimPrefix(key, "Bearer ")
|
||||
if key == "" {
|
||||
jsonErr(w, 401, "authorization required")
|
||||
return "", false
|
||||
}
|
||||
userID, ok := s.userTokens.Check(key)
|
||||
if !ok {
|
||||
jsonErr(w, 401, "invalid or expired token")
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
|
||||
mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
|
||||
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
|
||||
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
|
||||
mux.HandleFunc("/api/client/pair", s.handleClientPair)
|
||||
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
|
||||
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
|
||||
mux.HandleFunc("/api/client/me", s.handleClientMe)
|
||||
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
|
||||
mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
|
||||
mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
|
||||
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
|
||||
mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
|
||||
mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
|
||||
mux.HandleFunc("/forgot", s.handleUserWebForgot)
|
||||
mux.HandleFunc("/reset", s.handleUserWebReset)
|
||||
mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
|
||||
mux.HandleFunc("/register", s.handleUserWebRegister)
|
||||
mux.HandleFunc("/login", s.handleUserWebLogin)
|
||||
mux.HandleFunc("/dashboard", s.handleUserDashboard)
|
||||
mux.HandleFunc("/logout", s.handleUserWebLogout)
|
||||
mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
||||
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
|
||||
mux.HandleFunc("/admin/users", s.handleAdminUsers)
|
||||
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
|
||||
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
|
||||
mux.HandleFunc("/admin/", s.handleAdminAPI)
|
||||
mux.HandleFunc("/", s.handleNotFound)
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
const serverSchema = `
|
||||
CREATE TABLE IF NOT EXISTS server_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT,
|
||||
token_prefix TEXT,
|
||||
token_suffix TEXT,
|
||||
user_id TEXT,
|
||||
client_version TEXT,
|
||||
last_ip TEXT,
|
||||
last_seen TEXT,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_revisions (
|
||||
rev INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
op_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_ops (
|
||||
op_id TEXT PRIMARY KEY,
|
||||
server_sequence INTEGER,
|
||||
device_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
idempotency_key TEXT,
|
||||
client_sequence INTEGER DEFAULT 0,
|
||||
last_seen_server_seq INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_tombstones (
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (entity_type, entity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_blobs (
|
||||
sha256 TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_smtp_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_email_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_user_devices (
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
device_id TEXT,
|
||||
ip TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type pairRateLimit struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]int
|
||||
}
|
||||
|
||||
func (p *pairRateLimit) allow(ip string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.attempts == nil {
|
||||
p.attempts = make(map[string]int)
|
||||
}
|
||||
p.attempts[ip]++
|
||||
return p.attempts[ip] <= 5
|
||||
}
|
||||
|
||||
func (p *pairRateLimit) reset(ip string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.attempts, ip)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
cfg *Config
|
||||
tokens *tokenStore
|
||||
userTokens *userTokenStore
|
||||
blobsDir string
|
||||
mux *http.ServeMux
|
||||
pairLimit *pairRateLimit
|
||||
}
|
||||
|
||||
func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) {
|
||||
s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// Run schema.
|
||||
for _, stmt := range strings.Split(serverSchema, ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
}
|
||||
// Migrations for older databases.
|
||||
db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
|
||||
db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN token_hash TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN token_prefix TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN token_suffix TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN user_id TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN client_version TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN last_ip TEXT")
|
||||
db.Exec("ALTER TABLE server_devices ADD COLUMN revoked_at TEXT")
|
||||
|
||||
// Migration: add server_sequence and tombstones.
|
||||
db.Exec("ALTER TABLE server_ops ADD COLUMN server_sequence INTEGER")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS server_tombstones (
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (entity_type, entity_id)
|
||||
)`)
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS server_idempotency_keys (
|
||||
idempotency_key TEXT PRIMARY KEY,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`)
|
||||
db.Exec(`ALTER TABLE server_ops ADD COLUMN idempotency_key TEXT`)
|
||||
db.Exec(`ALTER TABLE server_ops ADD COLUMN client_sequence INTEGER DEFAULT 0`)
|
||||
db.Exec(`ALTER TABLE server_ops ADD COLUMN last_seen_server_seq INTEGER DEFAULT 0`)
|
||||
|
||||
blobsDir := filepath.Join(dataDir, "blobs")
|
||||
if err := os.MkdirAll(blobsDir, 0750); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
tokens: newTokenStore(),
|
||||
userTokens: newUserTokenStore(),
|
||||
blobsDir: blobsDir,
|
||||
pairLimit: &pairRateLimit{},
|
||||
}
|
||||
s.mux = s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) locale() string {
|
||||
return "ru"
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
return http.ListenAndServe(addr, s.mux)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) smtpGet(key string) string {
|
||||
var val string
|
||||
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *Server) smtpSet(key, val string) error {
|
||||
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
|
||||
return err
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func genDeviceToken() (token, prefix, suffix string) {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token = "vs_dev_" + hex.EncodeToString(b)
|
||||
prefix = token[:16]
|
||||
suffix = token[len(token)-8:]
|
||||
return
|
||||
}
|
||||
|
||||
func sel(v, want string) string {
|
||||
if v == want {
|
||||
return " selected"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
|
||||
addr := net.JoinHostPort(host, port)
|
||||
switch security {
|
||||
case "tls":
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
cl, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
return cl, nil
|
||||
default:
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
cl, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
if security != "none" {
|
||||
if ok, _ := cl.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
if err := cl.StartTLS(tlsCfg); err != nil {
|
||||
cl.Close()
|
||||
return nil, fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cl, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
|
||||
if user != "" {
|
||||
auth := smtp.PlainAuth("", user, pass, host)
|
||||
if err := cl.Auth(auth); err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := cl.Mail(from); err != nil {
|
||||
return fmt.Errorf("mail from: %w", err)
|
||||
}
|
||||
if err := cl.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("rcpt: %w", err)
|
||||
}
|
||||
w, err := cl.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
w.Close()
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) smtpSend(to, subject, body string) error {
|
||||
host := s.smtpGet("smtp_host")
|
||||
port := s.smtpGet("smtp_port")
|
||||
user := s.smtpGet("smtp_user")
|
||||
pass := s.smtpGet("smtp_pass")
|
||||
from := s.smtpGet("smtp_from")
|
||||
security := s.smtpGet("smtp_security")
|
||||
if host == "" || port == "" || from == "" {
|
||||
err := fmt.Errorf("SMTP not configured")
|
||||
log.Printf("smtp: %v (to=%s)", err, to)
|
||||
return err
|
||||
}
|
||||
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
|
||||
msg := []byte("From: " + from + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" + body + "\r\n")
|
||||
cl, err := s.smtpConnect(host, port, user, pass, security)
|
||||
if err != nil {
|
||||
log.Printf("smtp: connect error: %v", err)
|
||||
return err
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
|
||||
log.Printf("smtp: send error: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("smtp: sent OK to %s", to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
|
||||
if host == "" || port == "" || from == "" {
|
||||
return fmt.Errorf("SMTP not configured")
|
||||
}
|
||||
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
|
||||
cl, err := s.smtpConnect(host, port, user, pass, security)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cl.Close()
|
||||
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/i18n"
|
||||
)
|
||||
|
||||
func userRegisterHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
|
||||
</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="email" name="email" required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required minlength="8" maxlength="256">
|
||||
<button>%s</button>
|
||||
<p>%s <a href="/login">%s</a></p>
|
||||
</form>
|
||||
</body></html>`,
|
||||
i18n.T(locale, "server.registerTitle"),
|
||||
i18n.T(locale, "server.register"),
|
||||
i18n.T(locale, "server.username"),
|
||||
i18n.T(locale, "server.email"),
|
||||
i18n.T(locale, "server.password"),
|
||||
i18n.T(locale, "server.registerBtn"),
|
||||
i18n.T(locale, "server.alreadyHaveAccount"),
|
||||
i18n.T(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func userLoginHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Verstak Sync</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required>
|
||||
<button>%s</button>
|
||||
<div class="links">
|
||||
<a href="/forgot">%s</a><br>
|
||||
<a href="/register">%s</a> · <a href="/admin/login">%s</a>
|
||||
</div>
|
||||
</form>
|
||||
</body></html>`,
|
||||
i18n.T(locale, "server.loginTitle"),
|
||||
i18n.T(locale, "server.usernameOrEmail"),
|
||||
i18n.T(locale, "server.password"),
|
||||
i18n.T(locale, "server.loginBtn"),
|
||||
i18n.T(locale, "server.forgotPassword"),
|
||||
i18n.T(locale, "server.registerBtn"),
|
||||
i18n.T(locale, "server.adminLink"),
|
||||
)
|
||||
}
|
||||
|
||||
func adminLoginHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Verstak Sync</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required>
|
||||
<button>%s</button>
|
||||
</form>
|
||||
</body></html>`,
|
||||
i18n.T(locale, "admin.login"),
|
||||
i18n.T(locale, "admin.username"),
|
||||
i18n.T(locale, "admin.password"),
|
||||
i18n.T(locale, "admin.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func adminUsersHTML(locale string) string {
|
||||
newPassResult := i18n.T(locale, "server.newPasswordResult")
|
||||
newPassParts := strings.SplitN(newPassResult, "%s", 2)
|
||||
newPassPrefix := newPassParts[0]
|
||||
newPassSuffix := strings.ReplaceAll(newPassParts[1], "\n", "\\n")
|
||||
|
||||
deleteMsg := i18n.T(locale, "admin.deleteUserMessage")
|
||||
deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2)
|
||||
delMsgPrefix := deleteMsgParts[0]
|
||||
delMsgSuffix := deleteMsgParts[1]
|
||||
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%[1]s</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
|
||||
a{color:#6366f1}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
table{width:100%%;border-collapse:collapse;margin-top:12px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
||||
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
|
||||
th:hover{color:#b0b0c0}
|
||||
th.sorted{color:#6366f1}
|
||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
||||
.btn:hover{background:#222233}
|
||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
||||
.btn-danger:hover{background:#3a2222}
|
||||
.btn-sm{padding:2px 8px;font-size:11px}
|
||||
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;box-sizing:border-box}
|
||||
input:focus{outline:none;border-color:#6366f1}
|
||||
.toolbar{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap;align-items:center}
|
||||
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;justify-content:center}
|
||||
.pagination span{padding:4px 8px;font-size:12px;color:#888}
|
||||
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
|
||||
.badge-green{background:#064e3b;color:#34d399}
|
||||
.badge-red{background:#4a2222;color:#ff6b6b}
|
||||
.badge-yellow{background:#4a3e00;color:#fbbf24}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:400px;max-width:90vw;position:relative}
|
||||
.modal h2{margin-top:0;font-size:16px}
|
||||
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
||||
.modal-close:hover{color:#e4e4ef}
|
||||
.form-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
|
||||
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
||||
.form-row input{flex:1}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>%[2]s</h1>
|
||||
<p><a href="/admin/dashboard">%[3]s</a></p>
|
||||
|
||||
<div class="toolbar">
|
||||
<input id="filter-input" placeholder="%[4]s" style="width:200px" onkeyup="loadUsers()">
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th onclick="sortBy('username')">%[5]s <span id="s-username"></span></th>
|
||||
<th onclick="sortBy('email')">%[6]s <span id="s-email"></span></th>
|
||||
<th onclick="sortBy('confirmed')">%[7]s <span id="s-confirmed"></span></th>
|
||||
<th onclick="sortBy('devices')">%[8]s <span id="s-devices"></span></th>
|
||||
<th onclick="sortBy('last_seen')">%[9]s <span id="s-last_seen"></span></th>
|
||||
<th>%[10]s</th>
|
||||
</tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" id="pagination"></div>
|
||||
|
||||
<div id="confirm-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeConfirm()">×</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()">×</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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'"')}
|
||||
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()">×</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()">×</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"))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenStore struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]time.Time
|
||||
}
|
||||
|
||||
func newTokenStore() *tokenStore {
|
||||
return &tokenStore{tokens: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (ts *tokenStore) Create() string {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
ts.tokens[tok] = time.Now().Add(24 * time.Hour)
|
||||
return tok
|
||||
}
|
||||
|
||||
func (ts *tokenStore) Check(tok string) bool {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
exp, ok := ts.tokens[tok]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(ts.tokens, tok)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// userTokenStore embeds tokenStore but also tracks the user_id per token.
|
||||
type userTokenStore struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]userTokenEntry
|
||||
}
|
||||
|
||||
type userTokenEntry struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func newUserTokenStore() *userTokenStore {
|
||||
return &userTokenStore{tokens: make(map[string]userTokenEntry)}
|
||||
}
|
||||
|
||||
func (uts *userTokenStore) Create(userID string) string {
|
||||
uts.mu.Lock()
|
||||
defer uts.mu.Unlock()
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
return tok
|
||||
}
|
||||
|
||||
func (uts *userTokenStore) Check(tok string) (string, bool) {
|
||||
uts.mu.Lock()
|
||||
defer uts.mu.Unlock()
|
||||
entry, ok := uts.tokens[tok]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
delete(uts.tokens, tok)
|
||||
return "", false
|
||||
}
|
||||
return entry.UserID, true
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Verstak Sync Server
|
||||
Documentation=https://github.com/anomalyco/verstak
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=verstak
|
||||
Group=verstak
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StateDirectory=verstak-server
|
||||
RuntimeDirectory=verstak-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/vault"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
@@ -38,6 +40,8 @@ func main() {
|
||||
runLog(os.Args[2:])
|
||||
case "index":
|
||||
runIndex(os.Args[2:])
|
||||
case "sync":
|
||||
runSync(os.Args[2:])
|
||||
case "plugin":
|
||||
runPlugin(os.Args[2:])
|
||||
default:
|
||||
@@ -56,6 +60,7 @@ func usage() {
|
||||
fmt.Println(" node Manage nodes")
|
||||
fmt.Println(" action Manage actions")
|
||||
fmt.Println(" --version Show version")
|
||||
fmt.Println(" sync Sync with server (push/pull/status)")
|
||||
fmt.Println(" --help Show this help")
|
||||
}
|
||||
|
||||
@@ -597,6 +602,170 @@ func runIndexRebuild(args []string) {
|
||||
fmt.Printf("indexed %d nodes\n", count)
|
||||
}
|
||||
|
||||
// --- sync ---
|
||||
|
||||
func runSync(args []string) {
|
||||
if len(args) == 0 {
|
||||
fmt.Println("verstak sync — synchronize with server")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: verstak sync <command> [options]")
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" push Push local changes to server")
|
||||
fmt.Println(" pull Pull remote changes from server")
|
||||
fmt.Println(" status Show sync status")
|
||||
os.Exit(0)
|
||||
}
|
||||
switch args[0] {
|
||||
case "push":
|
||||
runSyncPush(args[1:])
|
||||
case "pull":
|
||||
runSyncPull(args[1:])
|
||||
case "status":
|
||||
runSyncStatus(args[1:])
|
||||
case "--help", "-h":
|
||||
runSync(nil)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func openSyncDB(args []string) (*storage.DB, string) {
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return db, abs
|
||||
}
|
||||
|
||||
func runSyncPush(args []string) {
|
||||
db, abs := openSyncDB(args)
|
||||
defer db.Close()
|
||||
|
||||
cfg, err := config.Load(abs)
|
||||
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
deviceID := cfg.Sync.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = "cli-" + abs[:8]
|
||||
}
|
||||
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||
|
||||
unpushed, err := syncSvc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(unpushed) == 0 {
|
||||
fmt.Println("Nothing to push.")
|
||||
return
|
||||
}
|
||||
|
||||
_, _, lastSeq, _, _ := syncSvc.GetState()
|
||||
for i := range unpushed {
|
||||
unpushed[i].LastSeenServerSeq = lastSeq
|
||||
}
|
||||
|
||||
result, err := client.Push(unpushed)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Printf("WARNING: %d conflict(s) detected\n", len(result.Conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func runSyncPull(args []string) {
|
||||
db, abs := openSyncDB(args)
|
||||
defer db.Close()
|
||||
|
||||
cfg, err := config.Load(abs)
|
||||
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Sync not configured.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
deviceID := cfg.Sync.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = "cli-" + abs[:8]
|
||||
}
|
||||
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||
|
||||
_, _, lastSeq, _, err := syncSvc.GetState()
|
||||
if err != nil {
|
||||
lastSeq = 0
|
||||
}
|
||||
|
||||
result, err := client.Pull(lastSeq)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var opIDs []string
|
||||
for _, op := range result.Ops {
|
||||
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
|
||||
opIDs = append(opIDs, op.OpID)
|
||||
}
|
||||
|
||||
if len(opIDs) > 0 {
|
||||
syncSvc.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
fmt.Printf("Pulled %d ops (server seq: %d)\n", len(result.Ops), result.ServerSequence)
|
||||
}
|
||||
|
||||
func runSyncStatus(args []string) {
|
||||
db, abs := openSyncDB(args)
|
||||
defer db.Close()
|
||||
|
||||
cfg, err := config.Load(abs)
|
||||
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
|
||||
serverURL := ""
|
||||
deviceID := ""
|
||||
if cfg != nil {
|
||||
serverURL = cfg.Sync.ServerURL
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
unpushed := 0
|
||||
if configured {
|
||||
if deviceID == "" {
|
||||
deviceID = "cli-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
ops, _ := syncSvc.GetUnpushedOps()
|
||||
unpushed = len(ops)
|
||||
}
|
||||
|
||||
fmt.Println("Sync Status")
|
||||
fmt.Println(" Configured:", configured)
|
||||
fmt.Println(" Server:", serverURL)
|
||||
fmt.Println(" Device:", deviceID)
|
||||
fmt.Println(" Unpushed ops:", unpushed)
|
||||
}
|
||||
|
||||
// --- plugin ---
|
||||
|
||||
func runPlugin(args []string) {
|
||||
|
||||
@@ -18,7 +18,11 @@ func runNodeCreate(vault, parentID, typ, title string) error {
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
n, err := repo.Create(parentID, typ, title, "")
|
||||
var pid *string
|
||||
if parentID != "" {
|
||||
pid = &parentID
|
||||
}
|
||||
n, err := repo.Create(pid, typ, title, 0, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,7 +69,7 @@ func runNodeList(vault, parentID string) error {
|
||||
repo := nodes.NewRepository(db)
|
||||
var list []nodes.Node
|
||||
if parentID == "" {
|
||||
list, err = repo.ListRoots(false, "")
|
||||
list, err = repo.ListRoots(false)
|
||||
} else {
|
||||
list, err = repo.ListChildren(parentID, false)
|
||||
}
|
||||
@@ -87,7 +91,11 @@ func runNodeMove(vault, id, parentID string, sortOrder int) error {
|
||||
defer db.Close()
|
||||
|
||||
repo := nodes.NewRepository(db)
|
||||
if err := repo.Move(id, parentID, sortOrder); err != nil {
|
||||
var pid *string
|
||||
if parentID != "" {
|
||||
pid = &parentID
|
||||
}
|
||||
if err := repo.Move(id, pid, sortOrder); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("moved")
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
||||
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
|
||||
|
||||
## Главные принципы
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ Markdown-файл внутри vault. Резервная копия при пе
|
||||
|
||||
Любой файл, привязанный к делу. Открывается системным приложением.
|
||||
|
||||
### Ссылка
|
||||
|
||||
Отдельный артефакт дела, а не заметка. Хранит URL, название,
|
||||
hostname, заметку и дату добавления. Открывается во внешнем браузере
|
||||
и показывается во вкладке "Ссылки" конкретного дела.
|
||||
|
||||
### Действие
|
||||
|
||||
Кнопка запуска: URL, файл, папка, команда. Опасные — с подтверждением.
|
||||
@@ -81,6 +87,21 @@ Markdown-файл внутри vault. Резервная копия при пе
|
||||
Следы работы: открыт файл, изменена заметка, запущено действие.
|
||||
Используется для восстановления времени.
|
||||
|
||||
### Неразобранное
|
||||
|
||||
Единый входящий поток. Любой внешний материал — файл, папка,
|
||||
изображение, URL или текст из drag-and-drop, Ctrl+V или кнопки
|
||||
"Вставить из буфера" — сначала создаётся как unresolved artifact.
|
||||
Верстак хранит контекст захвата по `node_id` или системному разделу,
|
||||
показывает материал в глобальном "Неразобранном" и, если захват был
|
||||
внутри дела, в локальной вкладке "Неразобранное" этого дела.
|
||||
|
||||
При разборе artifact маршрутизируется по типу:
|
||||
|
||||
- файл / папка / изображение → "Файлы" выбранного дела;
|
||||
- текст → "Заметки" выбранного дела;
|
||||
- URL → "Ссылки" выбранного дела.
|
||||
|
||||
## 5. Примеры дерева (универсальные)
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
```text
|
||||
┌────────────────────┐
|
||||
│ GUI/Wails │
|
||||
│ GUI/Wails v2 │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
|
||||
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
|
||||
└─────────────┘ └───────┬────────┘ └─────────────┘
|
||||
┌───────▼────────┐ ┌─────────────┐
|
||||
│ Core Library │◀───│ CLI Commands │
|
||||
└───────┬────────┘ └─────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Local Vault+SQLite │
|
||||
@@ -161,8 +161,6 @@ verstak/
|
||||
activity/
|
||||
search/
|
||||
importers/
|
||||
sync/
|
||||
security/
|
||||
config/
|
||||
|
||||
gui/
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
.verstak/
|
||||
index.db
|
||||
config.yml
|
||||
device.yml
|
||||
sync_state.db
|
||||
device_token.json
|
||||
blobs/
|
||||
inbox/
|
||||
plugins/
|
||||
trash/
|
||||
history/
|
||||
originals/
|
||||
@@ -51,6 +53,10 @@ CREATE TABLE nodes (
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
path TEXT NULL,
|
||||
section TEXT NULL,
|
||||
template_id TEXT NOT NULL DEFAULT '',
|
||||
fs_path TEXT NOT NULL DEFAULT '',
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
@@ -102,6 +108,26 @@ CREATE TABLE notes (
|
||||
);
|
||||
```
|
||||
|
||||
### links
|
||||
|
||||
Resolved URL artifacts. Unresolved captured URLs live as inbox artifacts
|
||||
with capture metadata until the user assigns them to a target node.
|
||||
|
||||
```sql
|
||||
CREATE TABLE links (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
captured_at TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### actions
|
||||
|
||||
```sql
|
||||
@@ -135,6 +161,7 @@ CREATE TABLE worklog_entries (
|
||||
billable INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT NOT NULL,
|
||||
details TEXT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -145,38 +172,25 @@ CREATE TABLE worklog_entries (
|
||||
```sql
|
||||
CREATE TABLE activity_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NULL REFERENCES nodes(id),
|
||||
source TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
title TEXT NULL,
|
||||
path TEXT NULL,
|
||||
url TEXT NULL,
|
||||
app_name TEXT NULL,
|
||||
process_name TEXT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NULL,
|
||||
duration_seconds INTEGER NULL,
|
||||
metadata_json TEXT NULL,
|
||||
confidence INTEGER NOT NULL DEFAULT 100,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
target_path TEXT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### worklog_suggestions
|
||||
### worklog_entry_events
|
||||
|
||||
```sql
|
||||
CREATE TABLE worklog_suggestions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
date TEXT NOT NULL,
|
||||
started_at TEXT NULL,
|
||||
ended_at TEXT NULL,
|
||||
suggested_minutes INTEGER NULL,
|
||||
summary TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
CREATE TABLE worklog_entry_events (
|
||||
entry_id TEXT NOT NULL REFERENCES worklog_entries(id) ON DELETE CASCADE,
|
||||
event_id TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_id, event_id)
|
||||
);
|
||||
```
|
||||
|
||||
@@ -208,10 +222,45 @@ CREATE TABLE sync_ops (
|
||||
pushed_at TEXT NULL,
|
||||
applied_at TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_state (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
server_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
last_push_rev INTEGER NOT NULL DEFAULT 0,
|
||||
last_pull_rev INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_at TEXT,
|
||||
last_pull_seq INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
## 3. Правила хранения
|
||||
|
||||
### Capture / Inbox
|
||||
|
||||
Внешний захват материала не создаёт сразу обычную заметку, файл или
|
||||
ссылку внутри дела. Сначала создаётся root-level inbox artifact с
|
||||
metadata в `node_meta`:
|
||||
|
||||
```text
|
||||
capture.inbox = true
|
||||
capture.status = unresolved | resolved
|
||||
capture.context_type = node | section | global
|
||||
capture.context_node_id = <node_id>
|
||||
capture.context_section = today | journal | settings | inbox | root | ...
|
||||
capture.suggested_target_node_id = <node_id>
|
||||
capture.source_kind = file | folder | url | text | image
|
||||
capture.source = drop | paste | clipboard_button
|
||||
capture.created_at = RFC3339
|
||||
```
|
||||
|
||||
Контекст дела хранится только по `node_id`, не по названию.
|
||||
|
||||
Файлы, папки и изображения до разбора лежат в `.verstak/inbox/...`.
|
||||
После "Оставить здесь" или "Разложить" они физически перемещаются в
|
||||
нормальную vault-иерархию выбранного дела через существующую логику
|
||||
`MoveNode`.
|
||||
|
||||
### Заметки
|
||||
|
||||
- physical `.md` file;
|
||||
@@ -224,6 +273,13 @@ CREATE TABLE sync_ops (
|
||||
- open with system app;
|
||||
- metadata in SQLite.
|
||||
|
||||
### Ссылки
|
||||
|
||||
- unresolved URL хранится как inbox artifact типа `link` с URL metadata;
|
||||
- после разбора создаётся запись в `links`;
|
||||
- вкладка "Ссылки" показывает только resolved links текущего node;
|
||||
- URL открывается системным браузером.
|
||||
|
||||
### Secret notes
|
||||
|
||||
- encrypted file, e.g. `access.secret.md.enc`;
|
||||
|
||||
@@ -13,14 +13,20 @@
|
||||
|
||||
`verstak-server` — простой Go HTTP server.
|
||||
|
||||
Функции:
|
||||
Реализовано в коде:
|
||||
|
||||
- API key auth;
|
||||
- device registry;
|
||||
- user auth + device registry;
|
||||
- device token / API key auth;
|
||||
- operation log;
|
||||
- blob storage by sha256;
|
||||
- snapshots;
|
||||
- push/pull operation exchange;
|
||||
- blob upload/download;
|
||||
- web pages for login/register/dashboard/admin.
|
||||
|
||||
Ещё не реализовано:
|
||||
|
||||
- restore manifest;
|
||||
- snapshots;
|
||||
- deleted item retention.
|
||||
|
||||
Пример server storage:
|
||||
@@ -42,9 +48,10 @@ GET /api/v1/health
|
||||
POST /api/v1/device/register
|
||||
POST /api/v1/sync/push
|
||||
POST /api/v1/sync/pull
|
||||
POST /api/v1/blobs/upload
|
||||
POST /api/v1/blobs/
|
||||
GET /api/v1/blobs/{sha256}
|
||||
GET /api/v1/restore/manifest
|
||||
POST /api/v1/auth/register
|
||||
POST /api/v1/auth/login
|
||||
```
|
||||
|
||||
Auth:
|
||||
@@ -60,8 +67,7 @@ Client push:
|
||||
```json
|
||||
{
|
||||
"device_id": "device-uuid",
|
||||
"ops": [],
|
||||
"blobs": ["sha256..."]
|
||||
"ops": []
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,8 +75,9 @@ Server response:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true,
|
||||
"missing_blobs": ["sha256..."]
|
||||
"accepted": ["op-id"],
|
||||
"count": 1,
|
||||
"conflicts": []
|
||||
}
|
||||
```
|
||||
|
||||
@@ -78,8 +85,7 @@ Client pull:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "device-uuid",
|
||||
"since_revision": 42
|
||||
"since_sequence": 42
|
||||
}
|
||||
```
|
||||
|
||||
@@ -87,7 +93,7 @@ Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_revision": 50,
|
||||
"server_sequence": 50,
|
||||
"ops": []
|
||||
}
|
||||
```
|
||||
@@ -157,7 +163,7 @@ retention:
|
||||
|
||||
Верстак не должен быть шпионской программой. Activity tracking должен быть явным и настраиваемым.
|
||||
|
||||
### Источники MVP
|
||||
### Источники текущего MVP
|
||||
|
||||
- opened node;
|
||||
- edited note;
|
||||
@@ -165,6 +171,8 @@ retention:
|
||||
- changed file through app;
|
||||
- launched action.
|
||||
|
||||
На текущем этапе события используются для conservative worklog suggestions и для глобальной/узловой ленты активности.
|
||||
|
||||
### Источники later
|
||||
|
||||
- file watcher/scanner;
|
||||
|
||||
@@ -18,23 +18,23 @@
|
||||
- import/dokuwiki/mysql-cleanup.txt
|
||||
|
||||
Быстрые действия:
|
||||
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
|
||||
[Новое дело] [Добавить файл] [Журнал] [Sync]
|
||||
```
|
||||
|
||||
## 2. Общий layout
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Search | Quick Add | Sync Status | Settings │
|
||||
├───────────────┬────────────────────────────────────┤
|
||||
│ Tree sidebar │ Main content │
|
||||
│ │ │
|
||||
│ Сегодня │ Case / Note / File / Search │
|
||||
│ Клиенты │ │
|
||||
┌───────────────┬────────────────────────────────────┐
|
||||
│ Tree sidebar │ Header: Section/Node Title │
|
||||
│ ├────────────────────────────────────┤
|
||||
│ Сегодня │ Main content │
|
||||
│ Клиенты │ Case / Note / File / Search │
|
||||
│ Проекты │ │
|
||||
│ Рецепты │ │
|
||||
│ Документы │ │
|
||||
└───────────────┴────────────────────────────────────┘
|
||||
|
||||
*Примечание: Sync Status реализован в footer сайдбара и открывает Settings → Sync. Quick Add как отдельная глобальная панель пока не реализован.*
|
||||
```
|
||||
|
||||
## 3. Sidebar tree
|
||||
@@ -82,7 +82,7 @@
|
||||
2026-05-12 — чистка кеша и проверка формы — 40м
|
||||
|
||||
Вкладки:
|
||||
Обзор | Заметки | Файлы | Документы | Скрипты | Действия | Журнал | Активность
|
||||
Обзор | Заметки | Файлы | Неразобранное | Ссылки | Действия | Журнал | Активность
|
||||
```
|
||||
|
||||
## 5. Заметки
|
||||
@@ -100,7 +100,8 @@
|
||||
Actions:
|
||||
|
||||
- add file;
|
||||
- drag & drop;
|
||||
- drag & drop в приложении создаёт unresolved artifact в Неразобранном;
|
||||
- явные кнопки добавления файла/папки импортируют в выбранный раздел файлов;
|
||||
- copy into vault;
|
||||
- link external;
|
||||
- open with system app;
|
||||
@@ -167,6 +168,8 @@ Backup сайта run_script, confirm required
|
||||
|
||||
## 9. Активность
|
||||
|
||||
Текущий MVP фиксирует события внутри приложения: создание/изменение заметок, добавление/перемещение/удаление файлов и папок, создание/изменение узлов, запуск actions и добавление worklog. На основе этих событий строятся предложения для журнала работ.
|
||||
|
||||
```text
|
||||
14:05 opened admin.romashka.ru
|
||||
14:18 opened catalog.xlsx
|
||||
@@ -197,24 +200,50 @@ Search results:
|
||||
|
||||
## 11. Неразобранное
|
||||
|
||||
Неразобранное — единый входящий лоток для внешнего материала.
|
||||
Пользователь может кинуть или вставить материал в любое место
|
||||
приложения; Верстак сначала сохраняет его как unresolved artifact,
|
||||
а не добавляет напрямую в дело.
|
||||
|
||||
Туда попадают:
|
||||
|
||||
- импортированные DokuWiki pages;
|
||||
- файлы без дела;
|
||||
- папки из scan work;
|
||||
- конфликты sync;
|
||||
- activity suggestions без уверенного дела.
|
||||
- файлы и папки из drag-and-drop;
|
||||
- изображения из paste/drop, если webview отдаёт данные;
|
||||
- URL из браузера (`text/uri-list`, `text/x-moz-url`, `text/plain`);
|
||||
- обычный текст из Ctrl+V;
|
||||
- текст/URL из кнопки "Вставить из буфера" через backend clipboard bridge;
|
||||
- будущие импортированные материалы без уверенного назначения;
|
||||
- будущие конфликты sync.
|
||||
|
||||
Глобальное "Неразобранное" показывает все unresolved artifacts.
|
||||
Локальная вкладка "Неразобранное" внутри дела показывает artifacts,
|
||||
захваченные в контексте этого `node_id` или предложенные для него.
|
||||
|
||||
Actions:
|
||||
|
||||
- создать дело;
|
||||
- привязать к делу;
|
||||
- переместить;
|
||||
- архивировать;
|
||||
- игнорировать;
|
||||
- удалить в trash.
|
||||
- "Оставить здесь" — если есть suggested target;
|
||||
- "Разложить" / "Переместить..." — выбрать target node;
|
||||
- удалить artifact и staged данные.
|
||||
|
||||
## 12. TUI UX
|
||||
Маршрутизация при разборе:
|
||||
|
||||
- файл / папка / изображение → вкладка "Файлы" target node;
|
||||
- текст → вкладка "Заметки" target node;
|
||||
- URL → вкладка "Ссылки" target node.
|
||||
|
||||
## 12. Ссылки
|
||||
|
||||
Вкладка "Ссылки" внутри дела показывает только resolved URL artifacts
|
||||
этого node. Ссылки не смешиваются с файлами, заметками и действиями.
|
||||
|
||||
Минимальные действия:
|
||||
|
||||
- открыть во внешнем браузере;
|
||||
- скопировать URL;
|
||||
- редактировать название, URL и заметку;
|
||||
- удалить ссылку.
|
||||
|
||||
## 13. TUI UX
|
||||
|
||||
TUI нужен для быстрого:
|
||||
|
||||
@@ -231,7 +260,7 @@ TUI нужен для быстрого:
|
||||
- preview документов;
|
||||
- настройки sync.
|
||||
|
||||
## 13. CLI UX
|
||||
## 14. CLI UX
|
||||
|
||||
CLI команды:
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Верстак — roadmap
|
||||
|
||||
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
|
||||
|
||||
## Milestone 0 — Skeleton
|
||||
|
||||
- Go module;
|
||||
- cmd/verstak;
|
||||
- cmd/verstak-gui stub;
|
||||
- cmd/verstak-server stub;
|
||||
- cmd/verstak-gui entrypoint;
|
||||
- cmd/verstak-server entrypoint;
|
||||
- internal/core;
|
||||
- SQLite connection;
|
||||
- migrations;
|
||||
@@ -65,6 +67,8 @@ Acceptance:
|
||||
|
||||
## Milestone 4 — GUI MVP
|
||||
|
||||
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
|
||||
|
||||
- Wails app;
|
||||
- sidebar tree;
|
||||
- create node/note;
|
||||
@@ -131,28 +135,42 @@ Acceptance:
|
||||
|
||||
## Milestone 9 — Work folder intake
|
||||
|
||||
- scan folder;
|
||||
- show unassigned folders/files;
|
||||
- create case from folder;
|
||||
- copy/move/link into vault.
|
||||
- unified Capture / Inbox pipeline for external drop, paste and clipboard button;
|
||||
- stage captured files/folders/images under `.verstak/inbox`;
|
||||
- store capture context by `node_id` or system section;
|
||||
- global Inbox view for all unresolved artifacts;
|
||||
- local Inbox tab inside a node;
|
||||
- resolve artifacts into Files / Notes / Links;
|
||||
- dedicated Links tab for resolved URL artifacts;
|
||||
- scanner for a whole work folder remains future work.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- можно постепенно разобрать папку `work`.
|
||||
- можно кинуть или вставить файл, папку, картинку, URL или текст в приложение;
|
||||
- материал не теряется и сначала виден в "Неразобранном";
|
||||
- после разбора он физически или логически оказывается в выбранном деле.
|
||||
|
||||
## Milestone 10 — Sync server MVP
|
||||
## Milestone 10 — Sync server/client MVP
|
||||
|
||||
- HTTP server;
|
||||
- API key;
|
||||
- user auth + device registration;
|
||||
- API key / device token flow;
|
||||
- server db;
|
||||
- push/pull ops;
|
||||
- blob upload/download;
|
||||
- restore manifest.
|
||||
- manual sync from GUI;
|
||||
- sync settings in Settings.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- две машины обмениваются изменениями через сервер.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- restore manifest/download flow is not implemented;
|
||||
- conflict resolver UI is not implemented;
|
||||
- retention/snapshots are not implemented.
|
||||
|
||||
## Milestone 11 — Conflicts and restore
|
||||
|
||||
- conflict copy;
|
||||
@@ -179,7 +197,12 @@ Acceptance:
|
||||
|
||||
- Верстак предлагает worklog на основе следов.
|
||||
|
||||
## Milestone 13 — File scanner/watcher
|
||||
Current gaps:
|
||||
|
||||
- file watcher/scanner events are not implemented;
|
||||
- browser/active-window tracking is not implemented.
|
||||
|
||||
## Milestone 13 — File scanner/watcher (PAUSED)
|
||||
|
||||
- snapshot scanner;
|
||||
- fsnotify watcher;
|
||||
@@ -191,7 +214,7 @@ Acceptance:
|
||||
|
||||
- изменения файлов снаружи приложения обнаруживаются.
|
||||
|
||||
## Milestone 14 — TUI MVP
|
||||
## Milestone 14 — TUI MVP (PAUSED)
|
||||
|
||||
- Bubble Tea;
|
||||
- tree/search;
|
||||
@@ -203,7 +226,7 @@ Acceptance:
|
||||
|
||||
- можно из терминала быстро работать с Верстаком.
|
||||
|
||||
## Milestone 15 — 1.0 polish
|
||||
## Milestone 15 — 1.0 polish (PAUSED)
|
||||
|
||||
- installers;
|
||||
- backup export;
|
||||
|
||||
@@ -216,7 +216,7 @@ Acceptance:
|
||||
- GET /health;
|
||||
- POST /sync/push;
|
||||
- POST /sync/pull;
|
||||
- POST /blobs/upload;
|
||||
- POST /api/v1/blobs/;
|
||||
- GET /blobs/{sha256};
|
||||
- blob storage by sha256.
|
||||
```
|
||||
|
||||
@@ -6,34 +6,40 @@
|
||||
|
||||
## Обязательные функции
|
||||
|
||||
- [ ] Создать vault.
|
||||
- [ ] Создать дерево дел.
|
||||
- [ ] Создать Markdown-заметку.
|
||||
- [ ] Редактировать Markdown-заметку.
|
||||
- [ ] Добавить файл в дело.
|
||||
- [ ] Открыть файл системным приложением.
|
||||
- [ ] Создать action “Открыть URL”.
|
||||
- [ ] Создать action “Открыть папку”.
|
||||
- [ ] Создать action “Запустить команду”.
|
||||
- [ ] Добавить запись работы.
|
||||
- [ ] Скопировать отчёт по работам.
|
||||
- [ ] Поиск по заметкам.
|
||||
- [ ] Поиск по именам файлов.
|
||||
- [x] Создать vault.
|
||||
- [x] Создать дерево дел.
|
||||
- [x] Создать Markdown-заметку.
|
||||
- [x] Редактировать Markdown-заметку.
|
||||
- [x] Добавить файл в дело.
|
||||
- [x] Открыть файл системным приложением.
|
||||
- [x] Создать action “Открыть URL”.
|
||||
- [x] Создать action “Открыть папку”.
|
||||
- [x] Создать action “Запустить команду”.
|
||||
- [x] Добавить запись работы.
|
||||
- [x] Скопировать отчёт по работам.
|
||||
- [x] Поиск по заметкам.
|
||||
- [x] Поиск по именам файлов.
|
||||
- [x] Поиск по журналу работ.
|
||||
- [x] Базовый импорт DokuWiki (плагин).
|
||||
- [x] Базовая ручная синхронизация через сервер.
|
||||
- [x] Базовые activity events и предложения для журнала работ.
|
||||
- [x] Настройки vault/sync/templates/plugins.
|
||||
|
||||
## Необязательные, но желательные
|
||||
|
||||
- [ ] Неразобранное.
|
||||
- [ ] Скан папки work.
|
||||
- [ ] Внешние linked files.
|
||||
- [ ] Action log.
|
||||
- [ ] Activity events MVP.
|
||||
- [x] Action log.
|
||||
- [x] Activity events MVP.
|
||||
- [ ] Conflict resolver UI.
|
||||
- [ ] Restore from sync server.
|
||||
- [ ] Backup export / retention snapshots.
|
||||
- [ ] TUI MVP.
|
||||
|
||||
## Не входит в первый MVP
|
||||
|
||||
- [ ] Полная синхронизация.
|
||||
- [ ] Полная синхронизация с restore/conflict UI/retention.
|
||||
- [ ] Активные окна.
|
||||
- [ ] Browser tracking.
|
||||
- [ ] Encrypted secret notes.
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Сервер синхронизации Верстак — руководство
|
||||
|
||||
## 1. Зачем нужен сервер
|
||||
|
||||
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
|
||||
|
||||
Он не обязателен — Верстак работает полностью локально и без сервера.
|
||||
|
||||
## 2. Установка
|
||||
|
||||
### Быстрая установка (systemd)
|
||||
|
||||
```bash
|
||||
# Сборка бинарника
|
||||
go build -o verstak-server ./cmd/verstak-server/
|
||||
|
||||
# Установка (от root)
|
||||
sudo ./verstak-server/install.sh \
|
||||
--port 47732 \
|
||||
--admin-user admin \
|
||||
--admin-pass 'мой-надёжный-пароль'
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
- создаёт системного пользователя `verstak`;
|
||||
- копирует бинарник в `/usr/local/bin/verstak-server`;
|
||||
- создаёт `/var/lib/verstak-server` для данных;
|
||||
- записывает админа в конфиг;
|
||||
- устанавливает systemd-сервис `verstak-server.service`;
|
||||
- запускает и включает автозапуск.
|
||||
|
||||
### Ручной запуск (для тестирования)
|
||||
|
||||
```bash
|
||||
# Одной командой
|
||||
./verstak-server \
|
||||
--port 47732 \
|
||||
--data ./server-data \
|
||||
--admin-user admin \
|
||||
--admin-pass 'мой-надёжный-пароль'
|
||||
```
|
||||
|
||||
Флаги:
|
||||
| Флаг | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `--port` | `47732` | Порт сервера |
|
||||
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
|
||||
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
|
||||
| `--admin-pass` | — | Пароль администратора |
|
||||
|
||||
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
|
||||
|
||||
## 3. Админ-панель
|
||||
|
||||
### Как открыть
|
||||
|
||||
Откройте в браузере: `http://<сервер>:47732/admin/login`
|
||||
|
||||
Войдите с логином и паролем, указанными при установке.
|
||||
|
||||
### Что там есть
|
||||
|
||||
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей, форма настройки SMTP.
|
||||
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
|
||||
- **Настройка SMTP** — сервер, порт, логин, пароль, email отправителя, URL сервера (для ссылок в письмах). SMTP нужен для отправки писем подтверждения email и сброса пароля.
|
||||
|
||||
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
|
||||
|
||||
### Несколько админов
|
||||
|
||||
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
|
||||
|
||||
## 4. Регистрация пользователей
|
||||
|
||||
### Как зарегистрироваться
|
||||
|
||||
Откройте в браузере: `http://<сервер>:47732/register`
|
||||
|
||||
Форма принимает:
|
||||
- **Логин** — латинские буквы и цифры
|
||||
- **Email** — для подтверждения и сброса пароля
|
||||
- **Пароль** — минимум 8 символов, латинские буквы и цифры
|
||||
|
||||
После отправки формы на email приходит письмо с ссылкой подтверждения (если SMTP настроен). Если SMTP не настроен, подтверждение происходит автоматически, и можно сразу логиниться.
|
||||
|
||||
### Как войти
|
||||
|
||||
`http://<сервер>:47732/login` — форма логина принимает логин или email.
|
||||
|
||||
### Личный кабинет
|
||||
|
||||
После входа — `/dashboard` — список устройств пользователя с полными API-ключами, кнопками копирования и удаления, форма добавления нового устройства.
|
||||
|
||||
## 5. API-ключи (устройства)
|
||||
|
||||
### Что такое API-ключ
|
||||
|
||||
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
|
||||
|
||||
### Как создать
|
||||
|
||||
#### Через веб-интерфейс пользователя
|
||||
|
||||
1. Зайти в `/login`, войти.
|
||||
2. В `/dashboard` ввести имя устройства и нажать "Connect".
|
||||
3. Скопировать сгенерированный ключ.
|
||||
|
||||
#### Через админ-панель
|
||||
|
||||
1. Зайти в `/admin/login`.
|
||||
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
|
||||
3. Скопировать сгенерированный ключ.
|
||||
|
||||
#### Через API (требует логин+пароль пользователя)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:47732/api/v1/device/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"мой-ноутбук","username":"ivan","password":"пароль-пользователя"}'
|
||||
```
|
||||
Ответ: `{"device_id":"...","api_key":"..."}`
|
||||
|
||||
### Как использовать
|
||||
|
||||
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
|
||||
```bash
|
||||
curl -X POST http://localhost:47732/api/v1/sync/push \
|
||||
-H "Authorization: Bearer b10c5d8e3f2a..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
|
||||
```
|
||||
|
||||
В GUI Верстак достаточно указать URL сервера, логин и пароль — устройство зарегистрируется автоматически.
|
||||
|
||||
### Один ключ на все устройства или отдельный на каждое?
|
||||
|
||||
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в личном кабинете), не затронув остальные.
|
||||
|
||||
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
|
||||
- его нельзя будет удалить, не отключив все устройства разом;
|
||||
- в логах сервера все операции будут выглядеть как от одного устройства;
|
||||
- это не поддерживаемый сценарий.
|
||||
|
||||
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
|
||||
|
||||
## 6. Настройка клиента
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
verstak sync push --vault /путь/к/vault
|
||||
verstak sync pull --vault /путь/к/vault
|
||||
verstak sync status --vault /путь/к/vault
|
||||
```
|
||||
|
||||
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
|
||||
```yaml
|
||||
sync:
|
||||
server_url: http://мой-сервер:47732
|
||||
api_key: мой-ключ
|
||||
device_id: id-устройства # опционально
|
||||
auto_sync: false
|
||||
```
|
||||
|
||||
API-ключ и device_id можно получить, зарегистрировав устройство через API:
|
||||
```bash
|
||||
curl -s http://сервер:47732/api/v1/device/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"мой-пк","username":"ivan","password":"мой-пароль"}' | jq .
|
||||
```
|
||||
|
||||
### GUI
|
||||
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера, логин и пароль, нажмите "Test Connection" для проверки, затем "Сохранить". Кнопка "Синхронизировать" (или пункт меню в сайдбаре) запускает push + pull.
|
||||
|
||||
## 7. Безопасность
|
||||
|
||||
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
|
||||
|
||||
Что стоит учесть:
|
||||
- Регистрация устройств требует аутентификации пользователя.
|
||||
- Подтверждение email обязательно, если настроен SMTP (иначе подтверждение автоматическое).
|
||||
- Нет logout'а для админа — сессия живёт 24 часа или до перезапуска сервера.
|
||||
- Нет rate limiting'а — возможен перебор пароля.
|
||||
- Пароли хранятся в bcrypt — база данных не должна быть общедоступной.
|
||||
- SMTP-пароль хранится в открытом виде в конфиге сервера.
|
||||
|
||||
Рекомендации для production:
|
||||
- Закрыть порт сервера фаерволом (только доверенные IP).
|
||||
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
|
||||
- Поставить nginx/Caddy перед сервером с HTTPS.
|
||||
- Настроить SMTP для полноценного подтверждения email и сброса пароля.
|
||||
|
||||
## 8. Полный API
|
||||
|
||||
### Открытые endpoint'ы
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/health` | Проверка здоровья сервера |
|
||||
|
||||
### Требуют API-ключ (Authorization: Bearer)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
|
||||
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
|
||||
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
|
||||
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
|
||||
|
||||
### Требуют логин+пароль (body JSON)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | Регистрация (username, email, password) |
|
||||
| GET | `/api/v1/auth/confirm?token=` | Подтверждение email |
|
||||
| POST | `/api/v1/auth/login` | Вход, возвращает Bearer-токен |
|
||||
| POST | `/api/v1/auth/forgot` | Запрос сброса пароля (email) |
|
||||
| POST | `/api/v1/auth/reset` | Сброс пароля (token, password) |
|
||||
| POST | `/api/v1/device/register` | Регистрация устройства (name, username, password) |
|
||||
|
||||
### Требуют сессию пользователя (Bearer token или cookie)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/user/devices` | Список устройств пользователя |
|
||||
|
||||
### Требуют сессию админа (cookie)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET/POST | `/admin/login` | Страница входа / отправка формы |
|
||||
| GET | `/admin/dashboard` | Дашборд |
|
||||
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
|
||||
| POST | `/admin/api/keys` | Создать ключ |
|
||||
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
|
||||
| POST | `/admin/api/smtp` | Сохранить SMTP-конфигурацию |
|
||||
|
||||
### Пользовательский веб-интерфейс (cookie)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET/POST | `/register` | Форма регистрации |
|
||||
| GET/POST | `/login` | Форма входа |
|
||||
| GET | `/dashboard` | Личный кабинет (устройства) |
|
||||
| GET | `/logout` | Выход |
|
||||
@@ -21,65 +21,23 @@
|
||||
| 8 | FTS5 Search | ✅ выполнен |
|
||||
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
|
||||
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
|
||||
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
|
||||
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
|
||||
| 13 | **Drag-and-drop** | ⬜ не начат |
|
||||
| 14 | **MVP stabilization** | ⬜ не начат |
|
||||
| 15 | Sync Server Skeleton | 🔒 PAUSED |
|
||||
| 16 | Sync Client MVP | 🔒 PAUSED |
|
||||
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
|
||||
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
|
||||
| 19 | Integrity Check + Repair | 🔒 PAUSED |
|
||||
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
|
||||
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
|
||||
| 22 | Calendar/Kanban | 🔒 PAUSED |
|
||||
| 23 | New templates/integrations | 🔒 PAUSED |
|
||||
|
||||
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
|
||||
|
||||
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
|
||||
|
||||
**GUI Build (Wails v2):**
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
||||
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
./verstak-gui
|
||||
```
|
||||
|
||||
**GUI Build (Wails v2):**
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
||||
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
./verstak-gui
|
||||
```
|
||||
|
||||
Или для dev режима: `wails dev` (требует Wails v2 CLI)
|
||||
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
|
||||
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||
| 14 | **MVP stabilization** | ✅ выполнено |
|
||||
| 15 | **Sync Server + Client** | ✅ выполнено |
|
||||
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
|
||||
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
|
||||
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
|
||||
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
|
||||
| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence |
|
||||
| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
|
||||
| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
|
||||
| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
|
||||
|
||||
---
|
||||
|
||||
## Текущий этап: Wails v2 Vertical MVP
|
||||
|
||||
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
|
||||
|
||||
**Прогресс:**
|
||||
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
||||
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
||||
- 🔄 Notes bindings + UI
|
||||
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
||||
- 🔄 Node creation
|
||||
- 🔄 Section filtering
|
||||
|
||||
**Пауза (не начинать до завершения vertical MVP):**
|
||||
- Файлы/папки workflow
|
||||
- Drag-and-drop
|
||||
- Sync, plugins, Lua, browser extension, TUI
|
||||
- Новые шаблоны, DokuWiki importer
|
||||
|
||||
---
|
||||
|
||||
## Выполненные шаги (1-10)
|
||||
## Выполненные шаги (1-14)
|
||||
|
||||
### ШАГ 1 — Git Init + Skeleton
|
||||
- go module `verstak`, структура cmd/internal/migrations
|
||||
@@ -138,11 +96,28 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
- CLI: `plugin list/enable/disable/templates`
|
||||
- Lua runtime — stub (placeholder)
|
||||
|
||||
---
|
||||
### ШАГ 11 — Wails Desktop GUI
|
||||
|
||||
## Текущий этап: ШАГ 11 — Wails Desktop GUI
|
||||
|
||||
**Целевой commit:** `gui/wails-file-workflow`
|
||||
**Прогресс:**
|
||||
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
||||
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
||||
- ✅ Notes bindings + UI (create/read/save/dirty state)
|
||||
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
||||
- ✅ Node creation + template selection (FromTemplate)
|
||||
- ✅ Section filtering
|
||||
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
|
||||
- ✅ Drag-and-drop (internal + external OS file drops)
|
||||
- ✅ Actions CRUD (create/list/run/delete)
|
||||
- ✅ Worklog entry form
|
||||
- ✅ Today dashboard + Activity feed (global + per-case)
|
||||
- ✅ Search
|
||||
- ✅ Import dialog with safety checks (PreviewImport)
|
||||
- ✅ Keyboard shortcuts
|
||||
- ✅ i18n (ru/en)
|
||||
- ✅ First-run/recovery flow без авто-создания vault
|
||||
- ✅ Settings window: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup
|
||||
- ✅ Sync settings перенесены в Settings; в основном UI остался status/manual sync widget
|
||||
- ✅ Rendered GUI smoke: first-run, recovery, Settings icons, workspace, tabs, files back button, create flows, worklog, sync conflict/applyError warnings, mobile viewport
|
||||
|
||||
Архитектура:
|
||||
|
||||
@@ -151,15 +126,16 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
│ Frontend (Wails) │
|
||||
│ frontend/src/ │
|
||||
│ App.svelte │
|
||||
│ components/Sidebar, TopBar, CaseView, ... │
|
||||
│ stores/selection, nodes, files, ui │
|
||||
│ styles/theme.css │
|
||||
│ FileTreeRow, TreeNode │
|
||||
│ lib/FileBreadcrumbs, FilePreviewModal, ... │
|
||||
│ lib/i18n/ locales (ru, en) │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│ Wails bindings
|
||||
┌──────────────────▼──────────────────────────────┐
|
||||
│ Go Core (internal/core/) │
|
||||
│ nodes, vault, storage, notes, files, │
|
||||
│ actions, worklog, search, plugins │
|
||||
│ actions, worklog, search, plugins, sync, │
|
||||
│ activity │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────▼──────────────────────────────┐
|
||||
@@ -167,137 +143,84 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Действия
|
||||
### ШАГ 12 — Files/Folders full workflow
|
||||
|
||||
1. Создать Wails app skeleton (`wails init`)
|
||||
2. Структура frontend/ — Svelte/Vue/vanilla TS
|
||||
3. Backend bindings — методы Wails над core services
|
||||
4. Перенести текущий UI shell из inline HTML в frontend
|
||||
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
|
||||
Core service extensions:
|
||||
- `AddPathCopy` — рекурсивный импорт директорий
|
||||
- `AddPathLink` — привязка внешних файлов без копирования
|
||||
- Folder model: node type `folder`
|
||||
- Name conflict resolution: `name (2)`, `name (3)`
|
||||
- Safety checks: PreviewImport с summary (count, size, warnings)
|
||||
- Trash: soft delete → `.verstak/trash`
|
||||
|
||||
### Backend bindings (минимум)
|
||||
### ШАГ 13 — Drag-and-drop
|
||||
|
||||
```go
|
||||
// Nodes
|
||||
ListSections() ([]SectionDTO, error)
|
||||
ListNodes(section string) ([]NodeDTO, error)
|
||||
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
|
||||
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
|
||||
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
|
||||
DeleteNode(id string) error
|
||||
MoveNode(id, parentID string) error
|
||||
- Internal DnD: перетаскивание узлов в дереве
|
||||
- External DnD: файлы из ОС → активное дело / вкладка Файлы
|
||||
- Диалог подтверждения с выбором: копировать / переместить / привязать
|
||||
|
||||
// Notes
|
||||
CreateNote(parentID, title string) (NodeDTO, error)
|
||||
ReadNote(noteID string) (string, error)
|
||||
SaveNote(noteID, content string) error
|
||||
### ШАГ 14 — MVP stabilization
|
||||
|
||||
// Files
|
||||
ListFiles(nodeID string) ([]FileDTO, error)
|
||||
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
|
||||
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
|
||||
DeleteFileOrFolder(id string) error
|
||||
OpenFile(id string) error
|
||||
OpenFolder(id string) error
|
||||
PickFile() (string, error)
|
||||
PickDirectory() (string, error)
|
||||
|
||||
// Actions/Worklog/Search
|
||||
ListActions(nodeID string) ([]ActionDTO, error)
|
||||
RunAction(id string) error
|
||||
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
|
||||
Search(query string) ([]SearchResultDTO, error)
|
||||
```
|
||||
- Все операции атомарны (DB+FS)
|
||||
- Template файлы/папки создаются как полноценные ноды с rollback
|
||||
- fs_path валидируется (SafeVaultPath)
|
||||
- sync_apply FS-first
|
||||
- Delete находит ошибки trash-переноса
|
||||
- 24+ integration tests проходят
|
||||
- **Известные пробелы:** Restore из trash не реализован, ensureTemplateChildren продолжает при ошибках (backward compat), parent_id+title в applyRemoteNodeUpdate без полной транзакции
|
||||
|
||||
---
|
||||
|
||||
## ШАГ 12 — Files/Folders full workflow
|
||||
## Завершённый базовый этап: ШАГ 16 — Activity Suggestions
|
||||
|
||||
### Core service extensions
|
||||
**Статус:** ✅ базово выполнено — activity-based suggestions + global worklog dashboard + conservative estimator.
|
||||
|
||||
Расширить `files.Service`:
|
||||
|
||||
```go
|
||||
AddPathCopy(nodeID string, sourcePath string) ([]Node, error)
|
||||
AddPathLink(nodeID string, sourcePath string) ([]Node, error)
|
||||
```
|
||||
|
||||
Логика:
|
||||
- `os.Stat(sourcePath)` → если директория → рекурсивный обход
|
||||
- Каждый файл → File node + file record
|
||||
- Каждая папка → Folder node
|
||||
- Структура сохраняется через parent_id
|
||||
|
||||
### Folder model
|
||||
|
||||
Папка = node type `folder` (не file record с mime=directory).
|
||||
|
||||
При импорте `romashka-docs/`:
|
||||
```
|
||||
Folder node: romashka-docs (type=folder)
|
||||
File node: dogovor.docx (type=file)
|
||||
Folder node: screenshots (type=folder)
|
||||
File node: error.png (type=file)
|
||||
```
|
||||
|
||||
### Name conflict resolution
|
||||
|
||||
Если в target уже есть `docs`:
|
||||
```
|
||||
docs
|
||||
docs (2)
|
||||
docs (3)
|
||||
```
|
||||
|
||||
### Safety checks
|
||||
|
||||
При добавлении папки показать summary:
|
||||
- количество файлов/папок
|
||||
- общий размер
|
||||
- предупреждение если > 1000 файлов, > 1 GB, содержит `.git`/`node_modules`/`.cache`
|
||||
|
||||
### Trash
|
||||
|
||||
- Soft delete node + children
|
||||
- Vault files → `.verstak/trash`
|
||||
- External files — только удалить связь
|
||||
- Не `rm -rf`
|
||||
|
||||
### Tests
|
||||
|
||||
1. Copy single file → vault, record created, source intact
|
||||
2. Link single file → no copy, external path saved
|
||||
3. Copy folder → tree created, files in vault
|
||||
4. Link folder → node created, no content copied
|
||||
5. Delete vault file → soft-deleted, file in trash
|
||||
6. Delete vault folder → children soft-deleted
|
||||
7. Name conflict → no overwrite, safe suffix
|
||||
8. Open file → mocked opener (no real app launch)
|
||||
Что реализовано:
|
||||
- `suggest.go` — структура Suggestion (nodeId, nodeTitle, summary, suggestedMin, confidence)
|
||||
- `bindings_suggest.go` — GetSuggestions с burst detection + conservative estimator (5–30 min)
|
||||
- `bindings_suggest.go` — AcceptSuggestionWith (date/minutes overrides)
|
||||
- `worklog/report.go` — ReportFilter, ListReport, Summary, ExportCSV, ExportMarkdown
|
||||
- `app.go` — WorklogDTO расширен: date, details, approximate, billable, nodeTitle
|
||||
- `bindings_worklog.go` — CreateWorklogFull, ListWorklogReport, WorklogSummary, Export*
|
||||
- Новый системный раздел "Журнал" с таблицей, фильтрами (даты, includeChildren), сводкой
|
||||
- Предложения на экране "Сегодня" + вкладке "Журнал" + вкладке "Журнал дела"
|
||||
- Badge с количеством предложений в боковом меню
|
||||
- Консервативный estimator:
|
||||
- burst detection (группировка событий в окне 10 мин)
|
||||
- time spread analysis
|
||||
- 5–30 мин, 60+ только при сильном evidence
|
||||
- Confidence: low/medium/high + reason
|
||||
|
||||
---
|
||||
|
||||
## ШАГ 13 — Drag-and-drop
|
||||
## Ожидающие шаги (17-23)
|
||||
|
||||
### External D&D
|
||||
### ШАГ 17 — File Scanner/Watcher
|
||||
- fsnotify watcher на vault
|
||||
- snapshot scanner для обнаружения изменений при закрытом приложении
|
||||
- обнаружение missing файлов
|
||||
|
||||
Drop target: активное дело / вкладка Файлы / Неразобранное.
|
||||
### ШАГ 18 — TUI MVP (Bubble Tea)
|
||||
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
|
||||
|
||||
После drop → диалог:
|
||||
```
|
||||
Добавить в "ООО Ромашка / Сайт"
|
||||
Файлов: 3, Папок: 1, 240 MB
|
||||
[Скопировать] [Переместить] [Привязать] [Отмена]
|
||||
```
|
||||
### ШАГ 19 — Plugins: Lua runtime
|
||||
- gopher-lua runtime
|
||||
- хуки на события (node created, file added, etc.)
|
||||
- песочница
|
||||
|
||||
---
|
||||
### ШАГ 20 — Browser Extension
|
||||
- Расширение для браузера: трекинг времени, скриншоты, evidence
|
||||
|
||||
## ШАГ 14 — MVP stabilization
|
||||
### ШАГ 21 — Calendar/Kanban
|
||||
- Календарный вид по датам
|
||||
- Канбан-доска по статусам
|
||||
|
||||
- Smoke tests базовых сценариев
|
||||
- Проверка: дело → заметка → файл → папка → trash → перезапуск
|
||||
- go test ./... pass
|
||||
- Обновление документации
|
||||
- Остановка перед следующими фичами
|
||||
### ШАГ 22 — Integrity Check + Repair
|
||||
- Проверка целостности vault
|
||||
- Контрольные суммы, восстановление после сбоев
|
||||
|
||||
### ШАГ 23 — New templates/integrations
|
||||
- Community-плагины и шаблоны
|
||||
|
||||
---
|
||||
|
||||
@@ -307,22 +230,33 @@ Drop target: активное дело / вкладка Файлы / Нераз
|
||||
verstak/
|
||||
go.mod
|
||||
README.md
|
||||
PLAN.md
|
||||
AGENTS.md # Инструкции для AI-ассистента
|
||||
|
||||
cmd/
|
||||
verstak/ # CLI
|
||||
verstak-gui/ # Wails GUI main
|
||||
verstak-tui/ # Bubble Tea TUI
|
||||
verstak-gui/ # Wails v2 GUI main
|
||||
verstak-server/ # Sync server
|
||||
|
||||
frontend/ # Wails frontend
|
||||
frontend/ # Wails v2 Svelte frontend
|
||||
package.json
|
||||
wails.json
|
||||
vite.config.js
|
||||
src/
|
||||
App.svelte
|
||||
components/
|
||||
stores/
|
||||
styles/
|
||||
FileTreeRow.svelte
|
||||
TreeNode.svelte
|
||||
lib/
|
||||
FileBreadcrumbs.svelte
|
||||
FilePreviewModal.svelte
|
||||
ConfirmModal.svelte
|
||||
FileIcon.svelte
|
||||
FileActions.svelte
|
||||
fileUtils.js
|
||||
i18n/ # Локали (ru, en)
|
||||
api/verstak.js
|
||||
|
||||
scripts/
|
||||
build.sh # Раздельная сборка (gui/server/all)
|
||||
check-i18n.sh # Проверка интернационализации
|
||||
|
||||
internal/
|
||||
core/
|
||||
@@ -336,10 +270,12 @@ verstak/
|
||||
activity/
|
||||
search/
|
||||
sync/
|
||||
security/
|
||||
config/
|
||||
templates/
|
||||
plugins/ # manager, lua (stub)
|
||||
|
||||
i18n/ # Go-локали (ru, en)
|
||||
|
||||
contrib/
|
||||
plugins/
|
||||
importer-dokuwiki/
|
||||
@@ -351,11 +287,56 @@ verstak/
|
||||
004_add_notes.sql
|
||||
005_add_actions.sql
|
||||
006_add_worklog.sql
|
||||
007_search.sql
|
||||
008_sync.sql
|
||||
009_section.sql
|
||||
...
|
||||
|
||||
docs/
|
||||
00_README.md
|
||||
01_Product_Spec.md
|
||||
02_Architecture.md
|
||||
03_Data_Model_Storage.md
|
||||
04_Sync_Backup_Activity.md
|
||||
05_UI_UX.md
|
||||
06_Roadmap.md
|
||||
09_Extensibility.md
|
||||
10_Sync_Server_Guide.md
|
||||
PLAN.md
|
||||
TEMPLATES.md
|
||||
VAULT_LAYOUT.md
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Полная сборка
|
||||
./scripts/build.sh
|
||||
|
||||
# По отдельности
|
||||
./scripts/build.sh gui # Wails v2 GUI
|
||||
./scripts/build.sh server # Sync server
|
||||
./scripts/build.sh all # Оба
|
||||
|
||||
# GUI smoke check перед коммитом
|
||||
./scripts/check-gui.sh
|
||||
|
||||
# Полная проверка GUI:
|
||||
# - i18n/source hygiene
|
||||
# - frontend production build
|
||||
# - embedded Wails assets == frontend/dist
|
||||
# - go build с тегами "webkit2_41 desktop production"
|
||||
# - rendered Chromium smoke с Wails-mock и action-flow проверками
|
||||
|
||||
# Только rendered smoke фронтенда с Wails-mock
|
||||
cd frontend && npm run smoke:gui
|
||||
|
||||
# Dev-режим GUI
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
## RAID
|
||||
|
||||
- **Критично:** Wails требует Node.js для frontend-сборки
|
||||
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
||||
- **Зависимость:** Steps 15+ ждут завершения step 14
|
||||
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
|
||||
- **Сборка GUI:** требуется `webkit2_41` tag для WebKitGTK 4.1+
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Template System
|
||||
|
||||
## What is a Template?
|
||||
|
||||
A template defines the **type**, **default structure**, and **available modules**
|
||||
for a Node. Every workspace node is created from a template.
|
||||
|
||||
## System Templates
|
||||
|
||||
Verstak ships with these built-in (system) templates:
|
||||
|
||||
| ID | Type | Default Modules | Default Files | Default Folders |
|
||||
|---|---|---|---|---|
|
||||
| `folder.default` | folder | overview, children, activity | — | — |
|
||||
| `project.default` | project | overview, notes, files, activity, actions, worklog | Overview.md | Documents, Notes, Files |
|
||||
| `client.default` | client | overview, notes, files, activity, actions | Overview.md | Notes, Files |
|
||||
| `document.default` | document | overview, files, activity | — | — |
|
||||
| `recipe.default` | recipe | overview, notes, files, activity | Overview.md | — |
|
||||
|
||||
## Template Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "project.default",
|
||||
"title": "Проект",
|
||||
"type": "project",
|
||||
"enabled": true,
|
||||
"system": true,
|
||||
"icon": "project",
|
||||
"default_modules": ["overview", "notes", "files", "activity", "actions", "worklog"],
|
||||
"default_files": [
|
||||
{"path": "Overview.md", "content_template": "project_overview"}
|
||||
],
|
||||
"default_folders": ["Documents", "Notes", "Files"],
|
||||
"allowed_parent_types": ["folder", "root"],
|
||||
"allowed_child_templates": ["*"]
|
||||
}
|
||||
```
|
||||
|
||||
## How Templates Drive the UI
|
||||
|
||||
1. The creation menu is built from **enabled templates**.
|
||||
2. When you right-click a node, you see "Create inside" → [list of enabled templates].
|
||||
3. Selecting a template creates a Node with that template's type and defaults.
|
||||
4. Disabling a template removes it from the creation menu.
|
||||
|
||||
## Template Fields
|
||||
|
||||
- **id** — unique identifier
|
||||
- **title** — i18n key for display name
|
||||
- **type** — technical type (folder, project, client, etc.)
|
||||
- **enabled** — whether it appears in the creation menu
|
||||
- **system** — whether it's a built-in template
|
||||
- **default_modules** — which tabs/modules are available in the node view
|
||||
- **default_files** — files to create inside the node folder
|
||||
- **default_folders** — subfolders to create inside the node folder
|
||||
- **allowed_parent_types** — which node types can be parents of this template
|
||||
- **allowed_child_templates** — which templates are allowed as children ("*" = any)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Vault Layout
|
||||
|
||||
## Philosophy
|
||||
|
||||
Verstak's vault is designed to be **human-readable**. You can close Verstak, open your vault
|
||||
in any file manager, and find materials by browsing the folder structure.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
vault/
|
||||
.verstak/ # App data — not user-facing
|
||||
index.db
|
||||
config.yml
|
||||
device_token.json
|
||||
blobs/
|
||||
originals/
|
||||
thumbnails/
|
||||
plugins/
|
||||
trash/
|
||||
history/
|
||||
|
||||
Проекты/ # User-created workspace nodes
|
||||
Рабочие/
|
||||
Разработка серверной/
|
||||
Overview.md
|
||||
Documents/
|
||||
Notes/
|
||||
Files/
|
||||
Archive/
|
||||
|
||||
Клиенты/
|
||||
ИТ-Вектор/
|
||||
Projects/
|
||||
LMS/
|
||||
Overview.md
|
||||
Documents/
|
||||
Contracts/
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Every Node has a folder** — the folder name matches the node title (sanitized).
|
||||
2. **Nesting reflects parent-child relationships** — if Node A is parent of Node B,
|
||||
B's folder is inside A's folder.
|
||||
3. **Safe names** — folder names preserve Cyrillic, spaces, and readable characters.
|
||||
Illegal filename chars (`/\:*?"<>|`) are replaced. Control chars are removed.
|
||||
4. **Uniqueness** — if a folder already exists, a numeric suffix is added:
|
||||
`Project`, `Project (2)`, `Project (3)`
|
||||
5. **No UUIDs in user paths** — UUIDs are stored in the database and in `.verstak/`,
|
||||
never in the user-visible folder tree.
|
||||
6. **Template default files** — templates may create default files like `Overview.md`
|
||||
inside the node folder.
|
||||
7. **Archive/Delete** — archiving sets `archived = true` but doesn't move the folder.
|
||||
Deletion moves the folder to `.verstak/trash/`.
|
||||
|
||||
## `.verstak/` directory
|
||||
|
||||
Contains all application internal data:
|
||||
|
||||
- `index.db` — SQLite database
|
||||
- `config.yml` — vault-local settings and vault id
|
||||
- `device_token.json` — sync device token, stored separately from general config
|
||||
- `blobs/` — sync blob cache
|
||||
- `originals/` — imported originals
|
||||
- `thumbnails/` — generated thumbnails
|
||||
- `plugins/` — optional local plugins
|
||||
- `trash/` — moved here on deletion
|
||||
- `history/` — file version history
|
||||
|
||||
Global application settings live outside the vault at
|
||||
`~/.config/verstak/config.json`. This includes the active vault path, UI
|
||||
settings, enabled templates and sync UI settings.
|
||||
|
||||
## Migration
|
||||
|
||||
Existing vaults can be migrated with the `MigrateVaultLayout()` command,
|
||||
which computes `fs_path` for every node based on the parent-child tree
|
||||
and creates the corresponding folders on disk.
|
||||
@@ -0,0 +1,389 @@
|
||||
# Unified Capture Inbox Links Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build one predictable Capture / Inbox / Links pipeline for external drag-and-drop, paste, and clipboard-button input.
|
||||
|
||||
**Architecture:** All external payloads become unresolved inbox artifacts first. Backend stores capture status/context/source metadata on artifact nodes, stages binary payloads under `.verstak/inbox`, resolves artifacts by source kind into Files, Notes, or a dedicated Links table, and exposes global/local inbox APIs. Frontend uses one `resolveCaptureContext()` and one capture dispatcher for drop, paste, and clipboard button instead of screen-specific handlers.
|
||||
|
||||
**Tech Stack:** Go, SQLite migrations, Wails v2 bindings, Svelte 4, Vite 5, existing rendered GUI smoke harness.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Capture Metadata And Inbox Queries
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/verstak-gui/bindings_capture.go`
|
||||
- Modify: `cmd/verstak-gui/bindings_inbox.go`
|
||||
- Modify: `cmd/verstak-gui/capture_test.go`
|
||||
- Modify: `cmd/verstak-gui/inbox_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for context metadata**
|
||||
|
||||
Add tests that capture text on a section and a file while a node is active. Assert:
|
||||
- `captureStatus == "unresolved"`
|
||||
- `captureContextType == "section"` or `"node"`
|
||||
- `captureContextNodeId` equals the target node ID for node context
|
||||
- `suggestedTargetNodeId` equals the node ID for node context
|
||||
- local inbox lookup uses node ID, not title
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestCapture.*Context|TestListInboxNodesForTarget' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because context-aware capture APIs and local inbox query do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement metadata helpers**
|
||||
|
||||
Add a JSON context DTO:
|
||||
|
||||
```go
|
||||
type CaptureContextDTO struct {
|
||||
ContextType string `json:"contextType"`
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
Section string `json:"section,omitempty"`
|
||||
SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Extend capture metadata with:
|
||||
|
||||
```text
|
||||
capture.status
|
||||
capture.context_type
|
||||
capture.context_node_id
|
||||
capture.context_section
|
||||
capture.suggested_target_node_id
|
||||
capture.source_kind
|
||||
capture.source
|
||||
capture.created_at
|
||||
capture.inbox
|
||||
capture.kind
|
||||
```
|
||||
|
||||
Keep old `CaptureText`, `CaptureURL`, `CapturePath`, and `CaptureFileData` as compatibility wrappers.
|
||||
|
||||
- [ ] **Step 4: Implement local inbox API**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error)
|
||||
```
|
||||
|
||||
Return unresolved artifacts where `capture.context_node_id = nodeID OR capture.suggested_target_node_id = nodeID`.
|
||||
|
||||
- [ ] **Step 5: Verify GREEN and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestCapture.*Context|TestListInboxNodesForTarget|TestListInboxNodesReturnsOnlyCapturedArtifacts' -count=1
|
||||
git add cmd/verstak-gui/bindings_capture.go cmd/verstak-gui/bindings_inbox.go cmd/verstak-gui/capture_test.go cmd/verstak-gui/inbox_test.go docs/superpowers/plans/2026-06-05-unified-capture-inbox-links.md
|
||||
git commit -m "feat: track capture context in inbox"
|
||||
git push
|
||||
```
|
||||
|
||||
### Task 2: Dedicated Link Artifacts And Resolve Routing
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/core/storage/migrations_015.sql.go`
|
||||
- Modify: `internal/core/storage/storage.go`
|
||||
- Create: `cmd/verstak-gui/bindings_links.go`
|
||||
- Modify: `cmd/verstak-gui/bindings_capture.go`
|
||||
- Modify: `cmd/verstak-gui/bindings_inbox.go`
|
||||
- Modify: `cmd/verstak-gui/inbox_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for URL capture and resolve**
|
||||
|
||||
Add tests that capture URL, resolve it into Project A, and assert:
|
||||
- unresolved URL uses `sourceKind == "url"`
|
||||
- it appears in global and local inbox before resolve
|
||||
- after resolve it disappears from inbox
|
||||
- `ListLinks(ProjectA)` contains exactly the resolved link
|
||||
- `ListLinks(ProjectB)` does not contain it
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestResolve.*URL|TestListLinks' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because link table/API and URL routing do not exist.
|
||||
|
||||
- [ ] **Step 3: Add links table and bindings**
|
||||
|
||||
Create `links` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
captured_at TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_node ON links(node_id);
|
||||
```
|
||||
|
||||
Expose `ListLinks`, `UpdateLink`, `DeleteLink`, and `OpenLink`.
|
||||
|
||||
- [ ] **Step 4: Implement resolve routing**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func (a *App) ResolveInboxNode(nodeID, targetParentID string) (*NodeDTO, error)
|
||||
func (a *App) ResolveInboxNodeHere(nodeID string) (*NodeDTO, error)
|
||||
```
|
||||
|
||||
Route source kinds:
|
||||
- `file`, `folder`, `image`: move node into target via existing `MoveNode`
|
||||
- `text`: move note into target via existing `MoveNode`
|
||||
- `url`: create a row in `links`, then remove/hide the unresolved artifact
|
||||
|
||||
Keep `AssignInboxNode` as a compatibility alias to `ResolveInboxNode`.
|
||||
|
||||
- [ ] **Step 5: Verify GREEN and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestResolve.*URL|TestAssignInboxNode|TestDeleteInboxNode|TestListLinks' -count=1
|
||||
git add internal/core/storage/storage.go internal/core/storage/migrations_015.sql.go cmd/verstak-gui/bindings_links.go cmd/verstak-gui/bindings_capture.go cmd/verstak-gui/bindings_inbox.go cmd/verstak-gui/inbox_test.go
|
||||
git commit -m "feat: resolve inbox links separately"
|
||||
git push
|
||||
```
|
||||
|
||||
### Task 3: Native Clipboard Bridge And Capture Bindings
|
||||
|
||||
**Files:**
|
||||
- Create: `cmd/verstak-gui/bindings_clipboard.go`
|
||||
- Modify: `frontend/src/wailsjs/go/main/App.js`
|
||||
- Modify: `cmd/verstak-gui/capture_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing test for clipboard text routing**
|
||||
|
||||
Add a focused test for a helper that classifies clipboard text as URL before plain text.
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestClassifyClipboardText' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because helper does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement backend clipboard bridge**
|
||||
|
||||
Expose:
|
||||
|
||||
```go
|
||||
func (a *App) ReadClipboardText() (string, error)
|
||||
func (a *App) CaptureClipboardTextWithContext(contextJSON string) (*InboxNodeDTO, error)
|
||||
```
|
||||
|
||||
Use Wails v2 `runtime.ClipboardGetText(a.ctx)`. Convert backend errors to a user-facing message; do not surface browser `NotAllowedError`.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestClassifyClipboardText|TestCapture.*' -count=1
|
||||
git add cmd/verstak-gui/bindings_clipboard.go cmd/verstak-gui/capture_test.go frontend/src/wailsjs/go/main/App.js
|
||||
git commit -m "feat: add native clipboard capture bridge"
|
||||
git push
|
||||
```
|
||||
|
||||
### Task 4: Frontend Unified Capture Pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.svelte`
|
||||
- Modify: `frontend/src/wailsjs/go/main/App.js`
|
||||
- Modify: `frontend/src/lib/i18n/locales/ru.js`
|
||||
- Modify: `frontend/src/lib/i18n/locales/en.js`
|
||||
- Modify: `scripts/check-gui-render.mjs`
|
||||
|
||||
- [ ] **Step 1: Update smoke mock for new bindings**
|
||||
|
||||
Add mock methods:
|
||||
- `CaptureTextWithContext`
|
||||
- `CaptureURLWithContext`
|
||||
- `CapturePathWithContext`
|
||||
- `CaptureFileDataWithContext`
|
||||
- `CaptureClipboardTextWithContext`
|
||||
- `ResolveInboxNode`
|
||||
- `ResolveInboxNodeHere`
|
||||
- `ListInboxNodesForTarget`
|
||||
- `ListLinks`
|
||||
- `UpdateLink`
|
||||
- `DeleteLink`
|
||||
- `OpenLink`
|
||||
|
||||
- [ ] **Step 2: Refactor capture inputs**
|
||||
|
||||
Implement:
|
||||
|
||||
```js
|
||||
function resolveCaptureContext()
|
||||
async function captureTextPayload(text, source)
|
||||
async function captureUrlPayload(url, title, source)
|
||||
async function capturePathPayload(paths, source)
|
||||
async function captureFilePayload(file, source)
|
||||
async function handleGlobalPaste(event)
|
||||
async function handleGlobalDrop(event)
|
||||
```
|
||||
|
||||
Use `event.clipboardData` for Ctrl+V and Wails/native clipboard binding for the button.
|
||||
|
||||
- [ ] **Step 3: Add drop overlay**
|
||||
|
||||
Show:
|
||||
- `Будет добавлено в Неразобранное для: <node title/path>` when a node is selected
|
||||
- `Будет добавлено в глобальное Неразобранное` on system/global sections
|
||||
|
||||
- [ ] **Step 4: Verify frontend build and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH npm run build
|
||||
git add frontend/src/App.svelte frontend/src/wailsjs/go/main/App.js frontend/src/lib/i18n/locales/ru.js frontend/src/lib/i18n/locales/en.js scripts/check-gui-render.mjs
|
||||
git commit -m "feat: unify frontend capture pipeline"
|
||||
git push
|
||||
```
|
||||
|
||||
### Task 5: Local Inbox And Links UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.svelte`
|
||||
- Modify: `frontend/src/lib/i18n/locales/ru.js`
|
||||
- Modify: `frontend/src/lib/i18n/locales/en.js`
|
||||
- Modify: `scripts/check-gui-render.mjs`
|
||||
|
||||
- [ ] **Step 1: Add tabs**
|
||||
|
||||
Add node tabs:
|
||||
- `Неразобранное`
|
||||
- `Ссылки`
|
||||
|
||||
Local Inbox shows unresolved artifacts for the current node only. Links shows resolved link artifacts for the current node only.
|
||||
|
||||
- [ ] **Step 2: Add link actions**
|
||||
|
||||
Implement minimal actions:
|
||||
- open external browser via `OpenLink`
|
||||
- edit title / URL / note via modal
|
||||
- delete via `DeleteLink`
|
||||
- copy URL using `navigator.clipboard.writeText` only as an optional best-effort UI action
|
||||
|
||||
- [ ] **Step 3: Verify GUI smoke and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh
|
||||
git add frontend/src/App.svelte frontend/src/lib/i18n/locales/ru.js frontend/src/lib/i18n/locales/en.js scripts/check-gui-render.mjs
|
||||
git commit -m "feat: add local inbox and links tabs"
|
||||
git push
|
||||
```
|
||||
|
||||
### Task 6: Editable Hotkey Guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.svelte`
|
||||
- Modify: `frontend/src/lib/FilePreviewModal.svelte`
|
||||
- Modify: `frontend/src/lib/FirstRun.svelte`
|
||||
- Modify: `scripts/check-gui-render.mjs`
|
||||
|
||||
- [ ] **Step 1: Add failing smoke assertion**
|
||||
|
||||
Open the add-action modal, type a title containing spaces, and assert the modal remains open until save/cancel.
|
||||
|
||||
- [ ] **Step 2: Implement editable target guard**
|
||||
|
||||
Use:
|
||||
|
||||
```js
|
||||
function isEditableTarget(target) {
|
||||
if (!target || !(target instanceof Element)) return false
|
||||
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
|
||||
}
|
||||
```
|
||||
|
||||
Global hotkeys and `onKeyActivate` must return early for editable targets.
|
||||
|
||||
- [ ] **Step 3: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh
|
||||
git add frontend/src/App.svelte frontend/src/lib/FilePreviewModal.svelte frontend/src/lib/FirstRun.svelte scripts/check-gui-render.mjs
|
||||
git commit -m "fix: ignore global hotkeys in editable fields"
|
||||
git push
|
||||
```
|
||||
|
||||
### Task 7: Full Verification And Documentation Alignment
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/01_Product_Spec.md`
|
||||
- Modify: `docs/03_Data_Model_Storage.md`
|
||||
- Modify: `docs/05_UI_UX.md`
|
||||
- Modify: `docs/06_Roadmap.md`
|
||||
|
||||
- [ ] **Step 1: Update docs**
|
||||
|
||||
Document current behavior:
|
||||
- all external capture creates unresolved inbox artifacts
|
||||
- local/global inbox semantics
|
||||
- capture context metadata by node ID
|
||||
- Links tab and link artifact model
|
||||
- backend clipboard bridge for button, `paste` event for Ctrl+V
|
||||
|
||||
- [ ] **Step 2: Run full required checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/verstak-go-cache go test ./...
|
||||
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH npm run build
|
||||
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH node scripts/check-gui-render.mjs
|
||||
env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH node scripts/check-i18n.sh
|
||||
```
|
||||
|
||||
If `scripts/check-i18n.sh` is not a Node script or does not exist, inspect scripts and run the repository’s actual i18n check.
|
||||
|
||||
- [ ] **Step 3: Commit and push**
|
||||
|
||||
```bash
|
||||
git add docs/01_Product_Spec.md docs/03_Data_Model_Storage.md docs/05_UI_UX.md docs/06_Roadmap.md
|
||||
git commit -m "docs: describe unified capture inbox flow"
|
||||
git push
|
||||
```
|
||||
|
||||
### Self-Review Checklist
|
||||
|
||||
- [ ] External drop anywhere creates unresolved artifact, never direct case content.
|
||||
- [ ] Ctrl+V uses `paste` event `clipboardData`.
|
||||
- [ ] Clipboard button uses backend/native bridge.
|
||||
- [ ] URL artifacts resolve into `links`, not notes.
|
||||
- [ ] Local Inbox is metadata view by node ID.
|
||||
- [ ] Global Inbox shows all unresolved artifacts.
|
||||
- [ ] Files/folders/images are staged under `.verstak/inbox` and moved to canonical target filesystem path on resolve.
|
||||
- [ ] Space inside editable fields never triggers global UI actions.
|
||||
- [ ] GUI smoke covers capture, resolve, links, and modal-space regression.
|
||||
@@ -2,7 +2,10 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/wails.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/app-icons/icon_16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/app-icons/icon_32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/assets/app-icons/icon_64x64.png" />
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/assets/app-icons/icon_128x128.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Верстак</title>
|
||||
<style>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-language-server": "^0.18.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.3.0",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
},
|
||||
@@ -27,6 +30,33 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emmetio/abbreviation": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
|
||||
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emmetio/scanner": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emmetio/css-abbreviation": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
|
||||
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emmetio/scanner": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emmetio/scanner": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
|
||||
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -855,6 +885,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/emmet-helper": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz",
|
||||
"integrity": "sha512-lUki5QLS47bz/U8IlG9VQ+1lfxMtxMZENmU5nu4Z71eOD5j9FK0SmYGL5NiVJg9WBWeAU0VxRADMY2Qpq7BfVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emmet": "^2.3.0",
|
||||
"jsonc-parser": "^2.3.0",
|
||||
"vscode-languageserver-textdocument": "^1.0.1",
|
||||
"vscode-languageserver-types": "^3.15.1",
|
||||
"vscode-nls": "^5.0.0",
|
||||
"vscode-uri": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/emmet-helper/node_modules/vscode-uri": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz",
|
||||
"integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/l10n": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
|
||||
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -888,6 +947,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/code-red": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||
@@ -934,6 +1009,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dedent-js": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
|
||||
"integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -944,6 +1026,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emmet": {
|
||||
"version": "2.4.11",
|
||||
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
|
||||
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/scanner",
|
||||
"./packages/abbreviation",
|
||||
"./packages/css-abbreviation",
|
||||
"./"
|
||||
],
|
||||
"dependencies": {
|
||||
"@emmetio/abbreviation": "^2.3.3",
|
||||
"@emmetio/css-abbreviation": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -993,6 +1092,24 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1008,6 +1125,13 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
@@ -1018,6 +1142,13 @@
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
|
||||
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -1035,6 +1166,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1126,6 +1264,47 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-svelte": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz",
|
||||
"integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"prettier": "^3.0.0",
|
||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
@@ -1178,6 +1357,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1227,6 +1426,101 @@
|
||||
"svelte": "^3.19.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-language-server": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-language-server/-/svelte-language-server-0.18.1.tgz",
|
||||
"integrity": "sha512-rQ1uQxxiul2zOUaDUsY6B115Xh8Yjb2Tmby/hjGoq8VKCmd7jD1qaVNqMdxW0nvoocRnJdJ3uXOOaGmXPTxOcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@vscode/emmet-helper": "2.8.4",
|
||||
"chokidar": "^4.0.1",
|
||||
"estree-walker": "^2.0.1",
|
||||
"fdir": "^6.2.0",
|
||||
"globrex": "^0.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"prettier": "~3.3.3",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte2tsx": "~0.7.56",
|
||||
"typescript-auto-import-cache": "^0.3.6",
|
||||
"vscode-css-languageservice": "~6.3.5",
|
||||
"vscode-html-languageservice": "~5.4.0",
|
||||
"vscode-languageserver": "9.0.1",
|
||||
"vscode-languageserver-protocol": "3.17.5",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"svelteserver": "bin/server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.2 || ^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-language-server/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svelte2tsx": {
|
||||
"version": "0.7.56",
|
||||
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.56.tgz",
|
||||
"integrity": "sha512-NTvqqL+goYlW8gWNajk81L07+uu7jw5V2m1Az5MZbYm3GEydcHXh+uTrLHM9SuGuaqCtF90vlMXkOVBotfH94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dedent-js": "^1.0.1",
|
||||
"scule": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0",
|
||||
"typescript": "^4.9.4 || ^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-auto-import-cache": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
|
||||
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-language-server": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz",
|
||||
"integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"typescript-language-server": "lib/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -1301,6 +1595,94 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-css-languageservice": {
|
||||
"version": "6.3.10",
|
||||
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
|
||||
"integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/l10n": "^0.0.18",
|
||||
"vscode-languageserver-textdocument": "^1.0.12",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"vscode-uri": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-html-languageservice": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.4.0.tgz",
|
||||
"integrity": "sha512-9/cbc90BSYCghmHI7/VbWettHZdC7WYpz2g5gBK6UDUI1MkZbM773Q12uAYJx9jzAiNHPpyo6KzcwmcnugncAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/l10n": "^0.0.18",
|
||||
"vscode-languageserver-textdocument": "^1.0.12",
|
||||
"vscode-languageserver-types": "^3.17.5",
|
||||
"vscode-uri": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-nls": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
|
||||
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||