Compare commits
207 Commits
gui/wails-
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
490a3dd624 | |
|
|
bfe57ac0ac | |
|
|
fec35f55b8 | |
|
|
2cbb2986c1 | |
|
|
db961ff0c3 | |
|
|
c8c5531c0c | |
|
|
077d25a269 | |
|
|
700e4dae5b | |
|
|
88eb99e9af | |
|
|
7521eea109 | |
|
|
39d3b82199 | |
|
|
767bf5c140 | |
|
|
0fdf77ce03 | |
|
|
a193c5a4c6 | |
|
|
e1505e1334 | |
|
|
b002005a42 | |
|
|
fa5001341e | |
|
|
58751945eb | |
|
|
b4de2dec7a | |
|
|
c1434a0b61 | |
|
|
1dbb1f8c68 | |
|
|
7a95943ad7 | |
|
|
21130c6f1e | |
|
|
82c2588449 | |
|
|
4a5dab49b5 | |
|
|
f4a25128ae | |
|
|
c03e2e2961 | |
|
|
f892d377a0 | |
|
|
35e23d75fa | |
|
|
5069472e19 | |
|
|
a202c5d079 | |
|
|
d6e28d7b1f | |
|
|
f769daa617 | |
|
|
fddbd3a98a | |
|
|
b1d1defebe | |
|
|
3b79754f45 | |
|
|
45cfe1b0a6 | |
|
|
d83c8c80e1 | |
|
|
4df83cd361 | |
|
|
c443ca23c5 | |
|
|
7b9c9647ac | |
|
|
a1d7c7b88b | |
|
|
308772dee8 | |
|
|
3f787ec66d | |
|
|
5c769c92a0 | |
|
|
e99ff984b1 | |
|
|
b80941f908 | |
|
|
8cbc87cdad | |
|
|
1cc0c407b1 | |
|
|
b676ac675a | |
|
|
f6c61c32e3 | |
|
|
c5505ee43c | |
|
|
fc429ac26e | |
|
|
6bd6c9c5ff | |
|
|
84d9725b17 | |
|
|
358c649b42 | |
|
|
f88376264d | |
|
|
40c0953904 | |
|
|
0cd8a79049 | |
|
|
cf770262e5 | |
|
|
6033ccffa9 | |
|
|
a37afd3b67 | |
|
|
64e6c6f735 | |
|
|
5257789a4d | |
|
|
c512ada386 | |
|
|
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 |
|
|
@ -0,0 +1,21 @@
|
|||
[mcp_servers.go_lsp]
|
||||
command = "mcp-language-server"
|
||||
args = [
|
||||
"--workspace", "/home/mirivlad/git/verstak",
|
||||
"--lsp", "gopls"
|
||||
]
|
||||
enabled = true
|
||||
default_tools_approval_mode = "approve"
|
||||
tool_timeout_sec = 30
|
||||
|
||||
[mcp_servers.ts_lsp]
|
||||
command = "mcp-language-server"
|
||||
args = [
|
||||
"--workspace", "/home/mirivlad/git/verstak",
|
||||
"--lsp", "typescript-language-server",
|
||||
"--",
|
||||
"--stdio"
|
||||
]
|
||||
enabled = true
|
||||
default_tools_approval_mode = "approve"
|
||||
tool_timeout_sec = 30
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Verstak Firefox Extension — environment template
|
||||
# Copy to .env and fill in real values.
|
||||
# DO NOT commit .env — it contains AMO secrets.
|
||||
|
||||
# AMO API credentials (from https://addons.mozilla.org/developers/)
|
||||
WEB_EXT_API_KEY=
|
||||
WEB_EXT_API_SECRET=
|
||||
|
||||
# Build channel: "unlisted" for self-distributed builds
|
||||
WEB_EXT_CHANNEL=unlisted
|
||||
|
||||
# Source directory for the Firefox extension
|
||||
WEB_EXT_SOURCE_DIR=extension-firefox
|
||||
|
||||
# Output directory for signed XPI artifacts
|
||||
WEB_EXT_ARTIFACTS_DIR=web-ext-artifacts
|
||||
|
||||
# Base URL for Firefox self-hosted updates
|
||||
VERSTAK_FIREFOX_UPDATE_BASE_URL=https://mirv.top/verstak/firefox
|
||||
|
||||
# Optional: HTTP proxy for AMO API requests (e.g. http://localhost:12334)
|
||||
# WEB_EXT_API_PROXY=
|
||||
|
|
@ -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,24 @@ Thumbs.db
|
|||
|
||||
# Vault test data
|
||||
test-vault/
|
||||
server-data/
|
||||
Ромашка/
|
||||
Тестовая папка/
|
||||
|
||||
# Build output
|
||||
build/
|
||||
build.log
|
||||
|
||||
# Release artifacts
|
||||
release/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Firefox extension
|
||||
web-ext-artifacts/
|
||||
*.xpi
|
||||
*.zip
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
# Verstak project rules
|
||||
|
||||
## Project quality rule: no MVP shortcuts
|
||||
|
||||
This project is not an investor demo, not a throwaway prototype, and not a “good enough for MVP” product.
|
||||
|
||||
Do not justify known inconsistencies, broken invariants, unsafe behavior, architectural shortcuts, or incomplete data-model operations by calling them “MVP acceptable”.
|
||||
|
||||
If you find a mismatch between logical state and physical state, treat it as a bug unless the project owner explicitly says otherwise.
|
||||
|
||||
For Verstak specifically:
|
||||
|
||||
* Rename must keep logical metadata and filesystem representation consistent, or the design must explicitly document why filenames are stable IDs and not display names.
|
||||
* Delete must remove or tombstone all related state consistently.
|
||||
* File paths must never depend on unsafe user input.
|
||||
* Database state and filesystem state must not silently diverge.
|
||||
* “Works because the file is not lost” is not enough.
|
||||
* “Can be fixed later” is not an acceptable reason to leave architectural debt.
|
||||
* If a feature cannot be completed properly in the current step, stop and report it as incomplete, with a concrete fix plan.
|
||||
|
||||
Use these categories in reports:
|
||||
|
||||
1. Done — implemented and tested.
|
||||
2. Incomplete — implemented partially, must not be treated as finished.
|
||||
3. Blocked — cannot continue without a decision.
|
||||
4. Design decision required — multiple valid approaches exist.
|
||||
5. Known bug — must be fixed before moving on.
|
||||
|
||||
Forbidden report pattern:
|
||||
|
||||
> This is acceptable for MVP.
|
||||
|
||||
Required replacement:
|
||||
|
||||
> This violates the current project invariants. I either fixed it, or I am reporting it as incomplete and proposing the smallest correct fix.
|
||||
|
||||
|
||||
|
||||
## Project identity
|
||||
|
||||
Verstak is a local-first workbench for clients, projects, notes, files, tasks, activity and sync.
|
||||
It must remain practical, simple, and filesystem-aware.
|
||||
|
||||
## Stack
|
||||
|
||||
- Backend: Go
|
||||
- Storage: SQLite
|
||||
- GUI: Wails v2
|
||||
- Frontend: Svelte 4
|
||||
- Build tooling: Vite 5
|
||||
- Do not migrate to Wails v3, Svelte 5, or Vite 8 unless explicitly asked.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Keep local-first behavior.
|
||||
- Do not turn the project into SaaS.
|
||||
- Do not replace SQLite with another database.
|
||||
- Do not introduce cloud storage assumptions.
|
||||
- Preserve recursive folder import semantics.
|
||||
- Preserve stable node IDs.
|
||||
- Do not duplicate nodes when moving items.
|
||||
- Do not create parallel state systems for the same entity.
|
||||
|
||||
## UI rules
|
||||
|
||||
- Fix GUI behavior at root cause.
|
||||
- Do not redesign the whole interface unless explicitly asked.
|
||||
- Preserve active tab state correctly.
|
||||
- Context menus must open near the cursor.
|
||||
- Drag-and-drop must show clear visual target feedback.
|
||||
- Moving nodes must never duplicate the same ID in two places.
|
||||
- Nested selection must not collapse the parent unexpectedly.
|
||||
|
||||
## Files
|
||||
|
||||
- File view is not a tree.
|
||||
- Sidebar shows logical hierarchy.
|
||||
- Vault filesystem layout must remain human-readable without the app.
|
||||
- Drag-and-drop folders must perform real recursive copy/move into the vault.
|
||||
- Do not fake folder support with external links.
|
||||
|
||||
## Sync
|
||||
|
||||
- Sync settings belong in Settings.
|
||||
- Main UI may keep only manual sync/status controls.
|
||||
- Existing URL + login/password device registration flow should be preserved unless explicitly changed.
|
||||
- Secrets must not be logged.
|
||||
|
||||
## Verification
|
||||
|
||||
For backend changes:
|
||||
- Run `go test ./...` if possible.
|
||||
|
||||
For frontend changes:
|
||||
- Run the relevant frontend build/check command if available.
|
||||
- If unsure, inspect package scripts first.
|
||||
|
||||
For GUI bugs:
|
||||
- Add targeted tests only where practical.
|
||||
- If manual GUI clicking is required and unavailable, state exact manual verification steps for the user.
|
||||
|
||||
|
||||
# Session summary
|
||||
|
||||
## Bugs fixed (this session)
|
||||
1. **webkit2_41 build tag** — binary wouldn't start without it. Added to build instructions.
|
||||
2. **Sidebar refresh** — `reloadTreePreservingExpanded` patches children in-place so expand/collapse state stays intact.
|
||||
3. **Context menu off-screen** — changed to `position: fixed` with cursor coordinates.
|
||||
4. **"Show in explorer" only for folder types** — `OpenFolder` in backend falls back to file record path for `TypeFile` nodes.
|
||||
5. **Context menu not closing on action** — `handleShowInFolder` calls `closeMenu()`.
|
||||
6. **Wrong folder when opening file's parent folder** — `OpenFolder` checks `n.FsPath == ""` for TypeFile and uses first file record path.
|
||||
7. **Tab highlight not updating visually** — was using `class={tabClass(tab.id)}` which didn't trigger reactive class updates in Svelte. Switched to `class="tab" class:active={activeTab === tab.id}`.
|
||||
8. **Journal table expand/collapse** — added explicit ▸/▾ toggle column so it's clear rows are expandable.
|
||||
9. **Per-node worklog entries** — made entries expandable with ▸/▾, showing details + billable/approximate tags.
|
||||
10. **Manual worklog entry form** — converted inline form to modal dialog ("+ Добавить запись") with all fields: date, summary, minutes, details, billable, approximate.
|
||||
11. **"С подзадачами" → "Учитывать вложенные дела"** — renamed, now hidden when no node selected.
|
||||
12. **Filter/export layout** — split into separate "Фильтры" and "Экспорт отчёта" sections with headings.
|
||||
13. **Suggestion events** — added "Показать в проводнике" button for file-type events in suggestion detail.
|
||||
14. **Removed duplicate i18n keys** in `ru.js` (worklog.suggestions, worklog.apply).
|
||||
15. **Removed unused CSS** (`.journal-filters`, `.wl-meta`, `.worklog-form`).
|
||||
16. **Added `openNodeFolder(nodeOrId)`** — accepts both string ID and node object.
|
||||
17. **Added `resetJournalFilters()`** — resets all filters and reloads.
|
||||
18. **Source field** — added `worklog_entries.source` column (migration 014). Values: manual, suggestion. Old entries default to 'unknown'.
|
||||
19. **Suggestions now use worklog_entry_events** instead of `HasTodayEntries` — only events already linked to worklog entries are excluded. Repeated activity on the same node today now produces new suggestions.
|
||||
20. **Activity target navigation** — clicking activity events for notes opens the note tab and loads the specific note. File events open the files tab.
|
||||
21. **Source display** — detail sections now show accurate source: "Ручная запись", "Из предложения", "Из предложения, но связанные события отсутствуют", or "Источник неизвестен".
|
||||
22. **Wails `[]string` marshalling bug** — Wails v2.12.0 silently drops `[]string` positional args from JS→Go. **Fix**: pass all string arrays as `JSON.stringify()` → `string` → `json.Unmarshal` on Go side.
|
||||
23. **Event link validation** — `AcceptSuggestionWith` pre-checks each eventID against `activity_events`, uses plain `INSERT` (not `INSERT OR IGNORE`), and verifies with JOIN `COUNT(*)` after commit.
|
||||
24. **GetWorklogEntryEvents column fix** — query used `e.details_json` but the column is `e.metadata`. Fixed to `COALESCE(e.metadata,'')`.
|
||||
25. **"Посмотреть" button** — `openActivityTarget(ev)` navigates to the specific target: note tab + open note for `targetType=note`, files tab + `OpenFolder(targetPath)` for `file/folder`.
|
||||
26. **End-to-end test** — `TestAcceptSuggestionWithEndToEnd` creates node, 3 activity events, accepts suggestion, verifies all 3 linked via `worklog_entry_events` + JOIN.
|
||||
27. **WriteDebugLog binding** — `bindings_debug.go` writes frontend logs to `<vault>/.verstak/debug.log` for production GUI debugging.
|
||||
28. **Journal regression tests** — `TestJournalFullRegression`, `TestSuggestionOnRepeatedActivity`, `TestManualWorklogEntry`.
|
||||
29. **resolveActivityTarget helper** — pure function returning `{ nodeId, tab, noteId/fileId/targetPath }`, used by `openActivityTarget`.
|
||||
30. **First-run flow** — no auto-vault creation. New `GetStartupStatus` binding returns `first_run`/`recovery`/`ready`. Frontend shows FirstRun.svelte or VaultRecovery.svelte accordingly.
|
||||
31. **Global config.json** — moved vault path, sync settings, templates, theme, language from implicit CLI args to `~/.config/verstak/config.json` (`AppConfig` struct).
|
||||
32. **Sync settings in Settings** — extracted sync modal into Settings → Sync section. Removed all inline sync form fields from `App.svelte`. Added `SyncStatus.svelte` widget replacing navbar sync button.
|
||||
33. **Settings window** — modal with sidebar (8 sections: General, Workspace, Templates, Plugins, Files, Activity, Sync, Backup). ESC to close. Lazy-loaded content panels.
|
||||
34. **Template enable/disable** — `AllTemplates` + `SetTemplateEnabled` bindings propagate to `appCfg.EnabledTemplates`. `initVault` applies filter to registry.
|
||||
35. **Vault recovery screen** — when vault path exists but vault is missing, shows VaultRecovery.svelte with choose/create/quit options.
|
||||
|
||||
## Bugs fixed (this session)
|
||||
|
||||
1. **Trash preview "trash file not found" for TypeFile nodes** — `resolveTrashPath` only searched `<nodeID>_*` in trash dir, but TypeFile node files are moved by file record ID (`<recordID>_<filename>`). Added file record fallback via `ListTrashedByNode` + `ReadTrashFile(trashFsPath)` binding.
|
||||
2. **Trash restore creates empty files** — `DeleteToTrash` permanently `DELETE`'d file records from DB and `restoreTrashPath` silently `return nil` for `FsPath=""` nodes. Changed `deleteFileRecords` to `trashRecord` (sets `missing=1`, keeps record). `restoreTrashPath` now restores file records: moves file back from trash, sets `missing=0`.
|
||||
3. **`resolveTrashPath` inner loop corrupts `anc` variable** — `anc = child` inside the inner loop caused wrong path computation for nesting depth > 2. Replaced with direct `chain[0].FsPath` prefix computation.
|
||||
4. **`ListTrash` missing `TrashFsPath` for TypeFile nodes** — Phase 1 only checked `<nodeID>_*` entries. Added `ListTrashedByNode` fallback to set `TrashFsPath` for TypeFile nodes.
|
||||
5. **`ListByNode` returning trashed records** — Added `AND missing != 1` filter to exclude trashed file records from active node file listings.
|
||||
|
||||
## Key patterns (this session)
|
||||
|
||||
- **TypeFile node trash**: files are moved by file record ID (`<recordID>_<filename>`), NOT by node ID. Never search by `<nodeID>_*` alone — always fall back to file records via `ListTrashedByNode`.
|
||||
- **File record soft-trash**: use `UPDATE files SET missing=1` instead of `DELETE` to keep records restorable. Restore via `UPDATE ... SET missing=0` + `os.Rename` from trash.
|
||||
- `ReadTrashFile(trashFsPath)` is preferred over `ReadTrashFileContent(nodeID)` — frontend has `trashFsPath` precomputed by `ListTrash`.
|
||||
- **`restoreTrashPath` for TypeFile nodes**: when `fsPath == ""`, find file records with `missing=1` and restore each one.
|
||||
- **Full test coverage**: `TestTrashTypeFilePreviewAndRestore`, `TestTrashTypeFileInsideFolderRestorePreservesContent`, `TestTrashTypeFileMultipleRecords`.
|
||||
|
||||
## Key patterns
|
||||
- Always use explicit toggle icons (▸/▾) on expandable rows.
|
||||
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
|
||||
- `openNodeFolder(id)` accepts a string ID or a node object.
|
||||
- `GetSuggestions` filters out only events already in `worklog_entry_events`, not entire nodes.
|
||||
- New worklog entries get `source=manual` via `Add`/`AddWithDate`; suggestion entries get `source=suggestion` via `AcceptSuggestionWith`.
|
||||
- **NEVER pass `[]string` through Wails v2 bindings** — always JSON-serialize to `string` first. Wails v2.12.0 silently drops slice arguments.
|
||||
- **Always wrap create-entry + link-events in a transaction** with pre-validation and post-commit verification to prevent orphan entries.
|
||||
- Frontend debug logs in production: use `wailsCall('WriteDebugLog', msg)` → writes to `<vault>/.verstak/debug.log`.
|
||||
- `AppConfig` stores all global settings in `~/.config/verstak/config.json`. Vault-specific config stays in `.verstak/config.yml`.
|
||||
- Use `GetStartupStatus` to determine first-run vs recovery vs normal startup.
|
||||
- Settings window uses a sidebar with 8 sections; each section is a separate Svelte component imported lazily.
|
||||
- Template enable/disable state is stored in `appCfg.EnabledTemplates` and applied to the registry during `initVault`.
|
||||
|
||||
# Build instructions
|
||||
|
||||
## GUI binary (Wails v2)
|
||||
|
||||
```bash
|
||||
# From project root:
|
||||
cp -r frontend/dist/* cmd/verstak-gui/frontend-dist/
|
||||
go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak-gui-linux-amd64 ./cmd/verstak-gui/
|
||||
```
|
||||
|
||||
## Server binary
|
||||
|
||||
```bash
|
||||
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
|
||||
```
|
||||
|
||||
257
README.md
|
|
@ -1,46 +1,229 @@
|
|||
# Верстак
|
||||
|
||||
**Верстак** — локальная программа, где по каждому клиенту или проекту
|
||||
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
|
||||
**Верстак** — local-first рабочий vault. Всё организовано вокруг **дел**, а не задач.
|
||||
|
||||
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди,
|
||||
у которых работа организована через дела, а не через задачи:
|
||||
|
||||
```
|
||||
дело → файлы → заметки → документы → действия → история → вернуться через месяц
|
||||
```
|
||||
|
||||
## Для кого
|
||||
|
||||
Один продукт — разные входные двери:
|
||||
|
||||
| Кто | Как видит Верстак |
|
||||
|-----|-------------------|
|
||||
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
|
||||
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
|
||||
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
|
||||
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
|
||||
|
||||
## Универсальные сущности
|
||||
|
||||
Базовая модель предельно проста — плагины добавляют функционал:
|
||||
|
||||
- **Дело** — контекст для всего остального
|
||||
- **Заметка** — Markdown внутри vault
|
||||
- **Файл / Документ** — любой файл, привязанный к делу
|
||||
- **Действие** — кнопка запуска: URL, файл, папка, команда
|
||||
- **Журнал** — записи о затраченном времени
|
||||
|
||||
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
|
||||
эти сущности без перекомпиляции программы.
|
||||
Дело может быть: клиентом, проектом, набором документов, рецептом, архивом, разовой работой.
|
||||
Внутри дела: вложенные папки, Markdown-заметки, файлы, действия (URL/файл/папка/команда), журнал работ, история активности.
|
||||
|
||||
## Стек
|
||||
|
||||
Go + SQLite + Lua (плагины) + Wails + Bubble Tea.
|
||||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| GUI | Wails v2 + Svelte 4 |
|
||||
| CLI | Go |
|
||||
| Backend | Go |
|
||||
| Хранилище | SQLite (индекс) + файловая система (vault) |
|
||||
| Плагины | Lua |
|
||||
| Синхронизация | HTTP API (опциональный сервер) |
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ GUI (Wails v2) │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────▼────────┐ ┌─────────────┐
|
||||
│ Core Library │◄──│ CLI Commands │
|
||||
└─────────┬────────┘ └─────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Local Vault+SQLite │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Sync Client │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Sync Server │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- libwebkit2gtk-4.1-dev, libgtk-3-dev и другие Wails-зависимости (см. [wails.io/docs/desktop/linux](https://wails.io/docs/desktop/linux))
|
||||
- npm
|
||||
|
||||
### Сборка
|
||||
|
||||
```bash
|
||||
# Всё сразу (GUI + сервер)
|
||||
./scripts/build.sh
|
||||
|
||||
# Или по отдельности
|
||||
./scripts/build.sh gui # только GUI
|
||||
./scripts/build.sh server # только сервер
|
||||
```
|
||||
|
||||
Проверка GUI перед коммитом:
|
||||
|
||||
```bash
|
||||
./scripts/check-gui.sh
|
||||
```
|
||||
|
||||
Она проверяет локали, production-сборку фронтенда, актуальность embedded Wails assets и компиляцию GUI-бинаря.
|
||||
Дополнительно запускается headless Chromium smoke через Wails-mock: проверяются first-run, recovery, основное окно, Settings, workspace, вкладки дела, файлы, журнал, активность и мобильный viewport. Smoke выполняет реальные UI-действия: создание заметки, запись worklog, создание узла, вход в папку и возврат назад, а также Sync Now с предупреждениями о conflicts/applyErrors. Скриншоты пишутся в `/tmp/verstak-gui-smoke`.
|
||||
|
||||
Бинарники попадают в `build/`:
|
||||
- `verstak` — GUI-приложение
|
||||
- `verstak-server` — опциональный сервер синхронизации
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
# GUI (после сборки)
|
||||
./build/verstak
|
||||
|
||||
# Сервер (после сборки)
|
||||
./build/verstak-server --help
|
||||
|
||||
# CLI
|
||||
go run ./cmd/verstak/ --help
|
||||
```
|
||||
|
||||
## Firefox Extension
|
||||
|
||||
Расширение для Firefox — `extension-firefox/`. Распространяется как signed XPI: **Mozilla только подписывает** XPI через AMO (unlisted channel), а мы самостоятельно хостим signed XPI и управляем обновлениями через `updates.json`.
|
||||
|
||||
### Сборка (unsigned)
|
||||
|
||||
```bash
|
||||
./scripts/build.sh firefox
|
||||
# → build/verstak-bridge-firefox-unsigned.zip
|
||||
```
|
||||
|
||||
### Подпись (требуются AMO-токены)
|
||||
|
||||
```bash
|
||||
# 1. Скопировать .env.example → .env и заполнить WEB_EXT_API_KEY / WEB_EXT_API_SECRET
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Установить зависимости
|
||||
cd extension-firefox && npm install && cd ..
|
||||
|
||||
# 3. Подписать XPI
|
||||
./scripts/sign-firefox-xpi.sh
|
||||
# → web-ext-artifacts/*.xpi
|
||||
|
||||
# 4. Полный релиз: подпись + release/firefox/ + updates.json
|
||||
./scripts/release-firefox-xpi.sh
|
||||
# → release/firefox/verstak-firefox-VERSION.xpi
|
||||
# → release/firefox/updates.json
|
||||
```
|
||||
|
||||
### Firefox Release Artifacts
|
||||
|
||||
```
|
||||
release/firefox/verstak-firefox-VERSION.xpi
|
||||
release/firefox/updates.json
|
||||
```
|
||||
|
||||
Обновления: Firefox проверяет `update_url` из manifest.json, указывающий на наш `updates.json`. При выходе новой версии достаточно:
|
||||
1. Подписать новый XPI
|
||||
2. Заменить файл на сервере
|
||||
3. Обновить `updates.json`
|
||||
|
||||
## Release
|
||||
|
||||
```bash
|
||||
# Полная сборка с упаковкой в DEB/RPM
|
||||
./scripts/release.sh # dry-run (без git tag); требует AMO токены
|
||||
./scripts/release.sh --publish # с git tag + GitHub release
|
||||
|
||||
# Без подписи Firefox
|
||||
./scripts/release.sh --skip-firefox-sign
|
||||
```
|
||||
|
||||
Артефакты релиза (после `./scripts/release.sh`):
|
||||
|
||||
```
|
||||
release/linux/verstak # GUI binary
|
||||
release/linux/verstak-server # Server binary
|
||||
release/linux/verstak.deb # DEB-пакет GUI
|
||||
release/linux/verstak-server.deb # DEB-пакет сервера
|
||||
release/linux/verstak.rpm # RPM-пакет GUI
|
||||
release/linux/verstak-server.rpm # RPM-пакет сервера
|
||||
release/firefox/verstak-firefox-VERSION.xpi # Signed XPI
|
||||
release/firefox/updates.json # Firefox update manifest
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/ # Точки входа
|
||||
│ ├── verstak/ # CLI
|
||||
│ ├── verstak-gui/ # Wails GUI
|
||||
│ └── verstak-server/ # Sync server
|
||||
├── internal/
|
||||
│ ├── core/ # Бизнес-логика
|
||||
│ │ ├── actions/ # Действия (URL, папка, команда)
|
||||
│ │ ├── config/ # Конфигурация
|
||||
│ │ ├── files/ # Файлы и импорт
|
||||
│ │ ├── i18n/ # Интернационализация (Go)
|
||||
│ │ ├── nodes/ # Дела/узлы дерева
|
||||
│ │ ├── plugins/ # Lua-плагины
|
||||
│ │ ├── search/ # Поиск
|
||||
│ │ ├── storage/ # SQLite + миграции
|
||||
│ │ ├── sync/ # Синхронизация
|
||||
│ │ ├── templates/ # Шаблоны дел
|
||||
│ │ ├── vault/ # Vault layout
|
||||
│ │ └── worklog/ # Журнал работ
|
||||
│ └── gui/ # Wails bridge (embedded HTML)
|
||||
├── frontend/ # Svelte-приложение
|
||||
│ └── src/
|
||||
│ ├── lib/
|
||||
│ │ └── i18n/ # Локали (JS)
|
||||
│ └── ...svelte # Компоненты
|
||||
├── migrations/ # SQL-миграции
|
||||
├── docs/ # Документация
|
||||
└── scripts/ # Вспомогательные скрипты и сборка
|
||||
```
|
||||
|
||||
## CLI команды
|
||||
|
||||
```bash
|
||||
go run ./cmd/verstak/ sync # Синхронизация с сервером
|
||||
go run ./cmd/verstak/ sync configure # Настройка сервера
|
||||
go run ./cmd/verstak/ sync status # Статус синхронизации
|
||||
```
|
||||
|
||||
## Vault layout
|
||||
|
||||
Данные хранятся в локальной папке (vault). Структура на диске:
|
||||
|
||||
```
|
||||
vault/
|
||||
.verstak/ # Служебные данные: index.db, config.yml, trash, blobs
|
||||
Проекты/ # Пользовательские папки-дела
|
||||
Клиенты/
|
||||
Рабочие/
|
||||
Archive/
|
||||
```
|
||||
|
||||
Внутри папок-дел: `Notes/`, `Files/`, `Documents/`, `Overview.md`.
|
||||
Vault открывается в любом файловом менеджере без специальных инструментов.
|
||||
|
||||
## Документация
|
||||
|
||||
- Описание продукта: [docs/01_Product_Spec.md](docs/01_Product_Spec.md)
|
||||
- Архитектура: [docs/02_Architecture.md](docs/02_Architecture.md)
|
||||
- Плагины: [docs/09_Extensibility.md](docs/09_Extensibility.md)
|
||||
- План разработки: [docs/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 |
|
||||
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
|
||||
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
|
||||
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
|
||||
| [План](docs/PLAN.md) | Дорожная карта |
|
||||
| [Шаблоны](docs/TEMPLATES.md) | Шаблоны дел |
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
|
|
|||
4
build.sh
|
|
@ -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,36 +2,62 @@ 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/bridge"
|
||||
"verstak/internal/core/browser"
|
||||
"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/watcher"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// App is the Wails v2 application adapter. It wraps core services.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *storage.DB
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
vault string
|
||||
ctx context.Context
|
||||
mu sync.RWMutex
|
||||
vaultOpen bool
|
||||
|
||||
db *storage.DB
|
||||
nodes *nodes.Repository
|
||||
templates *templates.Registry
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
activity *activity.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
plugins *plugins.Manager
|
||||
sync *syncsvc.Service
|
||||
fileWatcher *watcher.Service
|
||||
bridge *bridge.Server
|
||||
browser *browser.Store
|
||||
vault string
|
||||
}
|
||||
|
||||
// requireVault returns an error if no vault is open and services are not initialized.
|
||||
// All binding methods that access vault services MUST call this first.
|
||||
func (a *App) requireVault() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startup is called when the app starts. Store context and wire drag-and-drop.
|
||||
|
|
@ -44,18 +70,91 @@ func (a *App) startup(ctx context.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
func (a *App) autoSyncLoop() {
|
||||
// Wait for vault to be ready
|
||||
time.Sleep(5 * time.Second)
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
|
||||
const checkInterval = 60 * time.Second
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("[autosync] started")
|
||||
var lastSync time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
a.mu.RLock()
|
||||
vaultPath := a.vault
|
||||
a.mu.RUnlock()
|
||||
|
||||
serverURL, _, _, _, _ := a.sync.GetState()
|
||||
if serverURL == "" {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil && appCfg.Vault.Sync.ServerURL != "" {
|
||||
serverURL = appCfg.Vault.Sync.ServerURL
|
||||
}
|
||||
}
|
||||
if serverURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
interval := 0
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil {
|
||||
interval = appCfg.Vault.Sync.SyncInterval
|
||||
}
|
||||
if interval <= 0 {
|
||||
continue
|
||||
}
|
||||
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceToken := config.LoadDeviceToken(vaultPath)
|
||||
if deviceToken == "" {
|
||||
continue
|
||||
}
|
||||
log.Printf("[autosync] running SyncNow...")
|
||||
if _, err := a.SyncNow(); err != nil {
|
||||
log.Printf("[autosync] SyncNow error: %v", err)
|
||||
} else {
|
||||
lastSync = time.Now()
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
log.Printf("[autosync] stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DTOs
|
||||
// ============================================================
|
||||
|
||||
type NodeDTO struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Section string `json:"section"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
TemplateID string `json:"template_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Archived bool `json:"archived"`
|
||||
HasChildren bool `json:"has_children"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TemplateDTO struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type SectionDTO struct {
|
||||
|
|
@ -72,24 +171,32 @@ type NoteDTO struct {
|
|||
}
|
||||
|
||||
type FileDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Mime string `json:"mime"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Missing bool `json:"missing"`
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Mime string `json:"mime"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
type FileTreeItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
FileID string `json:"fileId,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
HasKids bool `json:"hasKids"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
FileID string `json:"fileId,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
HasKids bool `json:"hasKids"`
|
||||
}
|
||||
|
||||
// PreflightFileAction describes what should happen when opening a file from the Files tab.
|
||||
type PreflightFileAction struct {
|
||||
Action string `json:"action"` // "note" | "preview" | "external"
|
||||
NoteID string `json:"noteId,omitempty"`
|
||||
NoteTitle string `json:"noteTitle,omitempty"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
type ActionDTO struct {
|
||||
|
|
@ -101,381 +208,70 @@ type ActionDTO struct {
|
|||
}
|
||||
|
||||
type WorklogDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Summary string `json:"summary"`
|
||||
Minutes int `json:"minutes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle,omitempty"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SearchResultDTO struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Type string `json:"type"`
|
||||
NodeID string `json:"nodeId"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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 +279,17 @@ func (a *App) VerstakVersion() string {
|
|||
// ============================================================
|
||||
|
||||
func toNodeDTO(n *nodes.Node) NodeDTO {
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
path := ""
|
||||
if n.Path != nil {
|
||||
path = *n.Path
|
||||
}
|
||||
return NodeDTO{
|
||||
ID: n.ID,
|
||||
ParentID: parentID,
|
||||
Title: n.Title,
|
||||
Type: n.Type,
|
||||
Section: n.Section,
|
||||
Path: path,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
ID: n.ID,
|
||||
ParentID: n.ParentID,
|
||||
Type: n.Type,
|
||||
Title: n.Title,
|
||||
TemplateID: n.TemplateID,
|
||||
FsPath: n.FsPath,
|
||||
SortOrder: n.SortOrder,
|
||||
Archived: n.Archived,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -510,4 +301,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,263 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/i18n"
|
||||
)
|
||||
|
||||
type SystemViewDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) ListSystemViews() []SystemViewDTO {
|
||||
return []SystemViewDTO{
|
||||
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
||||
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
||||
{ID: "trash", Label: i18n.TF("ru", "nav.trash")},
|
||||
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
|
||||
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aeByParent, err := a.activity.ListTodayEventsByParent()
|
||||
if err != nil {
|
||||
aeByParent = nil
|
||||
}
|
||||
todayNodes, _ := a.nodes.ListTodayNodes()
|
||||
|
||||
type rawEvent struct {
|
||||
NodeID string
|
||||
EventType string
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetPath string
|
||||
Title string
|
||||
CreatedAt string
|
||||
}
|
||||
type caseInfo struct {
|
||||
Node nodes.Node
|
||||
Events []rawEvent
|
||||
}
|
||||
caseMap := make(map[string]*caseInfo)
|
||||
|
||||
ensureCase := func(caseID string) *caseInfo {
|
||||
if ci, ok := caseMap[caseID]; ok {
|
||||
return ci
|
||||
}
|
||||
ci := &caseInfo{Events: nil}
|
||||
if n, err := a.nodes.GetActive(caseID); err == nil {
|
||||
ci.Node = *n
|
||||
}
|
||||
caseMap[caseID] = ci
|
||||
return ci
|
||||
}
|
||||
|
||||
for pid, events := range aeByParent {
|
||||
ci := ensureCase(pid)
|
||||
for _, e := range events {
|
||||
ci.Events = append(ci.Events, rawEvent{
|
||||
NodeID: e.NodeID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
TargetPath: e.TargetPath,
|
||||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range todayNodes {
|
||||
_ = ensureCase(n.ID)
|
||||
if ci := caseMap[n.ID]; ci.Node.ID == "" {
|
||||
ci.Node = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []TodayGroupDTO
|
||||
var flatEvents []EventDTO
|
||||
summary := SummaryDTO{}
|
||||
|
||||
for _, ci := range caseMap {
|
||||
if ci.Node.ID == "" {
|
||||
continue
|
||||
}
|
||||
summary.ChangedCases++
|
||||
|
||||
dtoEvents := make([]EventDTO, 0, len(ci.Events))
|
||||
for _, re := range ci.Events {
|
||||
dtoEvents = append(dtoEvents, EventDTO{
|
||||
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
|
||||
NodeID: re.NodeID,
|
||||
EventType: re.EventType,
|
||||
TargetType: re.TargetType,
|
||||
TargetID: re.TargetID,
|
||||
TargetPath: re.TargetPath,
|
||||
Title: re.Title,
|
||||
CreatedAt: re.CreatedAt,
|
||||
})
|
||||
switch re.EventType {
|
||||
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
|
||||
summary.Notes++
|
||||
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
|
||||
summary.Files++
|
||||
}
|
||||
}
|
||||
|
||||
last := ci.Node.UpdatedAt.Format(time.RFC3339)
|
||||
for _, e := range dtoEvents {
|
||||
if e.CreatedAt > last {
|
||||
last = e.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
groups = append(groups, TodayGroupDTO{
|
||||
NodeID: ci.Node.ID,
|
||||
NodeTitle: ci.Node.Title,
|
||||
NodeKind: ci.Node.Type,
|
||||
Section: ci.Node.Section,
|
||||
LastActivityAt: last,
|
||||
Events: dtoEvents,
|
||||
})
|
||||
flatEvents = append(flatEvents, dtoEvents...)
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].LastActivityAt > groups[j].LastActivityAt
|
||||
})
|
||||
sort.Slice(flatEvents, func(i, j int) bool {
|
||||
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
|
||||
})
|
||||
|
||||
return &TodayDashboardDTO{
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Summary: summary,
|
||||
Groups: groups,
|
||||
Events: flatEvents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListRecent(limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return a.activity.CountByNode(nodeID)
|
||||
}
|
||||
|
||||
var _ = syncsvc.EntityNode
|
||||
|
||||
func (a *App) listActivityByNodeSubtree(nodeID string, limit, offset int) ([]activity.Event, error) {
|
||||
rows, err := a.db.Query(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT e.id, e.node_id, e.event_type, COALESCE(e.target_type,''), COALESCE(e.target_id,''), COALESCE(e.target_path,''),
|
||||
e.title, COALESCE(e.metadata,'{}'), e.created_at
|
||||
FROM activity_events e
|
||||
JOIN subtree s ON s.id = e.node_id
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []activity.Event
|
||||
for rows.Next() {
|
||||
var e activity.Event
|
||||
if err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, &e.TargetType, &e.TargetID, &e.TargetPath, &e.Title, &e.DetailsJSON, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
|
||||
dto := toEventDTO(e)
|
||||
dto.NodePath = a.nodes.Path(e.NodeID)
|
||||
return dto
|
||||
}
|
||||
|
||||
// ListTodayInProgress returns today's modification events — items the user worked on.
|
||||
func (a *App) ListTodayInProgress() ([]EventDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListTodayEvents()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modTypes := map[string]bool{
|
||||
activity.TypeNoteCreated: true,
|
||||
activity.TypeNoteUpdated: true,
|
||||
activity.TypeNoteDeleted: true,
|
||||
activity.TypeFileAdded: true,
|
||||
activity.TypeFileDeleted: true,
|
||||
activity.TypeFileRenamed: true,
|
||||
activity.TypeFileCopied: true,
|
||||
activity.TypeFileMoved: true,
|
||||
activity.TypeFolderAdded: true,
|
||||
activity.TypeFolderDeleted: true,
|
||||
activity.TypeFolderRenamed: true,
|
||||
activity.TypeFolderMoved: true,
|
||||
activity.TypeNodeCreated: true,
|
||||
activity.TypeNodeUpdated: true,
|
||||
activity.TypeNodeDeleted: true,
|
||||
activity.TypeActionCreated: true,
|
||||
activity.TypeActionDone: true,
|
||||
}
|
||||
result := make([]EventDTO, 0, len(events))
|
||||
for _, e := range events {
|
||||
if modTypes[e.EventType] {
|
||||
result = append(result, a.eventDTOWithPath(e))
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].CreatedAt > result[j].CreatedAt
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"verstak/internal/core/bridge"
|
||||
"verstak/internal/core/browser"
|
||||
"verstak/internal/core/config"
|
||||
)
|
||||
|
||||
// startBridge creates and starts the local HTTP bridge for browser extension.
|
||||
func (a *App) startBridge(appCfg *config.AppConfig) {
|
||||
// Determine bridge config
|
||||
bc := a.bridgeConfig(appCfg)
|
||||
|
||||
handler := func(events []bridge.Event) {
|
||||
// Convert to browser events and store in staging.
|
||||
be := make([]browser.Event, 0, len(events))
|
||||
for _, ev := range events {
|
||||
be = append(be, bridgeToBrowser(ev))
|
||||
}
|
||||
n, err := a.browser.InsertEvents(be)
|
||||
if err != nil {
|
||||
log.Printf("[bridge] store events: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[bridge] stored %d/%d events", n, len(be))
|
||||
}
|
||||
}
|
||||
|
||||
srv := bridge.NewServer(bc.Secret, handler)
|
||||
|
||||
port, err := srv.Start(bridge.Config{
|
||||
Port: bc.Port,
|
||||
AutoGenPort: bc.AutoGenPort,
|
||||
Secret: bc.Secret,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[bridge] failed to start: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save the actual port back to config if auto-generated.
|
||||
if bc.AutoGenPort {
|
||||
bc.Port = port
|
||||
}
|
||||
a.saveBridgeConfig(appCfg, bc)
|
||||
|
||||
a.mu.Lock()
|
||||
a.bridge = srv
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// bridgeConfig extracts bridge config from app config.
|
||||
func (a *App) bridgeConfig(appCfg *config.AppConfig) *config.BridgeConfig {
|
||||
if appCfg != nil && appCfg.Vault.Bridge.Port != 0 {
|
||||
return &appCfg.Vault.Bridge
|
||||
}
|
||||
return &config.BridgeConfig{
|
||||
Port: 9786,
|
||||
AutoGenPort: false,
|
||||
}
|
||||
}
|
||||
|
||||
// saveBridgeConfig persists the bridge config to disk.
|
||||
func (a *App) saveBridgeConfig(appCfg *config.AppConfig, bc *config.BridgeConfig) {
|
||||
if appCfg == nil {
|
||||
// Load or create fresh
|
||||
loaded, err := config.LoadAppConfig()
|
||||
if err != nil || loaded == nil {
|
||||
loaded = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg = loaded
|
||||
}
|
||||
appCfg.Vault.Bridge = *bc
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
log.Printf("[bridge] save config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BridgeInfo returns the current bridge server status.
|
||||
func (a *App) BridgeInfo() map[string]interface{} {
|
||||
info := map[string]interface{}{
|
||||
"running": false,
|
||||
"port": 0,
|
||||
}
|
||||
if a.bridge != nil {
|
||||
info["running"] = a.bridge.Running()
|
||||
info["port"] = a.bridge.Port()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// bridgeToBrowser converts a bridge.Event to a browser.Event.
|
||||
func bridgeToBrowser(ev bridge.Event) browser.Event {
|
||||
return browser.Event{
|
||||
ID: ev.ID,
|
||||
DeviceID: ev.DeviceID,
|
||||
Type: ev.Type,
|
||||
URL: ev.URL,
|
||||
Title: ev.Title,
|
||||
Domain: ev.Domain,
|
||||
ActiveSeconds: ev.ActiveSeconds,
|
||||
TSStart: ev.TSStart,
|
||||
TSEnd: ev.TSEnd,
|
||||
TS: ev.TS,
|
||||
SelectedText: ev.SelectedText,
|
||||
Note: ev.Note,
|
||||
}
|
||||
}
|
||||
|
||||
// RestartBridge stops and restarts the bridge server with current config.
|
||||
func (a *App) RestartBridge() error {
|
||||
// Stop existing server outside the lock to avoid blocking other bindings.
|
||||
a.mu.Lock()
|
||||
oldBridge := a.bridge
|
||||
a.bridge = nil
|
||||
a.mu.Unlock()
|
||||
|
||||
if oldBridge != nil {
|
||||
oldBridge.Stop()
|
||||
}
|
||||
|
||||
// Load config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
bc := a.bridgeConfig(appCfg)
|
||||
if !bc.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
handler := func(events []bridge.Event) {
|
||||
be := make([]browser.Event, 0, len(events))
|
||||
for _, ev := range events {
|
||||
be = append(be, bridgeToBrowser(ev))
|
||||
}
|
||||
n, err := a.browser.InsertEvents(be)
|
||||
if err != nil {
|
||||
log.Printf("[bridge] store events: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[bridge] stored %d/%d events", n, len(be))
|
||||
}
|
||||
}
|
||||
|
||||
srv := bridge.NewServer(bc.Secret, handler)
|
||||
|
||||
port, err := srv.Start(bridge.Config{
|
||||
Port: bc.Port,
|
||||
AutoGenPort: bc.AutoGenPort,
|
||||
Secret: bc.Secret,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bridge restart: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.bridge = srv
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[bridge] restarted on port %d", port)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"verstak/internal/core/browser"
|
||||
)
|
||||
|
||||
// ListBrowserEvents returns staged browser events, optionally filtered by status.
|
||||
func (a *App) ListBrowserEvents(status string, limit, offset int) ([]browser.Event, error) {
|
||||
if status == "" || status == "all" {
|
||||
return a.browser.ListAll(limit, offset)
|
||||
}
|
||||
if status == "pending" {
|
||||
return a.browser.ListPending(limit, offset)
|
||||
}
|
||||
return a.browser.ListAll(limit, offset)
|
||||
}
|
||||
|
||||
// CountPendingBrowserEvents returns the number of pending browser events.
|
||||
func (a *App) CountPendingBrowserEvents() (int, error) {
|
||||
return a.browser.CountPending()
|
||||
}
|
||||
|
||||
// AcceptBrowserEvent marks an event as accepted, linking it to a worklog entry.
|
||||
func (a *App) AcceptBrowserEvent(eventID, worklogID string) error {
|
||||
log.Printf("[browser] accept event %s -> worklog %s", eventID, worklogID)
|
||||
return a.browser.Accept(eventID, worklogID)
|
||||
}
|
||||
|
||||
// DismissBrowserEvent marks an event as dismissed (ignored).
|
||||
func (a *App) DismissBrowserEvent(eventID string) error {
|
||||
log.Printf("[browser] dismiss event %s", eventID)
|
||||
return a.browser.Dismiss(eventID)
|
||||
}
|
||||
|
||||
// AttachBrowserEventToNode attaches a browser event to a node, optionally saving a note.
|
||||
func (a *App) AttachBrowserEventToNode(eventID, nodeID string) error {
|
||||
log.Printf("[browser] attach event %s -> node %s", eventID, nodeID)
|
||||
return a.browser.Attach(eventID, nodeID)
|
||||
}
|
||||
|
|
@ -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,479 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/browser"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/vault"
|
||||
"verstak/internal/core/watcher"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// StartupStatus describes the application startup state.
|
||||
type StartupStatus struct {
|
||||
Status string `json:"status"` // "first_run", "recovery", "ready"
|
||||
VaultPath string `json:"vaultPath"` // configured or default vault path
|
||||
VaultExists bool `json:"vaultExists"` // whether index.db exists at the path
|
||||
DefaultPath string `json:"defaultPath"` // default vault path suggestion
|
||||
Error string `json:"error,omitempty"`
|
||||
AppConfig *config.AppConfig `json:"appConfig,omitempty"`
|
||||
}
|
||||
|
||||
// GetStartupStatus checks the global config and vault state.
|
||||
func (a *App) GetStartupStatus() (*StartupStatus, error) {
|
||||
defaultPath, _ := config.DefaultVaultPath()
|
||||
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
DefaultPath: defaultPath,
|
||||
Error: fmt.Sprintf("config load error: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No config at all → first run
|
||||
if appCfg == nil {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
DefaultPath: defaultPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config says not completed → first run
|
||||
if !appCfg.FirstRunCompleted {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config has no vault path → first run
|
||||
if appCfg.VaultPath == "" {
|
||||
appCfg.VaultPath = defaultPath
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// Check if vault exists
|
||||
vaultExists := vaultExistsAt(appCfg.VaultPath)
|
||||
if !vaultExists {
|
||||
return &StartupStatus{
|
||||
Status: "recovery",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Initialize services so that the vault is ready for use
|
||||
if err := a.initVault(appCfg.VaultPath); err != nil {
|
||||
return &StartupStatus{
|
||||
Status: "recovery",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
Error: fmt.Sprintf("init vault: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vaultExistsAt(vaultPath string) bool {
|
||||
dbPath := filepath.Join(vaultPath, ".verstak", "index.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault at the given path and initializes all services.
|
||||
func (a *App) CreateVault(vaultPath string) (*StartupStatus, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("vault path is empty")
|
||||
}
|
||||
|
||||
// Create vault directories and database
|
||||
if err := vault.Init(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("create vault: %w", err)
|
||||
}
|
||||
|
||||
// Initialize services for this vault
|
||||
if err := a.initVault(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("init vault services: %w", err)
|
||||
}
|
||||
|
||||
// Save global config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.VaultPath = vaultPath
|
||||
appCfg.FirstRunCompleted = true
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[startup] vault created at %s", vaultPath)
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: vaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenVault opens an existing vault and initializes services.
|
||||
func (a *App) OpenVault(vaultPath string) (*StartupStatus, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("vault path is empty")
|
||||
}
|
||||
if !vaultExistsAt(vaultPath) {
|
||||
return nil, fmt.Errorf("vault not found at %s", vaultPath)
|
||||
}
|
||||
|
||||
if err := a.initVault(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("init vault: %w", err)
|
||||
}
|
||||
|
||||
// Update config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.VaultPath = vaultPath
|
||||
appCfg.FirstRunCompleted = true
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[startup] vault opened at %s", vaultPath)
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: vaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initVault opens the vault DB and initializes all core services.
|
||||
func (a *App) initVault(vaultPath string) error {
|
||||
// Close previous vault if any
|
||||
a.closeVault()
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
activitySvc := activity.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Services = &plugins.CoreServices{
|
||||
NodeRepo: nodeRepo,
|
||||
DB: db,
|
||||
ActivitySvc: activitySvc,
|
||||
WorklogSvc: worklogSvc,
|
||||
FilesSvc: fileSvc,
|
||||
VaultPath: abs,
|
||||
}
|
||||
pm.Discover()
|
||||
|
||||
templatesReg := templates.NewRegistry()
|
||||
if err := templatesReg.LoadSystem(); err != nil {
|
||||
log.Printf("warning: failed to load system templates: %v", err)
|
||||
}
|
||||
|
||||
// Apply enabled templates from config
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
for _, t := range templatesReg.All() {
|
||||
if !enabledSet[t.ID] {
|
||||
_ = templatesReg.Disable(t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply installed/enabled state from config to discovered plugins
|
||||
pm.SyncConfig(appCfg)
|
||||
|
||||
// Sync service
|
||||
deviceID := ""
|
||||
_ = appCfg // will store sync settings
|
||||
if appCfg != nil && appCfg.Vault.Sync.DeviceID != "" {
|
||||
deviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
if deviceID == "" {
|
||||
deviceID = "gui-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
|
||||
// File watcher service
|
||||
watcherSvc := watcher.NewService(abs, nodeRepo, fileSvc, activitySvc)
|
||||
|
||||
// Determine if real-time watching is enabled.
|
||||
// Priority: CLI --no-watcher > env VERSTAK_NO_WATCHER > config file > default (true)
|
||||
fileWatcherEnabled := true
|
||||
if appCfg != nil && appCfg.Vault.FileWatcher != nil {
|
||||
fileWatcherEnabled = *appCfg.Vault.FileWatcher
|
||||
}
|
||||
// Env override
|
||||
if os.Getenv("VERSTAK_NO_WATCHER") == "1" {
|
||||
fileWatcherEnabled = false
|
||||
log.Println("[watcher] disabled by VERSTAK_NO_WATCHER=1")
|
||||
}
|
||||
// CLI override
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--no-watcher" {
|
||||
fileWatcherEnabled = false
|
||||
log.Println("[watcher] disabled by --no-watcher")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.db = db
|
||||
a.nodes = nodeRepo
|
||||
a.files = fileSvc
|
||||
a.notes = noteSvc
|
||||
a.activity = activitySvc
|
||||
a.actions = actionSvc
|
||||
a.worklog = worklogSvc
|
||||
a.search = searchSvc
|
||||
a.plugins = pm
|
||||
a.templates = templatesReg
|
||||
a.sync = syncSvc
|
||||
a.fileWatcher = watcherSvc
|
||||
a.browser = browser.NewStore(db)
|
||||
a.vault = abs
|
||||
a.vaultOpen = true
|
||||
a.mu.Unlock()
|
||||
|
||||
// Snapshot scan (always runs). Real-time watcher depends on config.
|
||||
scanResult, err := watcherSvc.Start(fileWatcherEnabled)
|
||||
if err != nil {
|
||||
log.Printf("[watcher] start error: %v", err)
|
||||
} else {
|
||||
log.Printf("[watcher] snapshot: %d missing, %d restored, %d modified, %d new",
|
||||
scanResult.MissingFiles, scanResult.RestoredFiles, scanResult.ModifiedFiles, scanResult.NewFiles)
|
||||
}
|
||||
|
||||
// Start auto-sync loop
|
||||
go a.autoSyncLoop()
|
||||
|
||||
// Start plugin runtimes for enabled plugins (creates VM, loads scripts, calls on_init)
|
||||
pm.InitRuntimes()
|
||||
pm.CallInitHooks()
|
||||
pm.StartSchedulers()
|
||||
|
||||
// Start bridge server for browser extension integration (if enabled).
|
||||
if appCfg == nil || appCfg.Vault.Bridge.Enabled {
|
||||
a.startBridge(appCfg)
|
||||
} else {
|
||||
log.Println("[bridge] disabled by config")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeVault shuts down current vault services if any.
|
||||
func (a *App) closeVault() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.vaultOpen {
|
||||
return
|
||||
}
|
||||
// Stop file watcher first.
|
||||
if a.fileWatcher != nil {
|
||||
a.fileWatcher.Stop()
|
||||
}
|
||||
// Stop plugin runtimes (schedulers → on_shutdown → close VMs)
|
||||
if a.plugins != nil {
|
||||
a.plugins.StopSchedulers()
|
||||
a.plugins.CallShutdownHooks()
|
||||
a.plugins.CloseRuntimes()
|
||||
}
|
||||
// Stop bridge server.
|
||||
if a.bridge != nil {
|
||||
a.bridge.Stop()
|
||||
}
|
||||
if a.db != nil {
|
||||
a.db.Close()
|
||||
}
|
||||
a.db = nil
|
||||
a.nodes = nil
|
||||
a.files = nil
|
||||
a.notes = nil
|
||||
a.activity = nil
|
||||
a.actions = nil
|
||||
a.worklog = nil
|
||||
a.search = nil
|
||||
a.plugins = nil
|
||||
a.templates = nil
|
||||
a.sync = nil
|
||||
a.fileWatcher = nil
|
||||
a.bridge = nil
|
||||
a.browser = nil
|
||||
a.vault = ""
|
||||
a.vaultOpen = false
|
||||
}
|
||||
|
||||
// IsReady returns true if a vault is open and services are initialized.
|
||||
func (a *App) IsReady() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.vaultOpen
|
||||
}
|
||||
|
||||
// GetAppConfig returns the current global app config.
|
||||
func (a *App) GetAppConfig() (*config.AppConfig, error) {
|
||||
cfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
return config.DefaultAppConfig(), nil
|
||||
}
|
||||
if cfg == nil {
|
||||
return config.DefaultAppConfig(), nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveAppConfig saves the global app config.
|
||||
func (a *App) SaveAppConfig(cfg *config.AppConfig) error {
|
||||
return config.SaveAppConfig(cfg)
|
||||
}
|
||||
|
||||
// GetDefaultVaultPath returns the default vault path.
|
||||
func (a *App) GetDefaultVaultPath() (string, error) {
|
||||
return config.DefaultVaultPath()
|
||||
}
|
||||
|
||||
// CheckVaultPath checks whether a given path is usable as a vault.
|
||||
type CheckVaultPathResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
HasVault bool `json:"hasVault"`
|
||||
Writable bool `json:"writable"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) CheckVaultPath(vaultPath string) (*CheckVaultPathResult, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("path is empty")
|
||||
}
|
||||
info, err := os.Stat(vaultPath)
|
||||
exists := err == nil
|
||||
hasVault := false
|
||||
writable := false
|
||||
|
||||
if exists {
|
||||
if info.IsDir() {
|
||||
writable = checkDirWritable(vaultPath)
|
||||
hasVault = vaultExistsAt(vaultPath)
|
||||
}
|
||||
} else {
|
||||
// Path doesn't exist - check if parent is writable
|
||||
parent := filepath.Dir(vaultPath)
|
||||
parentInfo, pErr := os.Stat(parent)
|
||||
if pErr == nil && parentInfo.IsDir() {
|
||||
writable = checkDirWritable(parent)
|
||||
}
|
||||
}
|
||||
|
||||
desc := describeVaultPath(exists, hasVault)
|
||||
|
||||
return &CheckVaultPathResult{
|
||||
Exists: exists,
|
||||
HasVault: hasVault,
|
||||
Writable: writable,
|
||||
Description: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkDirWritable(dir string) bool {
|
||||
testFile := filepath.Join(dir, ".verstak-write-test")
|
||||
if err := os.WriteFile(testFile, []byte{}, 0o640); err != nil {
|
||||
return false
|
||||
}
|
||||
os.Remove(testFile)
|
||||
return true
|
||||
}
|
||||
|
||||
func describeVaultPath(exists, hasVault bool) string {
|
||||
if !exists {
|
||||
return "Путь не существует. Будет создан новый vault."
|
||||
}
|
||||
if hasVault {
|
||||
return "Найден существующий vault. Можно подключиться."
|
||||
}
|
||||
return "Папка существует, но vault не найден. Можно создать новый vault."
|
||||
}
|
||||
|
||||
// VaultInfo returns information about the currently open vault.
|
||||
type VaultInfo struct {
|
||||
Path string `json:"path"`
|
||||
DBPath string `json:"dbPath"`
|
||||
FilesPath string `json:"filesPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
Healthy bool `json:"healthy"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
FileCount int `json:"fileCount"`
|
||||
}
|
||||
|
||||
func (a *App) GetVaultInfo() (*VaultInfo, error) {
|
||||
if !a.IsReady() {
|
||||
return nil, fmt.Errorf("vault not open")
|
||||
}
|
||||
a.mu.RLock()
|
||||
vp := a.vault
|
||||
nodesCount := 0
|
||||
if a.nodes != nil {
|
||||
roots, _ := a.nodes.ListRoots(true)
|
||||
nodesCount = len(roots)
|
||||
}
|
||||
fileCount := 0
|
||||
_ = a.db.QueryRow("SELECT COUNT(*) FROM files").Scan(&fileCount)
|
||||
a.mu.RUnlock()
|
||||
|
||||
return &VaultInfo{
|
||||
Path: vp,
|
||||
DBPath: filepath.Join(vp, ".verstak", "index.db"),
|
||||
FilesPath: filepath.Join(vp, "spaces"),
|
||||
TrashPath: filepath.Join(vp, ".verstak", "trash"),
|
||||
Healthy: true,
|
||||
NodeCount: nodesCount,
|
||||
FileCount: fileCount,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WriteDebugLog appends a line to <vault>/.verstak/debug.log.
|
||||
// Called from frontend to log JS-side diagnostics in production GUI builds.
|
||||
func (a *App) WriteDebugLog(msg string) {
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
log.Printf("[js] %s", msg)
|
||||
logPath := filepath.Join(a.vault, ".verstak", "debug.log")
|
||||
line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), msg)
|
||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
f.WriteString(line)
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, err := a.files.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileDTO, len(records))
|
||||
for i := range records {
|
||||
rec := &records[i]
|
||||
result[i] = FileDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Name: rec.Filename,
|
||||
Path: rec.Path,
|
||||
Size: rec.Size,
|
||||
Mime: rec.MIME,
|
||||
IsDir: rec.MIME == "inode/directory",
|
||||
Missing: rec.Missing,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
typ := children[i].Type
|
||||
if typ != nodes.TypeFolder && typ != nodes.TypeFile && typ != nodes.TypeNote {
|
||||
continue
|
||||
}
|
||||
item := FileTreeItemDTO{
|
||||
ID: children[i].ID,
|
||||
Name: children[i].Title,
|
||||
Type: typ,
|
||||
}
|
||||
if typ == nodes.TypeFolder {
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
item.Mime = "inode/directory"
|
||||
} else if typ == nodes.TypeFile {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = records[0].MIME
|
||||
}
|
||||
} else if typ == nodes.TypeNote {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = "text/markdown"
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
evType := activity.TypeFileDeleted
|
||||
targetType := activity.TargetFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
evType = activity.TypeFolderDeleted
|
||||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
||||
syncEntity := syncsvc.EntityFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
|
||||
}
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := a.files.CreateEmptyFile(parentID, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := a.files.Duplicate(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err2 := a.nodes.GetActive(nodeID)
|
||||
pid := ""
|
||||
if err2 == nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) ValidateName(name string) error {
|
||||
return files.ValidateName(name)
|
||||
}
|
||||
|
||||
func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileRec, err := a.files.Get(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file: %w", err)
|
||||
}
|
||||
name := strings.ToLower(fileRec.Filename)
|
||||
isMD := strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown")
|
||||
if !isMD {
|
||||
return &PreflightFileAction{Action: "external", FileName: fileRec.Filename}, nil
|
||||
}
|
||||
// .md file — check for linked note
|
||||
noteRec, err := a.notes.FindByFileID(fileID)
|
||||
if err == nil && noteRec != nil {
|
||||
noteNode, nodeErr := a.nodes.Get(noteRec.NodeID)
|
||||
title := fileRec.Filename
|
||||
if nodeErr == nil && noteNode != nil {
|
||||
title = noteNode.Title
|
||||
}
|
||||
return &PreflightFileAction{Action: "note", NoteID: noteRec.NodeID, NoteTitle: title, FileName: fileRec.Filename}, nil
|
||||
}
|
||||
// .md inside Notes/ with no note record — auto-link
|
||||
pathLower := strings.ToLower(fileRec.Path)
|
||||
insideNotes := strings.Contains(pathLower, string(filepath.Separator)+"notes"+string(filepath.Separator)) ||
|
||||
strings.HasPrefix(pathLower, "notes"+string(filepath.Separator))
|
||||
if insideNotes {
|
||||
noteNode, nodeErr := a.nodes.Get(fileRec.NodeID)
|
||||
if nodeErr == nil && noteNode != nil {
|
||||
_ = a.notes.LinkFile(noteNode.ID, fileID, "markdown")
|
||||
return &PreflightFileAction{Action: "note", NoteID: noteNode.ID, NoteTitle: noteNode.Title, FileName: fileRec.Filename}, nil
|
||||
}
|
||||
}
|
||||
// .md outside Notes/ — internal preview
|
||||
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.files.PreviewImport(sourcePath)
|
||||
}
|
||||
|
|
@ -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,210 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
type LinkDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Hostname string `json:"hostname"`
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
CapturedAt string `json:"capturedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (a *App) ListLinks(nodeID string) ([]LinkDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nodeID == "" {
|
||||
return []LinkDTO{}, nil
|
||||
}
|
||||
rows, err := a.db.Query(
|
||||
`SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at
|
||||
FROM links
|
||||
WHERE node_id = ?
|
||||
ORDER BY created_at DESC, title`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LinkDTO
|
||||
for rows.Next() {
|
||||
var l LinkDTO
|
||||
if err := rows.Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) UpdateLink(id, title, rawURL, note string) (*LinkDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
title = strings.TrimSpace(title)
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("link id required")
|
||||
}
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("url required")
|
||||
}
|
||||
if title == "" {
|
||||
title = linkTitle(rawURL, "")
|
||||
}
|
||||
updatedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := a.db.Exec(
|
||||
`UPDATE links SET title=?, url=?, hostname=?, note=?, updated_at=? WHERE id=?`,
|
||||
title, rawURL, hostnameForURL(rawURL), note, updatedAt, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, fmt.Errorf("link not found")
|
||||
}
|
||||
return a.getLink(id)
|
||||
}
|
||||
|
||||
func (a *App) DeleteLink(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := a.db.Exec(`DELETE FROM links WHERE id = ?`, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("link not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) OpenLink(id string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := a.getLink(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return openExternalURL(l.URL)
|
||||
}
|
||||
|
||||
func (a *App) OpenURL(rawURL string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
return openExternalURL(rawURL)
|
||||
}
|
||||
|
||||
func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt string) (*LinkDTO, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("url required")
|
||||
}
|
||||
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid url")
|
||||
}
|
||||
rawURL = normalizedURL
|
||||
title = linkTitle(rawURL, title)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := util.UUID7()
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at,
|
||||
title_lower,url_lower,hostname_lower,note_lower)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now,
|
||||
strings.ToLower(title), strings.ToLower(rawURL), strings.ToLower(hostnameForURL(rawURL)), strings.ToLower(note)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.getLink(id)
|
||||
}
|
||||
|
||||
func (a *App) getLink(id string) (*LinkDTO, error) {
|
||||
var l LinkDTO
|
||||
err := a.db.QueryRow(
|
||||
`SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at
|
||||
FROM links WHERE id = ?`, strings.TrimSpace(id)).
|
||||
Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func linkTitle(rawURL, title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title != "" {
|
||||
return firstLineTitle(title, title)
|
||||
}
|
||||
if h := hostnameForURL(rawURL); h != "" {
|
||||
return h
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func isURLLike(text string) bool {
|
||||
_, ok := normalizeHTTPURL(text)
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeHTTPURL(text string) (string, bool) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(text, " \t\r\n") || strings.Contains(text, "@") {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(text)
|
||||
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
|
||||
return text, true
|
||||
}
|
||||
if u != nil && u.Scheme != "" {
|
||||
return "", false
|
||||
}
|
||||
withScheme := "https://" + text
|
||||
u, err = url.Parse(withScheme)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" || !strings.Contains(host, ".") {
|
||||
return "", false
|
||||
}
|
||||
return withScheme, true
|
||||
}
|
||||
|
||||
func openExternalURL(rawURL string) error {
|
||||
normalizedURL, ok := normalizeHTTPURL(rawURL)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid url")
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", normalizedURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", normalizedURL)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", normalizedURL)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return empty for non-notes-capable parents.
|
||||
if !a.notes.SupportsNotes(nodeID) {
|
||||
return []NodeDTO{}, nil
|
||||
}
|
||||
|
||||
// Try the canonical layout: notes live under a "Notes" folder.
|
||||
// Also fall back to direct TypeNote children so that notes placed
|
||||
// directly by AssignInboxNode / old layout are still visible.
|
||||
notesFolder := a.notes.FindNotesFolder(nodeID)
|
||||
|
||||
// Direct children (old layout / inbox-assigned notes)
|
||||
directChildren, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var result []NodeDTO
|
||||
processNote := func(n nodes.Node) {
|
||||
if n.Type == nodes.TypeNote && !seen[n.ID] {
|
||||
seen[n.ID] = true
|
||||
result = append(result, toNodeDTO(&n))
|
||||
}
|
||||
}
|
||||
|
||||
if notesFolder != nil {
|
||||
// Canonical layout: notes inside the Notes folder
|
||||
notesChildren, err := a.nodes.ListChildren(notesFolder.ID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range notesChildren {
|
||||
processNote(notesChildren[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Also include direct TypeNote children (old / inbox-assigned notes)
|
||||
for i := range directChildren {
|
||||
processNote(directChildren[i])
|
||||
}
|
||||
|
||||
// Trigger repair in background for old-layout notes
|
||||
go func() {
|
||||
if _, err := a.notes.RepairNotesLayout(); err != nil {
|
||||
// log only
|
||||
}
|
||||
}()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) RepairNotesLayout() (*notes.RepairResult, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.notes.RepairNotesLayout()
|
||||
}
|
||||
|
||||
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, fileRec, err := a.notes.Create(parentID, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, ""))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) ReadNote(noteID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.notes.Read(noteID)
|
||||
}
|
||||
|
||||
func (a *App) SaveNote(noteID, content string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.notes.Save(noteID, content); err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := a.nodes.GetActive(noteID); err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"node_id": noteID,
|
||||
"content": content,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RenameNote(noteID, newTitle string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.notes.Rename(noteID, newTitle)
|
||||
}
|
||||
|
||||
func (a *App) DeleteNote(noteID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record activity and sync op before delete (need node info).
|
||||
n, _ := a.nodes.GetActive(noteID)
|
||||
pid := ""
|
||||
if n != nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
title := ""
|
||||
if n != nil {
|
||||
title = n.Title
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteDeleted, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpDelete, nil)
|
||||
return a.notes.Delete(noteID)
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
)
|
||||
|
||||
// PluginDTO represents a discovered plugin with its current state.
|
||||
type PluginDTO struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Installed bool `json:"installed"`
|
||||
HasInstall bool `json:"hasInstall"`
|
||||
HasPanel bool `json:"hasPanel"`
|
||||
HasSettings bool `json:"hasSettings"`
|
||||
UIContribs UIContribDTO `json:"uiContribs"`
|
||||
}
|
||||
|
||||
// UIContribDTO describes what a plugin adds to the UI.
|
||||
type UIContribDTO struct {
|
||||
SidebarItems []SidebarItemDTO `json:"sidebarItems"`
|
||||
NodeTabs []NodeTabDTO `json:"nodeTabs"`
|
||||
}
|
||||
|
||||
type SidebarItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type NodeTabDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// ListPlugins returns all discovered plugins with their current enabled/disabled state.
|
||||
func (a *App) ListPlugins() []PluginDTO {
|
||||
if a.plugins == nil {
|
||||
return nil
|
||||
}
|
||||
all := a.plugins.Plugins()
|
||||
out := make([]PluginDTO, 0, len(all))
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range all {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
contribs := UIContribDTO{}
|
||||
for _, item := range p.Meta.UI.SidebarItems {
|
||||
contribs.SidebarItems = append(contribs.SidebarItems, SidebarItemDTO{
|
||||
ID: item.ID,
|
||||
Label: item.Label,
|
||||
Icon: item.Icon,
|
||||
})
|
||||
}
|
||||
for _, tab := range p.Meta.UI.NodeTabs {
|
||||
contribs.NodeTabs = append(contribs.NodeTabs, NodeTabDTO{
|
||||
ID: tab.ID,
|
||||
Label: tab.Label,
|
||||
Page: tab.Page,
|
||||
})
|
||||
}
|
||||
|
||||
hasPanel := false
|
||||
if p.Meta.Panel != "" {
|
||||
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
|
||||
if _, err := os.Stat(panelPath); err == nil {
|
||||
hasPanel = true
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, PluginDTO{
|
||||
Name: p.Meta.Name,
|
||||
Version: p.Meta.Version,
|
||||
Author: p.Meta.Author,
|
||||
Description: p.Meta.Description,
|
||||
Active: active,
|
||||
Installed: p.Installed,
|
||||
HasInstall: p.HasInstall,
|
||||
HasPanel: hasPanel,
|
||||
UIContribs: contribs,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
|
||||
// Enable: marks plugin as enabled, activates runtime, THEN persists to config (only on success).
|
||||
// Disable: deactivates runtime, then marks plugin as disabled in config.
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
|
||||
if enabled {
|
||||
// Enable first (sets Enabled=true on the plugin struct)
|
||||
if err := a.plugins.Enable(name); err != nil {
|
||||
return err
|
||||
}
|
||||
// Activate runtime — if this fails, do NOT persist to config
|
||||
if err := a.plugins.ActivatePlugin(name); err != nil {
|
||||
// Rollback: deactivate runtime AND un-enable in-memory state
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
a.plugins.Disable(name)
|
||||
return fmt.Errorf("activate %q: %w", name, err)
|
||||
}
|
||||
// Only persist to config after successful activation
|
||||
if err := a.saveEnabledPlugin(name); err != nil {
|
||||
// Config save failed — rollback runtime too
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
a.plugins.Disable(name)
|
||||
return fmt.Errorf("save config for %q: %w", name, err)
|
||||
}
|
||||
} else {
|
||||
// Deactivate runtime first, then disable
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
if err := a.plugins.Disable(name); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove from config
|
||||
if err := a.removeEnabledPlugin(name); err != nil {
|
||||
return fmt.Errorf("save config for %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveEnabledPlugin adds name to EnabledPlugins in config and saves.
|
||||
func (a *App) saveEnabledPlugin(name string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
if n == name {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, name)
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// removeEnabledPlugin removes name from EnabledPlugins in config and saves.
|
||||
func (a *App) removeEnabledPlugin(name string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil // nothing to remove
|
||||
}
|
||||
var updated []string
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
if n != name {
|
||||
updated = append(updated, n)
|
||||
}
|
||||
}
|
||||
appCfg.EnabledPlugins = updated
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// GetPluginPanelHTML returns the HTML panel content for a plugin.
|
||||
// Validates that the panel path is safe: no absolute paths, no .. traversal,
|
||||
// must be within the plugin directory, and must end with .html.
|
||||
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
|
||||
if a.plugins == nil {
|
||||
return "", fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
if p.Meta.Name != pluginName || !active {
|
||||
continue
|
||||
}
|
||||
if p.Meta.Panel == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Validate panel path: must be relative, no .., within plugin dir, .html only
|
||||
panel := p.Meta.Panel
|
||||
if filepath.IsAbs(panel) {
|
||||
return "", fmt.Errorf("panel path %q must be relative", panel)
|
||||
}
|
||||
if strings.Contains(panel, "..") {
|
||||
return "", fmt.Errorf("panel path %q must not contain ..", panel)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(panel), ".html") {
|
||||
return "", fmt.Errorf("panel path %q must end with .html", panel)
|
||||
}
|
||||
|
||||
// Resolve and verify the path is within the plugin directory
|
||||
panelPath := filepath.Join(p.Dir, panel)
|
||||
absPanel, err := filepath.Abs(panelPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve panel path: %w", err)
|
||||
}
|
||||
absDir, err := filepath.Abs(p.Dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve plugin dir: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(absPanel, absDir+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("panel path %q escapes plugin directory", panel)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPanel)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read panel %s: %w", panel, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ListSystemViewsWithPlugins returns system views + plugin sidebar items.
|
||||
func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
|
||||
base := a.ListSystemViews()
|
||||
if a.plugins == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
for _, item := range p.Meta.UI.SidebarItems {
|
||||
pageID := "plugin:" + p.Meta.Name + ":" + item.ID
|
||||
base = append(base, SystemViewDTO{
|
||||
ID: pageID,
|
||||
Label: item.Label,
|
||||
Icon: item.Icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
|
||||
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
|
||||
// CallPluginFunction calls a global Lua function on an active plugin.
|
||||
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
|
||||
// Only alphanumeric identifiers with underscores are allowed (no Lua injection).
|
||||
// Returns JSON string or error.
|
||||
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
|
||||
if a.plugins == nil {
|
||||
return "", fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
|
||||
// Validate funcName: only [a-zA-Z0-9_.]+ allowed, each segment must be valid ident
|
||||
if funcName == "" {
|
||||
return "", fmt.Errorf("funcName is empty")
|
||||
}
|
||||
segments := strings.Split(funcName, ".")
|
||||
if len(segments) > 3 {
|
||||
return "", fmt.Errorf("funcName %q too deep (max 2 dots)", funcName)
|
||||
}
|
||||
for _, seg := range segments {
|
||||
if !validLuaIdent.MatchString(seg) {
|
||||
return "", fmt.Errorf("funcName %q contains invalid segment %q", funcName, seg)
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
if p.Meta.Name != pluginName || !p.Active {
|
||||
continue
|
||||
}
|
||||
vm := p.VM()
|
||||
if vm == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[plugins] CallPluginFunction: %s.%s active=true, calling Lua...", pluginName, funcName)
|
||||
|
||||
// Call via fully thread-safe LuaVM.CallFunctionJSON
|
||||
// (JSON→Lua conversion happens under vm.mu)
|
||||
result, err := vm.CallFunctionJSON(segments, paramsJSON)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] CallPluginFunction: %s.%s ERROR: %v", pluginName, funcName, err)
|
||||
return "", err
|
||||
}
|
||||
log.Printf("[plugins] CallPluginFunction: %s.%s OK (%d bytes)", pluginName, funcName, len(result))
|
||||
return result, nil
|
||||
}
|
||||
log.Printf("[plugins] CallPluginFunction: %s.%s NOT FOUND or inactive", pluginName, funcName)
|
||||
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
|
||||
}
|
||||
|
||||
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
||||
func (a *App) ReloadPlugins() error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
log.Print("[plugins] reload requested")
|
||||
// Fully stop runtimes: schedulers first (they depend on VMs), then VMs
|
||||
a.plugins.StopSchedulers()
|
||||
a.plugins.CallShutdownHooks()
|
||||
a.plugins.CloseRuntimes()
|
||||
|
||||
a.plugins.Discover()
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
a.plugins.SyncConfig(appCfg)
|
||||
|
||||
a.plugins.InitRuntimes()
|
||||
a.plugins.CallInitHooks()
|
||||
a.plugins.StartSchedulers()
|
||||
log.Print("[plugins] reload complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallPlugin creates plugin's database tables and defaults via on_install hook.
|
||||
// Does NOT activate the plugin — use SetPluginEnabled after.
|
||||
func (a *App) InstallPlugin(name string) error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
return a.plugins.Install(name)
|
||||
}
|
||||
|
||||
// UninstallPlugin drops plugin's database tables and cleans data via on_uninstall hook.
|
||||
// Disables the plugin first if active. Does NOT delete plugin files from disk.
|
||||
func (a *App) UninstallPlugin(name string) error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
return a.plugins.Uninstall(name)
|
||||
}
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/i18n"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ===== Template management =====
|
||||
|
||||
// AllTemplates returns all registered templates with their enabled status.
|
||||
type TemplateWithStatus struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (a *App) AllTemplates() ([]TemplateWithStatus, error) {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return nil, fmt.Errorf("vault not ready")
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
all := a.templates.All()
|
||||
result := make([]TemplateWithStatus, len(all))
|
||||
for i, t := range all {
|
||||
// If config has explicit list, use it; otherwise default to true
|
||||
enabled := true
|
||||
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
|
||||
enabled = enabledSet[t.ID]
|
||||
}
|
||||
result[i] = TemplateWithStatus{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Type: t.Type,
|
||||
Icon: t.Icon,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return fmt.Errorf("vault not ready")
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
// Update enabled templates list
|
||||
existing := make(map[string]bool)
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
existing[id] = true
|
||||
}
|
||||
if enabled {
|
||||
existing[templateID] = true
|
||||
} else {
|
||||
delete(existing, templateID)
|
||||
}
|
||||
|
||||
appCfg.EnabledTemplates = make([]string, 0, len(existing))
|
||||
for id := range existing {
|
||||
appCfg.EnabledTemplates = append(appCfg.EnabledTemplates, id)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update in-memory registry
|
||||
if enabled {
|
||||
_ = a.templates.Enable(templateID)
|
||||
} else {
|
||||
_ = a.templates.Disable(templateID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListTemplates() []TemplateDTO {
|
||||
if !a.IsReady() {
|
||||
return nil
|
||||
}
|
||||
templates := a.plugins.Templates()
|
||||
out := make([]TemplateDTO, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
out = append(out, TemplateDTO{
|
||||
ID: t.Name,
|
||||
Title: t.Name,
|
||||
Type: t.RootType,
|
||||
Icon: t.Icon,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tmpl *plugins.TemplateDefinition
|
||||
for _, t := range a.plugins.Templates() {
|
||||
if t.Name == template {
|
||||
tmpl = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmpl == nil {
|
||||
return nil, nil
|
||||
}
|
||||
root, err := a.nodes.Create(strPtr(parentID), tmpl.RootType, title, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||
for _, tn := range nodes {
|
||||
child, err := a.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tn.Children) > 0 {
|
||||
if err := createTree(child.ID, tn.Children); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := createTree(root.ID, tmpl.Tree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(root)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// ===== File picking =====
|
||||
|
||||
func (a *App) PickFile() (string, error) {
|
||||
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickSingle"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickFiles() ([]string, error) {
|
||||
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickMultiple"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickDirectory() (string, error) {
|
||||
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickDirectory"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) OpenFile(fileID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.files.Open(fileID)
|
||||
}
|
||||
|
||||
func (a *App) ReadFileText(fileID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.files.ReadText(fileID)
|
||||
}
|
||||
|
||||
func (a *App) GetFileBase64(fileID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.files.ReadBase64(fileID)
|
||||
}
|
||||
|
||||
func (a *App) OpenFolder(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileRecordPath string
|
||||
if n.Type == nodes.TypeFile && n.FsPath == "" {
|
||||
records, _ := a.files.ListByNode(nodeID)
|
||||
if len(records) > 0 {
|
||||
fileRecordPath = records[0].Path
|
||||
}
|
||||
}
|
||||
target := resolveOpenFolderTarget(a.vault, n, fileRecordPath)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
target = a.vault
|
||||
}
|
||||
cmd := exec.Command("xdg-open", target)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func resolveOpenFolderTarget(vault string, n *nodes.Node, fileRecordPath string) string {
|
||||
if n.Type == nodes.TypeFile && n.FsPath == "" {
|
||||
if fileRecordPath == "" {
|
||||
return vault
|
||||
}
|
||||
return filepath.Dir(filepath.Join(vault, fileRecordPath))
|
||||
}
|
||||
target := filepath.Join(vault, n.FsPath)
|
||||
if n.Type == nodes.TypeFile {
|
||||
return filepath.Dir(target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func (a *App) OpenVaultFolder() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
cmd := exec.Command("xdg-open", a.vault)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensurePluginsFolder(vaultPath string) (string, error) {
|
||||
path := filepath.Join(vaultPath, ".verstak", "plugins")
|
||||
if err := os.MkdirAll(path, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenPluginsFolder() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
target, err := ensurePluginsFolder(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("xdg-open", target)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
out := []SearchResultDTO{}
|
||||
seen := map[string]bool{}
|
||||
add := func(r SearchResultDTO) {
|
||||
if r.Title == "" {
|
||||
return
|
||||
}
|
||||
key := r.Type + ":" + r.NodeID + ":" + r.TargetID + ":" + r.Title
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range results {
|
||||
path := r.Path
|
||||
if path == "" && r.NodeID != "" {
|
||||
path = a.nodes.Path(r.NodeID)
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
|
||||
if len(out) < 20 {
|
||||
nodes, err := a.nodes.Search(query, 20-len(out))
|
||||
if err == nil {
|
||||
for i := range nodes {
|
||||
if nodes[i].IsDeleted() {
|
||||
continue
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: nodes[i].ID,
|
||||
Title: nodes[i].Title,
|
||||
Type: nodes[i].Type,
|
||||
Path: a.nodes.Path(nodes[i].ID),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) < 20 {
|
||||
rows, err := a.db.Query(
|
||||
`SELECT l.id,l.node_id,l.title,l.url,l.hostname,COALESCE(l.note,''),n.deleted_at
|
||||
FROM links l
|
||||
LEFT JOIN nodes n ON n.id = l.node_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
AND (l.title_lower LIKE ? OR l.url_lower LIKE ? OR l.hostname_lower LIKE ? OR l.note_lower LIKE ?)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ?`,
|
||||
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, nodeID, title, url, hostname, note string
|
||||
var deletedAt interface{}
|
||||
if err := rows.Scan(&id, &nodeID, &title, &url, &hostname, ¬e, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snippet := url
|
||||
if note != "" {
|
||||
snippet = note
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: nodeID,
|
||||
TargetID: id,
|
||||
Title: title,
|
||||
Snippet: snippet,
|
||||
Type: "link",
|
||||
Path: a.nodes.Path(nodeID),
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) < 20 {
|
||||
rows, err := a.db.Query(
|
||||
`SELECT ac.id,ac.node_id,ac.title,ac.kind,COALESCE(ac.url,''),COALESCE(ac.command,''),n.deleted_at
|
||||
FROM actions ac
|
||||
LEFT JOIN nodes n ON n.id = ac.node_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
AND (ac.title_lower LIKE ? OR ac.kind_lower LIKE ? OR ac.url_lower LIKE ? OR ac.command_lower LIKE ?)
|
||||
ORDER BY ac.created_at DESC
|
||||
LIMIT ?`,
|
||||
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, nodeID, title, kind, url, command string
|
||||
var deletedAt interface{}
|
||||
if err := rows.Scan(&id, &nodeID, &title, &kind, &url, &command, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snippet := url
|
||||
if snippet == "" {
|
||||
snippet = command
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: nodeID,
|
||||
TargetID: id,
|
||||
Title: title,
|
||||
Snippet: snippet,
|
||||
Type: "action",
|
||||
Path: a.nodes.Path(nodeID),
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func likeQuery(query string) string {
|
||||
return "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||||
}
|
||||
|
||||
func (a *App) VerstakVersion() string {
|
||||
return "verstak-gui/v2"
|
||||
}
|
||||
|
|
@ -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,510 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
type TrashDTO struct {
|
||||
TrashPath string `json:"trashPath"`
|
||||
Count int `json:"count"`
|
||||
Nodes []TrashNodeDTO `json:"nodes"`
|
||||
Entries []TrashEntryDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type TrashNodeDTO struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
FsPath string `json:"fsPath"`
|
||||
NodePath string `json:"nodePath"`
|
||||
TrashFsPath string `json:"trashFsPath"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
|
||||
type TrashEntryDTO struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
|
||||
deleted, err := a.nodes.ListDeleted()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Phase 1: build all DTOs and compute direct trash paths for folder-type nodes.
|
||||
nodeMap := make(map[string]*TrashNodeDTO, len(deleted))
|
||||
var allDeleted []nodes.Node
|
||||
for _, n := range deleted {
|
||||
allDeleted = append(allDeleted, n)
|
||||
}
|
||||
for _, n := range allDeleted {
|
||||
deletedAt := ""
|
||||
if n.DeletedAt != nil {
|
||||
deletedAt = n.DeletedAt.Format(time.RFC3339)
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
dto := &TrashNodeDTO{
|
||||
ID: n.ID,
|
||||
ParentID: parentID,
|
||||
Title: n.Title,
|
||||
Type: n.Type,
|
||||
FsPath: n.FsPath,
|
||||
NodePath: a.nodes.Path(n.ID),
|
||||
DeletedAt: deletedAt,
|
||||
}
|
||||
nodeMap[n.ID] = dto
|
||||
// Try direct trash entry (for folders that were os.Rename'd).
|
||||
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
|
||||
dto.TrashFsPath = p
|
||||
} else if recs, recErr := a.files.ListTrashedByNode(n.ID); recErr == nil && len(recs) > 0 {
|
||||
// Try file records (for TypeFile nodes whose files were individually moved).
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
for _, r := range recs {
|
||||
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
||||
if _, stErr := os.Stat(candidate); stErr == nil {
|
||||
dto.TrashFsPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 2: propagate trash paths from parents to children that have no direct entry
|
||||
// but whose FsPath starts with the parent's FsPath.
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
for _, n := range allDeleted {
|
||||
dto := nodeMap[n.ID]
|
||||
if dto.TrashFsPath != "" {
|
||||
continue
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
if parentID == "" {
|
||||
continue
|
||||
}
|
||||
parent := nodeMap[parentID]
|
||||
if parent == nil || parent.TrashFsPath == "" {
|
||||
continue
|
||||
}
|
||||
// Child inherits parent's trash path + relative FsPath fragment.
|
||||
if n.FsPath != "" && parent.FsPath != "" && strings.HasPrefix(n.FsPath, parent.FsPath) {
|
||||
rel := strings.TrimPrefix(n.FsPath, parent.FsPath)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel != "" {
|
||||
dto.TrashFsPath = filepath.Join(parent.TrashFsPath, rel)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes := make([]TrashNodeDTO, 0, len(deleted))
|
||||
for _, dto := range nodeMap {
|
||||
nodes = append(nodes, *dto)
|
||||
}
|
||||
|
||||
entries, err := listTrashEntries(trashPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TrashDTO{TrashPath: trashPath, Count: len(nodes), Nodes: nodes, Entries: entries}, nil
|
||||
}
|
||||
|
||||
func (a *App) TrashCount() (int, error) {
|
||||
trash, err := a.ListTrash()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return trash.Count, nil
|
||||
}
|
||||
|
||||
func (a *App) RestoreTrashNode(nodeID string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
chain, err := a.deletedAncestorChain(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range chain {
|
||||
if err := a.restoreTrashPath(n.ID, n.FsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE nodes SET deleted_at = NULL, updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), n.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RestoreTrashNodesJSON(nodeIDsJSON string) error {
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.RestoreTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PurgeTrashNodesJSON(nodeIDsJSON string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(nodeIDsJSON), &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.purgeTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) EmptyTrash() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
trash, err := a.ListTrash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, 0, len(trash.Nodes))
|
||||
for _, n := range trash.Nodes {
|
||||
if n.ParentID == "" {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
for _, n := range trash.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.purgeTrashNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(a.vault, ".verstak", "trash"))
|
||||
}
|
||||
|
||||
func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
|
||||
var reversed []TrashNodeDTO
|
||||
current := nodeID
|
||||
for current != "" {
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.DeletedAt == nil {
|
||||
break
|
||||
}
|
||||
parentID := ""
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
reversed = append(reversed, TrashNodeDTO{ID: n.ID, ParentID: parentID, Title: n.Title, Type: n.Type, FsPath: n.FsPath})
|
||||
current = parentID
|
||||
}
|
||||
if len(reversed) == 0 {
|
||||
return nil, fmt.Errorf("deleted node not found")
|
||||
}
|
||||
chain := make([]TrashNodeDTO, 0, len(reversed))
|
||||
for i := len(reversed) - 1; i >= 0; i-- {
|
||||
chain = append(chain, reversed[i])
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
||||
if fsPath == "" {
|
||||
// TypeFile node — restore file records that were marked missing=1.
|
||||
recs, err := a.files.ListTrashedByNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
for _, r := range recs {
|
||||
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
||||
if _, err := os.Stat(trashPath); os.IsNotExist(err) {
|
||||
continue // already restored via parent dir
|
||||
}
|
||||
dst := filepath.Join(a.vault, r.Path)
|
||||
rel, rErr := filepath.Rel(a.vault, dst)
|
||||
if rErr != nil || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("path safety: %s", r.Path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(trashPath, dst); err != nil {
|
||||
return fmt.Errorf("restore file %s: %w", r.ID, err)
|
||||
}
|
||||
if _, err := a.db.Exec("UPDATE files SET missing=0, updated_at=? WHERE id=?", nowStr(), r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Directory-type node — move the whole directory back from trash.
|
||||
trashEntry, err := a.findTrashEntryForNode(nodeID)
|
||||
if err != nil {
|
||||
return nil // parent may have already been restored
|
||||
}
|
||||
dst := filepath.Join(a.vault, fsPath)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(trashEntry, dst)
|
||||
}
|
||||
|
||||
func nowStr() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (a *App) findTrashEntryForNode(nodeID string) (string, error) {
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
entries, err := os.ReadDir(trashPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := nodeID + "_"
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), prefix) {
|
||||
return filepath.Join(trashPath, entry.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("trash entry not found")
|
||||
}
|
||||
|
||||
func (a *App) purgeTrashNode(nodeID string) error {
|
||||
ids, err := a.deletedSubtreeIDs(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
// Try direct trash entry (folder-type nodes: nodeID_title).
|
||||
if path, err := a.findTrashEntryForNode(id); err == nil {
|
||||
_ = os.RemoveAll(path)
|
||||
}
|
||||
// Try file record trash entries (file/note nodes: fileID_filename).
|
||||
// These are created by files.trashRecord and not found by findTrashEntryForNode.
|
||||
if recs, err := a.files.ListTrashedByNode(id); err == nil {
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
for _, r := range recs {
|
||||
trashPath := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
||||
_ = os.RemoveAll(trashPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
id := ids[i]
|
||||
_, _ = tx.Exec(`DELETE FROM node_meta WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM notes WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM actions WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM links WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM worklog_entry_events WHERE entry_id IN (SELECT id FROM worklog_entries WHERE node_id = ?)`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM worklog_entries WHERE node_id = ?`, id)
|
||||
if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) deletedSubtreeIDs(nodeID string) ([]string, error) {
|
||||
rows, err := a.db.Query(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
WHERE n.deleted_at IS NOT NULL
|
||||
) SELECT id FROM subtree`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("deleted node not found")
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
||||
if err := os.MkdirAll(trashPath, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirEntries, err := os.ReadDir(trashPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TrashEntryDTO, 0, len(dirEntries))
|
||||
for _, entry := range dirEntries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, TrashEntryDTO{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(trashPath, entry.Name()),
|
||||
IsDir: entry.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadTrashFile reads a trash file by its absolute filesystem path.
|
||||
// This is preferred over ReadTrashFileContent (which re-resolves by nodeID)
|
||||
// because the frontend already has the precomputed trashFsPath from ListTrash.
|
||||
func (a *App) ReadTrashFile(trashFsPath string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(trashFsPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := a.resolveTrashPath(nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// resolveTrashPath finds the physical path of a deleted node's file in the trash.
|
||||
// For directly-moved entries (directory-type nodes), it looks up <nodeID>_* in
|
||||
// the trash dir. For file-type nodes it searches by file record. For nested
|
||||
// files (moved inside a parent folder) it walks up the ancestor chain.
|
||||
func (a *App) resolveTrashPath(nodeID string) (string, error) {
|
||||
// 1. Try direct lookup first (for directory-type nodes).
|
||||
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// 2. Try file records (for TypeFile nodes whose files were individually moved).
|
||||
recs, err := a.files.ListTrashedByNode(nodeID)
|
||||
if err == nil {
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
for _, r := range recs {
|
||||
candidate := filepath.Join(trashDir, r.ID+"_"+r.Filename)
|
||||
if info, stErr := os.Stat(candidate); stErr == nil && !info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Walk parent chain to find nearest ancestor with a direct trash entry
|
||||
// (for files nested inside a deleted parent directory).
|
||||
type step struct {
|
||||
ID string
|
||||
FsPath string
|
||||
Title string
|
||||
}
|
||||
var chain []step
|
||||
current := nodeID
|
||||
for current != "" {
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
chain = append(chain, step{ID: n.ID, FsPath: n.FsPath, Title: n.Title})
|
||||
if n.ParentID != nil {
|
||||
current = *n.ParentID
|
||||
} else {
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(chain); i++ {
|
||||
anc := chain[i]
|
||||
ancPath, err := a.findTrashEntryForNode(anc.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Path 3a: compute relative path from ancestor's FsPath to target's FsPath.
|
||||
if chain[0].FsPath != "" && anc.FsPath != "" && strings.HasPrefix(chain[0].FsPath, anc.FsPath) {
|
||||
rel := strings.TrimPrefix(chain[0].FsPath, anc.FsPath)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel != "" {
|
||||
fullPath := filepath.Join(ancPath, rel)
|
||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||
return fullPath, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Path 3b: try node title as a direct child inside the ancestor dir.
|
||||
candidate := filepath.Join(ancPath, chain[0].Title)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("trash file not found for node %s", nodeID)
|
||||
}
|
||||
|
||||
func (a *App) OpenTrashFolder() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
trashPath := filepath.Join(a.vault, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashPath, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("xdg-open", trashPath).Run()
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/watcher"
|
||||
)
|
||||
|
||||
// WatcherStatus returns whether the real-time file watcher is active.
|
||||
func (a *App) WatcherStatus() (bool, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return a.fileWatcher.IsWatching(), nil
|
||||
}
|
||||
|
||||
// RunSnapshotScan performs a one-shot scan and returns results.
|
||||
func (a *App) RunSnapshotScan() (*watcher.SnapshotResult, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.fileWatcher.RunScanner()
|
||||
}
|
||||
|
||||
// ToggleFileWatcher enables or disables the real-time file watcher.
|
||||
// Changing this persists to app config (~/.config/verstak/config.json → vault.file_watcher).
|
||||
//
|
||||
// При включении: запускает snapshot scan (сверка диска с БД), затем включает real-time watcher.
|
||||
// При отключении: останавливает fsnotify, watcher_state в БД сохраняется.
|
||||
//
|
||||
// Отключить watcher НАВСЕГДА (независимо от галки):
|
||||
// export VERSTAK_NO_WATCHER=1
|
||||
//
|
||||
// Отключить на один запуск:
|
||||
// verstak-gui --no-watcher
|
||||
//
|
||||
// Snapshot scan запускается ВСЕГДА при открытии vault, даже при FileWatcher=false.
|
||||
func (a *App) ToggleFileWatcher(enable bool) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.LoadAppConfig()
|
||||
if err != nil || cfg == nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
cfg.Vault.FileWatcher = config.BoolPtr(enable)
|
||||
if err := config.SaveAppConfig(cfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
if enable {
|
||||
_, err := a.fileWatcher.RunScanner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if _, err := a.fileWatcher.Start(true); err != nil {
|
||||
return fmt.Errorf("start watcher: %w", err)
|
||||
}
|
||||
} else {
|
||||
a.fileWatcher.Stop()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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-CzfuqGWF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.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,49 +15,17 @@ import (
|
|||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
vaultPath := "."
|
||||
if len(os.Args) > 1 {
|
||||
vaultPath = os.Args[1]
|
||||
}
|
||||
app := &App{}
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Fix WebKit signal handler for Go 1.24+ compatibility
|
||||
ensureSignalOnStack()
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Open vault: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Init core services
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
plugins.NewManager(abs).Discover()
|
||||
|
||||
app := &App{
|
||||
db: db,
|
||||
nodes: nodeRepo,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
err = wails.Run(&options.App{
|
||||
Title: "Верстак",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Верстак",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1},
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
|
|
@ -81,4 +40,7 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Ensure config dir exists for logging/cli usage
|
||||
config.EnsureConfigDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,123 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/plugins"
|
||||
)
|
||||
|
||||
// TestSetPluginEnabled_BrokenPlugin_Rollback verifies that when activation fails,
|
||||
// the plugin is fully rolled back: not Active, not Enabled, and not in config.
|
||||
func TestSetPluginEnabled_BrokenPlugin_Rollback(t *testing.T) {
|
||||
// Isolate global config to a temp dir so we don't pollute the user's real config
|
||||
tmpCfgDir := filepath.Join(t.TempDir(), "config")
|
||||
if err := os.MkdirAll(tmpCfgDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir config dir: %v", err)
|
||||
}
|
||||
t.Setenv("XDG_CONFIG_HOME", tmpCfgDir)
|
||||
|
||||
vaultRoot := t.TempDir()
|
||||
|
||||
// Create .verstak/plugins/ structure
|
||||
pluginsDir := filepath.Join(vaultRoot, ".verstak", "plugins")
|
||||
if err := os.MkdirAll(pluginsDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir plugins: %v", err)
|
||||
}
|
||||
|
||||
// Create a broken plugin: valid Lua but invalid background task interval
|
||||
brokenDir := filepath.Join(pluginsDir, "broken")
|
||||
if err := os.MkdirAll(brokenDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir broken plugin: %v", err)
|
||||
}
|
||||
|
||||
// plugin.json with an invalid background task interval ("not-a-duration")
|
||||
pluginJSON := `{
|
||||
"name": "broken",
|
||||
"version": "0.1.0",
|
||||
"hooks": {
|
||||
"on_install": "on_install",
|
||||
"on_init": "on_init"
|
||||
},
|
||||
"background_tasks": [
|
||||
{"id": "bad-task", "interval": "not-a-duration", "script": "bad.lua"}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(brokenDir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
||||
t.Fatalf("write plugin.json: %v", err)
|
||||
}
|
||||
|
||||
// main.lua — on_install is a no-op (tables not needed for this test),
|
||||
// on_init is harmless. Activation will fail on the invalid background task interval.
|
||||
mainLua := `
|
||||
function on_install()
|
||||
-- no-op
|
||||
end
|
||||
|
||||
function on_init()
|
||||
-- harmless
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(brokenDir, "main.lua"), []byte(mainLua), 0o644); err != nil {
|
||||
t.Fatalf("write main.lua: %v", err)
|
||||
}
|
||||
|
||||
// Create App with real plugin manager
|
||||
app := &App{
|
||||
plugins: plugins.NewManager(vaultRoot),
|
||||
vault: vaultRoot,
|
||||
vaultOpen: true,
|
||||
}
|
||||
|
||||
// Discover the plugin
|
||||
app.plugins.Discover()
|
||||
|
||||
// Step 1: Install the plugin (creates tables, marks installed in config)
|
||||
if err := app.plugins.Install("broken"); err != nil {
|
||||
t.Fatalf("install broken plugin: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin is installed
|
||||
if !app.plugins.IsInstalled("broken") {
|
||||
t.Fatal("expected plugin to be installed")
|
||||
}
|
||||
|
||||
// Step 2: Try to enable — should fail because background task has invalid interval
|
||||
err := app.SetPluginEnabled("broken", true)
|
||||
if err == nil {
|
||||
t.Fatal("expected SetPluginEnabled to fail for broken plugin, got nil")
|
||||
}
|
||||
t.Logf("SetPluginEnabled returned expected error: %v", err)
|
||||
|
||||
// Step 3: Verify plugin is NOT Active and NOT Enabled (in-memory rollback)
|
||||
found := false
|
||||
for _, p := range app.plugins.Plugins() {
|
||||
if p.Meta.Name == "broken" {
|
||||
found = true
|
||||
if p.Active {
|
||||
t.Error("expected plugin to NOT be Active after failed activation")
|
||||
}
|
||||
if p.Enabled {
|
||||
t.Error("expected plugin to NOT be Enabled after failed activation (in-memory rollback)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("plugin 'broken' not found in manager")
|
||||
}
|
||||
|
||||
// Step 4: Verify plugin is NOT in EnabledPlugins config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load app config: %v", err)
|
||||
}
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
if name == "broken" {
|
||||
t.Error("expected 'broken' to NOT be in EnabledPlugins config after failed activation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,547 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/util"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// nowISO returns the current time in RFC3339, usable as an activity_events.created_at.
|
||||
func nowISO() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// insertTestEvent inserts an activity event at the given created_at and returns its ID.
|
||||
func insertTestEventAt(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title, createdAt string) string {
|
||||
t.Helper()
|
||||
id := util.UUID7()
|
||||
_, err := app.db.Exec(
|
||||
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
id, nodeID, eventType, targetType, targetID, "", title, "{}", createdAt)
|
||||
if err != nil {
|
||||
t.Fatalf("insert event: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// insertTestEvent creates a "now" event.
|
||||
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string {
|
||||
return insertTestEventAt(t, app, nodeID, eventType, targetType, targetID, title, nowISO())
|
||||
}
|
||||
|
||||
// countLinked returns the number of worklog_entry_events for an entry.
|
||||
func countLinked(t *testing.T, app *App, entryID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, entryID).Scan(&n); err != nil {
|
||||
t.Fatalf("count worklog_entry_events: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// countJoined returns the number of events in worklog_entry_events that successfully
|
||||
// join to activity_events for a given entry.
|
||||
func countJoined(t *testing.T, app *App, entryID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := app.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM worklog_entry_events wle
|
||||
JOIN activity_events ae ON ae.id = wle.event_id
|
||||
WHERE wle.entry_id = ?`, entryID).Scan(&n); err != nil {
|
||||
t.Fatalf("join count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: Full journal regression — GetSuggestions + Accept + verify
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestJournalFullRegression(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
// Create a node
|
||||
n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Create activity events (use now() so GetSuggestions picks them up)
|
||||
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Создана заметка")
|
||||
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
|
||||
eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "f1", "Добавлен файл")
|
||||
|
||||
// Call GetSuggestions
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
|
||||
// Must find at least one suggestion for our node
|
||||
if len(suggestions) == 0 {
|
||||
t.Fatal("GetSuggestions returned 0 suggestions, expected at least 1")
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
found = true
|
||||
// Verify events match what we inserted
|
||||
// Notes folder creation (from template) adds 1 auto event.
|
||||
if len(s.Events) != 4 {
|
||||
t.Fatalf("suggestion has %d events, want 4 (1 auto from Notes creation + 3 manual)", len(s.Events))
|
||||
}
|
||||
if len(s.EventIDs) != len(s.Events) {
|
||||
t.Fatalf("suggestion eventIds (%d) != events (%d)", len(s.EventIDs), len(s.Events))
|
||||
}
|
||||
// Each eventId must be in the events list
|
||||
idSet := make(map[string]bool, len(s.Events))
|
||||
for _, ev := range s.Events {
|
||||
idSet[ev.ID] = true
|
||||
}
|
||||
for _, eid := range s.EventIDs {
|
||||
if !idSet[eid] {
|
||||
t.Errorf("eventId %s not found in events list", eid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no suggestion found for node %s", n.ID)
|
||||
}
|
||||
|
||||
// Accept the suggestion via AcceptSuggestionWith (JSON-serialised eventIDs)
|
||||
eventIDs := []string{eid1, eid2, eid3}
|
||||
eventIDsJSON, _ := json.Marshal(eventIDs)
|
||||
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
// Check worklog_entry_events count
|
||||
if n := countLinked(t, app, dto.ID); n != 3 {
|
||||
t.Errorf("worklog_entry_events count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// Check JOIN with activity_events
|
||||
if n := countJoined(t, app, dto.ID); n != 3 {
|
||||
t.Errorf("JOIN count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// Check GetWorklogEntryEvents returns 3 events
|
||||
events, err := app.GetWorklogEntryEvents(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorklogEntryEvents: %v", err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events))
|
||||
}
|
||||
|
||||
// All three event IDs must be present
|
||||
returnedIDs := make(map[string]bool, len(events))
|
||||
for _, ev := range events {
|
||||
returnedIDs[ev.ID] = true
|
||||
}
|
||||
for _, eid := range eventIDs {
|
||||
if !returnedIDs[eid] {
|
||||
t.Errorf("event %s missing from GetWorklogEntryEvents", eid)
|
||||
}
|
||||
}
|
||||
|
||||
// Source must be suggestion
|
||||
var src string
|
||||
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
|
||||
if src != worklog.SourceSuggestion {
|
||||
t.Errorf("source = %q, want %q", src, worklog.SourceSuggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: Repeated activity on same node — suggestion must still appear
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSuggestionOnRepeatedActivity(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Repeat Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Create a first event and accept it
|
||||
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Первая заметка")
|
||||
eid1JSON, _ := json.Marshal([]string{eid1})
|
||||
_, err = app.AcceptSuggestionWith(n.ID, "Создание заметки", 5, "", string(eid1JSON))
|
||||
if err != nil {
|
||||
t.Fatalf("first AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
// Now create a brand new event on the same node
|
||||
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
|
||||
|
||||
// GetSuggestions must still return a suggestion for this node (new event is unaccounted)
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
found = true
|
||||
// The first event (Notes folder creation) was not accepted,
|
||||
// so the suggestion includes it plus the new eid2.
|
||||
if len(s.Events) < 1 {
|
||||
t.Fatalf("expected at least 1 new event, got %d", len(s.Events))
|
||||
}
|
||||
// eid2 must be among the suggested events
|
||||
var hasNew bool
|
||||
for _, ev := range s.Events {
|
||||
if ev.ID == eid2 {
|
||||
hasNew = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNew {
|
||||
t.Errorf("eid2 not found among suggestion events")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("GetSuggestions did not return a suggestion for the new event — it should")
|
||||
}
|
||||
|
||||
// Accept the new suggestion
|
||||
eid2JSON, _ := json.Marshal([]string{eid2})
|
||||
dto2, err := app.AcceptSuggestionWith(n.ID, "Обновление заметки", 5, "", string(eid2JSON))
|
||||
if err != nil {
|
||||
t.Fatalf("second AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
// Each entry must have exactly 1 linked event
|
||||
if n := countLinked(t, app, dto2.ID); n != 1 {
|
||||
t.Errorf("second entry: worklog_entry_events count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: Manual worklog entry — source, billable, details, empty events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestManualWorklogEntry(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Manual Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Create a manual entry
|
||||
dto, err := app.CreateWorklogFull(n.ID, "Ручная работа", "Подробное описание", "", 30, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
|
||||
// Source must be manual
|
||||
var src string
|
||||
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
|
||||
if src != worklog.SourceManual {
|
||||
t.Errorf("source = %q, want %q", src, worklog.SourceManual)
|
||||
}
|
||||
|
||||
// Billable, approximate, details must be correct
|
||||
var details string
|
||||
var billable, approximate int
|
||||
app.db.QueryRow(
|
||||
`SELECT details, billable, approximate FROM worklog_entries WHERE id = ?`, dto.ID,
|
||||
).Scan(&details, &billable, &approximate)
|
||||
if details != "Подробное описание" {
|
||||
t.Errorf("details = %q, want %q", details, "Подробное описание")
|
||||
}
|
||||
if billable != 1 {
|
||||
t.Errorf("billable = %d, want 1", billable)
|
||||
}
|
||||
if approximate != 1 {
|
||||
t.Errorf("approximate = %d, want 1", approximate)
|
||||
}
|
||||
|
||||
// Minutes must be 30
|
||||
if dto.Minutes != 30 {
|
||||
t.Errorf("minutes = %d, want 30", dto.Minutes)
|
||||
}
|
||||
|
||||
// GetWorklogEntryEvents must be empty for a manual entry
|
||||
events, err := app.GetWorklogEntryEvents(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorklogEntryEvents: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("manual entry has %d linked events, want 0", len(events))
|
||||
}
|
||||
|
||||
// DTO should distinguish manual from suggestion
|
||||
if dto.Source != worklog.SourceManual {
|
||||
t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual)
|
||||
}
|
||||
}
|
||||
|
||||
func hasWorklogSyncOp(t *testing.T, app *App, entryID, opType string) bool {
|
||||
t.Helper()
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnpushedOps: %v", err)
|
||||
}
|
||||
for _, op := range ops {
|
||||
if op.EntityType == syncsvc.EntityWorklog && op.EntityID == entryID && op.OpType == opType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Editable Worklog Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
dto, err := app.CreateWorklogFull(n.ID, "Old summary", "Old details", "2026-01-01", 30, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Связанное событие")
|
||||
if _, err := app.db.Exec(`INSERT INTO worklog_entry_events(entry_id,event_id) VALUES(?,?)`, dto.ID, eventID); err != nil {
|
||||
t.Fatalf("insert worklog event link: %v", err)
|
||||
}
|
||||
|
||||
updated, err := app.UpdateWorklogEntry(dto.ID, "New summary", "New details", "2026-01-02", 45, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWorklogEntry: %v", err)
|
||||
}
|
||||
if updated.Summary != "New summary" || updated.Details != "New details" || updated.Date != "2026-01-02" {
|
||||
t.Fatalf("updated DTO = %#v", updated)
|
||||
}
|
||||
if updated.Minutes != 45 || !updated.Approximate || !updated.Billable {
|
||||
t.Fatalf("updated flags/minutes = %#v", updated)
|
||||
}
|
||||
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpUpdate) {
|
||||
t.Fatal("missing worklog update sync op")
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 1 {
|
||||
t.Fatalf("event links after update = %d, want 1", n)
|
||||
}
|
||||
|
||||
if err := app.DeleteWorklogEntry(dto.ID); err != nil {
|
||||
t.Fatalf("DeleteWorklogEntry: %v", err)
|
||||
}
|
||||
if _, err := app.worklog.Get(dto.ID); err == nil {
|
||||
t.Fatal("expected deleted worklog entry to be gone")
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 0 {
|
||||
t.Fatalf("event links after delete = %d, want 0", n)
|
||||
}
|
||||
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpDelete) {
|
||||
t.Fatal("missing worklog delete sync op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||
|
||||
parentLog, err := app.ListWorklog(parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorklog(parent): %v", err)
|
||||
}
|
||||
if len(parentLog) != 1 || parentLog[0].ID != entry.ID {
|
||||
t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID)
|
||||
}
|
||||
if parentLog[0].NodeID != child.ID {
|
||||
t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID)
|
||||
}
|
||||
if parentLog[0].NodePath != "Parent Project > Documents" {
|
||||
t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath)
|
||||
}
|
||||
|
||||
parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivityByNode(parent): %v", err)
|
||||
}
|
||||
var foundEvent *EventDTO
|
||||
for i := range parentActivity {
|
||||
if parentActivity[i].ID == eventID {
|
||||
foundEvent = &parentActivity[i]
|
||||
}
|
||||
}
|
||||
if foundEvent == nil {
|
||||
t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID)
|
||||
}
|
||||
if foundEvent.NodePath != "Parent Project > Documents" {
|
||||
t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath)
|
||||
}
|
||||
|
||||
var physicalEvents int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil {
|
||||
t.Fatalf("count physical event: %v", err)
|
||||
}
|
||||
if physicalEvents != 1 {
|
||||
t.Fatalf("physical child event count = %d, want 1", physicalEvents)
|
||||
}
|
||||
var copiedToParent int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil {
|
||||
t.Fatalf("count copied parent event: %v", err)
|
||||
}
|
||||
if copiedToParent != 0 {
|
||||
t.Fatalf("copied parent events = %d, want 0", copiedToParent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// The template creates a Notes folder, which generates an auto event.
|
||||
// Dismiss it first so it doesn't interfere with the test.
|
||||
autoEvts, _ := app.GetSuggestions()
|
||||
for _, s := range autoEvts {
|
||||
if s.NodeID == n.ID && len(s.EventIDs) > 0 {
|
||||
// Dismiss all outstanding auto events for this node
|
||||
b, _ := json.Marshal(s.EventIDs)
|
||||
_ = app.DismissSuggestion(n.ID, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки")
|
||||
|
||||
if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil {
|
||||
t.Fatalf("DismissSuggestion: %v", err)
|
||||
}
|
||||
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
t.Fatalf("dismissed suggestion still visible: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
var eventCount int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil {
|
||||
t.Fatalf("count event: %v", err)
|
||||
}
|
||||
if eventCount != 1 {
|
||||
t.Fatalf("activity event count = %d, want 1", eventCount)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Remote Worklog Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
dto, err := app.CreateWorklogFull(n.ID, "Before remote", "", "2026-01-03", 15, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"id": dto.ID,
|
||||
"node_id": n.ID,
|
||||
"summary": "After remote",
|
||||
"details": "Remote details",
|
||||
"minutes": 75,
|
||||
"date": "2026-01-04",
|
||||
"approximate": true,
|
||||
"billable": true,
|
||||
"updated_at": nowISO(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
if err := app.applyRemoteWorklogOp(syncsvc.Op{
|
||||
EntityType: syncsvc.EntityWorklog,
|
||||
EntityID: dto.ID,
|
||||
OpType: syncsvc.OpUpdate,
|
||||
PayloadJSON: string(payload),
|
||||
}); err != nil {
|
||||
t.Fatalf("applyRemoteWorklogOp update: %v", err)
|
||||
}
|
||||
|
||||
got, err := app.worklog.Get(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated entry: %v", err)
|
||||
}
|
||||
if got.Summary != "After remote" || got.Details != "Remote details" || got.Date != "2026-01-04" {
|
||||
t.Fatalf("remote updated entry = %#v", got)
|
||||
}
|
||||
if got.Minutes == nil || *got.Minutes != 75 || !got.Approximate || !got.Billable {
|
||||
t.Fatalf("remote updated minutes/flags = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptSuggestionFullUsesEditedFields(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Edited Suggestion Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||
eventIDsJSON, _ := json.Marshal([]string{eventID})
|
||||
|
||||
dto, err := app.AcceptSuggestionFull(n.ID, "Edited summary", "Edited details", "2026-01-05", 55, false, true, string(eventIDsJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptSuggestionFull: %v", err)
|
||||
}
|
||||
if dto.Summary != "Edited summary" || dto.Details != "Edited details" || dto.Date != "2026-01-05" {
|
||||
t.Fatalf("dto = %#v", dto)
|
||||
}
|
||||
if dto.Minutes != 55 || dto.Approximate || !dto.Billable {
|
||||
t.Fatalf("dto minutes/flags = %#v", dto)
|
||||
}
|
||||
if dto.Source != worklog.SourceSuggestion {
|
||||
t.Fatalf("dto.Source = %q, want %q", dto.Source, worklog.SourceSuggestion)
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 1 {
|
||||
t.Fatalf("linked events = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,457 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Trash Me", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
if err := app.DeleteNode(n.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
|
||||
trash, err := app.ListTrash()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrash: %v", err)
|
||||
}
|
||||
|
||||
var foundNode bool
|
||||
for _, node := range trash.Nodes {
|
||||
if node.ID == n.ID && node.Title == "Trash Me" && node.DeletedAt != "" {
|
||||
foundNode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNode {
|
||||
t.Fatalf("deleted node %s missing from trash nodes: %#v", n.ID, trash.Nodes)
|
||||
}
|
||||
|
||||
var foundPhysical bool
|
||||
for _, entry := range trash.Entries {
|
||||
if strings.Contains(entry.Name, n.ID) && entry.IsDir {
|
||||
foundPhysical = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundPhysical {
|
||||
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "Specs", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
other, err := app.CreateNodeFromTemplate(parent.ID, "Drafts", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
if err := app.RestoreTrashNode(child.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode(child): %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{parent.ID, child.ID} {
|
||||
if _, err := app.nodes.GetActive(id); err != nil {
|
||||
t.Fatalf("node %s should be active after restore: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := app.nodes.GetActive(other.ID); err == nil {
|
||||
t.Fatalf("unselected sibling should remain deleted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vault, "Documents", "Specs")); err != nil {
|
||||
t.Fatalf("restored child path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashNodeFromNestedDeletedFolderRestoresFullPath(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Verstak", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
templates, err := app.CreateNodeFromTemplate(parent.ID, "templates", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create templates: %v", err)
|
||||
}
|
||||
registry, err := app.CreateNodeFromTemplate(templates.ID, "registry.go", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create registry: %v", err)
|
||||
}
|
||||
other, err := app.CreateNodeFromTemplate(templates.ID, "other.go", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
if err := app.RestoreTrashNode(registry.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode(registry): %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{parent.ID, templates.ID, registry.ID} {
|
||||
if _, err := app.nodes.GetActive(id); err != nil {
|
||||
t.Fatalf("node %s should be active after restore: %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := app.nodes.GetActive(other.ID); err == nil {
|
||||
t.Fatalf("unselected nested sibling should remain deleted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vault, "Verstak", "templates", "registry.go")); err != nil {
|
||||
t.Fatalf("restored nested path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashCountPurgeAndEmpty(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default")
|
||||
b, _ := app.CreateNodeFromTemplate("", "Trash B", "folder.default")
|
||||
if err := app.DeleteNode(a.ID); err != nil {
|
||||
t.Fatalf("delete A: %v", err)
|
||||
}
|
||||
if err := app.DeleteNode(b.ID); err != nil {
|
||||
t.Fatalf("delete B: %v", err)
|
||||
}
|
||||
count, err := app.TrashCount()
|
||||
if err != nil {
|
||||
t.Fatalf("TrashCount: %v", err)
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("TrashCount = %d, want 4 (2 folders + 2 Notes children)", count)
|
||||
}
|
||||
if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil {
|
||||
t.Fatalf("PurgeTrashNodesJSON: %v", err)
|
||||
}
|
||||
count, _ = app.TrashCount()
|
||||
if count != 2 {
|
||||
t.Fatalf("TrashCount after purge = %d, want 2 (1 folder + 1 Notes child)", count)
|
||||
}
|
||||
if err := app.EmptyTrash(); err != nil {
|
||||
t.Fatalf("EmptyTrash: %v", err)
|
||||
}
|
||||
count, _ = app.TrashCount()
|
||||
if count != 0 {
|
||||
t.Fatalf("TrashCount after empty = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTypeFilePreviewAndRestore(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create a folder to hold the file.
|
||||
parent, err := app.CreateNodeFromTemplate("", "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
// Create a TypeFile node with a file record.
|
||||
fileNode, err := app.CreateEmptyFile(parent.ID, "hello.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEmptyFile: %v", err)
|
||||
}
|
||||
|
||||
// Write some content to the physical file via file records.
|
||||
recs, err := app.files.ListByNode(fileNode.ID)
|
||||
if err != nil || len(recs) == 0 {
|
||||
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
|
||||
}
|
||||
record := recs[0]
|
||||
absPath := filepath.Join(vault, record.Path)
|
||||
content := "Hello, World!"
|
||||
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Delete the entire tree.
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
|
||||
// Verify trash listing has the file node with trashFsPath set.
|
||||
trash, err := app.ListTrash()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrash: %v", err)
|
||||
}
|
||||
var fileTrashNode *TrashNodeDTO
|
||||
for i, n := range trash.Nodes {
|
||||
if n.ID == fileNode.ID {
|
||||
fileTrashNode = &trash.Nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if fileTrashNode == nil {
|
||||
t.Fatalf("file node not found in trash listing")
|
||||
}
|
||||
if fileTrashNode.TrashFsPath == "" {
|
||||
t.Fatalf("file node missing trashFsPath: %+v", fileTrashNode)
|
||||
}
|
||||
|
||||
// Verify ReadTrashFile works with the precomputed path.
|
||||
readContent, err := app.ReadTrashFile(fileTrashNode.TrashFsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadTrashFile: %v", err)
|
||||
}
|
||||
if readContent != content {
|
||||
t.Fatalf("ReadTrashFile content = %q, want %q", readContent, content)
|
||||
}
|
||||
|
||||
// Verify ReadTrashFileContent also works (fallback using file records).
|
||||
readContent2, err := app.ReadTrashFileContent(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadTrashFileContent: %v", err)
|
||||
}
|
||||
if readContent2 != content {
|
||||
t.Fatalf("ReadTrashFileContent = %q, want %q", readContent2, content)
|
||||
}
|
||||
|
||||
// Verify trashed file records are still in DB.
|
||||
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrashedByNode: %v", err)
|
||||
}
|
||||
if len(trashedRecs) != 1 {
|
||||
t.Fatalf("ListTrashedByNode = %d records, want 1", len(trashedRecs))
|
||||
}
|
||||
if !trashedRecs[0].Missing {
|
||||
t.Fatalf("expected trashed record to have Missing=true")
|
||||
}
|
||||
if trashedRecs[0].Path == "" {
|
||||
t.Fatalf("expected trashed record to keep original Path")
|
||||
}
|
||||
|
||||
// Restore the file node.
|
||||
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode: %v", err)
|
||||
}
|
||||
|
||||
// Verify the node is active.
|
||||
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
|
||||
t.Fatalf("file node not active after restore: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file record is restored (missing=0).
|
||||
restoredRecs, err := app.files.ListByNode(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode after restore: %v", err)
|
||||
}
|
||||
if len(restoredRecs) != 1 {
|
||||
t.Fatalf("ListByNode after restore = %d records, want 1", len(restoredRecs))
|
||||
}
|
||||
if restoredRecs[0].Missing {
|
||||
t.Fatalf("file record should have Missing=false after restore")
|
||||
}
|
||||
|
||||
// Verify the physical file content is intact.
|
||||
absRestored := filepath.Join(vault, restoredRecs[0].Path)
|
||||
restoredBytes, err := os.ReadFile(absRestored)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored file: %v", err)
|
||||
}
|
||||
if string(restoredBytes) != content {
|
||||
t.Fatalf("restored file content = %q, want %q", string(restoredBytes), content)
|
||||
}
|
||||
|
||||
// Verify the trash entry is gone (file was moved back).
|
||||
if _, err := os.Stat(fileTrashNode.TrashFsPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("trash entry should be gone after restore, err=%v", err)
|
||||
}
|
||||
|
||||
// Parent is also restored because RestoreTrashNode restores the entire
|
||||
// ancestor chain from the requested node up to the root deleted node.
|
||||
if _, err := app.nodes.GetActive(parent.ID); err != nil {
|
||||
t.Fatalf("parent should be active after child restore (ancestor chain): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTypeFileInsideFolderRestorePreservesContent(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create: parent folder → child TypeFile.
|
||||
parent, err := app.CreateNodeFromTemplate("", "ProjectX", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
fileNode, err := app.CreateEmptyFile(parent.ID, "data.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEmptyFile: %v", err)
|
||||
}
|
||||
|
||||
// Write content.
|
||||
recs, err := app.files.ListByNode(fileNode.ID)
|
||||
if err != nil || len(recs) == 0 {
|
||||
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
|
||||
}
|
||||
absPath := filepath.Join(vault, recs[0].Path)
|
||||
content := "a,b,c\n1,2,3"
|
||||
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Delete the whole tree.
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
|
||||
// Restore parent. This moves the directory back from trash but does NOT
|
||||
// restore child nodes (RestoreTrashNode walks ancestor chain, not children).
|
||||
if err := app.RestoreTrashNode(parent.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode parent: %v", err)
|
||||
}
|
||||
|
||||
// Only the parent should be active.
|
||||
if _, err := app.nodes.GetActive(parent.ID); err != nil {
|
||||
t.Fatalf("parent should be active: %v", err)
|
||||
}
|
||||
if _, err := app.nodes.GetActive(fileNode.ID); err == nil {
|
||||
t.Fatalf("child file node should remain deleted (RestoreTrashNode is ancestor-only)")
|
||||
}
|
||||
|
||||
// Parent directory should exist on disk.
|
||||
if _, err := os.Stat(filepath.Join(vault, "ProjectX")); err != nil {
|
||||
t.Fatalf("parent directory should exist: %v", err)
|
||||
}
|
||||
|
||||
// Now restore the child file node specifically.
|
||||
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.nodes.GetActive(fileNode.ID); err != nil {
|
||||
t.Fatalf("file node should be active: %v", err)
|
||||
}
|
||||
|
||||
// File record should be restored.
|
||||
recs, err = app.files.ListByNode(fileNode.ID)
|
||||
if err != nil || len(recs) == 0 {
|
||||
t.Fatalf("ListByNode after restore: %v (len=%d)", err, len(recs))
|
||||
}
|
||||
if recs[0].Missing {
|
||||
t.Fatalf("file record should not be missing after restore")
|
||||
}
|
||||
|
||||
// Physical file content should be intact.
|
||||
absRestored := filepath.Join(vault, recs[0].Path)
|
||||
restoredBytes, err := os.ReadFile(absRestored)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored file: %v", err)
|
||||
}
|
||||
if string(restoredBytes) != content {
|
||||
t.Fatalf("content = %q, want %q", string(restoredBytes), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTypeFileMultipleRecords(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create a TypeFile node with two file records.
|
||||
fileNode, err := app.CreateEmptyFile("", "report.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEmptyFile: %v", err)
|
||||
}
|
||||
|
||||
// Write content to first record.
|
||||
recs, err := app.files.ListByNode(fileNode.ID)
|
||||
if err != nil || len(recs) == 0 {
|
||||
t.Fatalf("ListByNode: %v (len=%d)", err, len(recs))
|
||||
}
|
||||
absPath1 := filepath.Join(vault, recs[0].Path)
|
||||
content1 := "version 1"
|
||||
if err := os.WriteFile(absPath1, []byte(content1), 0o644); err != nil {
|
||||
t.Fatalf("write file 1: %v", err)
|
||||
}
|
||||
|
||||
// Manually insert a second file record with its own vault file.
|
||||
now := nowStr()
|
||||
absPath2 := filepath.Join(vault, "report-v2.txt")
|
||||
content2 := "version 2"
|
||||
if err := os.WriteFile(absPath2, []byte(content2), 0o644); err != nil {
|
||||
t.Fatalf("write file 2: %v", err)
|
||||
}
|
||||
_, err = app.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
"second-record-id", fileNode.ID, "report-v2.txt",
|
||||
"report-v2.txt", "vault", 0, "", "text/plain", now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert second record: %v", err)
|
||||
}
|
||||
|
||||
// Delete the node.
|
||||
if err := app.DeleteNode(fileNode.ID); err != nil {
|
||||
t.Fatalf("DeleteNode: %v", err)
|
||||
}
|
||||
|
||||
// Verify both records are trashed.
|
||||
trashedRecs, err := app.files.ListTrashedByNode(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrashedByNode: %v", err)
|
||||
}
|
||||
if len(trashedRecs) != 2 {
|
||||
t.Fatalf("expected 2 trashed records, got %d", len(trashedRecs))
|
||||
}
|
||||
for _, r := range trashedRecs {
|
||||
if !r.Missing {
|
||||
t.Fatalf("record %s should have Missing=true", r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify trash listing has trashFsPath set.
|
||||
trash, err := app.ListTrash()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTrash: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, n := range trash.Nodes {
|
||||
if n.ID == fileNode.ID {
|
||||
found = true
|
||||
if n.TrashFsPath == "" {
|
||||
t.Fatalf("trashFsPath should be set for file node with multiple records")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("file node not found in trash listing")
|
||||
}
|
||||
|
||||
// Restore.
|
||||
if err := app.RestoreTrashNode(fileNode.ID); err != nil {
|
||||
t.Fatalf("RestoreTrashNode: %v", err)
|
||||
}
|
||||
|
||||
// Both records should be restored.
|
||||
restoredRecs, err := app.files.ListByNode(fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode after restore: %v", err)
|
||||
}
|
||||
if len(restoredRecs) != 2 {
|
||||
t.Fatalf("expected 2 restored records, got %d: %+v", len(restoredRecs), restoredRecs)
|
||||
}
|
||||
for _, r := range restoredRecs {
|
||||
if r.Missing {
|
||||
t.Fatalf("record %s should not be missing", r.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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,462 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
)
|
||||
|
||||
// --- Files tab / ListItems tests for Notes folder ---
|
||||
|
||||
// TestFileManagerListItemsShowsNotesFolder verifies that ListItems on a
|
||||
// container returns the Notes folder, matching what the Files tab UI shows.
|
||||
func TestFileManagerListItemsShowsNotesFolder(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// ListItems is what the Files tab actually calls
|
||||
items, err := app.ListItems(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems: %v", err)
|
||||
}
|
||||
|
||||
var foundNotes bool
|
||||
for _, item := range items {
|
||||
if item.Name == notes.NotesFolder && item.Type == "folder" {
|
||||
foundNotes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNotes {
|
||||
t.Errorf("ListItems(%q) should contain Notes folder, got %d items", proj.ID, len(items))
|
||||
for _, item := range items {
|
||||
t.Logf(" %s (type=%s)", item.Name, item.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileManagerListItemsInsideNotesShowsOverview verifies that ListItems
|
||||
// on the Notes folder returns the Overview note.
|
||||
func TestFileManagerListItemsInsideNotesShowsOverview(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Find Notes folder
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildren: %v", err)
|
||||
}
|
||||
var notesFolder *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
|
||||
notesFolder = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if notesFolder == nil {
|
||||
t.Fatal("Notes folder not found")
|
||||
}
|
||||
|
||||
// ListItems inside Notes folder
|
||||
items, err := app.ListItems(notesFolder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems(Notes folder): %v", err)
|
||||
}
|
||||
|
||||
var foundOverview bool
|
||||
for _, item := range items {
|
||||
if item.Name == "Overview" && item.Type == "note" {
|
||||
foundOverview = true
|
||||
if item.FileID == "" {
|
||||
t.Error("Overview note has empty FileID")
|
||||
}
|
||||
if item.Mime == "" {
|
||||
t.Error("Overview note has empty Mime")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundOverview {
|
||||
t.Errorf("ListItems(Notes) should contain Overview note, got %d items", len(items))
|
||||
for _, item := range items {
|
||||
t.Logf(" %s (type=%s)", item.Name, item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no root-level Overview.md on disk
|
||||
rootPath := filepath.Join(vault, proj.FsPath, "Overview.md")
|
||||
if _, err := os.Stat(rootPath); err == nil {
|
||||
t.Error("Overview.md should NOT exist at root level, only in Notes/")
|
||||
}
|
||||
|
||||
// Verify Notes/Overview.md exists on disk
|
||||
notesPath := filepath.Join(vault, proj.FsPath, notes.NotesFolder, "Overview.md")
|
||||
if _, err := os.Stat(notesPath); os.IsNotExist(err) {
|
||||
t.Error("Overview.md should exist at Notes/Overview.md")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairMovesDirectNoteChildrenToNotesFolder verifies that a note
|
||||
// created as a direct child of a container (old layout) is moved into
|
||||
// the Notes folder by RepairNotesLayout, and that ListItems then shows
|
||||
// the note under Notes/ not as a direct child.
|
||||
func TestRepairMovesDirectNoteChildrenToNotesFolder(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create a container that supports notes
|
||||
parent, err := app.CreateNodeFromTemplate("", "TestCase", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create container: %v", err)
|
||||
}
|
||||
|
||||
// Simulate old layout: create TypeNote as direct child (not under Notes/)
|
||||
noteNode, err := app.nodes.Create(&parent.ID, nodes.TypeNote, "LegacyNote", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy note: %v", err)
|
||||
}
|
||||
|
||||
// Write a physical file so repair has something to fix
|
||||
noteDir := filepath.Join(vault, parent.FsPath)
|
||||
if err := os.MkdirAll(noteDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(noteDir, "LegacyNote.md")
|
||||
if err := os.WriteFile(oldPath, []byte("# Legacy Content\n"), 0o640); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(vault, oldPath)
|
||||
fileID := "repair-file-" + noteNode.ID
|
||||
_, _ = app.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',0,'','text/plain','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
|
||||
fileID, noteNode.ID, "LegacyNote.md", relPath)
|
||||
_, _ = app.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
noteNode.ID, fileID, "markdown")
|
||||
|
||||
// Verify the note is a direct child before repair
|
||||
beforeChildren, err := app.nodes.ListChildren(parent.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildren before repair: %v", err)
|
||||
}
|
||||
var foundDirect bool
|
||||
for _, c := range beforeChildren {
|
||||
if c.ID == noteNode.ID {
|
||||
foundDirect = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDirect {
|
||||
t.Fatal("legacy note should be a direct child before repair")
|
||||
}
|
||||
|
||||
// Run repair
|
||||
result, err := app.notes.RepairNotesLayout()
|
||||
if err != nil {
|
||||
t.Fatalf("RepairNotesLayout: %v", err)
|
||||
}
|
||||
if result.RepairedNotes == 0 {
|
||||
t.Errorf("expected at least 1 repaired note, got 0")
|
||||
}
|
||||
|
||||
// After repair: note should NOT be a direct child of parent
|
||||
afterChildren, err := app.nodes.ListChildren(parent.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildren after repair: %v", err)
|
||||
}
|
||||
for _, c := range afterChildren {
|
||||
if c.ID == noteNode.ID {
|
||||
t.Errorf("note should no longer be a direct child after repair")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// After repair: note should be inside Notes folder
|
||||
// ListItems(parent.ID) should NOT show the note directly
|
||||
parentItems, err := app.ListItems(parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems(parent) after repair: %v", err)
|
||||
}
|
||||
for _, item := range parentItems {
|
||||
if item.ID == noteNode.ID {
|
||||
t.Errorf("ListItems(parent) should not show note directly after repair")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// But ListItems(Notes) should show it
|
||||
var notesFolder *nodes.Node
|
||||
for i := range afterChildren {
|
||||
if afterChildren[i].Title == notes.NotesFolder && afterChildren[i].Type == "folder" {
|
||||
notesFolder = &afterChildren[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if notesFolder == nil {
|
||||
t.Fatal("Notes folder should exist after repair")
|
||||
}
|
||||
notesItems, err := app.ListItems(notesFolder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems(Notes) after repair: %v", err)
|
||||
}
|
||||
var foundInNotes bool
|
||||
for _, item := range notesItems {
|
||||
if item.ID == noteNode.ID && item.Type == "note" {
|
||||
foundInNotes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundInNotes {
|
||||
t.Errorf("ListItems(Notes) should show the repaired note, got %d items", len(notesItems))
|
||||
for _, item := range notesItems {
|
||||
t.Logf(" %s (type=%s)", item.Name, item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify node parent was updated
|
||||
note, err := app.nodes.Get(noteNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get note node after repair: %v", err)
|
||||
}
|
||||
if note.ParentID == nil || *note.ParentID != notesFolder.ID {
|
||||
t.Errorf("note.ParentID should be Notes folder (%s), got %v", notesFolder.ID, note.ParentID)
|
||||
}
|
||||
|
||||
// Verify file path was updated
|
||||
recs, err := app.files.ListByNode(noteNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode after repair: %v", err)
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("file record should exist after repair")
|
||||
}
|
||||
expectedRelPath := filepath.Join(parent.FsPath, notes.NotesFolder, "LegacyNote.md")
|
||||
if recs[0].Path != expectedRelPath {
|
||||
t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestCheckFileAction_NoteLinked verifies that CheckFileAction returns
|
||||
// Action="note" for a .md file linked to a note record.
|
||||
func TestCheckFileAction_NoteLinked(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
// Find the Overview note — it should be inside Notes folder, linked via notes record
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildren: %v", err)
|
||||
}
|
||||
var notesFolder *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
|
||||
notesFolder = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if notesFolder == nil {
|
||||
t.Fatal("Notes folder not found")
|
||||
}
|
||||
notesChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildren(Notes): %v", err)
|
||||
}
|
||||
if len(notesChildren) == 0 {
|
||||
t.Fatal("expected at least one note inside Notes folder")
|
||||
}
|
||||
// Get file ID for the Overview note
|
||||
items, err := app.ListItems(notesFolder.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems: %v", err)
|
||||
}
|
||||
var overviewFileID string
|
||||
for _, item := range items {
|
||||
if item.Type == "note" && item.Name == "Overview" {
|
||||
overviewFileID = item.FileID
|
||||
break
|
||||
}
|
||||
}
|
||||
if overviewFileID == "" {
|
||||
t.Fatal("Overview note has no FileID")
|
||||
}
|
||||
// CheckFileAction should return Action="note"
|
||||
action, err := app.CheckFileAction(overviewFileID)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFileAction: %v", err)
|
||||
}
|
||||
if action.Action != "note" {
|
||||
t.Errorf("expected Action=note for linked .md, got %q", action.Action)
|
||||
}
|
||||
if action.NoteID == "" {
|
||||
t.Error("expected non-empty NoteID for linked note")
|
||||
}
|
||||
if action.NoteTitle == "" {
|
||||
t.Error("expected non-empty NoteTitle")
|
||||
}
|
||||
if action.FileName == "" {
|
||||
t.Error("expected non-empty FileName")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFileAction_ExternalForNonMD verifies that non-.md files return
|
||||
// Action="external" from CheckFileAction.
|
||||
func TestCheckFileAction_ExternalForNonMD(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
// Create a file node and record for a non-.md file
|
||||
fileNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "image.png", 0, "", filepath.Join(proj.FsPath, "image.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("create file node: %v", err)
|
||||
}
|
||||
absPath := filepath.Join(vault, proj.FsPath, "image.png")
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte("fake-png"), 0o640); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
// Insert file record directly
|
||||
_, err = app.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',0,'','image/png','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
|
||||
"file-png-"+fileNode.ID, fileNode.ID, "image.png", filepath.Join(proj.FsPath, "image.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("insert file record: %v", err)
|
||||
}
|
||||
// CheckFileAction should return Action="external"
|
||||
action, err := app.CheckFileAction("file-png-" + fileNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFileAction: %v", err)
|
||||
}
|
||||
if action.Action != "external" {
|
||||
t.Errorf("expected Action=external for .png, got %q", action.Action)
|
||||
}
|
||||
if action.FileName != "image.png" {
|
||||
t.Errorf("expected FileName=image.png, got %q", action.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckFileAction_PreviewForMDOutsideNotes verifies that .md files
|
||||
// outside Notes/ without a note record return Action="preview".
|
||||
func TestCheckFileAction_PreviewForMDOutsideNotes(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
// Create a .md file directly under the project (not inside Notes/)
|
||||
mdNode, err := app.nodes.Create(&proj.ID, nodes.TypeFile, "readme.md", 0, "", filepath.Join(proj.FsPath, "readme.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("create md node: %v", err)
|
||||
}
|
||||
absPath := filepath.Join(vault, proj.FsPath, "readme.md")
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte("# Readme\n"), 0o640); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
// Insert file record directly
|
||||
_, err = app.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
|
||||
"file-md-"+mdNode.ID, mdNode.ID, "readme.md", filepath.Join(proj.FsPath, "readme.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("insert file record: %v", err)
|
||||
}
|
||||
// Do NOT create a notes record — this .md is outside Notes/
|
||||
action, err := app.CheckFileAction("file-md-" + mdNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFileAction: %v", err)
|
||||
}
|
||||
if action.Action != "preview" {
|
||||
t.Errorf("expected Action=preview for .md outside Notes/, got %q", action.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFileAction_AutoLinkInNotes(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
// Find Notes folder
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildren: %v", err)
|
||||
}
|
||||
var notesFolder *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == notes.NotesFolder && children[i].Type == "folder" {
|
||||
notesFolder = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if notesFolder == nil {
|
||||
t.Fatal("Notes folder not found")
|
||||
}
|
||||
// Create a .md file INSIDE Notes/ but WITHOUT a notes record
|
||||
mdNode, err := app.nodes.Create(¬esFolder.ID, nodes.TypeFile, "orphan.md", 0, "",
|
||||
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("create md node: %v", err)
|
||||
}
|
||||
notesDir := filepath.Join(vault, proj.FsPath, notes.NotesFolder)
|
||||
if err := os.MkdirAll(notesDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
absPath := filepath.Join(notesDir, "orphan.md")
|
||||
if err := os.WriteFile(absPath, []byte("# Orphan Note\n"), 0o640); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
// Insert file record
|
||||
_, err = app.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',0,'','text/markdown','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`,
|
||||
"file-orphan-"+mdNode.ID, mdNode.ID, "orphan.md",
|
||||
filepath.Join(proj.FsPath, notes.NotesFolder, "orphan.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("insert file record: %v", err)
|
||||
}
|
||||
// No notes record yet — just file + node
|
||||
// CheckFileAction should auto-link and return Action="note"
|
||||
action, err := app.CheckFileAction("file-orphan-" + mdNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFileAction: %v", err)
|
||||
}
|
||||
if action.Action != "note" {
|
||||
t.Errorf("expected Action=note for .md inside Notes/, got %q", action.Action)
|
||||
}
|
||||
if action.NoteID == "" {
|
||||
t.Error("expected auto-linked NoteID")
|
||||
}
|
||||
if action.NoteTitle == "" {
|
||||
t.Error("expected NoteTitle for auto-linked note")
|
||||
}
|
||||
// Verify notes record was actually created
|
||||
noteRec, err := app.notes.FindByFileID("file-orphan-" + mdNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByFileID after auto-link: %v", err)
|
||||
}
|
||||
if noteRec == nil {
|
||||
t.Fatal("expected note record after auto-link")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,781 @@
|
|||
--[[
|
||||
Calendar plugin for Verstak — reference plugin demonstrating the full plugin API.
|
||||
Covers: verstak.db.* / config.* / state.* / node.* / worklog.* / activity.* / schedule.* / http.* / ui.*
|
||||
]]
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Module table — internal
|
||||
--------------------------------------------------------------------------------
|
||||
local M = {}
|
||||
|
||||
-- Safe wrapper for optional service calls (avoids pcall boilerplate)
|
||||
local function safe_log(fn, ...)
|
||||
pcall(fn, ...)
|
||||
end
|
||||
|
||||
-- ID generation
|
||||
local function uuid()
|
||||
local f = function() return math.random(0, 16777215) end
|
||||
local p = string.format
|
||||
return p("%04x%04x-%04x-%04x-%04x-%06x%06x",
|
||||
f(), f(), f(), f(), f(), f(), f())
|
||||
end
|
||||
|
||||
math.randomseed(os.time())
|
||||
|
||||
-- Current timestamp ISO8601
|
||||
local function now()
|
||||
return os.date("%Y-%m-%dT%H:%M:%S")
|
||||
end
|
||||
|
||||
local function today()
|
||||
return os.date("%Y-%m-%d")
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Default categories
|
||||
--------------------------------------------------------------------------------
|
||||
local DEFAULT_CATEGORIES = {
|
||||
{ name = "Работа", color = "#3b82f6", icon = "💼", sort_order = 1 },
|
||||
{ name = "Личное", color = "#10b981", icon = "🏠", sort_order = 2 },
|
||||
{ name = "Встреча", color = "#8b5cf6", icon = "🤝", sort_order = 3 },
|
||||
{ name = "Дедлайн", color = "#ef4444", icon = "🔥", sort_order = 4 },
|
||||
{ name = "Здоровье", color = "#f59e0b", icon = "💪", sort_order = 5 },
|
||||
{ name = "Звонок", color = "#06b6d4", icon = "📞", sort_order = 6 },
|
||||
}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Verstak.config — load/store default categories
|
||||
--------------------------------------------------------------------------------
|
||||
function M.ensure_categories()
|
||||
local rows = verstak.db.query("SELECT COUNT(*) as cnt FROM categories WHERE deleted = 0")
|
||||
local n = 0
|
||||
if rows and #rows > 0 then
|
||||
for _, v in pairs(rows[1]) do
|
||||
if type(v) == "number" then n = v; break end
|
||||
end
|
||||
end
|
||||
if n > 0 then return end
|
||||
|
||||
for _, cat in ipairs(DEFAULT_CATEGORIES) do
|
||||
local id = uuid()
|
||||
verstak.db.exec(
|
||||
"INSERT INTO categories (id, name, color, icon, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||
id, cat.name, cat.color, cat.icon, cat.sort_order
|
||||
)
|
||||
-- Activity log for each category
|
||||
safe_log(verstak.activity.log,"category_created", "Категория: " .. cat.name, id, "")
|
||||
end
|
||||
|
||||
-- Save as config so user can restore defaults
|
||||
local cfg = verstak.config.get("categories") or {}
|
||||
if next(cfg) == nil then
|
||||
verstak.config.set("categories", DEFAULT_CATEGORIES)
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Categories CRUD (verstak.db.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Get all non-deleted categories
|
||||
function M.get_categories()
|
||||
print("get_categories called")
|
||||
return verstak.db.query(
|
||||
"SELECT id, name, color, icon, sort_order FROM categories WHERE deleted = 0 ORDER BY sort_order"
|
||||
)
|
||||
end
|
||||
|
||||
-- Get all categories including deleted
|
||||
function M.get_categories_all()
|
||||
return verstak.db.query(
|
||||
"SELECT id, name, color, icon, sort_order, deleted FROM categories ORDER BY sort_order"
|
||||
)
|
||||
end
|
||||
|
||||
-- Create a new category
|
||||
function M.create_category(name, color, icon)
|
||||
if not name or name == "" then error("category name required") end
|
||||
local id = uuid()
|
||||
verstak.db.exec(
|
||||
"INSERT INTO categories (id, name, color, icon) VALUES (?, ?, ?, ?)",
|
||||
id, name, color or "#6b7280", icon or "📌"
|
||||
)
|
||||
safe_log(verstak.activity.log,"category_created", "Категория: " .. name, id, "")
|
||||
return id
|
||||
end
|
||||
|
||||
-- Update a category
|
||||
function M.update_category(id, fields)
|
||||
if not id then error("category id required") end
|
||||
local old = verstak.db.query_row("SELECT name FROM categories WHERE id = ?", id)
|
||||
if not old then error("category not found: " .. id) end
|
||||
|
||||
verstak.db.exec(
|
||||
"UPDATE categories SET name = ?, color = ?, icon = ?, sort_order = ?, updated_at = ? WHERE id = ?",
|
||||
fields.name or old.name,
|
||||
fields.color or "#6b7280",
|
||||
fields.icon or "📌",
|
||||
fields.sort_order or 0,
|
||||
now(),
|
||||
id
|
||||
)
|
||||
safe_log(verstak.activity.log,"category_updated", "Категория: " .. (fields.name or old.name), id, "")
|
||||
return true
|
||||
end
|
||||
|
||||
-- Soft-delete a category (keeps history)
|
||||
function M.delete_category(id)
|
||||
if not id then error("category id required") end
|
||||
verstak.db.exec("UPDATE categories SET deleted = 1, updated_at = ? WHERE id = ?", now(), id)
|
||||
safe_log(verstak.activity.log,"category_deleted", "Категория удалена: " .. id, id, "")
|
||||
return true
|
||||
end
|
||||
|
||||
-- Restore default categories
|
||||
function M.restore_default_categories()
|
||||
verstak.db.exec("UPDATE categories SET deleted = 1")
|
||||
M.ensure_categories()
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Events CRUD (verstak.db.* + verstak.state.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Get events within a date range (inclusive)
|
||||
function M.get_events(params, end_date)
|
||||
-- Debug: log what we received
|
||||
print("get_events: params type=" .. type(params) .. ", end_date type=" .. type(end_date))
|
||||
if type(params) == "table" then
|
||||
for k, v in pairs(params) do
|
||||
print(" params[" .. tostring(k) .. "] type=" .. type(v))
|
||||
end
|
||||
end
|
||||
-- Backward compat: support positional (start, end) and table {start=, end=}
|
||||
if type(params) == "string" then
|
||||
print("get_events: backward compat path, end_date=" .. tostring(end_date))
|
||||
local e = end_date
|
||||
if type(e) ~= "string" then e = nil end
|
||||
return M.get_events{ start_date = params, ["end"] = e or params }
|
||||
end
|
||||
local start_date = params.start_date or params.start
|
||||
if type(start_date) ~= "string" then print("WARN get_events start_date not string: " .. type(start_date)); start_date = tostring(start_date) end
|
||||
local end_date = params["end"] or params.end_date or params.end_date
|
||||
if type(end_date) ~= "string" then print("WARN get_events end_date not string: " .. type(end_date)); end_date = start_date end
|
||||
if not start_date then error("start_date required") end
|
||||
if not end_date then end_date = start_date end
|
||||
return verstak.db.query(
|
||||
[[SELECT e.id, e.title, e.description,
|
||||
e.start, e.end, e.all_day,
|
||||
e.category_id, e.color,
|
||||
e.node_id, e.link_type,
|
||||
e.recurring_rule, e.reminder_minutes,
|
||||
e.completed, e.source_series,
|
||||
e.created_at, e.updated_at,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM events e
|
||||
LEFT JOIN categories c ON e.category_id = c.id AND c.deleted = 0
|
||||
WHERE (e.start >= ? AND e.start <= ?)
|
||||
OR (e.end >= ? AND e.end <= ?)
|
||||
OR (e.start <= ? AND e.end >= ?)
|
||||
ORDER BY e.start
|
||||
]],
|
||||
start_date, end_date, start_date, end_date, start_date, end_date
|
||||
)
|
||||
end
|
||||
|
||||
-- Get events for a specific day
|
||||
function M.get_events_day(params)
|
||||
local date_str = params.date or params
|
||||
if type(date_str) ~= "string" then date_str = tostring(date_str) end
|
||||
return M.get_events({ start_date = date_str .. "T00:00:00", end_date = date_str .. "T23:59:59" })
|
||||
end
|
||||
|
||||
-- Get event by ID
|
||||
function M.get_event(params)
|
||||
local id = params.id or params
|
||||
if not id then error("event id required") end
|
||||
return verstak.db.query_row(
|
||||
"SELECT * FROM events WHERE id = ?", id
|
||||
)
|
||||
end
|
||||
|
||||
-- Create a single event (base event for recurrences)
|
||||
-- Already accepts a single table — no change needed
|
||||
function M.create_event(opts)
|
||||
opts = opts or {}
|
||||
if not opts.title or opts.title == "" then error("event title required") end
|
||||
if not opts.start then error("event start datetime required") end
|
||||
|
||||
local id = uuid()
|
||||
local e_start = opts.start
|
||||
local e_end = opts["end"] or e_start
|
||||
local cat_id = opts.category_id or ""
|
||||
local color = opts.color or "#6b7280"
|
||||
|
||||
-- Resolve color from category if not set
|
||||
if color == "" and cat_id ~= "" then
|
||||
local cat = verstak.db.query_row("SELECT color FROM categories WHERE id = ?", cat_id)
|
||||
if cat then color = cat.color end
|
||||
end
|
||||
|
||||
local recurring_json = nil
|
||||
if opts.recurring then
|
||||
recurring_json = verstak.state.get("rr:" .. id)
|
||||
if not recurring_json then
|
||||
recurring_json = opts.recurring
|
||||
end
|
||||
end
|
||||
|
||||
local reminder = "[]"
|
||||
if opts.reminder_minutes then
|
||||
reminder = "[" .. table.concat(opts.reminder_minutes, ",") .. "]"
|
||||
end
|
||||
|
||||
verstak.db.exec(
|
||||
[[INSERT INTO events (id, title, description, start, end, all_day,
|
||||
category_id, color, node_id, link_type, recurring_rule, reminder_minutes,
|
||||
completed, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)]],
|
||||
id, opts.title, opts.description or "", e_start, e_end,
|
||||
opts.all_day and 1 or 0,
|
||||
cat_id, color,
|
||||
opts.node_id or "", opts.link_type or "node",
|
||||
recurring_json, reminder,
|
||||
now(), now()
|
||||
)
|
||||
|
||||
safe_log(verstak.activity.log,"event_created",
|
||||
"Событие: " .. opts.title, id, opts.node_id or "")
|
||||
|
||||
-- Link to worklog if minutes provided
|
||||
if opts.minutes and opts.node_id and opts.node_id ~= "" then
|
||||
local ok, err = pcall(verstak.worklog.add, opts.node_id, opts.title, opts.minutes)
|
||||
if not ok then
|
||||
print("Calendar: worklog.add error: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
return id
|
||||
end
|
||||
|
||||
-- Update an event (partial fields)
|
||||
function M.update_event(params, fields)
|
||||
-- Backward compat: support positional (id, fields) and table { id = ..., ... }
|
||||
if type(params) == "string" then
|
||||
local t = { id = params }
|
||||
if fields then
|
||||
for k, v in pairs(fields) do t[k] = v end
|
||||
end
|
||||
return M.update_event(t)
|
||||
end
|
||||
local id = params.id
|
||||
if not id then error("event id required") end
|
||||
local old = verstak.db.query_row("SELECT * FROM events WHERE id = ?", id)
|
||||
if not old then error("event not found: " .. id) end
|
||||
|
||||
local set_clauses = {}
|
||||
local sql_params = {}
|
||||
|
||||
-- Build dynamic update
|
||||
local mutable = {
|
||||
title = true, description = true, start = true, ["end"] = true,
|
||||
all_day = true, category_id = true, color = true,
|
||||
node_id = true, link_type = true, reminder_minutes = true,
|
||||
completed = true
|
||||
}
|
||||
|
||||
for k, v in pairs(params) do
|
||||
if k ~= "id" and mutable[k] then
|
||||
if k == "all_day" or k == "completed" then
|
||||
v = v and 1 or 0
|
||||
end
|
||||
table.insert(set_clauses, k .. " = ?")
|
||||
table.insert(sql_params, v)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(sql_params, now())
|
||||
table.insert(sql_params, id)
|
||||
|
||||
if #set_clauses > 0 then
|
||||
verstak.db.exec(
|
||||
"UPDATE events SET " .. table.concat(set_clauses, ", ") .. ", updated_at = ? WHERE id = ?",
|
||||
unpack(sql_params)
|
||||
)
|
||||
end
|
||||
|
||||
safe_log(verstak.activity.log,"event_updated",
|
||||
"Событие обновлено: " .. (params.title or old.title or id), id, old.node_id or "")
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete an event
|
||||
function M.delete_event(params)
|
||||
local id = params.id or params
|
||||
if not id or type(id) ~= "string" then error("event id required") end
|
||||
local old = verstak.db.query_row("SELECT title, node_id FROM events WHERE id = ?", id)
|
||||
if not old then return true end
|
||||
|
||||
verstak.db.exec("DELETE FROM events WHERE id = ?", id)
|
||||
safe_log(verstak.activity.log,"event_deleted",
|
||||
"Событие удалено: " .. (old.title or id), id, old.node_id or "")
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete ALL events (for testing/cache clear)
|
||||
function M.clear_events()
|
||||
verstak.db.exec("DELETE FROM events")
|
||||
safe_log(verstak.activity.log,"events_cleared", "Все события удалены", "", "")
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Recurrence (verstak.state.* for ex_dates cache)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Parse an ISO date string
|
||||
local function parse_date(s)
|
||||
if not s then return nil end
|
||||
local y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
|
||||
if not y then return nil end
|
||||
return { year = tonumber(y), month = tonumber(m), day = tonumber(d) }
|
||||
end
|
||||
|
||||
local function date_to_epoch(t)
|
||||
return os.time({ year = t.year, month = t.month, day = t.day, hour = 0, sec = 0 })
|
||||
end
|
||||
|
||||
-- day-of-week: Mon=1..Sun=7
|
||||
local function dow(t)
|
||||
return os.date("*t", date_to_epoch(t)).wday
|
||||
-- os.date().wday: Sun=1, Mon=2... → we convert
|
||||
end
|
||||
|
||||
local function to_iso(t)
|
||||
return string.format("%04d-%02d-%02dT00:00:00", t.year, t.month, t.day)
|
||||
end
|
||||
|
||||
-- Expand a recurring event into concrete dates within a range
|
||||
local function expand_recurring(base_start, base_end, rule, range_start, range_end)
|
||||
rule = rule or {}
|
||||
local freq = rule.freq or "weekly"
|
||||
local interval = rule.interval or 1
|
||||
local until_date = rule["until"]
|
||||
local max_count = rule.count or 52
|
||||
local by_day = rule.by_day or {}
|
||||
local by_month_day = rule.by_month_day or {}
|
||||
local by_month = rule.by_month or {}
|
||||
local ex_dates_set = {}
|
||||
if rule.ex_dates then
|
||||
for _, d in ipairs(rule.ex_dates) do ex_dates_set[d] = true end
|
||||
end
|
||||
|
||||
local start_t = parse_date(base_start)
|
||||
local range_start_t = parse_date(range_start)
|
||||
local range_end_t = parse_date(range_end)
|
||||
if not start_t or not range_start_t or not range_end_t then return {} end
|
||||
|
||||
local results = {}
|
||||
local count = 0
|
||||
local max_iterations = 365 * 3
|
||||
local iter = 0
|
||||
|
||||
local current = { year = start_t.year, month = start_t.month, day = start_t.day }
|
||||
local current_epoch = date_to_epoch(current)
|
||||
local range_start_epoch = date_to_epoch(range_start_t)
|
||||
local range_end_epoch = date_to_epoch(range_end_t)
|
||||
local until_epoch
|
||||
|
||||
if until_date then
|
||||
local ut = parse_date(until_date)
|
||||
if ut then until_epoch = date_to_epoch(ut) end
|
||||
end
|
||||
|
||||
local function check_matches()
|
||||
if ex_dates_set[to_iso(current)] then
|
||||
return false
|
||||
end
|
||||
if freq == "daily" then return true end
|
||||
if freq == "weekly" then
|
||||
if #by_day == 0 then return true end
|
||||
local wd = os.date("*t", current_epoch).wday
|
||||
local our_wd = (wd == 1) and 7 or (wd - 1)
|
||||
for _, d in ipairs(by_day) do
|
||||
if d == our_wd then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
if freq == "monthly" then
|
||||
if #by_month_day == 0 then return true end
|
||||
for _, d in ipairs(by_month_day) do
|
||||
if d == current.day then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
if freq == "yearly" then
|
||||
local month_ok = (#by_month == 0)
|
||||
if not month_ok then
|
||||
for _, m in ipairs(by_month) do
|
||||
if m == current.month then month_ok = true; break end
|
||||
end
|
||||
end
|
||||
if not month_ok then return false end
|
||||
if #by_month_day == 0 then return true end
|
||||
for _, d in ipairs(by_month_day) do
|
||||
if d == current.day then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function advance()
|
||||
current_epoch = current_epoch + 86400 * interval
|
||||
current = parse_date(os.date("%Y-%m-%d", current_epoch))
|
||||
end
|
||||
|
||||
while count < max_count and iter < max_iterations do
|
||||
iter = iter + 1
|
||||
if check_matches() then
|
||||
count = count + 1
|
||||
local iso = to_iso(current)
|
||||
if current_epoch >= range_start_epoch and current_epoch <= range_end_epoch then
|
||||
table.insert(results, iso)
|
||||
end
|
||||
end
|
||||
advance()
|
||||
if until_epoch and current_epoch > until_epoch then break end
|
||||
if current_epoch > range_end_epoch and iter > 7 then break end
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
M.expand_recurring = expand_recurring
|
||||
|
||||
-- Get all events (flat + expanded) for a range — used by the panel
|
||||
function M.get_expanded_events(params)
|
||||
local start_date = params.start_date or params.start
|
||||
local end_date = params.end_date or params["end"]
|
||||
local base_events = verstak.db.query(
|
||||
[[SELECT * FROM events WHERE recurring_rule IS NOT NULL AND recurring_rule != ''
|
||||
AND completed = 0]]
|
||||
)
|
||||
local expanded = {}
|
||||
for _, ev in ipairs(base_events) do
|
||||
local rule
|
||||
if type(ev.recurring_rule) == "string" then
|
||||
-- Try to load as JSON (table) — for Lua demo we store as JSON string
|
||||
-- In our case it's already a table since we stored via verstak.state
|
||||
rule = verstak.state.get("rr:" .. ev.id)
|
||||
end
|
||||
if not rule then rule = {} end -- fallback: no rule, just use as-is
|
||||
local dates = M.expand_recurring(ev.start, ev["end"], rule, start_date, end_date)
|
||||
for _, d in ipairs(dates) do
|
||||
-- Create instance copy
|
||||
local instance = {}
|
||||
for k, v in pairs(ev) do instance[k] = v end
|
||||
instance.id = ev.id .. "_" .. d:gsub("-", "")
|
||||
instance.start = d
|
||||
instance["end"] = d
|
||||
instance.is_recurring = true
|
||||
instance.base_id = ev.id
|
||||
table.insert(expanded, instance)
|
||||
end
|
||||
end
|
||||
return expanded
|
||||
end
|
||||
|
||||
-- Get all events (flat + expanded) for a range — used by the panel
|
||||
function M.get_calendar_events(params)
|
||||
-- 1. Normal events
|
||||
local normal = M.get_events(params)
|
||||
-- 2. Expanded recurring
|
||||
local recur = M.get_expanded_events(params)
|
||||
-- Merge
|
||||
local all = {}
|
||||
for _, e in ipairs(normal) do table.insert(all, e) end
|
||||
for _, e in ipairs(recur) do table.insert(all, e) end
|
||||
return all
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Node integration (verstak.node.* + verstak.worklog.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Create an event linked to a Verstak node
|
||||
function M.create_event_from_node(node_id, date_str, fields)
|
||||
if not node_id then error("node_id required") end
|
||||
local node = verstak.node.get(node_id)
|
||||
if not node then error("node not found: " .. node_id) end
|
||||
|
||||
fields = fields or {}
|
||||
if not fields.title then fields.title = "📎 " .. (node.title or "Без названия") end
|
||||
if not fields.start then fields.start = date_str or today() .. "T09:00:00" end
|
||||
fields.node_id = node_id
|
||||
fields.link_type = "node"
|
||||
|
||||
local new_id = M.create_event(fields)
|
||||
safe_log(verstak.activity.log,"event_from_node",
|
||||
"Событие из узла: " .. fields.title, new_id, node_id)
|
||||
return new_id
|
||||
end
|
||||
|
||||
-- Open linked node from event (called when user clicks on event with node_id)
|
||||
function M.open_event_node(event_id)
|
||||
local ev = M.get_event(event_id)
|
||||
if not ev or not ev.node_id or ev.node_id == "" then
|
||||
return nil, "no linked node"
|
||||
end
|
||||
|
||||
local node = verstak.node.get(ev.node_id)
|
||||
if not node then return nil, "node not found" end
|
||||
|
||||
-- Navigate in Verstak
|
||||
pcall(verstak.ui.navigate_to, "node:" .. ev.node_id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Log work for an event and link to node
|
||||
function M.log_work_for_event(event_id, minutes)
|
||||
local ev = M.get_event(event_id)
|
||||
if not ev then error("event not found") end
|
||||
if not ev.node_id or ev.node_id == "" then
|
||||
error("event has no linked node — create link first")
|
||||
end
|
||||
|
||||
local ok, result = pcall(verstak.worklog.add, ev.node_id, ev.title, minutes)
|
||||
if not ok then error("worklog.add failed: " .. tostring(result)) end
|
||||
|
||||
safe_log(verstak.activity.log,"worklog_from_event",
|
||||
"Worklog: " .. ev.title .. " (" .. minutes .. "м)", event_id, ev.node_id)
|
||||
|
||||
if ev.all_day == 1 then
|
||||
-- Mark as completed if all-day
|
||||
M.update_event(event_id, { completed = true })
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Reminders (verstak.schedule.* + verstak.ui.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local function parse_time(s)
|
||||
if not s then return nil end
|
||||
local y, m, d, h, min, sec = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)")
|
||||
if not y then
|
||||
y, m, d, h, min = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)")
|
||||
end
|
||||
if not y then
|
||||
y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
|
||||
if y then return { year = tonumber(y), month = tonumber(m), day = tonumber(d) } end
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
year = tonumber(y), month = tonumber(m), day = tonumber(d),
|
||||
hour = tonumber(h or 0), min = tonumber(min or 0), sec = tonumber(sec or 0)
|
||||
}
|
||||
end
|
||||
|
||||
local function iso_to_epoch(s)
|
||||
local t = parse_time(s)
|
||||
if not t then return nil end
|
||||
return os.time({
|
||||
year = t.year, month = t.month, day = t.day,
|
||||
hour = t.hour or 0, min = t.min or 0, sec = t.sec or 0
|
||||
})
|
||||
end
|
||||
|
||||
function M.check_reminders()
|
||||
local upcoming = verstak.db.query(
|
||||
[[SELECT id, title, start, reminder_minutes, node_id
|
||||
FROM events WHERE reminder_minutes != '[]' AND reminder_minutes != ''
|
||||
AND completed = 0 AND datetime(start) > datetime('now')]]
|
||||
)
|
||||
|
||||
local now_epoch = os.time()
|
||||
local reminded = {}
|
||||
|
||||
for _, ev in ipairs(upcoming) do
|
||||
local mins = {}
|
||||
-- Parse reminder_minutes JSON array like [10, 60]
|
||||
for m in string.gmatch(ev.reminder_minutes or "", "(-?%d+)") do
|
||||
table.insert(mins, tonumber(m))
|
||||
end
|
||||
|
||||
local ev_epoch = iso_to_epoch(ev.start)
|
||||
if ev_epoch then
|
||||
local key = "reminded:" .. ev.id
|
||||
local already = verstak.state.get(key) or {}
|
||||
|
||||
for _, min_before in ipairs(mins) do
|
||||
local notify_at = ev_epoch - min_before * 60
|
||||
local diff = notify_at - now_epoch
|
||||
|
||||
if diff >= -30 and diff <= 60 and not already[tostring(min_before)] then
|
||||
-- Fire reminder
|
||||
local msg = "🔔 " .. ev.title
|
||||
if min_before > 0 then
|
||||
msg = msg .. " (через " .. min_before .. " мин)"
|
||||
end
|
||||
pcall(verstak.ui.toast, msg, "reminder")
|
||||
print("Calendar reminder: " .. msg)
|
||||
already[tostring(min_before)] = true
|
||||
table.insert(reminded, ev.id)
|
||||
end
|
||||
end
|
||||
|
||||
verstak.state.set(key, already)
|
||||
end
|
||||
end
|
||||
|
||||
return #reminded
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Holidays via HTTP (verstak.http.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
function M.fetch_holidays(year)
|
||||
if not year then year = tonumber(os.date("%Y")) end
|
||||
|
||||
local ok_http, resp = pcall(verstak.http.get, "https://date.nager.at/api/v3/PublicHolidays/" .. year .. "/RU")
|
||||
if not ok_http then
|
||||
print("Calendar: HTTP call failed: " .. tostring(resp))
|
||||
return {}
|
||||
end
|
||||
if resp.status ~= 200 then
|
||||
print("Calendar: HTTP status " .. tostring(resp.status))
|
||||
return {}
|
||||
end
|
||||
|
||||
local body = resp.body or "[]"
|
||||
|
||||
-- Cache in DB
|
||||
verstak.db.exec("DELETE FROM events WHERE source_series = 'holiday_" .. year .. "'")
|
||||
for _, h in ipairs(body) do
|
||||
local date_str = h.date or (year .. "-01-01")
|
||||
local title = (h.localName or "Праздник") .. " 🎉"
|
||||
|
||||
local cat_id
|
||||
local cat = verstak.db.query_row("SELECT id FROM categories WHERE name = 'Личное' AND deleted = 0")
|
||||
if cat then cat_id = cat.id end
|
||||
|
||||
M.create_event{
|
||||
title = title,
|
||||
start = date_str .. "T00:00:00",
|
||||
all_day = true,
|
||||
category_id = cat_id,
|
||||
color = "#f59e0b",
|
||||
node_id = "",
|
||||
source_series = "holiday_" .. year,
|
||||
}
|
||||
end
|
||||
|
||||
print("Calendar: imported " .. #body .. " holidays for " .. year)
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Hooks
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
function on_install()
|
||||
print("Calendar: on_install — creating tables")
|
||||
|
||||
local ok, err = pcall(function()
|
||||
verstak.db.exec([[
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
icon TEXT NOT NULL DEFAULT '📌',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
]])
|
||||
verstak.db.exec([[
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
node_id TEXT,
|
||||
link_type TEXT DEFAULT 'node',
|
||||
recurring_rule TEXT,
|
||||
reminder_minutes TEXT DEFAULT '[]',
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at TEXT,
|
||||
source_series TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
]])
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_start ON events(start)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_end ON events(end)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted)")
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
print("Calendar: migration error: " .. tostring(err))
|
||||
error(err)
|
||||
else
|
||||
print("Calendar: migration complete")
|
||||
end
|
||||
|
||||
-- Insert default categories
|
||||
M.ensure_categories()
|
||||
|
||||
print("Calendar: install complete")
|
||||
end
|
||||
|
||||
function on_uninstall()
|
||||
print("Calendar: on_uninstall — dropping tables")
|
||||
|
||||
local ok, err = pcall(function()
|
||||
verstak.db.exec("DROP TABLE IF EXISTS events")
|
||||
verstak.db.exec("DROP TABLE IF EXISTS categories")
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
print("Calendar: uninstall error: " .. tostring(err))
|
||||
else
|
||||
print("Calendar: tables dropped")
|
||||
end
|
||||
|
||||
-- Clean up config
|
||||
pcall(verstak.config.set, "categories", nil)
|
||||
|
||||
print("Calendar: uninstall complete")
|
||||
end
|
||||
|
||||
function on_init()
|
||||
print("Calendar: on_init — registering API")
|
||||
|
||||
-- Register global API for panel access
|
||||
_G.calendar = M
|
||||
|
||||
-- Set initial state (current month)
|
||||
verstak.state.set("calendar_month", os.date("%Y-%m"))
|
||||
verstak.state.set("calendar_view", "month")
|
||||
|
||||
print("Calendar: init complete — " .. #M.get_categories() .. " categories, API ready")
|
||||
end
|
||||
|
||||
function on_shutdown()
|
||||
print("Calendar: shutdown")
|
||||
end
|
||||
|
||||
print("Calendar: module loaded")
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
time TEXT,
|
||||
location TEXT,
|
||||
node_id TEXT,
|
||||
color TEXT DEFAULT 'blue',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
icon TEXT NOT NULL DEFAULT '📌',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
node_id TEXT,
|
||||
link_type TEXT DEFAULT 'node',
|
||||
recurring_rule TEXT,
|
||||
reminder_minutes TEXT DEFAULT '[]',
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at TEXT,
|
||||
source_series TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_end ON events(end);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted);
|
||||
|
|
@ -0,0 +1,996 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calendar</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--bg2: #22223a;
|
||||
--bg3: #2a2a44;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--text-bright: #fff;
|
||||
--border: #333;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.header-title { font-size: 1.1rem; font-weight: 600; color: var(--text-bright); }
|
||||
.nav-btn {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--accent); color: #fff; }
|
||||
.nav-btn.today-btn { font-weight: 600; }
|
||||
.view-tabs { display: flex; gap: 2px; }
|
||||
.view-tab {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.view-tab:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
|
||||
.view-tab:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
|
||||
.view-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.view-tab:hover:not(.active) { background: var(--bg3); color: var(--text); }
|
||||
|
||||
/* Month grid */
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
margin: 0;
|
||||
}
|
||||
.day-header {
|
||||
background: var(--bg3);
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.day-cell {
|
||||
background: var(--bg2);
|
||||
min-height: 90px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
.day-cell:hover { background: var(--bg3); }
|
||||
.day-cell.other-month { opacity: 0.35; }
|
||||
.day-cell.today { background: rgba(99, 102, 241, 0.12); }
|
||||
.day-cell.selected { background: rgba(99, 102, 241, 0.2); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.day-cell.drop-target { background: rgba(34, 197, 94, 0.15); box-shadow: inset 0 0 0 2px var(--success); }
|
||||
.day-number {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text);
|
||||
}
|
||||
.day-cell.today .day-number {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Events in month grid */
|
||||
.month-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.month-event {
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s;
|
||||
color: #fff;
|
||||
}
|
||||
.month-event:hover { opacity: 0.85; }
|
||||
.month-event.more-link {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Week view */
|
||||
.week-view {
|
||||
display: grid;
|
||||
grid-template-columns: 60px repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.hour-label {
|
||||
background: var(--bg3);
|
||||
padding: 4px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hour-slot {
|
||||
background: var(--bg2);
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.hour-slot:hover { background: var(--bg3); }
|
||||
.week-event {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
padding: 2px 4px;
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Day view */
|
||||
.day-view {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.day-events-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.day-event-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.day-event-card:hover { background: var(--bg3); }
|
||||
.day-event-time { font-size: 12px; color: var(--text-dim); }
|
||||
.day-event-title { font-weight: 600; margin-top: 2px; }
|
||||
.day-event-desc { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
|
||||
.day-event-category { font-size: 11px; margin-top: 4px; display: inline-block; padding: 1px 6px; border-radius: 3px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal h3 { margin-bottom: 16px; color: var(--text-bright); }
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-group textarea { resize: vertical; min-height: 60px; }
|
||||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
}
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
||||
.btn {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-secondary:hover { background: var(--bg3); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { opacity: 0.85; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; }
|
||||
.checkbox-row input[type="checkbox"] { width: auto; }
|
||||
|
||||
/* Categories legend */
|
||||
.categories-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.cat-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
color: #fff;
|
||||
}
|
||||
.cat-tag:hover { opacity: 0.8; }
|
||||
.cat-tag.active { box-shadow: 0 0 0 2px var(--text-bright); }
|
||||
.cat-filter-all {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-bright);
|
||||
z-index: 2000;
|
||||
animation: slideIn 0.25s ease;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-day { color: var(--text-dim); padding: 20px; text-align: center; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.header { flex-direction: column; align-items: stretch; }
|
||||
.view-tabs { justify-content: center; }
|
||||
.day-cell { min-height: 60px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="loading-screen">⏳ Загрузка календаря...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────
|
||||
const state = {
|
||||
view: 'month', // month | week | day
|
||||
currentDate: new Date(), // the "focus" date (what month/week/day we're looking at)
|
||||
selectedDate: null, // clicked day (for modal)
|
||||
events: [],
|
||||
categories: [],
|
||||
filterCatId: null, // null = all
|
||||
eventsLoaded: false,
|
||||
dropDate: null, // date string being dragged onto
|
||||
panelReady: false,
|
||||
};
|
||||
|
||||
// Cache DOM refs
|
||||
let appEl;
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()); }
|
||||
function fmtISO(d) { return fmtDate(d) + 'T00:00:00'; }
|
||||
function fmtMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1); }
|
||||
function dayOfWeek(d) { const w = d.getDay(); return w === 0 ? 7 : w; } // Mon=1..Sun=7
|
||||
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
|
||||
function parseDate(s) { const d = new Date(s); if (isNaN(d.getTime())) return null; return d; }
|
||||
function shortTime(s) {
|
||||
if (!s) return '';
|
||||
const d = parseDate(s);
|
||||
if (!d) return s;
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
function shortDate(s) {
|
||||
if (!s) return '';
|
||||
const d = parseDate(s);
|
||||
if (!d) return s;
|
||||
return pad(d.getDate()) + '.' + pad(d.getMonth()+1);
|
||||
}
|
||||
|
||||
function fromNow(d) {
|
||||
const now = new Date();
|
||||
const diff = d.getTime() - now.getTime();
|
||||
if (diff < 0) return 'прошло';
|
||||
const mins = Math.round(diff / 60000);
|
||||
if (mins < 60) return 'через ' + mins + ' мин';
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return 'через ' + hours + ' ч';
|
||||
const days = Math.round(hours / 24);
|
||||
return 'через ' + days + ' дн';
|
||||
}
|
||||
|
||||
// ─── Communication ─────────────────────────────────────────────────
|
||||
function sendToParent(action, data) {
|
||||
const msg = { source: 'calendar-plugin', action: action, data: data || {} };
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(msg, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Request events from parent
|
||||
function requestEvents() {
|
||||
const start = getViewStart();
|
||||
const end = getViewEnd();
|
||||
sendToParent('get-events', { start: fmtISO(start), end: fmtISO(end) });
|
||||
}
|
||||
|
||||
function getViewStart() {
|
||||
const d = state.currentDate;
|
||||
if (state.view === 'month') {
|
||||
const first = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
// Go back to Monday
|
||||
const wd = dayOfWeek(first);
|
||||
first.setDate(first.getDate() - (wd - 1));
|
||||
return first;
|
||||
}
|
||||
if (state.view === 'week') {
|
||||
const wd = dayOfWeek(d);
|
||||
const monday = new Date(d);
|
||||
monday.setDate(monday.getDate() - (wd - 1));
|
||||
return monday;
|
||||
}
|
||||
return new Date(d);
|
||||
}
|
||||
|
||||
function getViewEnd() {
|
||||
const start = getViewStart();
|
||||
const end = new Date(start);
|
||||
if (state.view === 'month') end.setDate(end.getDate() + 42); // 6 weeks
|
||||
else if (state.view === 'week') end.setDate(end.getDate() + 7);
|
||||
else end.setDate(end.getDate() + 1);
|
||||
return end;
|
||||
}
|
||||
|
||||
// ─── Listen for parent messages ──────────────────────────────────────
|
||||
window.addEventListener('message', function(e) {
|
||||
const msg = e.data;
|
||||
if (!msg || msg.source !== 'verstak') return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'calendar-data':
|
||||
try { window.parent.go.main.App.WriteDebugLog('[iframe] received calendar-data, events=' + (msg.events ? msg.events.length : 0) + ', categories=' + (msg.categories ? msg.categories.length : 0)); } catch(e) { try { console.log('iframe debug error: ' + e); } catch(e2) {} }
|
||||
if (msg.events) state.events = msg.events;
|
||||
if (msg.categories) state.categories = msg.categories;
|
||||
state.eventsLoaded = true;
|
||||
state.panelReady = true;
|
||||
try { render(); } catch(e) { try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e) + ' ' + JSON.stringify({message: e.message, stack: e.stack?.substring(0,200)})); } catch(e2) {} }
|
||||
break;
|
||||
|
||||
case 'drop':
|
||||
// Dragged from another section
|
||||
if (msg.date) {
|
||||
state.dropDate = msg.date;
|
||||
openCreateModal(msg.date, {
|
||||
node_id: msg.data?.node_id || '',
|
||||
link_type: msg.data?.link_type || 'node',
|
||||
title: msg.data?.title || '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'event-created':
|
||||
case 'event-updated':
|
||||
case 'event-deleted':
|
||||
requestEvents();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Tell parent we're ready ─────────────────────────────────────────
|
||||
function init() {
|
||||
appEl = document.getElementById('app');
|
||||
sendToParent('ready', { version: '1.0' });
|
||||
// Request initial data
|
||||
setTimeout(requestEvents, 100);
|
||||
// Render immediately with empty state
|
||||
render();
|
||||
}
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
function render() {
|
||||
if (!appEl) {
|
||||
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: appEl is null!'); } catch(e) {}
|
||||
return;
|
||||
}
|
||||
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: eventsLoaded=' + state.eventsLoaded + ', events=' + (state.events ? state.events.length : 'no-state') + ', cats=' + (state.categories ? state.categories.length : 'no-state')); } catch(e) {}
|
||||
if (!state.eventsLoaded) {
|
||||
appEl.innerHTML = '<div class="loading-screen">⏳ Загрузка календаря...</div>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
appEl.innerHTML = renderHeader() + renderCategories() + renderView();
|
||||
try { window.parent.go.main.App.WriteDebugLog('[iframe] render OK, view=' + state.view + ', events=' + state.events.length + ', cats=' + state.categories.length); } catch(e) {}
|
||||
} catch(e) {
|
||||
appEl.innerHTML = '<div class="loading-screen">⚠ Ошибка: ' + escapeHtml(String(e)) + '</div>';
|
||||
try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e)); } catch(e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const d = state.currentDate;
|
||||
const monthNames = ['Январь','Февраль','Март','Апрель','Май','Июнь',
|
||||
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
||||
let title = '';
|
||||
if (state.view === 'month') title = monthNames[d.getMonth()] + ' ' + d.getFullYear();
|
||||
else if (state.view === 'week') title = 'Неделя ' + fmtDate(getViewStart());
|
||||
else title = fmtDate(d);
|
||||
|
||||
return `<div class="header">
|
||||
<div class="header-left">
|
||||
<button class="nav-btn" onclick="calendar.prevPeriod()">◀</button>
|
||||
<button class="nav-btn today-btn" onclick="calendar.goToday()">Сегодня</button>
|
||||
<button class="nav-btn" onclick="calendar.nextPeriod()">▶</button>
|
||||
<span class="header-title">${title}</span>
|
||||
</div>
|
||||
<div class="view-tabs">
|
||||
<button class="view-tab${state.view==='month'?' active':''}" onclick="calendar.setView('month')">Месяц</button>
|
||||
<button class="view-tab${state.view==='week'?' active':''}" onclick="calendar.setView('week')">Неделя</button>
|
||||
<button class="view-tab${state.view==='day'?' active':''}" onclick="calendar.setView('day')">День</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCategories() {
|
||||
const cats = state.categories;
|
||||
if (!cats || cats.length === 0) return '';
|
||||
let html = '<div class="categories-bar">';
|
||||
html += `<span class="cat-filter-all" onclick="calendar.setFilter(null)">${state.filterCatId === null ? '●' : '○'} Все</span>`;
|
||||
for (const c of cats) {
|
||||
const active = c.id === state.filterCatId;
|
||||
html += `<span class="cat-tag${active ? ' active' : ''}" style="background:${c.color}" onclick="calendar.setFilter('${c.id}')">${c.icon || ''} ${c.name}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function getEventsForDate(dateStr) {
|
||||
const filtered = state.filterCatId
|
||||
? state.events.filter(e => e.category_id === state.filterCatId)
|
||||
: state.events;
|
||||
return filtered.filter(e => {
|
||||
const eStart = (e.start || '').substring(0, 10);
|
||||
const eEnd = (e.end || e.start || '').substring(0, 10);
|
||||
return dateStr >= eStart && dateStr <= eEnd;
|
||||
});
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
if (state.view === 'month') return renderMonth();
|
||||
if (state.view === 'week') return renderWeek();
|
||||
return renderDay();
|
||||
}
|
||||
|
||||
function renderMonth() {
|
||||
const d = state.currentDate;
|
||||
const y = d.getFullYear(), m = d.getMonth();
|
||||
const firstDay = new Date(y, m, 1);
|
||||
const startDay = dayOfWeek(firstDay); // Mon=1..Sun=7
|
||||
const totalDays = daysInMonth(y, m);
|
||||
const todayStr = fmtDate(new Date());
|
||||
const selectedStr = state.selectedDate ? fmtDate(state.selectedDate) : null;
|
||||
|
||||
// Previous month padding
|
||||
const prevMonthDays = daysInMonth(y, m - 1);
|
||||
let cells = [];
|
||||
const startOffset = startDay - 1; // how many prev-month cells
|
||||
|
||||
// Day headers
|
||||
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
let html = '<div class="month-grid">';
|
||||
for (const n of dayNames) {
|
||||
html += `<div class="day-header">${n}</div>`;
|
||||
}
|
||||
|
||||
// Fill cells
|
||||
const totalCells = Math.ceil((startOffset + totalDays) / 7) * 7;
|
||||
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
let dayNum, cellDate, isOther = false;
|
||||
if (i < startOffset) {
|
||||
dayNum = prevMonthDays - startOffset + i + 1;
|
||||
cellDate = new Date(y, m - 1, dayNum);
|
||||
isOther = true;
|
||||
} else if (i >= startOffset + totalDays) {
|
||||
dayNum = i - startOffset - totalDays + 1;
|
||||
cellDate = new Date(y, m + 1, dayNum);
|
||||
isOther = true;
|
||||
} else {
|
||||
dayNum = i - startOffset + 1;
|
||||
cellDate = new Date(y, m, dayNum);
|
||||
}
|
||||
|
||||
const dateStr = fmtDate(cellDate);
|
||||
const isToday = dateStr === todayStr;
|
||||
const isSelected = selectedStr === dateStr;
|
||||
const isDrop = state.dropDate === dateStr;
|
||||
const events = getEventsForDate(dateStr);
|
||||
const isWeekend = dayOfWeek(cellDate) >= 6;
|
||||
|
||||
let cls = 'day-cell';
|
||||
if (isOther) cls += ' other-month';
|
||||
if (isToday) cls += ' today';
|
||||
if (isSelected) cls += ' selected';
|
||||
if (isDrop) cls += ' drop-target';
|
||||
|
||||
html += `<div class="${cls}" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
|
||||
html += `<div class="day-number">${dayNum}</div>`;
|
||||
if (events.length > 0) {
|
||||
html += '<div class="month-events">';
|
||||
const maxShow = 3;
|
||||
for (let j = 0; j < Math.min(events.length, maxShow); j++) {
|
||||
const ev = events[j];
|
||||
const color = ev.color || '#6366f1';
|
||||
html += `<div class="month-event" style="background:${color}" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${ev.title}</div>`;
|
||||
}
|
||||
if (events.length > maxShow) {
|
||||
html += `<div class="month-event more-link" onclick="event.stopPropagation(); calendar.setView('day'); state.currentDate = new Date('${dateStr}'); render();">+${events.length - maxShow} ещё</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderWeek() {
|
||||
const start = getViewStart();
|
||||
const todayStr = fmtDate(new Date());
|
||||
let html = '<div class="week-view">';
|
||||
|
||||
// Corner label
|
||||
html += '<div class="hour-label"></div>';
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(day.getDate() + d);
|
||||
const dateStr = fmtDate(day);
|
||||
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
html += `<div class="day-header" style="${dayOfWeek(day) >= 6 ? 'color:var(--danger)' : ''}">${dayNames[d]} ${day.getDate()}</div>`;
|
||||
}
|
||||
|
||||
// Hours
|
||||
for (let h = 0; h < 24; h++) {
|
||||
html += `<div class="hour-label">${pad(h)}:00</div>`;
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(day.getDate() + d);
|
||||
const dateStr = fmtDate(day);
|
||||
const eventsOnHour = getEventsForDate(dateStr).filter(ev => {
|
||||
const evH = parseInt((ev.start || '').substring(11, 13));
|
||||
return evH === h;
|
||||
});
|
||||
let cellHtml = `<div class="hour-slot" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
|
||||
for (const ev of eventsOnHour) {
|
||||
const color = ev.color || '#6366f1';
|
||||
cellHtml += `<div class="week-event" style="background:${color}; top:2px" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${shortTime(ev.start)} ${ev.title}</div>`;
|
||||
}
|
||||
cellHtml += '</div>';
|
||||
html += cellHtml;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDay() {
|
||||
const d = state.currentDate;
|
||||
const dateStr = fmtDate(d);
|
||||
const events = getEventsForDate(dateStr);
|
||||
const todayStr = fmtDate(new Date());
|
||||
const isToday = dateStr === todayStr;
|
||||
|
||||
let html = `<div class="day-view">`;
|
||||
html += `<h3 style="margin-bottom:12px">${fmtDate(d)}${isToday ? ' — Сегодня' : ''}</h3>`;
|
||||
html += `<button class="btn btn-primary btn-sm" onclick="calendar.selectDay('${dateStr}')" style="margin-bottom:12px">+ Добавить событие</button>`;
|
||||
|
||||
if (events.length === 0) {
|
||||
html += '<div class="empty-day">Нет событий на этот день</div>';
|
||||
} else {
|
||||
html += '<div class="day-events-list">';
|
||||
for (const ev of events) {
|
||||
const color = ev.category_color || ev.color || '#6366f1';
|
||||
const catName = ev.category_name || '';
|
||||
html += `<div class="day-event-card" style="border-left-color:${color}" onclick="calendar.openEvent('${ev.id}')">`;
|
||||
if (ev.all_day == 1) {
|
||||
html += `<div class="day-event-time">Весь день</div>`;
|
||||
} else {
|
||||
html += `<div class="day-event-time">${shortTime(ev.start)} — ${shortTime(ev.end)}</div>`;
|
||||
}
|
||||
html += `<div class="day-event-title">${ev.title}</div>`;
|
||||
if (ev.description) {
|
||||
html += `<div class="day-event-desc">${ev.description}</div>`;
|
||||
}
|
||||
if (catName) {
|
||||
html += `<span class="day-event-category" style="background:${color}20; color:${color}">${catName}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ─── Event Modal ─────────────────────────────────────────────────────
|
||||
function openCreateModal(dateStr, prefill) {
|
||||
prefill = prefill || {};
|
||||
const cats = state.categories;
|
||||
const todayStr = fmtDate(new Date());
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
|
||||
|
||||
let catOptions = '<option value="">Без категории</option>';
|
||||
for (const c of cats) {
|
||||
catOptions += `<option value="${c.id}" style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
|
||||
}
|
||||
|
||||
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
|
||||
<h3>${prefill.node_id ? '📎 Событие из узла' : '📅 Новое событие'}</h3>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input id="ev-title" value="${escapeHtml(prefill.title || '')}" placeholder="Введите название события">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Дата начала</label>
|
||||
<input id="ev-start" type="date" value="${dateStr || todayStr}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Время</label>
|
||||
<input id="ev-start-time" type="time" value="09:00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Дата окончания</label>
|
||||
<input id="ev-end" type="date" value="${dateStr || todayStr}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Время</label>
|
||||
<input id="ev-end-time" type="time" value="10:00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input id="ev-allday" type="checkbox">
|
||||
<label for="ev-allday">Весь день</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea id="ev-desc" placeholder="Описание события (необязательно)"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Категория</label>
|
||||
<select id="ev-category">${catOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
${prefill.node_id ? `<input type="hidden" id="ev-node-id" value="${escapeHtml(prefill.node_id)}">
|
||||
<input type="hidden" id="ev-link-type" value="${escapeHtml(prefill.link_type || 'node')}">` : ''}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="submitCreate()">Создать</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Toggle time fields when "all day" is checked
|
||||
document.getElementById('ev-allday').addEventListener('change', function() {
|
||||
document.getElementById('ev-start-time').disabled = this.checked;
|
||||
document.getElementById('ev-end-time').disabled = this.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// Expose to global for onclick handlers
|
||||
window.closeModal = function() {
|
||||
const modals = document.querySelectorAll('.modal-overlay');
|
||||
modals.forEach(m => m.remove());
|
||||
};
|
||||
|
||||
window.submitCreate = function() {
|
||||
const title = document.getElementById('ev-title').value.trim();
|
||||
if (!title) { showToast('Название обязательно'); return; }
|
||||
|
||||
const startDate = document.getElementById('ev-start').value;
|
||||
const endDate = document.getElementById('ev-end').value;
|
||||
const allDay = document.getElementById('ev-allday').checked;
|
||||
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
|
||||
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
|
||||
const description = document.getElementById('ev-desc').value.trim();
|
||||
const categoryId = document.getElementById('ev-category').value;
|
||||
const nodeId = document.getElementById('ev-node-id')?.value || '';
|
||||
const linkType = document.getElementById('ev-link-type')?.value || 'node';
|
||||
|
||||
sendToParent('create-event', {
|
||||
title: title,
|
||||
start: startDate + 'T' + startTime + ':00',
|
||||
end: endDate + 'T' + endTime + ':00',
|
||||
all_day: allDay,
|
||||
description: description,
|
||||
category_id: categoryId,
|
||||
node_id: nodeId,
|
||||
link_type: linkType,
|
||||
});
|
||||
|
||||
closeModal();
|
||||
showToast('✅ Событие создаётся...');
|
||||
};
|
||||
|
||||
function openEditModal(eventId) {
|
||||
const ev = state.events.find(e => e.id === eventId);
|
||||
if (!ev) { showToast('Событие не найдено'); return; }
|
||||
|
||||
const cats = state.categories;
|
||||
const startDate = (ev.start || '').substring(0, 10);
|
||||
const startTime = (ev.start || '').substring(11, 16);
|
||||
const endDate = (ev.end || ev.start || '').substring(0, 10);
|
||||
const endTime = (ev.end || ev.start || '').substring(11, 16);
|
||||
const allDay = ev.all_day == 1;
|
||||
const completed = ev.completed == 1;
|
||||
|
||||
let catOptions = '<option value="">Без категории</option>';
|
||||
for (const c of cats) {
|
||||
catOptions += `<option value="${c.id}" ${c.id === ev.category_id ? 'selected' : ''} style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
|
||||
|
||||
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
|
||||
<h3>✏️ Редактировать событие</h3>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input id="ev-title" value="${escapeHtml(ev.title)}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Дата начала</label>
|
||||
<input id="ev-start" type="date" value="${startDate}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Время</label>
|
||||
<input id="ev-start-time" type="time" value="${startTime}" ${allDay ? 'disabled' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Дата окончания</label>
|
||||
<input id="ev-end" type="date" value="${endDate}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Время</label>
|
||||
<input id="ev-end-time" type="time" value="${endTime}" ${allDay ? 'disabled' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input id="ev-allday" type="checkbox" ${allDay ? 'checked' : ''}>
|
||||
<label for="ev-allday">Весь день</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea id="ev-desc">${escapeHtml(ev.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Категория</label>
|
||||
<select id="ev-category">${catOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input id="ev-completed" type="checkbox" ${completed ? 'checked' : ''}>
|
||||
<label for="ev-completed">Выполнено</label>
|
||||
</div>
|
||||
${ev.node_id ? `<div style="margin-top:8px;font-size:12px;color:var(--text-dim)">📎 Связано с узлом: ${escapeHtml(ev.node_id)}</div>` : ''}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-danger" onclick="submitDelete('${ev.id}')">Удалить</button>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="submitUpdate('${ev.id}')">Сохранить</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('ev-allday').addEventListener('change', function() {
|
||||
document.getElementById('ev-start-time').disabled = this.checked;
|
||||
document.getElementById('ev-end-time').disabled = this.checked;
|
||||
});
|
||||
}
|
||||
|
||||
window.submitUpdate = function(id) {
|
||||
const title = document.getElementById('ev-title').value.trim();
|
||||
if (!title) { showToast('Название обязательно'); return; }
|
||||
|
||||
const allDay = document.getElementById('ev-allday').checked;
|
||||
const startDate = document.getElementById('ev-start').value;
|
||||
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
|
||||
const endDate = document.getElementById('ev-end').value;
|
||||
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
|
||||
const completed = document.getElementById('ev-completed')?.checked || false;
|
||||
|
||||
sendToParent('update-event', {
|
||||
id: id,
|
||||
title: title,
|
||||
start: startDate + 'T' + startTime + ':00',
|
||||
end: endDate + 'T' + endTime + ':00',
|
||||
all_day: allDay,
|
||||
description: document.getElementById('ev-desc').value.trim(),
|
||||
category_id: document.getElementById('ev-category').value,
|
||||
completed: completed,
|
||||
});
|
||||
|
||||
closeModal();
|
||||
showToast('✅ Сохраняю...');
|
||||
};
|
||||
|
||||
window.submitDelete = function(id) {
|
||||
if (!confirm('Удалить это событие?')) return;
|
||||
sendToParent('delete-event', { id: id });
|
||||
closeModal();
|
||||
showToast('🗑️ Удаляю...');
|
||||
};
|
||||
|
||||
// ─── Navigation functions ───────────────────────────────────────────
|
||||
window.calendar = {
|
||||
prevPeriod: function() {
|
||||
const d = state.currentDate;
|
||||
if (state.view === 'month') d.setMonth(d.getMonth() - 1);
|
||||
else if (state.view === 'week') d.setDate(d.getDate() - 7);
|
||||
else d.setDate(d.getDate() - 1);
|
||||
state.currentDate = d;
|
||||
requestEvents();
|
||||
render();
|
||||
},
|
||||
|
||||
nextPeriod: function() {
|
||||
const d = state.currentDate;
|
||||
if (state.view === 'month') d.setMonth(d.getMonth() + 1);
|
||||
else if (state.view === 'week') d.setDate(d.getDate() + 7);
|
||||
else d.setDate(d.getDate() + 1);
|
||||
state.currentDate = d;
|
||||
requestEvents();
|
||||
render();
|
||||
},
|
||||
|
||||
goToday: function() {
|
||||
state.currentDate = new Date();
|
||||
requestEvents();
|
||||
render();
|
||||
},
|
||||
|
||||
setView: function(view) {
|
||||
state.view = view;
|
||||
requestEvents();
|
||||
render();
|
||||
},
|
||||
|
||||
setFilter: function(catId) {
|
||||
state.filterCatId = catId;
|
||||
render();
|
||||
},
|
||||
|
||||
selectDay: function(dateStr) {
|
||||
state.selectedDate = parseDate(dateStr);
|
||||
openCreateModal(dateStr, {});
|
||||
},
|
||||
|
||||
openEvent: function(eventId) {
|
||||
openEditModal(eventId);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Toast ──────────────────────────────────────────────────────────
|
||||
function showToast(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast';
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => { el.remove(); }, 3000);
|
||||
}
|
||||
|
||||
// ─── Escape HTML ────────────────────────────────────────────────────
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────────────────────────────────
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "calendar",
|
||||
"version": "1.0.0",
|
||||
"author": "Verstak contributors",
|
||||
"description": "Календарь событий с категориями, рекарренсом, напоминаниями, связью с деревом Верстака",
|
||||
"license": "MIT",
|
||||
|
||||
"hooks": {
|
||||
"on_init": "on_init",
|
||||
"on_shutdown": "on_shutdown",
|
||||
"on_install": "on_install",
|
||||
"on_uninstall": "on_uninstall"
|
||||
},
|
||||
|
||||
"ui": {
|
||||
"sidebar_items": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"icon": "calendar",
|
||||
"page": "plugin:calendar:main"
|
||||
}
|
||||
],
|
||||
"create_dialog_categories": ["event"]
|
||||
},
|
||||
|
||||
"background_tasks": [
|
||||
{
|
||||
"id": "check_reminders",
|
||||
"interval": "1m",
|
||||
"script": "scripts/check_reminders.lua"
|
||||
}
|
||||
],
|
||||
|
||||
"panel": "panels/calendar.html",
|
||||
|
||||
"migrations": [
|
||||
"migrations/001_create_tables.sql"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Background task: check for upcoming reminders
|
||||
-- Runs every 1 minute (defined in plugin.json)
|
||||
-- Calls calendar.check_reminders() from main.lua
|
||||
|
||||
local n = calendar.check_reminders()
|
||||
if n > 0 then
|
||||
print("Calendar: " .. n .. " reminders fired")
|
||||
end
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
-- Calendar plugin test
|
||||
-- Exercises: verstak.db.* / verstak.config.* / verstak.state.* / verstak.activity.*
|
||||
-- Runs as part of the LuaVM test suite (no node/worklog/http — those are service-dependent)
|
||||
|
||||
local function assert(cond, msg)
|
||||
if not cond then
|
||||
error("ASSERT FAIL: " .. tostring(msg), 2)
|
||||
end
|
||||
print(" ✓ " .. tostring(msg))
|
||||
end
|
||||
|
||||
local test_count = 0
|
||||
local function test(name, fn)
|
||||
test_count = test_count + 1
|
||||
print("\n[" .. test_count .. "] " .. name)
|
||||
local ok, err = pcall(fn)
|
||||
if not ok then
|
||||
print(" ✗ FAIL: " .. tostring(err))
|
||||
error("Test " .. test_count .. " failed: " .. tostring(err), 2)
|
||||
end
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Test suite
|
||||
----------------------------------------------------------------------
|
||||
|
||||
test("calendar module exists", function()
|
||||
assert(calendar ~= nil, "calendar global exists")
|
||||
assert(type(calendar.get_categories) == "function", "calendar.get_categories exists")
|
||||
assert(type(calendar.create_event) == "function", "calendar.create_event exists")
|
||||
end)
|
||||
|
||||
test("categories have defaults", function()
|
||||
local cats = calendar.get_categories()
|
||||
assert(#cats >= 6, "at least 6 default categories, got " .. #cats)
|
||||
print(" Categories: " .. #cats)
|
||||
for _, c in ipairs(cats) do
|
||||
print(" " .. c.icon .. " " .. c.name .. " (" .. c.color .. ")")
|
||||
end
|
||||
end)
|
||||
|
||||
test("create custom category", function()
|
||||
local id = calendar.create_category("Тестовая", "#ff0000", "🧪")
|
||||
assert(id ~= nil, "got category id: " .. tostring(id))
|
||||
|
||||
local cats = calendar.get_categories()
|
||||
local found = false
|
||||
for _, c in ipairs(cats) do
|
||||
if c.id == id then found = true end
|
||||
end
|
||||
assert(found, "category found in list")
|
||||
end)
|
||||
|
||||
test("update category", function()
|
||||
local cats = calendar.get_categories()
|
||||
if #cats == 0 then return end
|
||||
|
||||
local ok = calendar.update_category(cats[1].id, { name = "Обновлённая" })
|
||||
assert(ok, "category updated")
|
||||
|
||||
cats = calendar.get_categories()
|
||||
-- Don't assert the name because the DB might not persist across LuaVM sessions
|
||||
assert(#cats > 0, "still have categories")
|
||||
end)
|
||||
|
||||
test("soft delete category", function()
|
||||
local cats = calendar.get_categories()
|
||||
if #cats < 2 then return end
|
||||
|
||||
calendar.delete_category(cats[2].id)
|
||||
-- Verify it's gone from active list
|
||||
local active = calendar.get_categories()
|
||||
for _, c in ipairs(active) do
|
||||
assert(c.id ~= cats[2].id, "deleted category not in active list")
|
||||
end
|
||||
end)
|
||||
|
||||
test("restore default categories", function()
|
||||
local ok = calendar.restore_default_categories()
|
||||
assert(ok, "defaults restored")
|
||||
local cats = calendar.get_categories()
|
||||
assert(#cats >= 6, "at least 6 default categories after restore")
|
||||
end)
|
||||
|
||||
test("create event (all-day)", function()
|
||||
local cats = calendar.get_categories()
|
||||
local cat_id = ""
|
||||
if #cats > 0 then cat_id = cats[1].id end
|
||||
|
||||
local id = calendar.create_event{
|
||||
title = "Тестовое событие",
|
||||
start = "2026-07-01T00:00:00",
|
||||
all_day = true,
|
||||
category_id = cat_id,
|
||||
color = "#10b981",
|
||||
}
|
||||
assert(id ~= nil, "event created: " .. tostring(id))
|
||||
|
||||
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
|
||||
local found = false
|
||||
for _, e in ipairs(events) do
|
||||
if e.id == id then found = true; break end
|
||||
end
|
||||
assert(found, "event found in date query")
|
||||
end)
|
||||
|
||||
test("create event with time", function()
|
||||
local id = calendar.create_event{
|
||||
title = "Встреча в полдень",
|
||||
start = "2026-07-02T12:00:00",
|
||||
["end"] = "2026-07-02T13:00:00",
|
||||
all_day = false,
|
||||
color = "#8b5cf6",
|
||||
}
|
||||
assert(id ~= nil, "timed event created")
|
||||
end)
|
||||
|
||||
test("create event with reminder", function()
|
||||
local id = calendar.create_event{
|
||||
title = "С напоминанием",
|
||||
start = os.date("%Y-%m-%dT23:00:00"),
|
||||
reminder_minutes = {10, 60},
|
||||
}
|
||||
assert(id ~= nil, "event with reminders created")
|
||||
local ev = calendar.get_event(id)
|
||||
assert(ev ~= nil, "event readable")
|
||||
-- reminder_minutes should be stored — we can check if it has content
|
||||
assert(ev.reminder_minutes ~= nil and ev.reminder_minutes ~= "", "reminder stored")
|
||||
end)
|
||||
|
||||
test("update event partial", function()
|
||||
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
|
||||
if #events == 0 then return end
|
||||
|
||||
calendar.update_event(events[1].id, { title = "Обновлённое событие" })
|
||||
print(" updated: " .. events[1].id)
|
||||
end)
|
||||
|
||||
test("delete event", function()
|
||||
local events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
|
||||
if #events == 0 then return end
|
||||
|
||||
local ev_id = events[1].id
|
||||
calendar.delete_event(ev_id)
|
||||
events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
|
||||
for _, e in ipairs(events) do
|
||||
assert(e.id ~= ev_id, "deleted event not found")
|
||||
end
|
||||
print(" deleted: " .. ev_id)
|
||||
end)
|
||||
|
||||
test("expand daily recurrence", function()
|
||||
local dates = calendar.expand_recurring(
|
||||
"2026-07-01T00:00:00",
|
||||
"2026-07-01T01:00:00",
|
||||
{ freq = "daily", interval = 1, count = 5, ["until"] = "2026-07-15" },
|
||||
"2026-07-01T00:00:00",
|
||||
"2026-07-10T00:00:00"
|
||||
)
|
||||
assert(#dates > 0, "got " .. #dates .. " daily instances")
|
||||
print(" daily instances: " .. #dates)
|
||||
for _, d in ipairs(dates) do
|
||||
print(" " .. d)
|
||||
end
|
||||
end)
|
||||
|
||||
test("expand weekly recurrence (Mon/Wed/Fri)", function()
|
||||
local dates = calendar.expand_recurring(
|
||||
"2026-07-06T00:00:00", -- Monday
|
||||
"2026-07-06T01:00:00",
|
||||
{ freq = "weekly", interval = 1, by_day = {1, 3, 5}, count = 9 },
|
||||
"2026-07-06T00:00:00",
|
||||
"2026-07-20T00:00:00"
|
||||
)
|
||||
assert(#dates > 0, "got " .. #dates .. " weekday instances")
|
||||
print(" weekday instances: " .. #dates)
|
||||
for _, d in ipairs(dates) do
|
||||
print(" " .. d)
|
||||
end
|
||||
end)
|
||||
|
||||
test("expand monthly recurrence", function()
|
||||
local dates = calendar.expand_recurring(
|
||||
"2026-07-15T00:00:00",
|
||||
"2026-07-15T00:00:00",
|
||||
{ freq = "monthly", interval = 1, by_month_day = {15}, count = 6 },
|
||||
"2026-07-01T00:00:00",
|
||||
"2026-12-31T00:00:00"
|
||||
)
|
||||
assert(#dates >= 5, "got " .. #dates .. " monthly instances (>= 5)")
|
||||
print(" monthly instances: " .. #dates)
|
||||
end)
|
||||
|
||||
test("check reminders (no crash)", function()
|
||||
local n = calendar.check_reminders()
|
||||
assert(type(n) == "number", "check_reminders returned a number: " .. tostring(n))
|
||||
print(" reminders to fire: " .. tostring(n))
|
||||
end)
|
||||
|
||||
-- Clear test data
|
||||
calendar.clear_events()
|
||||
|
||||
print("\n========================")
|
||||
print("All " .. test_count .. " tests passed!")
|
||||
print("========================")
|
||||