Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b3de296b9 | ||
|
|
cd982f2f28 | ||
|
|
9878ab4fb3 | ||
|
|
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 |
@@ -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=
|
||||
+14
@@ -49,3 +49,17 @@ 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/
|
||||
|
||||
@@ -1,3 +1,105 @@
|
||||
# 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)
|
||||
@@ -37,6 +139,22 @@
|
||||
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.
|
||||
@@ -66,3 +184,4 @@ go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak
|
||||
```bash
|
||||
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| GUI | Wails v2 + Svelte 4 |
|
||||
| CLI/TUI | Go (bubbletea-like) |
|
||||
| CLI | Go |
|
||||
| Backend | Go |
|
||||
| Хранилище | SQLite (индекс) + файловая система (vault) |
|
||||
| Плагины | Lua |
|
||||
@@ -70,28 +70,94 @@
|
||||
Дополнительно запускается headless Chromium smoke через Wails-mock: проверяются first-run, recovery, основное окно, Settings, workspace, вкладки дела, файлы, журнал, активность и мобильный viewport. Smoke выполняет реальные UI-действия: создание заметки, запись worklog, создание узла, вход в папку и возврат назад, а также Sync Now с предупреждениями о conflicts/applyErrors. Скриншоты пишутся в `/tmp/verstak-gui-smoke`.
|
||||
|
||||
Бинарники попадают в `build/`:
|
||||
- `verstak-gui-linux-amd64` — GUI-приложение
|
||||
- `verstak-server-linux-amd64` — опциональный сервер синхронизации
|
||||
- `verstak` — GUI-приложение
|
||||
- `verstak-server` — опциональный сервер синхронизации
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
# GUI (после сборки)
|
||||
./build/verstak-gui-linux-amd64
|
||||
./build/verstak
|
||||
|
||||
# Сервер (после сборки)
|
||||
./build/verstak-server-linux-amd64 --help
|
||||
./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/TUI
|
||||
│ ├── verstak/ # CLI
|
||||
│ ├── verstak-gui/ # Wails GUI
|
||||
│ └── verstak-server/ # Sync server
|
||||
├── internal/
|
||||
@@ -151,7 +217,7 @@ Vault открывается в любом файловом менеджере
|
||||
| [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync |
|
||||
| [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы |
|
||||
| [Синхронизация](docs/04_Sync_Backup_Activity.md) | Sync, backup, activity |
|
||||
| [UI/UX](docs/05_UI_UX.md) | Экраны GUI/TUI |
|
||||
| [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) | Структура папок на диске |
|
||||
|
||||
+33
-16
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/bridge"
|
||||
"verstak/internal/core/browser"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/watcher"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
@@ -31,18 +34,21 @@ type App struct {
|
||||
mu sync.RWMutex
|
||||
vaultOpen bool
|
||||
|
||||
db *storage.DB
|
||||
nodes *nodes.Repository
|
||||
templates *templates.Registry
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
activity *activity.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
plugins *plugins.Manager
|
||||
sync *syncsvc.Service
|
||||
vault string
|
||||
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.
|
||||
@@ -185,6 +191,14 @@ type FileTreeItemDTO struct {
|
||||
HasKids bool `json:"hasKids"`
|
||||
}
|
||||
|
||||
// PreflightFileAction describes what should happen when opening a file from the Files tab.
|
||||
type PreflightFileAction struct {
|
||||
Action string `json:"action"` // "note" | "preview" | "external"
|
||||
NoteID string `json:"noteId,omitempty"`
|
||||
NoteTitle string `json:"noteTitle,omitempty"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
type ActionDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
@@ -209,10 +223,13 @@ type WorklogDTO struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type EventDTO struct {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
type SystemViewDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) ListSystemViews() []SystemViewDTO {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/browser"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/vault"
|
||||
"verstak/internal/core/watcher"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
@@ -197,6 +199,14 @@ func (a *App) initVault(vaultPath string) error {
|
||||
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()
|
||||
@@ -218,6 +228,9 @@ func (a *App) initVault(vaultPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply installed/enabled state from config to discovered plugins
|
||||
pm.SyncConfig(appCfg)
|
||||
|
||||
// Sync service
|
||||
deviceID := ""
|
||||
_ = appCfg // will store sync settings
|
||||
@@ -229,6 +242,29 @@ func (a *App) initVault(vaultPath string) error {
|
||||
}
|
||||
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
|
||||
@@ -241,13 +277,36 @@ func (a *App) initVault(vaultPath string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -258,6 +317,20 @@ func (a *App) closeVault() {
|
||||
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()
|
||||
}
|
||||
@@ -272,6 +345,9 @@ func (a *App) closeVault() {
|
||||
a.plugins = nil
|
||||
a.templates = nil
|
||||
a.sync = nil
|
||||
a.fileWatcher = nil
|
||||
a.bridge = nil
|
||||
a.browser = nil
|
||||
a.vault = ""
|
||||
a.vaultOpen = false
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -13,6 +14,7 @@ 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)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -42,24 +46,33 @@ func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
|
||||
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: children[i].Type,
|
||||
Type: typ,
|
||||
}
|
||||
if children[i].Type == nodes.TypeFolder {
|
||||
if typ == nodes.TypeFolder {
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
} else if children[i].Type == nodes.TypeFile {
|
||||
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)
|
||||
}
|
||||
@@ -159,6 +172,44 @@ 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
|
||||
|
||||
@@ -127,9 +127,11 @@ func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := util.UUID7()
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now); err != nil {
|
||||
`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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
@@ -172,8 +173,43 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Create default folders FIRST so Notes/ exists before default files.
|
||||
var notesFolderID string
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr)
|
||||
}
|
||||
created = append(created, childInfo{id: childNode.ID, file: false})
|
||||
if folderName == notes.NotesFolder {
|
||||
notesFolderID = childNode.ID
|
||||
}
|
||||
|
||||
childFsPath := folderSeg
|
||||
if fsPath != "" {
|
||||
childFsPath = filepath.Join(fsPath, folderSeg)
|
||||
}
|
||||
childPhysPath := filepath.Join(a.vault, childFsPath)
|
||||
childPhysPath = templates.UniquePath(childPhysPath)
|
||||
childRel, _ := filepath.Rel(a.vault, childPhysPath)
|
||||
childFsPath = childRel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err)
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
// 2. Create default files, placing them under Notes folder when it exists.
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fpath := filepath.Join(physPath, df.Path)
|
||||
fpath := filepath.Join(physPath, notes.NotesFolder, df.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create directory for %s: %w", df.Path, err)
|
||||
@@ -182,7 +218,12 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
|
||||
parentForFile := &n.ID
|
||||
if notesFolderID != "" {
|
||||
parentForFile = ¬esFolderID
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(parentForFile, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child node for %s: %w", df.Path, childErr)
|
||||
@@ -209,9 +250,13 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
parentIDForSync := n.ID
|
||||
if notesFolderID != "" {
|
||||
parentIDForSync = notesFolderID
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
|
||||
"node_id": childNode.ID,
|
||||
"parent_id": n.ID,
|
||||
"parent_id": parentIDForSync,
|
||||
"title": fileTitle,
|
||||
"file_id": fileID,
|
||||
"format": "markdown",
|
||||
@@ -223,35 +268,6 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
||||
})
|
||||
}
|
||||
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr)
|
||||
}
|
||||
created = append(created, childInfo{id: childNode.ID, file: false})
|
||||
|
||||
childFsPath := folderSeg
|
||||
if fsPath != "" {
|
||||
childFsPath = filepath.Join(fsPath, folderSeg)
|
||||
}
|
||||
childPhysPath := filepath.Join(a.vault, childFsPath)
|
||||
childPhysPath = templates.UniquePath(childPhysPath)
|
||||
childRel, _ := filepath.Rel(a.vault, childPhysPath)
|
||||
childFsPath = childRel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
|
||||
rollbackChildren()
|
||||
return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err)
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
pid := ""
|
||||
if parentID != "" {
|
||||
pid = parentID
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
@@ -12,19 +13,65 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
|
||||
// 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
|
||||
for i := range children {
|
||||
if children[i].Type == nodes.TypeNote {
|
||||
result = append(result, toNodeDTO(&children[i]))
|
||||
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
|
||||
@@ -67,3 +114,29 @@ func (a *App) SaveNote(noteID, content string) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -262,25 +263,140 @@ 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
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
type TrashDTO struct {
|
||||
@@ -18,13 +20,14 @@ type TrashDTO struct {
|
||||
}
|
||||
|
||||
type TrashNodeDTO struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
FsPath string `json:"fsPath"`
|
||||
NodePath string `json:"nodePath"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
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 {
|
||||
@@ -45,8 +48,13 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes := make([]TrashNodeDTO, 0, len(deleted))
|
||||
// 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)
|
||||
@@ -55,7 +63,7 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
if n.ParentID != nil {
|
||||
parentID = *n.ParentID
|
||||
}
|
||||
nodes = append(nodes, TrashNodeDTO{
|
||||
dto := &TrashNodeDTO{
|
||||
ID: n.ID,
|
||||
ParentID: parentID,
|
||||
Title: n.Title,
|
||||
@@ -63,7 +71,59 @@ func (a *App) ListTrash() (*TrashDTO, error) {
|
||||
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)
|
||||
@@ -186,11 +246,39 @@ func (a *App) deletedAncestorChain(nodeID string) ([]TrashNodeDTO, error) {
|
||||
|
||||
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
|
||||
return nil // parent may have already been restored
|
||||
}
|
||||
dst := filepath.Join(a.vault, fsPath)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
@@ -202,6 +290,10 @@ func (a *App) restoreTrashPath(nodeID, fsPath string) error {
|
||||
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)
|
||||
@@ -223,9 +315,19 @@ func (a *App) purgeTrashNode(nodeID string) error {
|
||||
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 {
|
||||
@@ -298,6 +400,104 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,8 +19,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-DNvOo-CH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CXWRKHDk.css">
|
||||
<script type="module" crossorigin src="/assets/main-C0D__Sxo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,9 @@ func TestJournalFullRegression(t *testing.T) {
|
||||
if s.NodeID == n.ID {
|
||||
found = true
|
||||
// Verify events match what we inserted
|
||||
if len(s.Events) != 3 {
|
||||
t.Fatalf("suggestion has %d events, want 3", len(s.Events))
|
||||
// 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))
|
||||
@@ -194,15 +195,21 @@ func TestSuggestionOnRepeatedActivity(t *testing.T) {
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
found = true
|
||||
// Only the second event should be suggested
|
||||
if len(s.Events) != 1 {
|
||||
t.Fatalf("expected 1 new event, got %d", len(s.Events))
|
||||
// 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))
|
||||
}
|
||||
if s.Events[0].ID != eid2 {
|
||||
t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID)
|
||||
// eid2 must be among the suggested events
|
||||
var hasNew bool
|
||||
for _, ev := range s.Events {
|
||||
if ev.ID == eid2 {
|
||||
hasNew = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 {
|
||||
t.Errorf("eventIds does not contain the new event")
|
||||
if !hasNew {
|
||||
t.Errorf("eid2 not found among suggestion events")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,6 +424,18 @@ func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
|
||||
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 {
|
||||
|
||||
@@ -136,15 +136,15 @@ func TestTrashCountPurgeAndEmpty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("TrashCount: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("TrashCount = %d, want 2", count)
|
||||
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 != 1 {
|
||||
t.Fatalf("TrashCount after purge = %d, want 1", count)
|
||||
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)
|
||||
@@ -154,3 +154,304 @@ func TestTrashCountPurgeAndEmpty(t *testing.T) {
|
||||
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,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +126,17 @@ func TestVaultLayout_CreateProjectTree(t *testing.T) {
|
||||
t.Error("expected project folder on disk")
|
||||
}
|
||||
|
||||
// 4. Verify template created Overview.md
|
||||
overviewPath := filepath.Join(serverFolder, "Overview.md")
|
||||
// 4. Verify template created Overview.md inside Notes/ subfolder
|
||||
overviewPath := filepath.Join(serverFolder, "Notes", "Overview.md")
|
||||
if _, err := os.Stat(overviewPath); os.IsNotExist(err) {
|
||||
t.Log("note: Overview.md from template not created (may not be implemented)")
|
||||
t.Errorf("expected Overview.md at %s", overviewPath)
|
||||
}
|
||||
// Verify no Overview.md in project root
|
||||
overviewRootPath := filepath.Join(serverFolder, "Overview.md")
|
||||
if _, err := os.Stat(overviewRootPath); err == nil {
|
||||
t.Error("Overview.md should not be in project root, only in Notes/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
@@ -150,16 +155,14 @@ func TestVaultLayout_CreateNoteInsideProject(t *testing.T) {
|
||||
t.Fatal("expected non-nil node and file record")
|
||||
}
|
||||
|
||||
// Verify the note .md file is inside the project folder
|
||||
expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md")
|
||||
// Verify the note .md file is inside the project's notes/ subfolder
|
||||
// SafeDisplayNameToPathSegment preserves spaces: "Моя заметка" stays as-is
|
||||
expectedPath := filepath.Join(vault, proj.FsPath, "Notes", "Моя заметка.md")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
// Try the safe-display-name variant
|
||||
expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md")
|
||||
if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) {
|
||||
// Show what actually exists
|
||||
entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath))
|
||||
t.Errorf("expected note file in project folder, found: %v", listNames(entries))
|
||||
}
|
||||
// Show what actually exists in notes subfolder
|
||||
notesDir := filepath.Join(vault, proj.FsPath, "Notes")
|
||||
entries, _ := os.ReadDir(notesDir)
|
||||
t.Errorf("expected note file at %s, found in notes/: %v", expectedPath, listNames(entries))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,13 +1121,11 @@ func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
for _, child := range children {
|
||||
expectedType, ok := expected[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("unexpected child %q (type=%q)", child.Title, child.Type)
|
||||
continue
|
||||
continue // Overview is now under Notes/, not a direct child
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
@@ -1139,8 +1140,30 @@ func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(children) < 4 {
|
||||
t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children))
|
||||
if len(children) < 3 {
|
||||
t.Errorf("expected at least 3 children (3 folders), got %d", len(children))
|
||||
}
|
||||
|
||||
// Overview should be inside Notes folder, not a direct child
|
||||
var notesFolder *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == "Notes" && children[i].Type == "folder" {
|
||||
notesFolder = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if notesFolder == nil {
|
||||
t.Fatalf("Notes folder not found among children")
|
||||
}
|
||||
noteChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list Notes children: %v", err)
|
||||
}
|
||||
if len(noteChildren) != 1 || noteChildren[0].Title != "Overview" {
|
||||
t.Errorf("Notes folder should have one child 'Overview', got %d children", len(noteChildren))
|
||||
for _, c := range noteChildren {
|
||||
t.Logf(" child: %s (type=%s)", c.Title, c.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,21 +1176,37 @@ func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Find the Overview note child
|
||||
// Overview is now created inside Notes/ folder, find it there
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
|
||||
var overview *nodes.Node
|
||||
var notesFolder *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == "Overview" {
|
||||
overview = &children[i]
|
||||
if children[i].Title == "Notes" && children[i].Type == "folder" {
|
||||
notesFolder = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if notesFolder == nil {
|
||||
t.Fatal("expected Notes folder child from template")
|
||||
}
|
||||
|
||||
noteChildren, err := app.nodes.ListChildren(notesFolder.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list Notes children: %v", err)
|
||||
}
|
||||
|
||||
var overview *nodes.Node
|
||||
for i := range noteChildren {
|
||||
if noteChildren[i].Title == "Overview" {
|
||||
overview = ¬eChildren[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if overview == nil {
|
||||
t.Fatal("expected 'Overview' child node from template")
|
||||
t.Fatal("expected 'Overview' child node from template inside Notes/")
|
||||
}
|
||||
if overview.Type != "note" {
|
||||
t.Errorf("expected type 'note', got %q", overview.Type)
|
||||
|
||||
@@ -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("========================")
|
||||
+3
-3
@@ -29,10 +29,10 @@
|
||||
## Файлы пакета
|
||||
|
||||
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии.
|
||||
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server, плагины.
|
||||
2. [[02_Architecture]] — архитектура core/GUI/CLI/server, плагины.
|
||||
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
|
||||
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
|
||||
5. [[05_UI_UX]] — экраны GUI/TUI.
|
||||
5. [[05_UI_UX]] — экраны GUI.
|
||||
6. [[06_Roadmap]] — план разработки по этапам.
|
||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
5. **Не таймтрекер, а восстановитель следов.**
|
||||
|
||||
6. **GUI основной, TUI быстрый, CLI служебный.**
|
||||
6. **GUI основной, CLI служебный.**
|
||||
|
||||
7. **Sync не уничтожает данные.**
|
||||
|
||||
|
||||
+1
-25
@@ -28,7 +28,7 @@
|
||||
|
||||
### Core Library
|
||||
|
||||
Общее ядро, которое не зависит от GUI/TUI.
|
||||
Общее ядро, которое не зависит от GUI.
|
||||
|
||||
Отвечает за:
|
||||
|
||||
@@ -72,27 +72,6 @@
|
||||
- импорт DokuWiki;
|
||||
- разбор конфликтов.
|
||||
|
||||
### TUI
|
||||
|
||||
Быстрый интерфейс из терминала.
|
||||
|
||||
Рекомендуемый стек:
|
||||
|
||||
- Bubble Tea;
|
||||
- Bubbles;
|
||||
- Lip Gloss.
|
||||
|
||||
TUI не обязан повторять весь GUI.
|
||||
|
||||
Функции:
|
||||
|
||||
- найти дело;
|
||||
- открыть заметку;
|
||||
- добавить worklog;
|
||||
- запустить action;
|
||||
- запустить sync;
|
||||
- посмотреть “Сегодня”.
|
||||
|
||||
### CLI
|
||||
|
||||
CLI нужен не как главный интерфейс, а как служебный вход.
|
||||
@@ -144,8 +123,6 @@ verstak/
|
||||
main.go
|
||||
verstak-gui/
|
||||
main.go
|
||||
verstak-tui/
|
||||
main.go
|
||||
verstak-server/
|
||||
main.go
|
||||
|
||||
@@ -164,7 +141,6 @@ verstak/
|
||||
config/
|
||||
|
||||
gui/
|
||||
tui/
|
||||
server/
|
||||
|
||||
frontend/
|
||||
|
||||
+1
-18
@@ -243,24 +243,7 @@ Actions:
|
||||
- редактировать название, URL и заметку;
|
||||
- удалить ссылку.
|
||||
|
||||
## 13. TUI UX
|
||||
|
||||
TUI нужен для быстрого:
|
||||
|
||||
- fuzzy search;
|
||||
- открыть дело;
|
||||
- добавить worklog;
|
||||
- запустить action;
|
||||
- sync now.
|
||||
|
||||
Не нужно делать в TUI:
|
||||
|
||||
- импорт DokuWiki;
|
||||
- сложный conflict resolver;
|
||||
- preview документов;
|
||||
- настройки sync.
|
||||
|
||||
## 14. CLI UX
|
||||
## 13. CLI UX
|
||||
|
||||
CLI команды:
|
||||
|
||||
|
||||
@@ -214,17 +214,6 @@ Acceptance:
|
||||
|
||||
- изменения файлов снаружи приложения обнаруживаются.
|
||||
|
||||
## Milestone 14 — TUI MVP (PAUSED)
|
||||
|
||||
- Bubble Tea;
|
||||
- tree/search;
|
||||
- add worklog;
|
||||
- run action;
|
||||
- sync.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- можно из терминала быстро работать с Верстаком.
|
||||
|
||||
## Milestone 15 — 1.0 polish (PAUSED)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- заметки и файлы физически лежат обычными файлами в vault;
|
||||
- SQLite хранит индекс, связи, метаданные, FTS и sync state;
|
||||
- основная сущность — вложенное дерево nodes;
|
||||
- GUI, TUI и CLI должны использовать общий core;
|
||||
- GUI и CLI должны использовать общий core;
|
||||
- бизнес-логику нельзя завязывать на UI;
|
||||
- не делать SaaS;
|
||||
- не делать ИИ;
|
||||
@@ -25,7 +25,6 @@
|
||||
- Go;
|
||||
- SQLite;
|
||||
- Wails для GUI;
|
||||
- Bubble Tea для TUI позже;
|
||||
- fsnotify + snapshot scanner позже;
|
||||
- sync server позже.
|
||||
|
||||
@@ -272,22 +271,7 @@ Acceptance:
|
||||
- manual command `verstak scan`.
|
||||
```
|
||||
|
||||
## Prompt 14 — TUI MVP
|
||||
|
||||
```markdown
|
||||
Добавь TUI на Bubble Tea.
|
||||
|
||||
Функции:
|
||||
- tree/search;
|
||||
- open node;
|
||||
- add worklog;
|
||||
- run action;
|
||||
- sync now.
|
||||
|
||||
TUI не должен повторять весь GUI.
|
||||
```
|
||||
|
||||
## Prompt 15 — Integrity/restore
|
||||
## Prompt 14 — Integrity/restore
|
||||
|
||||
```markdown
|
||||
Добавь команды:
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
- [ ] Conflict resolver UI.
|
||||
- [ ] Restore from sync server.
|
||||
- [ ] Backup export / retention snapshots.
|
||||
- [ ] TUI MVP.
|
||||
|
||||
## Не входит в первый MVP
|
||||
|
||||
|
||||
+575
-90
@@ -1,30 +1,36 @@
|
||||
# Верстак — архитектура плагинов
|
||||
|
||||
## Принцип
|
||||
## 1. Принцип
|
||||
|
||||
Верстак — это минималистичный движок с деревом дел.
|
||||
Всё, что не входит в минимальную модель, — плагин.
|
||||
Верстак — минималистичный движок с деревом дел и базовыми операциями
|
||||
(заметки, файлы, действия, журнал, поиск, активность).
|
||||
|
||||
Плагин — это директория в `.verstak/plugins/<name>/`, которую
|
||||
программа подхватывает без перекомпиляции.
|
||||
Плагины расширяют функциональность без перекомпиляции:
|
||||
- новые типы узлов и кастомные поля;
|
||||
- календарь, канбан, импортёры;
|
||||
- UI-элементы (пункты сайдбара, вкладки, страницы);
|
||||
- фоновые задачи по расписанию;
|
||||
- хуки на события (создание/удаление/изменение узлов).
|
||||
|
||||
## Структура плагина
|
||||
## 2. Структура плагина
|
||||
|
||||
```
|
||||
.verstak/plugins/<name>/
|
||||
plugin.json # мета: name, version, author, hooks
|
||||
main.lua # точка входа
|
||||
plugin.json # метаданные: имя, версия, хуки, UI-contributions
|
||||
main.lua # точка входа (обязательно, если есть Lua-код)
|
||||
panels/ # HTML-панели для GUI (опционально)
|
||||
calendar.html
|
||||
kanban.html
|
||||
templates/ # шаблоны дел (опционально)
|
||||
client.json
|
||||
repair.json
|
||||
panels/ # UI-панели для GUI (опционально)
|
||||
kanban.html
|
||||
calendar.html
|
||||
migrations/ # SQL-миграции (опционально)
|
||||
001_create_tables.sql
|
||||
assets/ # статика: иконки, CSS, изображения (опционально)
|
||||
icon.svg
|
||||
```
|
||||
|
||||
## plugin.json
|
||||
### 2.1 plugin.json — полная схема
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -32,55 +38,485 @@
|
||||
"version": "1.0.0",
|
||||
"author": "...",
|
||||
"description": "Календарь событий, привязанных к делам",
|
||||
|
||||
"hooks": {
|
||||
"on_init": "on_init",
|
||||
"on_node_open": "on_node_open"
|
||||
"on_vault_open": "on_vault_open",
|
||||
"on_node_create": "on_node_create",
|
||||
"on_node_delete": "on_node_delete",
|
||||
"on_shutdown": "on_shutdown"
|
||||
},
|
||||
|
||||
"ui": {
|
||||
"sidebar_items": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"icon": "calendar",
|
||||
"page": "plugin:calendar:main"
|
||||
}
|
||||
],
|
||||
"node_tabs": [
|
||||
{
|
||||
"id": "events",
|
||||
"label": "События",
|
||||
"page": "plugin:calendar:node_events"
|
||||
}
|
||||
],
|
||||
"node_actions": [
|
||||
{
|
||||
"id": "add_event",
|
||||
"label": "Добавить событие",
|
||||
"icon": "plus",
|
||||
"page": "plugin:calendar:add_event"
|
||||
}
|
||||
],
|
||||
"settings_pages": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"page": "plugin:calendar:settings"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"background_tasks": [
|
||||
{
|
||||
"id": "check_events",
|
||||
"interval": "5m",
|
||||
"script": "check_events.lua"
|
||||
}
|
||||
],
|
||||
|
||||
"node_types": ["event"],
|
||||
"panel": "panels/calendar.html",
|
||||
"migrations": ["migrations/001_create_tables.sql"]
|
||||
}
|
||||
```
|
||||
|
||||
## Lua API
|
||||
#### Поля plugin.json
|
||||
|
||||
Плагины пишутся на Lua (gopher-lua). API:
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `name` | string | Уникальное имя плагина |
|
||||
| `version` | string | SemVer |
|
||||
| `hooks` | map[string]string | Имя Lua-функции для каждого события |
|
||||
| `ui.sidebar_items` | [SidebarItem] | Пункты сайдбара |
|
||||
| `ui.node_tabs` | [NodeTab] | Вкладки в карточке дела |
|
||||
| `ui.node_actions` | [NodeAction] | Кнопки действий в деле |
|
||||
| `ui.settings_pages` | [SettingsPage] | Страницы настроек |
|
||||
| `background_tasks` | [BackgroundTask] | Фоновые задачи по расписанию |
|
||||
| `node_types` | [string] | Имена новых типов узлов, которые регистрирует плагин |
|
||||
| `panel` | string | HTML-файл панели (главная страница плагина) |
|
||||
| `migrations` | [string] | SQL-файлы миграций |
|
||||
|
||||
```lua
|
||||
-- Получить node по ID
|
||||
local node = verstak.node.get(id)
|
||||
### 2.2 SidebarItem
|
||||
|
||||
-- Создать node
|
||||
local n = verstak.node.create(parent_id, "type", "title")
|
||||
|
||||
-- Получить config value
|
||||
local v = verstak.config.get("key")
|
||||
|
||||
-- Записать в activity log
|
||||
verstak.activity.log({
|
||||
node_id = n.id,
|
||||
event_type = "calendar_event",
|
||||
title = "Встреча с клиентом"
|
||||
})
|
||||
|
||||
-- Зарегистрировать HTTP-эндпоинт (для GUI)
|
||||
verstak.http.route("GET", "/api/calendar/events", get_events)
|
||||
|
||||
-- Показать уведомление
|
||||
verstak.ui.toast("Событие добавлено")
|
||||
```json
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"icon": "calendar",
|
||||
"page": "plugin:calendar:main"
|
||||
}
|
||||
```
|
||||
|
||||
## Жизненный цикл плагина
|
||||
- `id` — уникальный в рамках плагина
|
||||
- `icon` — имя иконки из библиотеки GUI (или путь к `assets/icon.svg`)
|
||||
- `page` — идентификатор страницы в формате `plugin:<name>:<page_id>`
|
||||
- При клике — фронтенд переключается на эту страницу, отправляет Lua-скрипту событие `on_page_open`
|
||||
|
||||
1. **on_init** — при старте программы, до открытия vault.
|
||||
Инициализация, создание таблиц.
|
||||
2. **on_vault_open** — при открытии vault.
|
||||
3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
|
||||
4. **on_shutdown** — при закрытии.
|
||||
### 2.3 NodeTab
|
||||
|
||||
## Реестр типов дел
|
||||
```json
|
||||
{
|
||||
"id": "events",
|
||||
"label": "События",
|
||||
"page": "plugin:calendar:node_events"
|
||||
}
|
||||
```
|
||||
|
||||
Плагины могут регистрировать новые типы:
|
||||
- Появляется как дополнительная вкладка в карточке дела
|
||||
- Вкладка видна для всех типов узлов, если не указан `node_type_filter`
|
||||
- При переключении — фронтенд запрашивает данные через Lua API
|
||||
|
||||
### 2.4 BackgroundTask
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "check_events",
|
||||
"interval": "5m",
|
||||
"script": "check_events.lua"
|
||||
}
|
||||
```
|
||||
|
||||
- `interval` — интервал в человекочитаемом формате: `5m`, `1h`, `30s`
|
||||
- `script` — путь к Lua-файлу относительно корня плагина
|
||||
- Скрипт выполняется в том же Lua-стейте, что и main.lua, с полным доступом к API
|
||||
|
||||
## 3. Lua API — полный справочник
|
||||
|
||||
Плагины пишутся на Lua (gopher-lua). Доступны следующие модули:
|
||||
|
||||
### 3.1 `verstak.node` — работа с деревом дел
|
||||
|
||||
```lua
|
||||
-- Получить узел по ID
|
||||
local node = verstak.node.get(id)
|
||||
-- { id, parent_id, type, title, slug, sort_order, created_at, updated_at, section }
|
||||
|
||||
-- Создать узел
|
||||
local n = verstak.node.create(parent_id, "type", "title")
|
||||
-- Можно с дополнительными полями:
|
||||
local n = verstak.node.create(parent_id, "type", "title", { section = "work" })
|
||||
|
||||
-- Создать дочерний узел (сокращение)
|
||||
local n = verstak.node.create_child(parent_id, "type", "title")
|
||||
|
||||
-- Обновить узел
|
||||
verstak.node.update(id, { title = "Новый заголовок", section = "personal" })
|
||||
|
||||
-- Удалить узел (в корзину)
|
||||
verstak.node.delete(id)
|
||||
|
||||
-- Найти узел по slug (относительно parent)
|
||||
local n = verstak.node.find_by_slug(parent_id, "my-slug")
|
||||
|
||||
-- Поиск по дереву
|
||||
local results = verstak.node.search("query")
|
||||
-- results = [{ id, title, type, match_column, snippet }]
|
||||
|
||||
-- Получить детей узла
|
||||
local children = verstak.node.children(parent_id)
|
||||
-- children = [{ id, title, type, ... }]
|
||||
|
||||
-- Получить полный путь от корня
|
||||
local path = verstak.node.path(id)
|
||||
-- path = [{ id, title, type }, ...]
|
||||
```
|
||||
|
||||
### 3.2 `verstak.file` — файлы
|
||||
|
||||
```lua
|
||||
-- Получить файлы узла
|
||||
local files = verstak.file.list(node_id)
|
||||
|
||||
-- Добавить файл (копировать в vault)
|
||||
local f = verstak.file.add(node_id, "/path/to/file.pdf")
|
||||
|
||||
-- Удалить (в корзину)
|
||||
verstak.file.delete(file_id)
|
||||
|
||||
-- Получить содержимое текстового файла
|
||||
local content = verstak.file.read_text(file_id)
|
||||
```
|
||||
|
||||
### 3.3 `verstak.worklog` — журнал работ
|
||||
|
||||
```lua
|
||||
-- Добавить запись
|
||||
verstak.worklog.add(node_id, minutes, "Описание работы")
|
||||
|
||||
-- Добавить с датой
|
||||
verstak.worklog.add(node_id, minutes, "Описание", { date = "2025-06-07", billable = true })
|
||||
|
||||
-- Список записей узла
|
||||
local entries = verstak.worklog.list(node_id, { limit = 10, offset = 0 })
|
||||
|
||||
-- Сводка
|
||||
local summary = verstak.worklog.summary(node_id, { since = "2025-01-01", until = "2025-12-31" })
|
||||
```
|
||||
|
||||
### 3.4 `verstak.activity` — лог активности
|
||||
|
||||
```lua
|
||||
-- Записать событие
|
||||
verstak.activity.log({
|
||||
node_id = node_id,
|
||||
event_type = "calendar_event",
|
||||
title = "Встреча с клиентом",
|
||||
description = "Обсуждение макета",
|
||||
confidence = "high"
|
||||
})
|
||||
```
|
||||
|
||||
### 3.5 `verstak.config` — конфигурация плагина
|
||||
|
||||
```lua
|
||||
-- Читать
|
||||
local val = verstak.config.get("calendar.default_view")
|
||||
-- val == nil если ключ не задан, иначе { key, value, plugin }
|
||||
|
||||
-- Писать (сохраняется в .verstak/plugins/<name>/config.json)
|
||||
verstak.config.set("calendar.default_view", "month")
|
||||
verstak.config.set("calendar.working_hours", { start = 9, end = 18 })
|
||||
```
|
||||
|
||||
Конфиг плагина изолирован: ключи автоматически префиксируются именем плагина.
|
||||
|
||||
### 3.6 `verstak.db` — собственное хранилище плагина
|
||||
|
||||
Плагин может создавать и читать свои таблицы в отдельном SQLite-файле
|
||||
`.verstak/plugins/<name>/data.db`.
|
||||
|
||||
```lua
|
||||
-- Выполнить запрос (INSERT/UPDATE/DELETE)
|
||||
verstak.db.exec("INSERT INTO events (title, date, node_id) VALUES (?, ?, ?)",
|
||||
"Встреча", "2025-06-15", node_id)
|
||||
|
||||
-- Запрос с результатами
|
||||
local rows = verstak.db.query("SELECT * FROM events WHERE date >= ? ORDER BY date", "2025-06-01")
|
||||
-- rows = [{ id, title, date, node_id }, ...]
|
||||
|
||||
-- Одна строка
|
||||
local row = verstak.db.query_row("SELECT * FROM events WHERE id = ?", event_id)
|
||||
-- row = { id, title, date, node_id } или nil
|
||||
```
|
||||
|
||||
### 3.7 `verstak.ui` — взаимодействие с GUI
|
||||
|
||||
```lua
|
||||
-- Показать уведомление (тост)
|
||||
verstak.ui.toast("Событие добавлено")
|
||||
verstak.ui.toast("Что-то пошло не так", { type = "error", duration = 5000 })
|
||||
|
||||
-- Навигация
|
||||
verstak.ui.navigate_to("plugin:calendar:main", { date = "2025-06-07" })
|
||||
|
||||
-- Обновить данные на текущей странице
|
||||
verstak.ui.refresh()
|
||||
|
||||
-- Установить заголовок окна
|
||||
verstak.ui.set_title("Календарь — Июнь 2025")
|
||||
|
||||
-- Открыть диалог
|
||||
local result = verstak.ui.confirm("Удалить событие?")
|
||||
-- result = true/false
|
||||
```
|
||||
|
||||
### 3.8 `verstak.http` — HTTP-эндпоинты
|
||||
|
||||
Плагины могут регистрировать HTTP-эндпоинты на внутреннем сервере
|
||||
(тот же порт, что и Bridge/Extension, но с префиксом `/api/plugins/<name>/`).
|
||||
|
||||
```lua
|
||||
-- GET /api/plugins/calendar/events?month=2025-06
|
||||
verstak.http.route("GET", "/events", function(params)
|
||||
local events = verstak.db.query("SELECT * FROM events WHERE date LIKE ?", params.month .. "%")
|
||||
return { json = events }
|
||||
end)
|
||||
|
||||
-- POST /api/plugins/calendar/events
|
||||
verstak.http.route("POST", "/events", function(body)
|
||||
verstak.db.exec("INSERT INTO events (...) VALUES (?,?)", body.title, body.date)
|
||||
return { json = { ok = true } }
|
||||
end)
|
||||
```
|
||||
|
||||
Эндпоинты аутентифицируются через X-Verstak-Secret (как и Bridge).
|
||||
|
||||
### 3.9 `verstak.schedule` — фоновые задачи (из Lua)
|
||||
|
||||
Помимо декларативных `background_tasks` в plugin.json, можно создавать
|
||||
задачи динамически:
|
||||
|
||||
```lua
|
||||
-- Каждые 15 минут
|
||||
local timer_id = verstak.schedule.every("15m", function()
|
||||
local events = verstak.db.query("SELECT * FROM events WHERE date = date('now')")
|
||||
if #events > 0 then
|
||||
verstak.ui.toast("Сегодня " .. #events .. " событий")
|
||||
end
|
||||
end)
|
||||
|
||||
-- Отменить
|
||||
verstak.schedule.cancel(timer_id)
|
||||
|
||||
-- Однократно через N секунд
|
||||
verstak.schedule.once("30s", function()
|
||||
verstak.ui.toast("30 секунд прошло")
|
||||
end)
|
||||
|
||||
-- Каждое N-е число месяца (cron-подобно)
|
||||
verstak.schedule.cron("0 9 * * 1", function()
|
||||
-- Каждый понедельник в 9 утра
|
||||
verstak.ui.toast("Планирование недели")
|
||||
end)
|
||||
```
|
||||
|
||||
### 3.10 `verstak.state` — разделяемое состояние между хуками/тасками
|
||||
|
||||
```lua
|
||||
-- Сохранить значение в памяти плагина
|
||||
verstak.state.set("event_count", 42)
|
||||
|
||||
-- Прочитать
|
||||
local n = verstak.state.get("event_count")
|
||||
|
||||
-- Удалить
|
||||
verstak.state.delete("event_count")
|
||||
```
|
||||
|
||||
Состояние хранится только в runtime — при перезапуске теряется.
|
||||
Для постоянных данных используйте `verstak.config` или `verstak.db`.
|
||||
|
||||
## 4. Хуки — жизненный цикл
|
||||
|
||||
Имя в plugin.json → имя Lua-функции, которая вызывается в main.lua.
|
||||
|
||||
### 4.1 `on_init`
|
||||
|
||||
```lua
|
||||
function on_init()
|
||||
-- Вызывается при старте программы, ДО открытия vault.
|
||||
-- Здесь плагин может выполнить миграции БД,
|
||||
-- зарегистрировать свои таблицы.
|
||||
-- ВАЖНО: vault ещё не открыт, verstak.node недоступен.
|
||||
verstak.db.exec([[CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
node_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)]])
|
||||
end
|
||||
```
|
||||
|
||||
### 4.2 `on_vault_open`
|
||||
|
||||
```lua
|
||||
function on_vault_open(vault_path)
|
||||
-- Вызывается после открытия vault.
|
||||
-- verstak.node, verstak.file уже доступны.
|
||||
-- Можно зарегистрировать HTTP-эндпоинты.
|
||||
verstak.http.route("GET", "/events", list_events)
|
||||
end
|
||||
```
|
||||
|
||||
### 4.3 `on_node_create`
|
||||
|
||||
```lua
|
||||
function on_node_create(node)
|
||||
-- node = { id, parent_id, type, title, section, ... }
|
||||
-- Вызывается после создания любого узла.
|
||||
-- Нельзя отменить создание (позднее — вернём before_create).
|
||||
if node.type == "event" then
|
||||
verstak.activity.log({
|
||||
node_id = node.id,
|
||||
event_type = "calendar_event_created",
|
||||
title = "Создано событие: " .. node.title
|
||||
})
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 4.4 `on_node_delete`
|
||||
|
||||
```lua
|
||||
function on_node_delete(node)
|
||||
-- Вызывается при удалении узла.
|
||||
-- Плагин может почистить свои данные.
|
||||
verstak.db.exec("DELETE FROM events WHERE node_id = ?", node.id)
|
||||
end
|
||||
```
|
||||
|
||||
### 4.5 `on_shutdown`
|
||||
|
||||
```lua
|
||||
function on_shutdown()
|
||||
-- При закрытии программы.
|
||||
-- Сохранить состояние, закрыть соединения.
|
||||
end
|
||||
```
|
||||
|
||||
### 4.6 `on_page_open` (автоматический)
|
||||
|
||||
Когда пользователь переходит на страницу плагина (`plugin:calendar:main`),
|
||||
фронтенд посылает событие в Lua:
|
||||
|
||||
```lua
|
||||
-- Не нужно объявлять в plugin.json — вызывается автоматически
|
||||
function on_page_open(page_id, params)
|
||||
-- page_id = "main" или "node_events"
|
||||
-- params = { node_id = "...", date = "2025-06-07" }
|
||||
if page_id == "main" then
|
||||
local events = verstak.db.query("SELECT * FROM events ORDER BY date")
|
||||
-- Данные будут отправлены фронтенду через Wails binding
|
||||
verstak.state.set("page_data", events)
|
||||
-- Фронтенд получит данные через вызов GetPluginPageData()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 5. UI-интеграция — как это работает
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Svelte Frontend │
|
||||
│ │
|
||||
│ ┌──────────────┬──────────────────────────────┐ │
|
||||
│ │ Sidebar │ Main Workspace │ │
|
||||
│ │ │ │ │
|
||||
│ │ 📁 Дела │ ┌─ plugin:calendar:main ──┐ │ │
|
||||
│ │ 📅 Календарь │ │ [Calendar HTML Panel] │ │ │
|
||||
│ │ ⚙ Настройки │ │ │ │ │
|
||||
│ │ │ └────────────────────────┘ │ │
|
||||
│ └──────────────┴──────────────────────────────┘ │
|
||||
│ │
|
||||
│ Go Backend (Wails Bindings) │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ PluginManager │ │
|
||||
│ │ ├─ Lua Runtime (gopher-lua) │ │
|
||||
│ │ ├─ Scheduler │ │
|
||||
│ │ └─ HTTP Server │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.1 Регистрация UI-компонентов
|
||||
|
||||
При старте плагина Go-код:
|
||||
1. Читает `plugin.json`
|
||||
2. Из `ui.sidebar_items` регистрирует пункты в общем реестре сайдбара
|
||||
3. Из `ui.node_tabs` регистрирует вкладки для карточки дела
|
||||
4. Из `ui.node_actions` регистрирует кнопки действий
|
||||
5. Из `ui.settings_pages` регистрирует страницы настроек
|
||||
|
||||
Фронтенд читает реестр через Wails binding и рендерит соответствующие
|
||||
элементы UI.
|
||||
|
||||
### 5.2 HTML-панели
|
||||
|
||||
Плагин может поставлять HTML-панель (`panel` в plugin.json или `panels/`).
|
||||
|
||||
Панель **рендерится в iframe** внутри основного окна.
|
||||
Это изолирует DOM плагина от DOM приложения.
|
||||
|
||||
Каналы связи с бэкендом:
|
||||
- **Данные:** Wails binding `GetPluginPageData(plugin, page)` — Lua возвращает текущие `page_data`
|
||||
- **Действия:** Wails binding `CallPluginAction(plugin, action, params)` — вызывает `on_action` в Lua
|
||||
- **Уведомления:** Lua → фронтенд через `verstak.ui.toast()` (WS или polling)
|
||||
- **Навигация:** iframe может сообщить родителю о смене страницы через `window.parent.postMessage`
|
||||
|
||||
> **Важно:** HTML-панели — временное решение. В будущем плагины
|
||||
> смогут поставлять Svelte-компоненты, но это требует динамической
|
||||
> компиляции и горячей перезагрузки. Для MVP — iframe достаточно.
|
||||
|
||||
### 5.3 Данные для панели
|
||||
|
||||
Панель получает данные через `__VERSTAK_DATA__` (глобальная JS-переменная,
|
||||
инжектится при рендеринге панели). Механизм:
|
||||
|
||||
1. Фронтенд открывает страницу `plugin:calendar:main`
|
||||
2. Go бэкенд вызывает Lua `on_page_open("main", params)`
|
||||
3. Lua сохраняет данные через `verstak.state.set("page_data", ...)`
|
||||
4. Фронтенд вызывает `GetPluginPageData("calendar", "main")`
|
||||
5. Go возвращает Lua-данные, фронтенд инжектит их в iframe
|
||||
|
||||
### 5.4 Кастомные типы узлов
|
||||
|
||||
Плагины могут регистрировать новые типы узлов через `node_types` в plugin.json
|
||||
и/или через Lua API:
|
||||
|
||||
```lua
|
||||
verstak.node.register_type({
|
||||
@@ -88,72 +524,121 @@ verstak.node.register_type({
|
||||
label = "Событие",
|
||||
icon = "calendar",
|
||||
fields = {
|
||||
{ name = "date", label = "Дата", type = "date" },
|
||||
{ name = "time", label = "Время", type = "time" },
|
||||
{ name = "location", label = "Место", type = "text" },
|
||||
{ name = "date", label = "Дата", type = "date" },
|
||||
{ name = "time", label = "Время", type = "time" },
|
||||
{ name = "location", label = "Место", type = "text" },
|
||||
{ name = "color", label = "Цвет", type = "select", options = {"red","blue","green"} },
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
GUI рисует карточку дела на основе зарегистрированных полей типа.
|
||||
GUI рисует карточку дела на основе зарегистрированных полей.
|
||||
Поля сохраняются как метаданные узла.
|
||||
|
||||
## Шаблоны дела
|
||||
## 6. Песочница и безопасность
|
||||
|
||||
Шаблон — JSON-описание предзаполненного дерева:
|
||||
Lua-плагины работают в изолированной среде:
|
||||
|
||||
- **Нет доступа к ФС** напрямую — только через `verstak.*` API (который проверяет пути)
|
||||
- **Нет `io.*`, `os.execute`, `os.popen`** — библиотеки удалены
|
||||
- **Нет `loadlib`** — динамическая загрузка C-библиотек запрещена
|
||||
- **Память:** лимит на размер Lua-стейта (настраивается, по умолчанию 32 MB)
|
||||
- **Инструкции:** защита от бесконечных циклов (максимум N инструкций на вызов, default 10M)
|
||||
- **Сеть:** никаких сокетов из Lua — только через `verstak.http` (контролируемый роутинг)
|
||||
- **Таймаут:** каждый Lua-вызов (хук, HTTP-обработчик, таска) имеет таймаут 30s
|
||||
|
||||
### Конфигурация песочницы
|
||||
|
||||
В `config.json` vault:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Клиент",
|
||||
"icon": "user",
|
||||
"tree": [
|
||||
{ "type": "folder", "title": "Документы" },
|
||||
{ "type": "folder", "title": "Переписка" },
|
||||
{ "type": "folder", "title": "Скриншоты" },
|
||||
{ "type": "note", "title": "Overview" },
|
||||
{ "type": "action", "title": "Открыть сайт", "kind": "open_url", "url": "" }
|
||||
],
|
||||
"meta": [
|
||||
{ "key": "domain", "label": "Домен сайта", "type": "text" },
|
||||
{ "key": "admin_url", "label": "Админка", "type": "url" }
|
||||
]
|
||||
"plugins": {
|
||||
"sandbox": {
|
||||
"max_memory_mb": 32,
|
||||
"max_instructions": 10000000,
|
||||
"call_timeout_ms": 30000,
|
||||
"allow_plugins": ["calendar", "kanban"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
GUI: при создании дела пользователь выбирает шаблон — и дерево
|
||||
создаётся автоматически.
|
||||
## 7. Жизненный цикл — детально
|
||||
|
||||
## Песочница
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Старт программы │
|
||||
│ │
|
||||
│ 1. Инициализация config, storage │
|
||||
│ 2. PluginManager.NewManager(vaultRoot) │
|
||||
│ 3. Discover() — сканирование .verstak/plugins/*/plugin.json │
|
||||
│ 4. Для каждого активного плагина: │
|
||||
│ a. Выполнить SQL-миграции │
|
||||
│ b. Создать Lua-стейт (песочница) │
|
||||
│ c. Загрузить main.lua │
|
||||
│ d. Зарегистрировать node_types, ui. sidebar/tabs/actions │
|
||||
│ e. Вызвать on_init() │
|
||||
│ 5. Открытие vault │
|
||||
│ 6. Для каждого плагина: вызвать on_vault_open(path) │
|
||||
│ 7. Запуск фоновых задач (scheduler) │
|
||||
│ │
|
||||
│ ┌──── Эксплуатация ──────────────────────────────────────┐ │
|
||||
│ │ - Хуки на события дерева │ │
|
||||
│ │ - HTTP-запросы → Lua-обработчики │ │
|
||||
│ │ - UI-навигация → on_page_open() │ │
|
||||
│ │ - Фоновые задачи по расписанию │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 8. Закрытие программы │
|
||||
│ 9. Для каждого плагина: on_shutdown() → закрыть Lua-стейт │
|
||||
│ 10. Остановка scheduler │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Lua-плагины работают в песочнице:
|
||||
## 8. Распространение и CLI
|
||||
|
||||
- нет доступа к файловой системе напрямую (только через API vault);
|
||||
- нет `io.*`, `os.execute` и т.д.;
|
||||
- память ограничена;
|
||||
- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
|
||||
### CLI-команды
|
||||
|
||||
Go-плагины (buildmode=plugin) доступны для продвинутых
|
||||
разработчиков, но требуют совместимости версий.
|
||||
```bash
|
||||
verstak plugin list # Список плагинов
|
||||
verstak plugin enable <name> # Включить
|
||||
verstak plugin disable <name> # Выключить
|
||||
verstak plugin install <name> # Установить из registry (TODO)
|
||||
verstak plugin remove <name> # Удалить
|
||||
```
|
||||
|
||||
## Инициализация
|
||||
### Структура пакета
|
||||
|
||||
При старте `verstak init` создаёт `.verstak/plugins/`.
|
||||
При старте GUI/CLI/TUI:
|
||||
Плагин распространяется как zip-архив:
|
||||
|
||||
1. Сканировать `.verstak/plugins/*/plugin.json`
|
||||
2. Валидировать (имя, версия, структура)
|
||||
3. Загрузить миграции и выполнить
|
||||
4. Загрузить Lua-скрипты через gopher-lua
|
||||
5. Вызвать `on_init` у каждого плагина
|
||||
6. Зарегистрировать node types, HTTP routes, UI panels
|
||||
```bash
|
||||
calendar-1.0.0.vpk # Verstak Plugin Package
|
||||
plugin.json
|
||||
main.lua
|
||||
panels/calendar.html
|
||||
migrations/001_create_tables.sql
|
||||
```
|
||||
|
||||
## Распространение
|
||||
|
||||
Плагин — это zip-архив с правильной структурой.
|
||||
Репозиторий плагинов: `verstak-registry` (отдельный проект).
|
||||
|
||||
Установка:
|
||||
Установка из registry (будущее):
|
||||
```bash
|
||||
verstak plugin install calendar
|
||||
verstak plugin enable calendar
|
||||
verstak plugin list
|
||||
# → скачивает calendar-1.0.0.vpk
|
||||
# → распаковывает в .verstak/plugins/calendar/
|
||||
# → выполняет миграции
|
||||
# → активирует
|
||||
```
|
||||
|
||||
## 9. Пример: Calendar plugin
|
||||
|
||||
(См. `contrib/plugins/calendar/` — будет реализован как эталон.)
|
||||
|
||||
Плагин `calendar` демонстрирует:
|
||||
- Полный цикл: plugin.json → main.lua → panel → миграции
|
||||
- Регистрацию пункта в сайдбаре
|
||||
- Свою HTML-панель с календарной сеткой
|
||||
- Создание/удаление событий через Lua API
|
||||
- Привязку событий к делам
|
||||
- Фоновую задачу (проверка событий на сегодня)
|
||||
- Кастомный тип узла `event`
|
||||
- Настройки плагина через `verstak.config`
|
||||
|
||||
+80
-22
@@ -27,13 +27,12 @@
|
||||
| 14 | **MVP stabilization** | ✅ выполнено |
|
||||
| 15 | **Sync Server + Client** | ✅ выполнено |
|
||||
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
|
||||
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
|
||||
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
|
||||
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
|
||||
| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence |
|
||||
| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
|
||||
| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
|
||||
| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
|
||||
| 17 | **File Scanner/Watcher** | ✅ выполнено — fsnotify, snapshot scanner |
|
||||
| 18 | **Plugins: Lua runtime** | ✅ скелет — gopher-lua, хуки, песочница, шедулер, Calendar-пример |
|
||||
| 19 | **Browser Extension** | ✅ выполнено — tracking, capture, evidence |
|
||||
| 20 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
|
||||
| 21 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
|
||||
| 22 | **New templates/integrations** | ⏳ ожидает — community plugins |
|
||||
|
||||
---
|
||||
|
||||
@@ -193,33 +192,92 @@ Core service extensions:
|
||||
|
||||
---
|
||||
|
||||
## Ожидающие шаги (17-23)
|
||||
## Завершённый этап: ШАГ 17 — File Scanner/Watcher
|
||||
|
||||
### ШАГ 17 — File Scanner/Watcher
|
||||
- fsnotify watcher на vault
|
||||
- snapshot scanner для обнаружения изменений при закрытом приложении
|
||||
- обнаружение missing файлов
|
||||
**Статус:** ✅ выполнено
|
||||
|
||||
### ШАГ 18 — TUI MVP (Bubble Tea)
|
||||
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
|
||||
Что реализовано:
|
||||
- `internal/core/watcher/scanner.go` — Snapshot scanner:
|
||||
- Итерирует все ноды с FsPath, сканирует их директории на диске
|
||||
- Детектит missing файлы (запись в БД есть — файла нет) → MarkMissing + activity event
|
||||
- Детектит restored файлы (был missing — снова на диске) → MarkMissing(false) + activity event
|
||||
- Детектит modified файлы (SHA256 не совпал) → обновление размера/SHA в БД + activity event
|
||||
- Считает new файлы на диске без записи в БД (не авто-добавляет, только счётчик)
|
||||
- Пропускает `.verstak/` и скрытые директории
|
||||
- `internal/core/watcher/watcher.go` — fsnotify Watcher:
|
||||
- Реальное время: CREATE/REMOVE/RENAME/WRITE события
|
||||
- Debounce 2 секунды для группировки burst-событий
|
||||
- CREATE → авто-добавление file record + `file_added` event
|
||||
- REMOVE → MarkMissing(true) + `file_deleted` event
|
||||
- WRITE → обновление SHA256 + `file_modified` event
|
||||
- Рекурсивное добавление watcher-ов на поддиректории
|
||||
- Пропускает скрытые файлы и `.verstak/`
|
||||
- `internal/core/watcher/service.go` — Объединённый сервис:
|
||||
- Start(enableWatcher) → snapshot scan + опционально запуск real-time watcher
|
||||
- Stop() → остановка watcher
|
||||
- RunScanner() → одноразовое сканирование
|
||||
- `internal/core/activity/activity.go` — Новые типы событий: `file_modified`, `file_restored`
|
||||
- `internal/core/storage/migrations_017.sql.go` — Таблица `watcher_state` для трекинга состояния файлов
|
||||
- `internal/core/config/appconfig.go` — Настройка `Vault.FileWatcher` (по умолчанию true)
|
||||
- `cmd/verstak-gui/app.go` — Интеграция в App: initVault запускает сканер + watcher, closeVault останавливает
|
||||
- `cmd/verstak-gui/bindings_watcher.go` — Bindings: WatcherStatus(), RunSnapshotScan(), ToggleFileWatcher()
|
||||
- `internal/core/watcher/watcher_test.go` — 5 тестов: no changes, missing, restored, modified, hidden filter
|
||||
- `internal/core/files/file.go` — Новые методы: ListAllVault(), ListAllVaultWithTrashed()
|
||||
- `internal/core/nodes/repository.go` — Новый метод: ListAllWithFsPath()
|
||||
|
||||
### ШАГ 19 — Plugins: Lua runtime
|
||||
- gopher-lua runtime
|
||||
- хуки на события (node created, file added, etc.)
|
||||
- песочница
|
||||
### Как включить/отключить
|
||||
|
||||
### ШАГ 20 — Browser Extension
|
||||
| Способ | Команда | Действие |
|
||||
|--------|---------|----------|
|
||||
| **GUI** | Settings → "File Watcher" toggle | Вкл/выкл real-time, snapshot всегда |
|
||||
| **Config** | `~/.config/verstak/config.json` → `vault.file_watcher: false` | Навсегда |
|
||||
| **Env** | `VERSTAK_NO_WATCHER=1 ./verstak-gui` | На сессию (переопределяет config) |
|
||||
| **CLI** | `./verstak-gui --no-watcher` | Один запуск (переопределяет всё) |
|
||||
|
||||
Важно: **snapshot scan** (сверка диска с БД) выполняется ВСЕГДА при открытии vault.
|
||||
Real-time fsnotify watcher управляется отдельно.
|
||||
|
||||
### Как проверить что работает
|
||||
|
||||
После запуска в логе:
|
||||
```
|
||||
[watcher] snapshot: 0 missing, 0 restored, 0 modified, 0 new
|
||||
```
|
||||
|
||||
Через GUI: `WatcherStatus()` — true если watcher активен.
|
||||
Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`.
|
||||
|
||||
## Выполненные шаги (18-22 частично)
|
||||
|
||||
### ШАГ 18 — Plugins: Lua runtime ✅ скелет
|
||||
|
||||
**Статус:** ✅ Скелет готов — рантайм, хуки, песочница, шедулер, Calendar-пример.
|
||||
|
||||
Что реализовано:
|
||||
- `internal/core/plugins/runtime.go` — gopher-lua VM: загрузка main.lua, вызов хуков, таймауты
|
||||
- `internal/core/plugins/api.go` — регистрация таблицы `verstak` с субмодулями
|
||||
- `internal/core/plugins/api_node.go` — `verstak.node.*` (заглушки, ждут реализации)
|
||||
- `internal/core/plugins/api_ext.go` — `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.ui.*`, `verstak.schedule.*`, `verstak.http.*` (state — работает, остальное — заглушки)
|
||||
- `internal/core/plugins/api_utils.go` — вспомогательные функции
|
||||
- `internal/core/plugins/scheduler.go` — фоновые задачи по расписанию (goroutine + ticker)
|
||||
- `internal/core/plugins/manager.go` — обновлён: InitRuntimes, CallInitHooks, StartSchedulers, etc.
|
||||
- `internal/core/plugins/runtime_test.go` — 5 тестов: hook call, missing hook, sandbox, state, Manager lifecycle
|
||||
- `contrib/plugins/calendar/` — пример-плагин: plugin.json, main.lua, HTML-panel, migrations, background task
|
||||
|
||||
**Следующее:** реализовать `verstak.node.*`, `verstak.db.*`, `verstak.config.*`, `verstak.ui.*` полноценно, и пройтись по Calendar-плагину вживую.
|
||||
|
||||
### ШАГ 19 — Browser Extension
|
||||
- Расширение для браузера: трекинг времени, скриншоты, evidence
|
||||
|
||||
### ШАГ 21 — Calendar/Kanban
|
||||
### ШАГ 20 — Calendar/Kanban
|
||||
- Календарный вид по датам
|
||||
- Канбан-доска по статусам
|
||||
|
||||
### ШАГ 22 — Integrity Check + Repair
|
||||
### ШАГ 21 — Integrity Check + Repair
|
||||
- Проверка целостности vault
|
||||
- Контрольные суммы, восстановление после сбоев
|
||||
|
||||
### ШАГ 23 — New templates/integrations
|
||||
### ШАГ 22 — New templates/integrations
|
||||
- Community-плагины и шаблоны
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Frontend Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Verstak frontend is a Svelte 3 application running inside Wails v2 (Go bridge).
|
||||
The app manages a hierarchical vault of nodes (folders/cases, notes, files, links, actions)
|
||||
with sync capabilities, worklog/journal, and activity tracking.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **UI Framework:** Svelte 3 (plain JS, no TypeScript in components)
|
||||
- **Desktop Bridge:** Wails v2 (`window.go.main.App.*`)
|
||||
- **Bundler:** Vite (via Wails)
|
||||
- **Markdown:** Custom renderer in `lib/markdown/`
|
||||
- **i18n:** Custom lightweight system in `lib/i18n/`
|
||||
- **Styling:** Scoped CSS in Svelte components, dark theme
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── App.svelte # Root component (being modularised)
|
||||
├── TreeNode.svelte # Tree node for sidebar (inline)
|
||||
├── FileTreeRow.svelte # File row in file tab (inline)
|
||||
├── wailsjs/go/main/App.js # Auto-generated Wails bindings
|
||||
├── lib/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ └── notes/
|
||||
│ │ ├── NoteEditorPanel.svelte
|
||||
│ │ ├── MarkdownEditor.svelte
|
||||
│ │ ├── MarkdownPreview.svelte
|
||||
│ │ ├── InternalLinkPicker.svelte
|
||||
│ │ └── ObjectPickerModal.svelte
|
||||
│ ├── services/ # API/Data access layer
|
||||
│ │ ├── wails.js # Base Wails call helper
|
||||
│ │ ├── notes.js # Notes API
|
||||
│ │ ├── files.js # Files API
|
||||
│ │ ├── search.js # Search API
|
||||
│ │ ├── inbox.js # Inbox API
|
||||
│ │ ├── trash.js # Trash API
|
||||
│ │ ├── sync.js # Sync API
|
||||
│ │ ├── journal.js # Journal/Worklog API
|
||||
│ │ ├── actions.js # Actions API
|
||||
│ │ ├── links.js # Links API
|
||||
│ │ └── activity.js # Activity API
|
||||
│ ├── state/ # State management (planned)
|
||||
│ │ ├── navigation.js # Navigation state
|
||||
│ │ └── uiState.js # UI state
|
||||
│ ├── markdown/ # Markdown processing
|
||||
│ │ ├── markdown.ts
|
||||
│ │ └── internalLinks.ts
|
||||
│ ├── i18n/ # Internationalisation
|
||||
│ │ ├── index.js
|
||||
│ │ └── locales/
|
||||
│ │ ├── en.js
|
||||
│ │ └── ru.js
|
||||
│ ├── util/ # Utilities
|
||||
│ │ ├── keyboardLayout.ts
|
||||
│ │ └── markdown.test.js
|
||||
│ ├── AppHeader.svelte
|
||||
│ ├── GlobalSearch.svelte
|
||||
│ ├── FileBreadcrumbs.svelte
|
||||
│ ├── FileIcon.svelte
|
||||
│ ├── FilePreviewModal.svelte
|
||||
│ ├── ConfirmModal.svelte
|
||||
│ ├── TodayScreen.svelte
|
||||
│ ├── BrowserEvents.svelte
|
||||
│ ├── FirstRun.svelte
|
||||
│ ├── VaultRecovery.svelte
|
||||
│ ├── SyncStatus.svelte
|
||||
│ ├── TemplateIcon.svelte
|
||||
│ ├── CalendarPluginPage.svelte
|
||||
│ ├── SettingsWindow.svelte
|
||||
│ ├── SettingsSidebar.svelte
|
||||
│ ├── SettingsGeneral.svelte
|
||||
│ ├── SettingsSync.svelte
|
||||
│ ├── SettingsPlugins.svelte
|
||||
│ ├── SettingsBrowserBridge.svelte
|
||||
│ ├── SettingsWorkspace.svelte
|
||||
│ ├── SettingsTemplates.svelte
|
||||
│ ├── SettingsFiles.svelte
|
||||
│ ├── SettingsBackup.svelte
|
||||
│ ├── SettingsActivity.svelte
|
||||
│ ├── actionIcons.js
|
||||
│ └── fileUtils.js
|
||||
```
|
||||
|
||||
## Wails Bridge
|
||||
|
||||
All backend calls go through `window.go.main.App[method](...)`.
|
||||
The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
|
||||
|
||||
## Planned Components (to extract from App.svelte)
|
||||
|
||||
### Layout
|
||||
- `AppShell.svelte` — root layout wrapper
|
||||
- `Sidebar.svelte` — navigation sidebar
|
||||
- `MainWorkspace.svelte` — main content area
|
||||
|
||||
### Pages/Tab Content
|
||||
- `OverviewTab.svelte` — node overview with meta and quick actions
|
||||
- `NotesTab.svelte` — notes list and creation
|
||||
- `FilesTab.svelte` — file browser with breadcrumbs
|
||||
- `InboxContent.svelte` + `InboxFullScreen.svelte`
|
||||
- `LinksTab.svelte`
|
||||
- `ActionsTab.svelte`
|
||||
- `WorklogTab.svelte`
|
||||
- `ActivityTabContent.svelte`
|
||||
- `TrashContent.svelte`
|
||||
- `JournalScreen.svelte`
|
||||
- `ActivityFeedScreen.svelte`
|
||||
- `WelcomeScreen.svelte`
|
||||
|
||||
### Modals
|
||||
- `CreateNodeModal.svelte`
|
||||
- `WorklogModal.svelte`
|
||||
- `CreateActionModal.svelte`
|
||||
- `ImportModal.svelte`
|
||||
- `RenameModal.svelte`
|
||||
- `AssignInboxModal.svelte`
|
||||
- `EditLinkModal.svelte`
|
||||
- `LinkInsertModal.svelte`
|
||||
- `NoteRenameModal.svelte`
|
||||
- `ContextMenu.svelte`
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User interacts with UI component
|
||||
2. Component calls a service function (e.g., `notesApi.createNote(...)`)
|
||||
3. Service calls `wailsCall('CreateNote', ...)`
|
||||
4. Wails bridge forwards to Go backend
|
||||
5. Go backend returns result → Wails → service → component updates state
|
||||
|
||||
## State Management
|
||||
|
||||
Currently all state lives in App.svelte as local variables.
|
||||
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`.
|
||||
|
||||
### Files Flow
|
||||
|
||||
- **Component:** `lib/components/files/FilesTab.svelte` — self-contained file browser
|
||||
- **API services:** `lib/services/files.js`, `lib/services/nodes.js`
|
||||
- **Events emitted:**
|
||||
- `on:openNote` — when a .md file linked to a note is opened
|
||||
- `on:refreshParent` — after file operations that modify the tree
|
||||
- `on:error` — on operation failures
|
||||
- `on:rename` — requests parent to show rename modal
|
||||
- `on:confirm` — requests parent to show confirm dialog
|
||||
- **Public methods:**
|
||||
- `resetToNode(nodeId)` — reset state when selected node changes
|
||||
- `filesHandleKeydown(e)` — keyboard handler for files tab
|
||||
- **.md → note editor flow:** Handled inside FilesTab via `CheckFileAction` Wails call. If action is 'note', emits `openNote`. If 'external', opens in system viewer. Otherwise shows built-in preview.
|
||||
- **File preview:** `FilePreviewModal.svelte` (already existed), invoked by FilesTab
|
||||
- **Import dialog:** Inline in FilesTab template (moved from App.svelte)
|
||||
|
||||
## Build & Verification
|
||||
|
||||
- `npm run build` in `frontend/` directory
|
||||
- `go test ./...` from project root
|
||||
- `bash scripts/build.sh gui` for full GUI binary
|
||||
- Manual smoke testing via Wails dev server
|
||||
@@ -0,0 +1,107 @@
|
||||
# Frontend Change Map
|
||||
|
||||
## Purpose
|
||||
|
||||
This document tracks the refactoring of `App.svelte` from a 4794-line monolith
|
||||
into a modular frontend architecture. Each step preserves behaviour exactly.
|
||||
|
||||
## Phase 1: Documentation & Foundation
|
||||
|
||||
- [x] Audit App.svelte (all 4794 lines read and mapped)
|
||||
- [x] Create `docs/frontend-architecture.md`
|
||||
- [x] Create `docs/frontend-change-map.md`
|
||||
|
||||
## Phase 2: API Layer
|
||||
|
||||
Extract all Wails calls into service modules.
|
||||
|
||||
- [x] Create `lib/services/wails.js` — base `wailsCall` helper
|
||||
- [x] Create `lib/services/notes.js` — `listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
|
||||
- [x] Create `lib/services/files.js` — `loadFolder`, `addFile`, `deleteFile`, etc.
|
||||
- [x] Create `lib/services/search.js` — `searchNodes`, `getNodeDetail`, `searchWorkspace`
|
||||
- [x] Create `lib/services/inbox.js` — inbox capture and management
|
||||
- [x] Create `lib/services/trash.js` — trash operations
|
||||
- [x] Create `lib/services/sync.js` — sync status and trigger
|
||||
- [x] Create `lib/services/journal.js` — worklog CRUD and reports
|
||||
- [x] Create `lib/services/actions.js` — actions CRUD
|
||||
- [x] Create `lib/services/links.js` — links CRUD
|
||||
- [x] Create `lib/services/activity.js` — activity feed
|
||||
- [x] Create `lib/services/nodes.js` — tree, node CRUD, system views
|
||||
- [x] Create `lib/services/suggestions.js` — worklog suggestions
|
||||
- [x] Create `lib/services/today.js` — today dashboard
|
||||
- [x] Create `lib/services/browserEvents.js` — browser extension events
|
||||
- [x] Create `lib/services/search.js` — `searchNodes`
|
||||
- [x] Create `lib/services/inbox.js` — `listInbox`, `captureClipboard`, etc.
|
||||
- [x] Create `lib/services/trash.js` — `loadTrash`, `restore`, `purge`
|
||||
- [x] Create `lib/services/sync.js` — `loadSyncStatus`, `runSync`
|
||||
- [x] Create `lib/services/journal.js` — `loadJournal`, `worklog CRUD`
|
||||
- [x] Create `lib/services/actions.js` — `listActions`, `createAction`, `deleteAction`
|
||||
- [x] Create `lib/services/links.js` — `listLinks`, `updateLink`, `deleteLink`
|
||||
- [x] Create `lib/services/activity.js` — `loadActivityFeed`, `loadCaseActivity`
|
||||
|
||||
## Phase 3: State Extraction
|
||||
|
||||
- [ ] Create `lib/state/navigation.js` — `selectedSection`, `selectedNode`, `activeTab`, `navHistory`
|
||||
- [ ] Create `lib/state/uiState.js` — modals state, confirm state, rename state, drag state
|
||||
|
||||
## Phase 4: Component Extraction — Layout
|
||||
|
||||
- [ ] Extract `Sidebar.svelte` — brand, nav items, workspace tree, footer
|
||||
- [ ] Extract `MainWorkspace.svelte` — content routing
|
||||
- [ ] Create `AppShell.svelte` — root layout wrapper
|
||||
|
||||
## Phase 5: Component Extraction — Tab Content
|
||||
|
||||
- [x] Extract `OverviewTab.svelte`
|
||||
- [x] Extract `NotesTab.svelte`
|
||||
- [x] Extract `FilesTab.svelte`
|
||||
- [ ] Extract `LinksTab.svelte`
|
||||
- [ ] Extract `ActionsTab.svelte`
|
||||
- [ ] Extract `WorklogTab.svelte`
|
||||
- [ ] Extract `ActivityTabContent.svelte`
|
||||
- [ ] Extract `InboxContent.svelte`
|
||||
- [ ] Extract `InboxFullScreen.svelte`
|
||||
- [ ] Extract `TrashContent.svelte`
|
||||
- [ ] Extract `JournalScreen.svelte`
|
||||
- [ ] Extract `ActivityFeedScreen.svelte`
|
||||
- [ ] Extract `WelcomeScreen.svelte`
|
||||
|
||||
## Phase 6: Component Extraction — Modals
|
||||
|
||||
- [ ] Extract `CreateNodeModal.svelte`
|
||||
- [ ] Extract `WorklogModal.svelte`
|
||||
- [ ] Extract `CreateActionModal.svelte`
|
||||
- [ ] Extract `ImportModal.svelte`
|
||||
- [ ] Extract `RenameModal.svelte`
|
||||
- [ ] Extract `AssignInboxModal.svelte`
|
||||
- [ ] Extract `EditLinkModal.svelte`
|
||||
- [ ] Extract `ContextMenu.svelte`
|
||||
|
||||
## Phase 7: Extract Inline Components
|
||||
|
||||
- [ ] Extract `NoteEditorHeader.svelte` (note editor header with rename)
|
||||
- [ ] Extract `ErrorBanner.svelte`
|
||||
- [ ] Extract `CaptureDropOverlay.svelte`
|
||||
- [ ] Extract `SidebarFooter.svelte`
|
||||
|
||||
## Phase 8: Verification
|
||||
|
||||
- [ ] `npm run build` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Smoke checklist:
|
||||
1. Sidebar renders with system views
|
||||
2. Workspace tree loads and is expandable
|
||||
3. Selecting a node shows tabs
|
||||
4. Overview tab shows metadata and quick actions
|
||||
5. Notes tab — create, rename, delete notes
|
||||
6. Note editor — edit, preview, save, internal links, external links
|
||||
7. Files tab — browse, add file/folder, navigate breadcrumbs
|
||||
8. File preview — open, close
|
||||
9. Inbox — list, sort, group, assign, delete
|
||||
10. Trash — browse, restore, purge
|
||||
11. Journal — filter, export, worklog CRUD
|
||||
12. Activity feed — load and open events
|
||||
13. Today screen — dashboard, suggestions, browser events
|
||||
14. Settings — open/close, sections
|
||||
15. Context menu on workspace tree
|
||||
16. Create node modal — templates
|
||||
@@ -0,0 +1,203 @@
|
||||
# Plugin Security Model
|
||||
|
||||
## Overview
|
||||
|
||||
Verstak's Lua plugin system is designed with a **sandbox-first** approach. Plugins run in isolated Lua 5.1 states with restricted access to the host system. This document defines the security boundaries.
|
||||
|
||||
## Lua Sandbox
|
||||
|
||||
### Blocked Global Functions
|
||||
|
||||
The following Lua globals are set to `nil` in every plugin VM:
|
||||
|
||||
| Function | Reason |
|
||||
|----------|--------|
|
||||
| `dofile` | Arbitrary file read/execute |
|
||||
| `loadfile` | Arbitrary file read |
|
||||
| `require` | Module loading (no filesystem access) |
|
||||
| `module` | Module system (deprecated, unsafe) |
|
||||
| `load` | Arbitrary code execution from string |
|
||||
| `rawequal` | Bypass metatable protections |
|
||||
| `rawget` | Bypass metatable protections |
|
||||
| `rawset` | Bypass metatable protections |
|
||||
| `rawlen` | Bypass metatable protections |
|
||||
| `setfenv` | Environment manipulation |
|
||||
| `getfenv` | Environment manipulation |
|
||||
|
||||
### Blocked Libraries
|
||||
|
||||
| Library | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| `io` | **Not opened** | No file I/O |
|
||||
| `debug` | **Not opened** | No debug introspection |
|
||||
| `coroutine` | **Not opened** | No coroutine creation |
|
||||
| `os` | **Restricted** | Only `clock`, `date`, `difftime`, `time`, `tmpname` |
|
||||
| `package` | **Restricted** | Only `seeall` is available; `loadlib`, `preload`, `loaders`, `loaded`, `path`, `cpath`, `config`, `searchpath` are blocked |
|
||||
|
||||
### Restricted `os.*` Functions
|
||||
|
||||
| Function | Status | Reason |
|
||||
|----------|--------|--------|
|
||||
| `os.clock` | ✅ Allowed | Safe timing |
|
||||
| `os.date` | ✅ Allowed | Safe date formatting |
|
||||
| `os.difftime` | ✅ Allowed | Safe time diff |
|
||||
| `os.time` | ✅ Allowed | Safe timestamp |
|
||||
| `os.tmpname` | ✅ Allowed | Safe temp name |
|
||||
| `os.execute` | ❌ Blocked | Shell command execution |
|
||||
| `os.exit` | ❌ Blocked | Process termination |
|
||||
| `os.remove` | ❌ Blocked | File deletion |
|
||||
| `os.rename` | ❌ Blocked | File renaming |
|
||||
| `os.setlocale` | ❌ Blocked | Locale manipulation |
|
||||
| `os.getenv` | ❌ Blocked | Environment variable access |
|
||||
|
||||
### Safe Libraries (Available)
|
||||
|
||||
- `string` — string manipulation
|
||||
- `table` — table operations
|
||||
- `math` — mathematical functions
|
||||
- `type`, `tostring`, `tonumber` — type operations
|
||||
- `pcall`, `xpcall` — protected calls
|
||||
- `error`, `assert` — error handling
|
||||
- `select`, `unpack` — argument manipulation
|
||||
- `pairs`, `ipairs`, `next` — iteration
|
||||
- `collectgarbage` — GC control
|
||||
|
||||
### Print Redirection
|
||||
|
||||
`print()` is redirected to Go's `log.Printf` with the `[lua]` prefix. Plugins cannot write to stdout directly.
|
||||
|
||||
## Plugin Function Call Security
|
||||
|
||||
### `CallPluginFunction` Validation
|
||||
|
||||
When the frontend calls a plugin function via `CallPluginFunction(pluginName, funcName, paramsJSON)`:
|
||||
|
||||
1. **funcName validation**: Only `[a-zA-Z_][a-zA-Z0-9_]*` segments separated by dots (max 2 dots, 3 segments)
|
||||
2. **No string-based code generation**: Functions are resolved via `_G` table walk, not `DoString`
|
||||
3. **Direct PCall**: Arguments are pushed as `lua.LValue` directly, not serialized to Lua code
|
||||
4. **Plugin must be active**: Only enabled plugins can receive calls
|
||||
|
||||
### Blocked funcName Patterns
|
||||
|
||||
```
|
||||
❌ "func;os.execute('id')" — semicolon injection
|
||||
❌ "func..name" — empty segment
|
||||
❌ "func[name]" — bracket injection
|
||||
❌ "func()" — parentheses
|
||||
❌ "func name" — space injection
|
||||
❌ "func-name" — special chars
|
||||
❌ "a.b.c.d" — too deep (>3 segments)
|
||||
❌ "123func" — starts with number
|
||||
```
|
||||
|
||||
## iframe Bridge Security
|
||||
|
||||
### PluginPage.svelte ↔ iframe Communication
|
||||
|
||||
1. **Source verification**: Messages are only accepted from the specific `iframeEl.contentWindow` (not just any `source: 'calendar-plugin'`)
|
||||
2. **Type checking**: `msg.source`, `msg.action` must be strings
|
||||
3. **sandbox attribute**: iframe uses `sandbox="allow-scripts allow-same-origin"` (no forms, no popups)
|
||||
4. **srcdoc**: iframe content is injected as `srcdoc` (same origin, no external network)
|
||||
|
||||
### Message Protocol
|
||||
|
||||
All messages must have:
|
||||
```js
|
||||
{ source: 'calendar-plugin', action: 'ready' | 'get-events' | 'create-event' | ... }
|
||||
```
|
||||
|
||||
Messages from iframe to Svelte are validated:
|
||||
- `e.source === iframeEl.contentWindow` (origin check)
|
||||
- `msg.source === 'calendar-plugin'` (source check)
|
||||
- `typeof msg.action === 'string'` (type check)
|
||||
|
||||
## Plugin Lifecycle Security
|
||||
|
||||
### State Separation
|
||||
|
||||
| State | Description | Data Persistence |
|
||||
|-------|-------------|-----------------|
|
||||
| **Discovered** | Plugin found on disk | None |
|
||||
| **Installed** | `on_install` hook ran, tables created | DB tables exist |
|
||||
| **Enabled** | Plugin is active, Lua VM running | Full access |
|
||||
| **Active** | Runtime is initialized | Full access |
|
||||
| **Disabled** | Plugin deactivated, data preserved | DB tables exist |
|
||||
| **Uninstalled** | `on_uninstall` hook ran, tables dropped | Data cleaned |
|
||||
|
||||
### Idempotency
|
||||
|
||||
- `Install` on already-installed plugin → error (no duplicate config entries)
|
||||
- `Uninstall` on non-installed plugin → error
|
||||
- `Enable` on non-installed plugin → error
|
||||
- `Disable` on already-disabled plugin → no-op (error, but harmless)
|
||||
- `ReloadPlugins` → stops schedulers, calls shutdown hooks, closes VMs, re-discovers
|
||||
|
||||
### ReloadPlugins Cleanup Order
|
||||
|
||||
1. `StopSchedulers()` — stops all background tasks, sets `scheduler = nil`
|
||||
2. `CallShutdownHooks()` — calls `on_shutdown` for all active plugins
|
||||
3. `CloseRuntimes()` — closes all Lua VMs, sets `vm = nil`
|
||||
4. `Discover()` — re-scans plugins directory
|
||||
5. `SyncConfig()` — applies installed/enabled state from config
|
||||
6. `InitRuntimes()` — creates new VMs and schedulers
|
||||
7. `CallInitHooks()` — calls `on_init` for all active plugins
|
||||
8. `StartSchedulers()` — starts background tasks
|
||||
|
||||
## Network Access
|
||||
|
||||
Plugins have **no direct network access**. All HTTP/network operations must go through the `verstak.http.*` API (registered by the core), which:
|
||||
|
||||
- Uses Go's `net/http` (not Lua socket)
|
||||
- Can be rate-limited by the core
|
||||
- Goes through the proxy if configured
|
||||
|
||||
## Filesystem Access
|
||||
|
||||
Plugins have **no direct filesystem access**. All file operations must go through:
|
||||
|
||||
- `verstak.db.*` — SQLite queries (plugin's own DB only)
|
||||
- `verstak.node.*` — Node CRUD (via core services)
|
||||
- `verstak.files.*` — File operations (via core services)
|
||||
|
||||
The `io` library is not opened. `os.remove`, `os.rename` are blocked.
|
||||
|
||||
## Database Isolation
|
||||
|
||||
Each plugin gets its own SQLite database at `.verstak/plugins/<name>/data/`. Plugins cannot access:
|
||||
- The main Verstak database
|
||||
- Other plugins' databases
|
||||
- System databases
|
||||
|
||||
## Calendar Plugin (Reference)
|
||||
|
||||
The Calendar plugin in `contrib/plugins/calendar/` is a **reference implementation**, not a core feature:
|
||||
|
||||
- It is **not** loaded by default
|
||||
- It requires explicit `Install` + `Enable`
|
||||
- Its tables are created only during `on_install`
|
||||
- It can be fully `Uninstall`ed without affecting core functionality
|
||||
- It does not add calendar UI to any core views
|
||||
- It demonstrates the full plugin API: `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.node.*`, `verstak.activity.*`, `verstak.schedule.*`, `verstak.http.*`
|
||||
|
||||
## Security Tests
|
||||
|
||||
All security properties are verified by tests in `internal/core/plugins/security_test.go`:
|
||||
|
||||
- `TestLuaVM_Sandbox_Comprehensive` — 12 dangerous APIs blocked
|
||||
- `TestLuaVM_Sandbox_SafeLibs` — 9 safe libraries available
|
||||
- `TestLuaVM_Sandbox_GlobalsRemoved` — 11 dangerous globals nil
|
||||
- `TestLuaVM_Sandbox_OsRestricted` — 6 os.* functions blocked, 5 safe ones available
|
||||
- `TestLuaVM_Sandbox_IoBlocked` — io library not opened
|
||||
- `TestLuaVM_Sandbox_DebugBlocked` — debug library not opened
|
||||
- `TestLuaVM_Sandbox_CoroutineBlocked` — coroutine library not opened
|
||||
- `TestLuaVM_Sandbox_Package*Blocked` — 9 package.* fields blocked
|
||||
- `TestLuaVM_Sandbox_PrintIsRedirected` — print goes to Go log
|
||||
- `TestLuaVM_DoStringTimeout` — infinite loops killed by timeout
|
||||
- `TestLuaVM_Isolation` — plugins cannot see each other's globals
|
||||
- `TestLuaVM_ClosedVM` — operations on closed VM return errors
|
||||
- `TestLuaVM_DoString_InjectionAttempt` — dangerous APIs return nil
|
||||
- `TestPluginManager_InstallIdempotent` — no duplicate config entries
|
||||
- `TestPluginManager_DisablePreservesData` — Disable keeps data
|
||||
- `TestPluginManager_UninstallRemovesFromConfig` — Uninstall cleans config
|
||||
- `TestPluginManager_ReloadPlugins_Cleanup` — full cleanup on reload
|
||||
- `TestCallPluginFunction_Validation` — funcName validation (5 valid, 10 invalid cases)
|
||||
@@ -0,0 +1 @@
|
||||
{"uploadUuid":"8634d0ba5e1641b2a504640b5992b540","channel":"unlisted","xpiCrcHash":"124e35e2b615071abcbccd2e75a8f4247f42ddb700077632affa3a7c3c82c2ef"}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Verstak Bridge for Firefox — Background Service Worker
|
||||
// Uses browser.* WebExtensions API with chrome.* polyfill layer.
|
||||
//
|
||||
// IMPORTANT: All event listeners are registered at global scope,
|
||||
// NOT inside onInstalled/onStartup. MV3 service workers can be restarted at
|
||||
// any time; listeners inside lifecycle hooks are lost on restart.
|
||||
|
||||
// Firefox uses browser.* namespace; provide chrome.* aliases for shared code.
|
||||
if (typeof browser !== 'undefined' && typeof chrome === 'undefined') {
|
||||
globalThis.chrome = browser;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'verstak_queue';
|
||||
const BRIDGE_KEY = 'verstak_bridge_config';
|
||||
const RECENT_KEY = 'verstak_recent';
|
||||
const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false };
|
||||
const DEFAULT_DEVICE_ID = generateDeviceId();
|
||||
|
||||
// Session state
|
||||
let session = {
|
||||
url: '',
|
||||
title: '',
|
||||
domain: '',
|
||||
tabId: -1,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// --- Global event listeners (registered once at script load) ---
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
setupAlarms();
|
||||
// Generate a stable device ID on first install
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const cfg = data[BRIDGE_KEY] || {};
|
||||
if (!cfg.deviceId) {
|
||||
chrome.storage.local.set({ [BRIDGE_KEY]: { ...DEFAULT_CONFIG, ...cfg, deviceId: DEFAULT_DEVICE_ID } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
setupAlarms();
|
||||
});
|
||||
|
||||
// Tab tracking — global scope
|
||||
chrome.tabs.onActivated.addListener(onTabActivated);
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
|
||||
|
||||
// Alarm handler — global scope
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'flushEvents') flushSession();
|
||||
if (alarm.name === 'pingBridge') pingBridge();
|
||||
});
|
||||
|
||||
// Message handler — global scope
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'FORCE_FLUSH') {
|
||||
flushSession();
|
||||
flushQueue();
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
if (msg.type === 'FORCE_PING') {
|
||||
pingBridge();
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
if (msg.type === 'SET_TRACKING') {
|
||||
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// --- Alarm setup ---
|
||||
|
||||
function setupAlarms() {
|
||||
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
|
||||
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
|
||||
}
|
||||
|
||||
// --- Tab tracking ---
|
||||
|
||||
function onTabActivated(activeInfo) {
|
||||
flushSession();
|
||||
startTracking(activeInfo.tabId);
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId, changeInfo, tab) {
|
||||
if (changeInfo.status === 'complete' && tab.active) {
|
||||
flushSession();
|
||||
startTracking(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowFocusChanged(windowId) {
|
||||
if (windowId === chrome.windows.WINDOW_ID_NONE) {
|
||||
flushSession();
|
||||
session = { url: '', title: '', domain: '', tabId: -1, startedAt: null };
|
||||
} else {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs && tabs.length > 0) {
|
||||
startTracking(tabs[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startTracking(tabId) {
|
||||
chrome.tabs.get(tabId, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab || !tab.url) return;
|
||||
if (isInternalPage(tab.url)) return;
|
||||
|
||||
session = {
|
||||
url: tab.url,
|
||||
title: tab.title || '',
|
||||
domain: extractDomain(tab.url),
|
||||
tabId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Session flush ---
|
||||
|
||||
function flushSession() {
|
||||
if (!session.url || !session.startedAt) return;
|
||||
|
||||
const duration = Math.round((Date.now() - session.startedAt) / 1000);
|
||||
if (duration < 3) {
|
||||
session.startedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: generateId(),
|
||||
type: 'page_visit',
|
||||
url: session.url,
|
||||
title: session.title,
|
||||
domain: session.domain,
|
||||
active_seconds: duration,
|
||||
ts_start: new Date(session.startedAt).toISOString(),
|
||||
ts_end: new Date().toISOString(),
|
||||
};
|
||||
|
||||
queueEvent(event);
|
||||
session.startedAt = Date.now();
|
||||
}
|
||||
|
||||
function queueEvent(event) {
|
||||
chrome.storage.local.get(STORAGE_KEY, (data) => {
|
||||
const queue = data[STORAGE_KEY] || [];
|
||||
queue.push(event);
|
||||
|
||||
if (queue.length > 500) {
|
||||
queue.splice(0, queue.length - 500);
|
||||
}
|
||||
|
||||
chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => {
|
||||
if (queue.length >= 5) {
|
||||
flushQueue();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bridge communication ---
|
||||
|
||||
function flushQueue() {
|
||||
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => {
|
||||
const queue = data[STORAGE_KEY] || [];
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
|
||||
const port = config.port || 9786;
|
||||
const secret = config.secret || '';
|
||||
const deviceId = config.deviceId || DEFAULT_DEVICE_ID;
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
// Only send secret header if it's a non-empty string
|
||||
if (secret && typeof secret === 'string' && secret.length > 0) {
|
||||
headers['X-Verstak-Secret'] = secret;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
device_id: 'firefox-' + deviceId,
|
||||
events: queue,
|
||||
};
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/events`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
for (const ev of queue) {
|
||||
updateRecent(ev);
|
||||
}
|
||||
chrome.storage.local.set({ [STORAGE_KEY]: [] });
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, bridgeReachable: true, lastPing: Date.now() }
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
|
||||
} else if (res.status === 401) {
|
||||
console.warn('[verstak] bridge auth failed, check secret');
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
|
||||
});
|
||||
} else {
|
||||
console.warn('[verstak] bridge error', res.status);
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[verstak] bridge unreachable:', err.message);
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pingBridge() {
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
|
||||
const port = config.port || 9786;
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/ping`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((info) => {
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() }
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
|
||||
})
|
||||
.catch(() => {
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Recent events ---
|
||||
|
||||
function updateRecent(event) {
|
||||
chrome.storage.local.get(RECENT_KEY, (data) => {
|
||||
const recent = data[RECENT_KEY] || [];
|
||||
recent.push({
|
||||
domain: event.domain,
|
||||
title: event.title,
|
||||
url: event.url,
|
||||
active_seconds: event.active_seconds,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
if (recent.length > 50) {
|
||||
recent.splice(0, recent.length - 50);
|
||||
}
|
||||
chrome.storage.local.set({ [RECENT_KEY]: recent });
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function extractDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalPage(url) {
|
||||
return url.startsWith('about:') ||
|
||||
url.startsWith('moz-extension://') ||
|
||||
url.startsWith('chrome://') ||
|
||||
url.startsWith('chrome-extension://') ||
|
||||
url.startsWith('edge://') ||
|
||||
url.startsWith('file://') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:') ||
|
||||
url.startsWith('view-source:') ||
|
||||
url.startsWith('resource:');
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).substring(2, 8);
|
||||
return `evt_${ts}_${rand}`;
|
||||
}
|
||||
|
||||
function generateDeviceId() {
|
||||
const buf = new Uint8Array(6);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 765 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Verstak Bridge",
|
||||
"version": "1.0.4",
|
||||
"description": "Отслеживает активные вкладки и отправляет события в Verstak",
|
||||
"author": "Verstak",
|
||||
"homepage_url": "https://git.mirv.top/mirivlad/verstak",
|
||||
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "verstak-bridge@verstak.app",
|
||||
"strict_min_version": "115.0",
|
||||
"update_url": "https://mirv.top/verstak/firefox/updates.json"
|
||||
}
|
||||
},
|
||||
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"alarms"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_title": "Verstak Bridge",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
Generated
+135
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "extension-firefox",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
|
||||
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/* Verstak Bridge Popup */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface2: #1e2d50;
|
||||
--accent: #0f9b8e;
|
||||
--accent-glow: rgba(15, 155, 142, 0.3);
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #8892b0;
|
||||
--danger: #e74c3c;
|
||||
--success: #2ecc71;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 320px;
|
||||
min-height: 400px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px var(--success);
|
||||
}
|
||||
|
||||
.dot.offline {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 6px var(--danger);
|
||||
}
|
||||
|
||||
.info {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.value.online {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.controls {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle input:checked + .slider::after {
|
||||
left: 18px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Recent events */
|
||||
.recent {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.recent h2 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#event-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-item .domain {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-item .title {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-item .duration {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Footer buttons */
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #0d8a7e;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: #253a60;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Settings panel */
|
||||
#settings-panel {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.port-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.port-prefix {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#port-input {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--surface2);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#port-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: #0d8a7e;
|
||||
}
|
||||
|
||||
.port-status {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.port-status.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.port-status.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verstak Bridge</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Main panel -->
|
||||
<div id="main-panel">
|
||||
<header>
|
||||
<h1>Verstak Bridge</h1>
|
||||
<span id="status-dot" class="dot offline"></span>
|
||||
</header>
|
||||
|
||||
<section class="info">
|
||||
<div class="row">
|
||||
<span class="label">Сервер</span>
|
||||
<span id="bridge-status" class="value offline">Недоступен</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Очередь</span>
|
||||
<span id="queue-count" class="value">—</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Адрес</span>
|
||||
<span id="bridge-port" class="value">—</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="track-toggle" checked>
|
||||
<span class="slider"></span>
|
||||
<span class="toggle-label">Отслеживать вкладки</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="recent">
|
||||
<h2>Последние события</h2>
|
||||
<div id="event-list">
|
||||
<p class="empty">Нет событий</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
|
||||
<button id="settings-btn" class="btn secondary">⚙</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel (hidden by default) -->
|
||||
<div id="settings-panel" style="display: none;">
|
||||
<header>
|
||||
<h1>Настройки</h1>
|
||||
</header>
|
||||
|
||||
<section class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="port-input">Порт сервера</label>
|
||||
<div class="port-row">
|
||||
<span class="port-prefix">127.0.0.1:</span>
|
||||
<input type="number" id="port-input" min="1024" max="65535" value="9786" />
|
||||
</div>
|
||||
<p class="hint">По умолчанию: 9786. Изменения вступят в силу при следующей отправке событий.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button id="test-port-btn" class="btn">Проверить</button>
|
||||
<button id="save-port-btn" class="btn primary">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div id="port-status" class="port-status"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,224 @@
|
||||
// Verstak Bridge — Popup Script
|
||||
// Shows connection status, queue count, recent events, and settings.
|
||||
|
||||
const STORAGE_KEY = 'verstak_queue';
|
||||
const BRIDGE_KEY = 'verstak_bridge_config';
|
||||
const RECENT_KEY = 'verstak_recent';
|
||||
const DEFAULT_PORT = 9786;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Force a fresh ping check on popup open, don't rely on stale cache
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_PING' });
|
||||
// Read cached state for immediate render
|
||||
restoreToggleState();
|
||||
restoreSettingsPanel();
|
||||
updateUI();
|
||||
|
||||
document.getElementById('sync-btn').addEventListener('click', forceSync);
|
||||
document.getElementById('track-toggle').addEventListener('change', onToggle);
|
||||
document.getElementById('settings-btn').addEventListener('click', toggleSettings);
|
||||
document.getElementById('save-port-btn').addEventListener('click', savePort);
|
||||
document.getElementById('test-port-btn').addEventListener('click', testPort);
|
||||
});
|
||||
|
||||
function updateUI() {
|
||||
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY, RECENT_KEY], (data) => {
|
||||
const queue = data[STORAGE_KEY] || [];
|
||||
const config = data[BRIDGE_KEY] || {};
|
||||
const recent = data[RECENT_KEY] || [];
|
||||
|
||||
// Queue count
|
||||
document.getElementById('queue-count').textContent = queue.length;
|
||||
|
||||
// Bridge status
|
||||
const statusEl = document.getElementById('bridge-status');
|
||||
const portEl = document.getElementById('bridge-port');
|
||||
const dotEl = document.getElementById('status-dot');
|
||||
|
||||
if (config.bridgeReachable === true) {
|
||||
statusEl.textContent = 'Доступен';
|
||||
statusEl.className = 'value online';
|
||||
dotEl.className = 'dot online';
|
||||
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
|
||||
} else if (config.bridgeReachable === false) {
|
||||
statusEl.textContent = 'Недоступен';
|
||||
statusEl.className = 'value offline';
|
||||
dotEl.className = 'dot offline';
|
||||
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
|
||||
} else {
|
||||
// Unknown — not yet checked
|
||||
statusEl.textContent = 'Проверка...';
|
||||
statusEl.className = 'value';
|
||||
dotEl.className = 'dot';
|
||||
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
|
||||
}
|
||||
|
||||
// Update settings panel port field
|
||||
const portInput = document.getElementById('port-input');
|
||||
if (portInput && !portInput.dataset.userEdited) {
|
||||
portInput.value = config.port || DEFAULT_PORT;
|
||||
}
|
||||
|
||||
// Recent events
|
||||
renderRecent(recent, config.bridgeReachable);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecent(events, reachable) {
|
||||
const list = document.getElementById('event-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
list.innerHTML = '<p class="empty">Нет событий</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lastFive = events.slice(-5).reverse();
|
||||
for (const ev of lastFive) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'event-item';
|
||||
item.innerHTML = `
|
||||
<div class="domain">${escapeHtml(ev.domain || '?')}</div>
|
||||
<div class="title">${escapeHtml(truncate(ev.title || ev.url, 60))}</div>
|
||||
<div class="duration">${ev.active_seconds || 0}с${reachable ? '' : ' (ожидает)'}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function forceSync() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Отправка...';
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, () => {
|
||||
setTimeout(() => {
|
||||
updateUI();
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 Отправить сейчас';
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function onToggle(e) {
|
||||
const enabled = e.target.checked;
|
||||
chrome.storage.local.set({ 'verstak_tracking_enabled': enabled });
|
||||
chrome.runtime.sendMessage({ type: 'SET_TRACKING', enabled });
|
||||
}
|
||||
|
||||
function restoreToggleState() {
|
||||
chrome.storage.local.get('verstak_tracking_enabled', (data) => {
|
||||
const enabled = data.verstak_tracking_enabled !== false;
|
||||
document.getElementById('track-toggle').checked = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings panel ---
|
||||
|
||||
function toggleSettings() {
|
||||
const panel = document.getElementById('settings-panel');
|
||||
const main = document.getElementById('main-panel');
|
||||
const btn = document.getElementById('settings-btn');
|
||||
|
||||
if (panel.style.display === 'none' || !panel.style.display) {
|
||||
panel.style.display = 'block';
|
||||
main.style.display = 'none';
|
||||
btn.textContent = '← Назад';
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
main.style.display = 'block';
|
||||
btn.textContent = '⚙';
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSettingsPanel() {
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const config = data[BRIDGE_KEY] || {};
|
||||
const portInput = document.getElementById('port-input');
|
||||
if (portInput) {
|
||||
portInput.value = config.port || DEFAULT_PORT;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function savePort() {
|
||||
const portInput = document.getElementById('port-input');
|
||||
const port = parseInt(portInput.value, 10);
|
||||
const statusEl = document.getElementById('port-status');
|
||||
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
statusEl.textContent = 'Порт: 1024–65535';
|
||||
statusEl.className = 'port-status error';
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const config = data[BRIDGE_KEY] || {};
|
||||
config.port = port;
|
||||
config.bridgeReachable = null; // Force re-check on next popup open
|
||||
chrome.storage.local.set({ [BRIDGE_KEY]: config }, () => {
|
||||
statusEl.textContent = 'Сохранено';
|
||||
statusEl.className = 'port-status ok';
|
||||
// Trigger immediate ping with new port
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_PING' });
|
||||
setTimeout(() => { updateUI(); statusEl.textContent = ''; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function testPort() {
|
||||
const portInput = document.getElementById('port-input');
|
||||
const port = parseInt(portInput.value, 10);
|
||||
const statusEl = document.getElementById('port-status');
|
||||
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
statusEl.textContent = 'Порт: 1024–65535';
|
||||
statusEl.className = 'port-status error';
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = 'Проверка...';
|
||||
statusEl.className = 'port-status';
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/ping`, { signal: AbortSignal.timeout(3000) })
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
statusEl.textContent = '✓ Сервер отвечает';
|
||||
statusEl.className = 'port-status ok';
|
||||
} else {
|
||||
statusEl.textContent = `✗ Ошибка ${res.status}`;
|
||||
statusEl.className = 'port-status error';
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
|
||||
} else {
|
||||
statusEl.textContent = '✗ Сервер недоступен';
|
||||
}
|
||||
statusEl.className = 'port-status error';
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for updates from background
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'UI_UPDATE') {
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return '';
|
||||
return s.length > n ? s.substring(0, n) + '...' : s;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Verstak Bridge — Background Service Worker (Chrome)
|
||||
// Tracks active tab changes and queues browser events.
|
||||
// Pushes events to the Verstak HTTP bridge when available.
|
||||
//
|
||||
// IMPORTANT: All chrome.* event listeners are registered at global scope,
|
||||
// NOT inside onInstalled/onStartup. MV3 service workers can be restarted at
|
||||
// any time; listeners inside lifecycle hooks are lost on restart.
|
||||
// See: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/events
|
||||
|
||||
const STORAGE_KEY = 'verstak_queue';
|
||||
const BRIDGE_KEY = 'verstak_bridge_config';
|
||||
const RECENT_KEY = 'verstak_recent';
|
||||
const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false };
|
||||
const IDLE_RESET_MS = 60_000;
|
||||
|
||||
// Session state
|
||||
let session = {
|
||||
url: '',
|
||||
title: '',
|
||||
domain: '',
|
||||
tabId: -1,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// --- Global event listeners (registered once at script load) ---
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
setupAlarms();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
setupAlarms();
|
||||
});
|
||||
|
||||
// Tab tracking — global scope
|
||||
chrome.tabs.onActivated.addListener(onTabActivated);
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
|
||||
|
||||
// Alarm handler — global scope
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'flushEvents') flushSession();
|
||||
if (alarm.name === 'pingBridge') pingBridge();
|
||||
});
|
||||
|
||||
// Message handler — global scope
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'FORCE_FLUSH') {
|
||||
flushSession();
|
||||
flushQueue();
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
if (msg.type === 'SET_TRACKING') {
|
||||
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// --- Alarm setup ---
|
||||
|
||||
function setupAlarms() {
|
||||
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
|
||||
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
|
||||
}
|
||||
|
||||
// --- Tab tracking ---
|
||||
|
||||
function onTabActivated(activeInfo) {
|
||||
flushSession();
|
||||
startTracking(activeInfo.tabId);
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId, changeInfo, tab) {
|
||||
if (changeInfo.status === 'complete' && tab.active) {
|
||||
flushSession();
|
||||
startTracking(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowFocusChanged(windowId) {
|
||||
if (windowId === chrome.windows.WINDOW_ID_NONE) {
|
||||
flushSession();
|
||||
session = { url: '', title: '', domain: '', tabId: -1, startedAt: null };
|
||||
} else {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs && tabs.length > 0) {
|
||||
startTracking(tabs[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startTracking(tabId) {
|
||||
chrome.tabs.get(tabId, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab || !tab.url) return;
|
||||
if (isInternalPage(tab.url)) return;
|
||||
|
||||
session = {
|
||||
url: tab.url,
|
||||
title: tab.title || '',
|
||||
domain: extractDomain(tab.url),
|
||||
tabId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Session flush ---
|
||||
|
||||
function flushSession() {
|
||||
if (!session.url || !session.startedAt) return;
|
||||
|
||||
const duration = Math.round((Date.now() - session.startedAt) / 1000);
|
||||
if (duration < 2) {
|
||||
session.startedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: generateId(),
|
||||
type: 'page_visit',
|
||||
url: session.url,
|
||||
title: session.title,
|
||||
domain: session.domain,
|
||||
active_seconds: Math.min(duration, IDLE_RESET_MS / 1000),
|
||||
ts_start: new Date(session.startedAt).toISOString(),
|
||||
ts_end: new Date().toISOString(),
|
||||
};
|
||||
|
||||
queueEvent(event);
|
||||
session.startedAt = Date.now();
|
||||
}
|
||||
|
||||
function queueEvent(event) {
|
||||
chrome.storage.local.get(STORAGE_KEY, (data) => {
|
||||
const queue = data[STORAGE_KEY] || [];
|
||||
queue.push(event);
|
||||
|
||||
if (queue.length > 500) {
|
||||
queue.splice(0, queue.length - 500);
|
||||
}
|
||||
|
||||
chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => {
|
||||
if (queue.length >= 5) {
|
||||
flushQueue();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bridge communication ---
|
||||
|
||||
function flushQueue() {
|
||||
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => {
|
||||
const queue = data[STORAGE_KEY] || [];
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
|
||||
const port = config.port || 9786;
|
||||
const secret = config.secret || '';
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
device_id: 'chrome-' + (config.deviceId || 'unknown'),
|
||||
events: queue,
|
||||
};
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Verstak-Secret': secret,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
for (const ev of queue) {
|
||||
updateRecent(ev);
|
||||
}
|
||||
chrome.storage.local.set({ [STORAGE_KEY]: [] });
|
||||
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
|
||||
} else if (res.status === 401) {
|
||||
console.warn('[verstak] bridge auth failed, check secret');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Bridge not available — keep events in queue
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pingBridge() {
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
|
||||
const port = config.port || 9786;
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/ping`)
|
||||
.then((res) => res.json())
|
||||
.then((info) => {
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() }
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
chrome.storage.local.set({
|
||||
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Recent events ---
|
||||
|
||||
function updateRecent(event) {
|
||||
chrome.storage.local.get(RECENT_KEY, (data) => {
|
||||
const recent = data[RECENT_KEY] || [];
|
||||
recent.push({
|
||||
domain: event.domain,
|
||||
title: event.title,
|
||||
url: event.url,
|
||||
active_seconds: event.active_seconds,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
if (recent.length > 50) {
|
||||
recent.splice(0, recent.length - 50);
|
||||
}
|
||||
chrome.storage.local.set({ [RECENT_KEY]: recent });
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function extractDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalPage(url) {
|
||||
const internal = [
|
||||
'chrome://', 'chrome-extension://', 'about:', 'edge://',
|
||||
'file://', 'data:', 'blob:', 'view-source:', 'devtools:',
|
||||
];
|
||||
return internal.some((p) => url.startsWith(p));
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).substring(2, 8);
|
||||
return `evt_${ts}_${rand}`;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 765 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Verstak Bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "Отслеживает активные вкладки и отправляет события в Verstak",
|
||||
"author": "Verstak",
|
||||
"homepage_url": "https://git.mirv.top/mirivlad/verstak",
|
||||
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"alarms"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_title": "Verstak Bridge",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/* Verstak Bridge Popup */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface2: #1e2d50;
|
||||
--accent: #0f9b8e;
|
||||
--accent-glow: rgba(15, 155, 142, 0.3);
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #8892b0;
|
||||
--danger: #e74c3c;
|
||||
--success: #2ecc71;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 320px;
|
||||
min-height: 400px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px var(--success);
|
||||
}
|
||||
|
||||
.dot.offline {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 6px var(--danger);
|
||||
}
|
||||
|
||||
.info {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.value.online {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.controls {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle input:checked + .slider::after {
|
||||
left: 18px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Recent events */
|
||||
.recent {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.recent h2 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#event-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-item .domain {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-item .title {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-item .duration {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Footer buttons */
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #0d8a7e;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: #253a60;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Settings panel */
|
||||
#settings-panel {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.port-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.port-prefix {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#port-input {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--surface2);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#port-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: #0d8a7e;
|
||||
}
|
||||
|
||||
.port-status {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.port-status.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.port-status.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verstak Bridge</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Main panel -->
|
||||
<div id="main-panel">
|
||||
<header>
|
||||
<h1>Verstak Bridge</h1>
|
||||
<span id="status-dot" class="dot offline"></span>
|
||||
</header>
|
||||
|
||||
<section class="info">
|
||||
<div class="row">
|
||||
<span class="label">Сервер</span>
|
||||
<span id="bridge-status" class="value offline">Недоступен</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Очередь</span>
|
||||
<span id="queue-count" class="value">—</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Адрес</span>
|
||||
<span id="bridge-port" class="value">—</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="track-toggle" checked>
|
||||
<span class="slider"></span>
|
||||
<span class="toggle-label">Отслеживать вкладки</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="recent">
|
||||
<h2>Последние события</h2>
|
||||
<div id="event-list">
|
||||
<p class="empty">Нет событий</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
|
||||
<button id="settings-btn" class="btn secondary">⚙</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel (hidden by default) -->
|
||||
<div id="settings-panel" style="display: none;">
|
||||
<header>
|
||||
<h1>Настройки</h1>
|
||||
</header>
|
||||
|
||||
<section class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="port-input">Порт сервера</label>
|
||||
<div class="port-row">
|
||||
<span class="port-prefix">127.0.0.1:</span>
|
||||
<input type="number" id="port-input" min="1024" max="65535" value="9786" />
|
||||
</div>
|
||||
<p class="hint">По умолчанию: 9786. Изменения вступят в силу при следующей отправке событий.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button id="test-port-btn" class="btn">Проверить</button>
|
||||
<button id="save-port-btn" class="btn primary">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div id="port-status" class="port-status"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,214 @@
|
||||
// Verstak Bridge — Popup Script
|
||||
// Shows connection status, queue count, recent events, and settings.
|
||||
|
||||
const STORAGE_KEY = 'verstak_queue';
|
||||
const BRIDGE_KEY = 'verstak_bridge_config';
|
||||
const RECENT_KEY = 'verstak_recent';
|
||||
const DEFAULT_PORT = 9786;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateUI();
|
||||
restoreToggleState();
|
||||
restoreSettingsPanel();
|
||||
|
||||
document.getElementById('sync-btn').addEventListener('click', forceSync);
|
||||
document.getElementById('track-toggle').addEventListener('change', onToggle);
|
||||
document.getElementById('settings-btn').addEventListener('click', toggleSettings);
|
||||
document.getElementById('save-port-btn').addEventListener('click', savePort);
|
||||
document.getElementById('test-port-btn').addEventListener('click', testPort);
|
||||
});
|
||||
|
||||
function updateUI() {
|
||||
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY, RECENT_KEY], (data) => {
|
||||
const queue = data[STORAGE_KEY] || [];
|
||||
const config = data[BRIDGE_KEY] || {};
|
||||
const recent = data[RECENT_KEY] || [];
|
||||
|
||||
// Queue count
|
||||
document.getElementById('queue-count').textContent = queue.length;
|
||||
|
||||
// Bridge status
|
||||
const statusEl = document.getElementById('bridge-status');
|
||||
const portEl = document.getElementById('bridge-port');
|
||||
const dotEl = document.getElementById('status-dot');
|
||||
|
||||
if (config.bridgeReachable) {
|
||||
statusEl.textContent = 'Доступен';
|
||||
statusEl.className = 'value online';
|
||||
dotEl.className = 'dot online';
|
||||
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
|
||||
} else {
|
||||
statusEl.textContent = 'Недоступен';
|
||||
statusEl.className = 'value offline';
|
||||
dotEl.className = 'dot offline';
|
||||
portEl.textContent = config.lastPing ? '—' : 'проверка...';
|
||||
}
|
||||
|
||||
// Update settings panel port field
|
||||
const portInput = document.getElementById('port-input');
|
||||
if (portInput && !portInput.dataset.userEdited) {
|
||||
portInput.value = config.port || DEFAULT_PORT;
|
||||
}
|
||||
|
||||
// Recent events
|
||||
renderRecent(recent, config.bridgeReachable);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecent(events, reachable) {
|
||||
const list = document.getElementById('event-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
list.innerHTML = '<p class="empty">Нет событий</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lastFive = events.slice(-5).reverse();
|
||||
for (const ev of lastFive) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'event-item';
|
||||
item.innerHTML = `
|
||||
<div class="domain">${escapeHtml(ev.domain || '?')}</div>
|
||||
<div class="title">${escapeHtml(truncate(ev.title || ev.url, 60))}</div>
|
||||
<div class="duration">${ev.active_seconds || 0}с${reachable ? '' : ' (ожидает)'}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function forceSync() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Отправка...';
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, () => {
|
||||
setTimeout(() => {
|
||||
updateUI();
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 Отправить сейчас';
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function onToggle(e) {
|
||||
const enabled = e.target.checked;
|
||||
chrome.storage.local.set({ 'verstak_tracking_enabled': enabled });
|
||||
chrome.runtime.sendMessage({ type: 'SET_TRACKING', enabled });
|
||||
}
|
||||
|
||||
function restoreToggleState() {
|
||||
chrome.storage.local.get('verstak_tracking_enabled', (data) => {
|
||||
const enabled = data.verstak_tracking_enabled !== false;
|
||||
document.getElementById('track-toggle').checked = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings panel ---
|
||||
|
||||
function toggleSettings() {
|
||||
const panel = document.getElementById('settings-panel');
|
||||
const main = document.getElementById('main-panel');
|
||||
const btn = document.getElementById('settings-btn');
|
||||
|
||||
if (panel.style.display === 'none' || !panel.style.display) {
|
||||
panel.style.display = 'block';
|
||||
main.style.display = 'none';
|
||||
btn.textContent = '← Назад';
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
main.style.display = 'block';
|
||||
btn.textContent = '⚙';
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSettingsPanel() {
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const config = data[BRIDGE_KEY] || {};
|
||||
const portInput = document.getElementById('port-input');
|
||||
if (portInput) {
|
||||
portInput.value = config.port || DEFAULT_PORT;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function savePort() {
|
||||
const portInput = document.getElementById('port-input');
|
||||
const port = parseInt(portInput.value, 10);
|
||||
const statusEl = document.getElementById('port-status');
|
||||
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
statusEl.textContent = 'Порт: 1024–65535';
|
||||
statusEl.className = 'port-status error';
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||
const config = data[BRIDGE_KEY] || {};
|
||||
config.port = port;
|
||||
chrome.storage.local.set({ [BRIDGE_KEY]: config }, () => {
|
||||
statusEl.textContent = 'Сохранено';
|
||||
statusEl.className = 'port-status ok';
|
||||
// Trigger immediate ping with new port
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' });
|
||||
setTimeout(() => { updateUI(); statusEl.textContent = ''; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function testPort() {
|
||||
const portInput = document.getElementById('port-input');
|
||||
const port = parseInt(portInput.value, 10);
|
||||
const statusEl = document.getElementById('port-status');
|
||||
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
statusEl.textContent = 'Порт: 1024–65535';
|
||||
statusEl.className = 'port-status error';
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = 'Проверка...';
|
||||
statusEl.className = 'port-status';
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/ping`, { signal: AbortSignal.timeout(3000) })
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
statusEl.textContent = '✓ Сервер отвечает';
|
||||
statusEl.className = 'port-status ok';
|
||||
} else {
|
||||
statusEl.textContent = `✗ Ошибка ${res.status}`;
|
||||
statusEl.className = 'port-status error';
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
|
||||
} else {
|
||||
statusEl.textContent = '✗ Сервер недоступен';
|
||||
}
|
||||
statusEl.className = 'port-status error';
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for updates from background
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'UI_UPDATE') {
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return '';
|
||||
return s.length > n ? s.substring(0, n) + '...' : s;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
Generated
+153
@@ -7,6 +7,15 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.2.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"marked": "^18.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.19",
|
||||
@@ -878,6 +887,15 @@
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -885,6 +903,31 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vscode/emmet-helper": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz",
|
||||
@@ -927,6 +970,11 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@@ -1026,6 +1074,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.10",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
|
||||
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/emmet": {
|
||||
"version": "2.4.11",
|
||||
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
|
||||
@@ -1043,6 +1099,17 @@
|
||||
"@emmetio/css-abbreviation": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1132,6 +1199,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
@@ -1159,6 +1234,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
@@ -1183,6 +1276,48 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.1",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it-task-lists": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
|
||||
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
@@ -1190,6 +1325,11 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1291,6 +1431,14 @@
|
||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -1521,6 +1669,11 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -16,5 +16,14 @@
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.3.0",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.2.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"marked": "^18.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
+1036
-959
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import GlobalSearch from './GlobalSearch.svelte'
|
||||
|
||||
export let title = ''
|
||||
export let subtitle = ''
|
||||
export let wailsCall = async () => []
|
||||
export let typeLabel = (type) => type || ''
|
||||
export let onOpenSearchResult = () => {}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="header-title" class:placeholder={!title}>{title || ''}</span>
|
||||
{#if subtitle}
|
||||
<span class="header-subtitle">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<GlobalSearch {wailsCall} {typeLabel} on:open={(e) => onOpenSearchResult(e.detail)} />
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header { padding: 10px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 16px; flex-shrink: 0; min-height: 52px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; min-width: 120px; flex: 0 1 auto; overflow: hidden; }
|
||||
.header-title { font-size: 15px; font-weight: 600; color: #e4e4ef; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-title.placeholder { color: #666; font-weight: 500; }
|
||||
.header-subtitle { font-size: 11px; color: #8888a0; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||||
.header-right { flex: 1; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
@media (max-width: 760px) {
|
||||
.header { padding: 8px 12px; gap: 10px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.header-left { flex: 1 1 100%; }
|
||||
.header-right { flex: 1 1 100%; justify-content: stretch; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script>
|
||||
import { t } from './i18n'
|
||||
|
||||
export let events = []
|
||||
export let loading = false
|
||||
|
||||
export let onAccept = (ev) => {}
|
||||
export let onDismiss = (ev) => {}
|
||||
export let onAttach = (ev) => {}
|
||||
export let onRefresh = () => {}
|
||||
export let formatTime = (iso) => ''
|
||||
|
||||
function eventIcon(type) {
|
||||
if (type === 'page_visit') return '🌐'
|
||||
if (type === 'note_capture') return '📝'
|
||||
if (type === 'screenshot') return '📸'
|
||||
return '•'
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return ''
|
||||
return s.length > n ? s.substring(0, n) + '...' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="browser-events">
|
||||
<div class="browser-header">
|
||||
<h3>{t('browserEvents', 'События браузера')}</h3>
|
||||
<button class="refresh-btn" on:click={onRefresh} disabled={loading}>
|
||||
{loading ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if events.length === 0}
|
||||
<div class="empty-state">
|
||||
{#if loading}
|
||||
<p class="loading">{t('loading', 'Загрузка...')}</p>
|
||||
{:else}
|
||||
<p class="empty-text">{t('noBrowserEvents', 'Нет событий браузера')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each events as ev}
|
||||
<div class="event-item" class:pending={ev.status === 'pending'}>
|
||||
<div class="event-icon">{eventIcon(ev.type)}</div>
|
||||
<div class="event-body">
|
||||
<div class="event-domain">{ev.domain || '?'}</div>
|
||||
<div class="event-title" title={ev.title}>{truncate(ev.title || ev.url, 80)}</div>
|
||||
<div class="event-meta">
|
||||
{#if ev.active_seconds > 0}
|
||||
<span class="duration">{ev.active_seconds}с</span>
|
||||
{/if}
|
||||
{#if ev.ts_start}
|
||||
<span class="time">{formatTime(ev.ts_start)}</span>
|
||||
{/if}
|
||||
{#if ev.status !== 'pending'}
|
||||
<span class="status-badge status-{ev.status}">{ev.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ev.selected_text}
|
||||
<div class="event-text">"{truncate(ev.selected_text, 120)}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ev.status === 'pending'}
|
||||
<div class="event-actions">
|
||||
<button class="btn-accept" title="Принять как worklog"
|
||||
on:click={() => onAccept(ev)}>✓</button>
|
||||
<button class="btn-attach" title="Прикрепить к делу"
|
||||
on:click={() => onAttach(ev)}>📎</button>
|
||||
<button class="btn-dismiss" title="Удалить"
|
||||
on:click={() => onDismiss(ev)}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.browser-events {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.browser-header h3 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim, #8892b0);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: var(--surface2, #1e2d50);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--surface, #16213e);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text, .loading {
|
||||
color: var(--text-dim, #8892b0);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface, #16213e);
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.event-item.pending {
|
||||
border-left: 2px solid var(--accent, #0f9b8e);
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.event-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-domain {
|
||||
font-size: 11px;
|
||||
color: var(--accent, #0f9b8e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 12px;
|
||||
color: var(--text, #e0e0e0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.duration, .time {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8892b0);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.status-dismissed {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.status-attached {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.event-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #8892b0);
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-actions button {
|
||||
background: none;
|
||||
border: 1px solid var(--surface2, #1e2d50);
|
||||
border-radius: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
border-color: #2ecc71;
|
||||
}
|
||||
|
||||
.btn-attach:hover {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.btn-dismiss:hover {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
|
||||
export let sectionId = ''
|
||||
|
||||
// Parse "plugin:name:pageId"
|
||||
$: parts = sectionId.split(':')
|
||||
$: pluginName = parts[1] || ''
|
||||
$: pageId = parts[2] || 'main'
|
||||
$: pageLabel = pageId.charAt(0).toUpperCase() + pageId.slice(1)
|
||||
|
||||
let htmlPanel = ''
|
||||
let loading = true
|
||||
let error = ''
|
||||
let iframeEl = null
|
||||
let messageQueue = []
|
||||
let iframeReady = false
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
const fn = window['go']['main']['App'][method]
|
||||
if (typeof fn === 'function') return fn(...args)
|
||||
}
|
||||
} catch (e) { console.error('Wails error:', method, e) }
|
||||
return Promise.reject(new Error('Wails not connected: ' + method))
|
||||
}
|
||||
|
||||
// Post message to iframe (queue if not ready)
|
||||
function postToIframe(msg) {
|
||||
console.log('[CalendarPluginPage] postToIframe: iframeReady=' + iframeReady + ', hasEl=' + !!iframeEl + ', hasCW=' + !!(iframeEl?.contentWindow))
|
||||
if (iframeEl && iframeEl.contentWindow && iframeReady) {
|
||||
console.log('[CalendarPluginPage] postToIframe: sending msg.type=' + msg.type)
|
||||
iframeEl.contentWindow.postMessage(msg, '*')
|
||||
} else {
|
||||
console.log('[CalendarPluginPage] postToIframe: queuing msg.type=' + msg.type)
|
||||
messageQueue.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle messages from iframe — only accept from our own iframeEl
|
||||
// The iframe identifies itself via msg.source (set by the panel HTML).
|
||||
// We accept any source that starts with "plugin:" to support generic plugin panels.
|
||||
function handleIframeMessage(e) {
|
||||
// Verify the message comes from our iframe (srcdoc = same origin)
|
||||
if (!iframeEl || !iframeEl.contentWindow || e.source !== iframeEl.contentWindow) return
|
||||
|
||||
const msg = e.data
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
if (!msg.source || typeof msg.source !== 'string') return
|
||||
if (!msg.action || typeof msg.action !== 'string') return
|
||||
|
||||
switch (msg.action) {
|
||||
case 'ready':
|
||||
iframeReady = true
|
||||
// Flush queued messages
|
||||
while (messageQueue.length > 0) {
|
||||
iframeEl.contentWindow.postMessage(messageQueue.shift(), '*')
|
||||
}
|
||||
// Load initial data
|
||||
loadCalendarData()
|
||||
break
|
||||
|
||||
case 'get-events':
|
||||
handleGetEvents(msg.data)
|
||||
break
|
||||
|
||||
case 'create-event':
|
||||
handleCreateEvent(msg.data)
|
||||
break
|
||||
|
||||
case 'update-event':
|
||||
handleUpdateEvent(msg.data)
|
||||
break
|
||||
|
||||
case 'delete-event':
|
||||
handleDeleteEvent(msg.data)
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('[CalendarPluginPage] Unknown iframe action:', msg.action)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the Lua function prefix from plugin name (e.g. "calendar" → "calendar.")
|
||||
$: funcPrefix = pluginName ? pluginName + '.' : ''
|
||||
|
||||
// Load events + categories from Lua backend and send to iframe
|
||||
async function loadCalendarData() {
|
||||
try {
|
||||
console.log('[CalendarPluginPage] loadCalendarData: step 1')
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth()
|
||||
const start = new Date(year, month, 1).toISOString().slice(0, 10) + 'T00:00:00'
|
||||
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10) + 'T23:59:59'
|
||||
console.log('[CalendarPluginPage] loadCalendarData: step 2, start=' + start + ', end=' + end)
|
||||
|
||||
console.log('[CalendarPluginPage] loadCalendarData: step 3, calling get_events...')
|
||||
const eventsPromise = wailsCall('CallPluginFunction', pluginName, funcPrefix + 'get_events', JSON.stringify({ start, end }))
|
||||
console.log('[CalendarPluginPage] loadCalendarData: step 4, calling get_categories...')
|
||||
const categoriesPromise = wailsCall('CallPluginFunction', pluginName, funcPrefix + 'get_categories', '{}')
|
||||
|
||||
console.log('[CalendarPluginPage] loadCalendarData: step 5, awaiting Promise.all...')
|
||||
const [eventsRaw, categoriesRaw] = await Promise.all([eventsPromise, categoriesPromise])
|
||||
console.log('[CalendarPluginPage] loadCalendarData: step 6, got results, eventsRaw=' + typeof eventsRaw + ', categoriesRaw=' + typeof categoriesRaw)
|
||||
|
||||
const events = eventsRaw ? JSON.parse(eventsRaw) : []
|
||||
const categories = categoriesRaw ? JSON.parse(categoriesRaw) : []
|
||||
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'calendar-data',
|
||||
events,
|
||||
categories,
|
||||
})
|
||||
// Confirm delivery via Go log
|
||||
try { window.go.main.App.WriteDebugLog('[CalendarPluginPage] calendar-data sent to iframe, events.len=' + events.length + ', categories.len=' + (categories?.length || 0) + ', typeof events=' + typeof events + ', typeof eventsRaw=' + typeof eventsRaw + ', eventsRaw.length=' + eventsRaw.length + ', eventsRaw.substr(0,10)=' + String(eventsRaw).substring(0,10)) } catch(e) {}
|
||||
} catch (e) {
|
||||
console.error('[CalendarPluginPage] loadCalendarData:', e)
|
||||
console.log('[CalendarPluginPage] error details:', JSON.stringify({message: String(e), stack: e?.stack}))
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetEvents(data) {
|
||||
try {
|
||||
const params = JSON.stringify({ start: data.start, end: data.end })
|
||||
const raw = await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'get_events', params)
|
||||
const events = raw ? JSON.parse(raw) : []
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'calendar-data',
|
||||
events,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[CalendarPluginPage] get-events:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateEvent(data) {
|
||||
try {
|
||||
const params = JSON.stringify(data)
|
||||
const raw = await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'create_event', params)
|
||||
const result = raw ? JSON.parse(raw) : {}
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'event-created',
|
||||
event: result,
|
||||
})
|
||||
loadCalendarData()
|
||||
} catch (e) {
|
||||
console.error('[CalendarPluginPage] create-event:', e)
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'error',
|
||||
message: String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateEvent(data) {
|
||||
try {
|
||||
const params = JSON.stringify(data)
|
||||
await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'update_event', params)
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'event-updated',
|
||||
event: data,
|
||||
})
|
||||
loadCalendarData()
|
||||
} catch (e) {
|
||||
console.error('[CalendarPluginPage] update-event:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEvent(data) {
|
||||
try {
|
||||
const params = JSON.stringify({ id: data.id })
|
||||
await wailsCall('CallPluginFunction', pluginName, funcPrefix + 'delete_event', params)
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'event-deleted',
|
||||
id: data.id,
|
||||
})
|
||||
loadCalendarData()
|
||||
} catch (e) {
|
||||
console.error('[CalendarPluginPage] delete-event:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
htmlPanel = (await wailsCall('GetPluginPanelHTML', pluginName)) || ''
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
loading = false
|
||||
|
||||
// Listen for messages from iframe
|
||||
window.addEventListener('message', handleIframeMessage)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('message', handleIframeMessage)
|
||||
})
|
||||
|
||||
// Expose drop handler for parent Svelte components
|
||||
export function handleDrop(data, date) {
|
||||
postToIframe({
|
||||
source: 'verstak',
|
||||
type: 'drop',
|
||||
date: date,
|
||||
data: data,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="plugin-page">
|
||||
{#if loading}
|
||||
<p class="loading">Загрузка…</p>
|
||||
{:else if !htmlPanel}
|
||||
<div class="empty-state">
|
||||
<p>Плагин «{pluginName}» активен, но HTML-панель не настроена.</p>
|
||||
<p class="hint">Для отображения контента добавьте поле <code>panel</code> в <code>plugin.json</code>.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="plugin-frame-container">
|
||||
{#if error}
|
||||
<div class="plugin-error-bar">⚠ {error}</div>
|
||||
{/if}
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
class="plugin-frame"
|
||||
srcdoc={htmlPanel}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
title="{pluginName} panel"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plugin-page { padding: 1.5rem; height: 100%; display: flex; flex-direction: column; }
|
||||
.plugin-page-header { margin-bottom: 1rem; flex-shrink: 0; }
|
||||
.plugin-page-header h2 { margin: 0; font-size: 1.1rem; color: var(--text, #e0e0e0); }
|
||||
.loading { color: var(--text-dim, #888); }
|
||||
.error { color: #f87171; }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-dim, #888); }
|
||||
.empty-state code { background: var(--surface-alt, #252538); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
|
||||
.plugin-frame-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.plugin-error-bar {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
border: 1px solid #f87171;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.plugin-frame { flex: 1; border: 1px solid var(--border, #2a2a3e); border-radius: 8px; background: #fff; width: 100%; min-height: 400px; display: block; }
|
||||
.plugin-frame-container .plugin-frame { border-radius: 0 0 8px 8px; border-top: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
|
||||
import { t } from './i18n'
|
||||
import { expandKeyboardVariants } from './util/keyboardLayout'
|
||||
|
||||
export let wailsCall = async () => []
|
||||
export let typeLabel = (type) => type || ''
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let query = ''
|
||||
let results = []
|
||||
let open = false
|
||||
let loading = false
|
||||
let selectedIndex = 0
|
||||
let inputEl
|
||||
let searchTimer
|
||||
|
||||
function clean(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resultPath(result) {
|
||||
return clean(result.path)
|
||||
}
|
||||
|
||||
function resultMeta(result) {
|
||||
const parts = []
|
||||
const type = typeLabel(result.type)
|
||||
const path = resultPath(result)
|
||||
if (type) parts.push(type)
|
||||
if (path) parts.push(path)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const q = clean(query)
|
||||
if (q.length < 2) {
|
||||
results = []
|
||||
open = false
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
// Build query variants: original + keyboard-swapped + lowercased
|
||||
const variants = expandKeyboardVariants(q)
|
||||
// Deduplicate preserving order
|
||||
const seenVariants = new Set()
|
||||
const queries = []
|
||||
for (const v of variants) {
|
||||
if (!seenVariants.has(v)) {
|
||||
seenVariants.add(v)
|
||||
queries.push(v)
|
||||
}
|
||||
}
|
||||
// Execute all variant queries in parallel, collect results
|
||||
const allResults = await Promise.all(
|
||||
queries.map(v => wailsCall('Search', v).catch(() => []))
|
||||
)
|
||||
// Merge and deduplicate by node ID (or type+title for links/actions)
|
||||
const merged = new Map()
|
||||
// First pass: results from the original query get priority
|
||||
for (let qi = 0; qi < allResults.length; qi++) {
|
||||
const arr = allResults[qi] || []
|
||||
for (const r of arr) {
|
||||
const key = r.type + ':' + r.nodeId + ':' + (r.targetId || '') + ':' + r.title
|
||||
if (!merged.has(key)) {
|
||||
merged.set(key, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
results = Array.from(merged.values())
|
||||
selectedIndex = 0
|
||||
open = results.length > 0
|
||||
} catch (e) {
|
||||
results = []
|
||||
open = false
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(runSearch, 180)
|
||||
}
|
||||
|
||||
function closeResults() {
|
||||
open = false
|
||||
selectedIndex = 0
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
query = ''
|
||||
results = []
|
||||
closeResults()
|
||||
}
|
||||
|
||||
function openResult(result) {
|
||||
if (!result) return
|
||||
dispatch('open', result)
|
||||
clearSearch()
|
||||
inputEl?.blur()
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
scheduleSearch()
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
clearSearch()
|
||||
inputEl?.blur()
|
||||
return
|
||||
}
|
||||
if (!open || results.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
selectedIndex = Math.min(results.length - 1, selectedIndex + 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
selectedIndex = Math.max(0, selectedIndex - 1)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openResult(results[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
async function focusSearch() {
|
||||
await tick()
|
||||
inputEl?.focus()
|
||||
inputEl?.select()
|
||||
}
|
||||
|
||||
function handleWindowKeydown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
focusSearch()
|
||||
return
|
||||
}
|
||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||
e.preventDefault()
|
||||
focusSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target || !(target instanceof Element)) return false
|
||||
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleWindowKeydown)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleWindowKeydown)
|
||||
clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="global-search">
|
||||
<input
|
||||
class="global-search-input"
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.placeholder')}
|
||||
autocomplete="off"
|
||||
on:input={handleInput}
|
||||
on:focus={() => { if (results.length > 0) open = true }}
|
||||
on:keydown={handleKeydown}
|
||||
on:blur={() => setTimeout(closeResults, 160)}
|
||||
/>
|
||||
{#if open}
|
||||
<div class="global-search-results">
|
||||
{#if loading}
|
||||
<div class="global-search-empty">{t('common.loading')}</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="global-search-empty">{t('search.noResults')}</div>
|
||||
{:else}
|
||||
{#each results as result, index}
|
||||
<button
|
||||
class="global-search-result"
|
||||
class:selected={index === selectedIndex}
|
||||
on:mousedown|preventDefault={() => openResult(result)}
|
||||
>
|
||||
<span class="global-search-title">{result.title}</span>
|
||||
{#if resultMeta(result)}
|
||||
<span class="global-search-meta">{resultMeta(result)}</span>
|
||||
{/if}
|
||||
{#if result.snippet}
|
||||
<span class="global-search-snippet">{result.snippet}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.global-search { position: relative; flex: 1 1 360px; min-width: 180px; max-width: 520px; }
|
||||
.global-search-input { width: 100%; height: 32px; padding: 0 12px; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; color: #e4e4ef; font: inherit; font-size: 13px; }
|
||||
.global-search-input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.18); }
|
||||
.global-search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 160; max-height: min(360px, calc(100vh - 88px)); overflow-y: auto; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; box-shadow: 0 14px 36px rgba(0,0,0,0.38); padding: 4px; }
|
||||
.global-search-result { display: flex; flex-direction: column; gap: 2px; width: 100%; border: 0; background: transparent; color: #e4e4ef; text-align: left; padding: 8px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.global-search-result:hover, .global-search-result.selected { background: #252545; }
|
||||
.global-search-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-meta { font-size: 11px; color: #a0a0bc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-snippet { font-size: 12px; color: #767690; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-empty { padding: 10px 12px; color: #8888a0; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script>
|
||||
import { t } from './i18n'
|
||||
|
||||
export let appConfig = null
|
||||
|
||||
let bridgeEnabled = false
|
||||
let bridgePort = 9786
|
||||
let bridgeRunning = false
|
||||
let bridgeActualPort = 0
|
||||
let saving = false
|
||||
let statusMsg = ''
|
||||
let statusKind = '' // 'ok' | 'error' | ''
|
||||
|
||||
// Load current bridge state from config
|
||||
function loadFromConfig() {
|
||||
if (appConfig && appConfig.vault && appConfig.vault.bridge) {
|
||||
const bc = appConfig.vault.bridge
|
||||
bridgeEnabled = bc.enabled !== false
|
||||
bridgePort = bc.port || 9786
|
||||
}
|
||||
}
|
||||
|
||||
// Also fetch live bridge status from backend
|
||||
async function fetchBridgeStatus() {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
const info = await window['go']['main']['App']['BridgeInfo']()
|
||||
bridgeRunning = info.running || false
|
||||
bridgeActualPort = info.port || 0
|
||||
}
|
||||
} catch (e) {
|
||||
bridgeRunning = false
|
||||
bridgeActualPort = 0
|
||||
}
|
||||
}
|
||||
|
||||
loadFromConfig()
|
||||
fetchBridgeStatus()
|
||||
|
||||
async function save() {
|
||||
saving = true
|
||||
statusMsg = ''
|
||||
statusKind = ''
|
||||
try {
|
||||
// Update config
|
||||
if (!appConfig) appConfig = {}
|
||||
if (!appConfig.vault) appConfig.vault = {}
|
||||
if (!appConfig.vault.bridge) appConfig.vault.bridge = {}
|
||||
appConfig.vault.bridge.enabled = bridgeEnabled
|
||||
appConfig.vault.bridge.port = bridgePort
|
||||
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
await window['go']['main']['App']['SaveAppConfig'](appConfig)
|
||||
}
|
||||
statusMsg = 'saved'
|
||||
statusKind = 'ok'
|
||||
// Refresh live status
|
||||
await fetchBridgeStatus()
|
||||
} catch (e) {
|
||||
statusMsg = String(e)
|
||||
statusKind = 'error'
|
||||
}
|
||||
saving = false
|
||||
}
|
||||
|
||||
async function restartBridge() {
|
||||
saving = true
|
||||
statusMsg = ''
|
||||
statusKind = ''
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
await window['go']['main']['App']['RestartBridge']()
|
||||
}
|
||||
statusMsg = 'bridge restarted'
|
||||
statusKind = 'ok'
|
||||
await fetchBridgeStatus()
|
||||
} catch (e) {
|
||||
statusMsg = String(e)
|
||||
statusKind = 'error'
|
||||
}
|
||||
saving = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Browser Bridge</h2>
|
||||
<p class="section-desc">
|
||||
Локальный HTTP-сервер для приёма событий от браузерного расширения Verstak Bridge.
|
||||
Работает только на 127.0.0.1.
|
||||
</p>
|
||||
|
||||
{#if statusMsg}
|
||||
<div class="status-msg" class:ok={statusKind === 'ok'} class:error={statusKind === 'error'}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={bridgeEnabled} />
|
||||
<span class="toggle-text">Включить Browser Bridge</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<span class="label-text">Порт</span>
|
||||
<input type="number" bind:value={bridgePort} min="1024" max="65535" disabled={!bridgeEnabled} />
|
||||
</label>
|
||||
<p class="field-hint">По умолчанию: 9786. Требуется перезапуск сервера при изменении.</p>
|
||||
</div>
|
||||
|
||||
<div class="bridge-status">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Статус</span>
|
||||
<span class="info-value" class:status-ok={bridgeRunning} class:status-off={!bridgeRunning}>
|
||||
{bridgeRunning ? 'Запущен' : 'Остановлен'}
|
||||
</span>
|
||||
</div>
|
||||
{#if bridgeRunning && bridgeActualPort}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Активный порт</span>
|
||||
<span class="info-value mono">127.0.0.1:{bridgeActualPort}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" on:click={save} disabled={saving}>
|
||||
Сохранить
|
||||
</button>
|
||||
{#if bridgeEnabled}
|
||||
<button class="btn" on:click={restartBridge} disabled={saving}>
|
||||
Перезапустить сервер
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-section {
|
||||
padding: 1.5rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text, #e0e0e0);
|
||||
}
|
||||
.section-desc {
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.settings-card {
|
||||
background: var(--surface-alt, #1e1e30);
|
||||
border: 1px solid var(--border, #2a2a3e);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.label-text {
|
||||
display: block;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
input[type="number"] {
|
||||
width: 120px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg, #13131f);
|
||||
border: 1px solid var(--border, #2a2a3e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #e0e0e0);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
input[type="number"]:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.field-hint {
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-text {
|
||||
color: var(--text, #e0e0e0);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.bridge-status {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border, #2a2a3e);
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.info-label {
|
||||
width: 140px;
|
||||
min-width: 140px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.info-value {
|
||||
color: var(--text, #e0e0e0);
|
||||
}
|
||||
.info-value.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.status-ok {
|
||||
color: #34d399;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-off {
|
||||
color: #888;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-msg {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.status-msg.ok {
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.3);
|
||||
color: #34d399;
|
||||
}
|
||||
.status-msg.error {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,13 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { t } from './i18n'
|
||||
|
||||
export let onPluginToggle = null
|
||||
|
||||
let plugins = []
|
||||
let loading = true
|
||||
let error = ''
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
@@ -11,19 +18,267 @@
|
||||
return Promise.reject(new Error('Wails not connected: ' + method))
|
||||
}
|
||||
|
||||
async function openPluginsDir() {
|
||||
try { await wailsCall('OpenPluginsFolder') } catch(e) {}
|
||||
onMount(async () => {
|
||||
try {
|
||||
plugins = await wailsCall('ListPlugins') || []
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
loading = false
|
||||
})
|
||||
|
||||
async function toggle(p) {
|
||||
const newState = !p.active
|
||||
try {
|
||||
await wailsCall('SetPluginEnabled', p.name, newState)
|
||||
// Force refresh from backend — ensures UI stays in sync
|
||||
plugins = await wailsCall('ListPlugins') || []
|
||||
if (onPluginToggle) onPluginToggle()
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function install(p) {
|
||||
error = ''
|
||||
try {
|
||||
await wailsCall('InstallPlugin', p.name)
|
||||
p.installed = true
|
||||
// Refresh list to reflect new state
|
||||
plugins = await wailsCall('ListPlugins') || []
|
||||
if (onPluginToggle) onPluginToggle()
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstall(p) {
|
||||
if (!confirm(`Удалить плагин «${p.name}»? Все данные плагина будут очищены. Файлы плагина сохранятся.`)) return
|
||||
error = ''
|
||||
try {
|
||||
await wailsCall('UninstallPlugin', p.name)
|
||||
p.installed = false
|
||||
p.active = false
|
||||
plugins = await wailsCall('ListPlugins') || []
|
||||
if (onPluginToggle) onPluginToggle()
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
$: sidebarCount = plugins.reduce((n, p) => n + (p.uiContribs?.sidebarItems?.length || 0), 0)
|
||||
$: tabCount = plugins.reduce((n, p) => n + (p.uiContribs?.nodeTabs?.length || 0), 0)
|
||||
</script>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>{t('settings.plugins')}</h2>
|
||||
<p class="section-desc">{t('settings.noPlugins')}</p>
|
||||
<button class="btn" on:click={openPluginsDir}>{t('settings.openPluginsDir')}</button>
|
||||
<div class="settings-plugins">
|
||||
<h3>{t('settings.plugins') || 'Плагины'}</h3>
|
||||
<p class="hint">Включение и отключение плагинов. Изменения применяются сразу.</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if plugins.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Нет установленных плагинов.</p>
|
||||
<p class="hint">Создайте папку <code>.verstak/plugins/</code> в хранилище и добавьте туда плагины.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="plugin-list">
|
||||
{#each plugins as p}
|
||||
<div class="plugin-card">
|
||||
<div class="plugin-info">
|
||||
<div class="plugin-name">
|
||||
{p.name}
|
||||
{#if p.version}
|
||||
<span class="plugin-version">v{p.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if p.description}
|
||||
<div class="plugin-desc">{p.description}</div>
|
||||
{/if}
|
||||
<div class="plugin-meta">
|
||||
{#if p.author}
|
||||
<span>{t('settings.author') || 'Автор'}: {p.author}</span>
|
||||
{/if}
|
||||
{#if p.uiContribs?.sidebarItems?.length}
|
||||
<span>{p.uiContribs.sidebarItems.length} пункт(а) в сайдбаре</span>
|
||||
{/if}
|
||||
{#if p.uiContribs?.nodeTabs?.length}
|
||||
<span>{p.uiContribs.nodeTabs.length} вкладок(и) в карточке дела</span>
|
||||
{/if}
|
||||
{#if p.hasPanel}
|
||||
<span>🗖 HTML-панель</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-toggle">
|
||||
{#if !p.installed}
|
||||
<button class="install-btn" on:click={() => install(p)}>
|
||||
📦 Установить
|
||||
</button>
|
||||
{:else}
|
||||
<div class="toggle-group">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={p.active}
|
||||
on:click={() => toggle(p)}
|
||||
role="switch"
|
||||
aria-checked={p.active}
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
<button class="uninstall-btn btn-sm" on:click={() => uninstall(p)} title="Удалить плагин (очистить данные)">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-section { padding: 1.5rem; max-width: 500px; }
|
||||
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
|
||||
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.settings-plugins {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text, #e0e0e0);
|
||||
}
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.loading { color: var(--text-dim, #888); }
|
||||
.error { color: #f87171; background: rgba(248,113,113,0.1); padding: 0.5rem 0.75rem; border-radius: 6px; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.empty-state code {
|
||||
background: var(--surface-alt, #252538);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.plugin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.plugin-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--surface, #1e1e2e);
|
||||
border: 1px solid var(--border, #2a2a3e);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.plugin-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugin-name {
|
||||
font-weight: 600;
|
||||
color: var(--text, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.plugin-version {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim, #888);
|
||||
font-weight: 400;
|
||||
}
|
||||
.plugin-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.plugin-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.plugin-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--surface-alt, #333);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border, #444);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: #6366f1;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: left 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toggle-btn.active .toggle-knob {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
padding: 6px 14px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.install-btn:hover { filter: brightness(1.15); }
|
||||
|
||||
.uninstall-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--surface, #1a1a2e);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239,68,68,0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.uninstall-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(239,68,68,0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{ id: 'activity', label: t('settings.activity') },
|
||||
{ id: 'sync', label: t('settings.sync') },
|
||||
{ id: 'backup', label: t('settings.backup') },
|
||||
{ id: 'browserBridge', label: 'Browser Bridge' },
|
||||
]
|
||||
|
||||
function select(id) {
|
||||
@@ -25,7 +26,7 @@
|
||||
<button
|
||||
class="settings-nav-item"
|
||||
class:active={activeSection === sec.id}
|
||||
class:disabled={sec.id === 'plugins' || sec.id === 'files' || sec.id === 'activity' || sec.id === 'backup'}
|
||||
class:disabled={sec.id === 'files' || sec.id === 'activity' || sec.id === 'backup'}
|
||||
on:click={() => select(sec.id)}
|
||||
>
|
||||
<span class="settings-nav-icon">
|
||||
@@ -57,6 +58,9 @@
|
||||
<path d="M5 4h12l2 2v14H5z"/>
|
||||
<path d="M8 4v6h8V4"/>
|
||||
<path d="M8 20v-6h8v6"/>
|
||||
{:else if sec.id === 'browserBridge'}
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
{/if}
|
||||
</svg>
|
||||
</span>
|
||||
@@ -97,6 +101,17 @@
|
||||
background: var(--accent-bg, rgba(99, 102, 241, 0.15));
|
||||
color: var(--accent, #818cf8);
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
}
|
||||
.settings-nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
background: var(--accent, #818cf8);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.settings-nav-item.disabled {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
import SettingsActivity from './SettingsActivity.svelte'
|
||||
import SettingsSync from './SettingsSync.svelte'
|
||||
import SettingsBackup from './SettingsBackup.svelte'
|
||||
import SettingsBrowserBridge from './SettingsBrowserBridge.svelte'
|
||||
|
||||
export let onClose = null
|
||||
export let onSyncRefresh = null
|
||||
export let onPluginToggle = null
|
||||
export let initialSection = 'general'
|
||||
|
||||
let activeSection = initialSection
|
||||
@@ -81,7 +83,7 @@
|
||||
{:else if activeSection === 'templates'}
|
||||
<SettingsTemplates onRefresh={loadConfig} />
|
||||
{:else if activeSection === 'plugins'}
|
||||
<SettingsPlugins />
|
||||
<SettingsPlugins {onPluginToggle} />
|
||||
{:else if activeSection === 'files'}
|
||||
<SettingsFiles />
|
||||
{:else if activeSection === 'activity'}
|
||||
@@ -90,6 +92,8 @@
|
||||
<SettingsSync onRefresh={handleSyncRefresh} />
|
||||
{:else if activeSection === 'backup'}
|
||||
<SettingsBackup />
|
||||
{:else if activeSection === 'browserBridge'}
|
||||
<SettingsBrowserBridge {appConfig} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { t } from './i18n'
|
||||
import BrowserEvents from './BrowserEvents.svelte'
|
||||
|
||||
export let todayDashboard = null
|
||||
export let suggestions = []
|
||||
@@ -8,11 +9,8 @@
|
||||
export let suggestionCount = 0
|
||||
|
||||
export let formatTime = (iso) => ''
|
||||
export let formatDate = (iso) => ''
|
||||
export let eventLabel = (type) => type
|
||||
export let eventIcon = (type) => ''
|
||||
export let nodeKindLabel = (kind) => kind
|
||||
export let captureKindLabel = (kind) => kind
|
||||
export let inboxMetaText = (item) => ''
|
||||
|
||||
export let onOpenNodeById = (id) => {}
|
||||
@@ -23,6 +21,15 @@
|
||||
export let onDeleteSuggestion = (s) => {}
|
||||
export let onOpenNodeFolder = (id) => {}
|
||||
export let onOpenInboxArtifact = (item) => {}
|
||||
export let onOpenTrashNode = (nodeId) => {}
|
||||
|
||||
// Browser events
|
||||
export let browserEvents = []
|
||||
export let browserLoading = false
|
||||
export let onBrowserAccept = (ev) => {}
|
||||
export let onBrowserDismiss = (ev) => {}
|
||||
export let onBrowserAttach = (ev) => {}
|
||||
export let onBrowserRefresh = () => {}
|
||||
|
||||
let activeTab = 'feed'
|
||||
|
||||
@@ -50,6 +57,7 @@
|
||||
}
|
||||
|
||||
function feedItemLabel(ev) {
|
||||
if (!ev || !ev.eventType) return ''
|
||||
return eventLabel(ev.eventType)
|
||||
}
|
||||
|
||||
@@ -67,14 +75,15 @@
|
||||
}
|
||||
|
||||
function handleFeedTitleClick(ev) {
|
||||
if (!ev || !ev.eventType) return
|
||||
if (ev.eventType === 'folder_deleted') {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
if (ev.targetId) onOpenTrashNode(ev.targetId)
|
||||
return
|
||||
}
|
||||
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
|
||||
if (ev.targetId) {
|
||||
onOpenActivityTarget(ev)
|
||||
} else {
|
||||
} else if (ev.nodeId) {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
}
|
||||
return
|
||||
@@ -82,20 +91,22 @@
|
||||
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
|
||||
if (ev.targetType === 'note' && ev.targetId) {
|
||||
onOpenActivityTarget(ev)
|
||||
} else {
|
||||
} else if (ev.nodeId) {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev.eventType === 'worklog_added') {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
if (ev.nodeId) onOpenNodeById(ev.nodeId)
|
||||
return
|
||||
}
|
||||
if (['action_created','action_done'].includes(ev.eventType)) {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
if (ev.nodeId) onOpenNodeById(ev.nodeId)
|
||||
return
|
||||
}
|
||||
onOpenActivityEvent(ev)
|
||||
if (ev.id || ev.nodeId) {
|
||||
onOpenActivityEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
function feedItemSubtitle(ev) {
|
||||
@@ -152,7 +163,6 @@
|
||||
|
||||
<div class="today-screen">
|
||||
<div class="today-header">
|
||||
<h2>{t('today.title')}</h2>
|
||||
<span class="today-date">{todayDashboard?.date || ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +181,10 @@
|
||||
{t('today.captured')}
|
||||
{#if todayCaptures.length > 0}<span class="tab-badge">{todayCaptures.length}</span>{/if}
|
||||
</button>
|
||||
<button class="today-tab" class:active={activeTab === 'browser'} on:click={() => activeTab = 'browser'}>
|
||||
Браузер
|
||||
{#if browserEvents.length > 0}<span class="tab-badge">{browserEvents.length}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'feed'}
|
||||
@@ -189,9 +203,9 @@
|
||||
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
|
||||
<div class="feed-body">
|
||||
<div class="feed-title-line">
|
||||
<span class="feed-type">{feedItemLabel(ev.eventType)}</span>
|
||||
<span class="feed-type">{feedItemLabel(ev)}</span>
|
||||
<span class="feed-colon">:</span>
|
||||
<span class="feed-title link-btn">{ev.title}</span>
|
||||
<span class="feed-title link-btn">{ev.title || ''}</span>
|
||||
</div>
|
||||
<div class="feed-meta-line">
|
||||
{#if feedItemSubtitle(ev)}
|
||||
@@ -327,13 +341,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'browser'}
|
||||
<div class="today-tab-content">
|
||||
<BrowserEvents
|
||||
events={browserEvents}
|
||||
loading={browserLoading}
|
||||
{formatTime}
|
||||
onAccept={onBrowserAccept}
|
||||
onDismiss={onBrowserDismiss}
|
||||
onAttach={onBrowserAttach}
|
||||
onRefresh={onBrowserRefresh}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.today-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.today-header h2 { font-size: 24px; margin: 0; }
|
||||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; min-height: 20px; }
|
||||
.today-date { font-size: 13px; color: #666; }
|
||||
|
||||
.today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
|
||||
@@ -368,7 +393,7 @@
|
||||
.feed-title:hover { text-decoration: underline; }
|
||||
.feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||
.feed-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-event-type { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||
.feed-event-type { font-size: 10px; color: #a5b4fc; background: rgba(99,102,241,0.1); padding: 2px 8px; border-radius: 8px; font-weight: 500; }
|
||||
.feed-time { font-size: 11px; color: #555; margin-left: auto; flex-shrink: 0; }
|
||||
.feed-nav-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; border-radius: 4px; flex-shrink: 0; margin-top: 2px; }
|
||||
.feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let show = false
|
||||
export let label = ''
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="capture-drop-overlay">
|
||||
<div class="capture-drop-box">{label}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.capture-drop-overlay { position: fixed; inset: 0; z-index: 120; pointer-events: none; display: flex; align-items: center; justify-content: center; background: rgba(19, 19, 31, 0.42); border: 2px dashed #818cf8; }
|
||||
.capture-drop-box { max-width: min(520px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid #3a3a5c; border-radius: 8px; background: #1a1a28; color: #e4e4ef; font-size: 14px; font-weight: 600; box-shadow: 0 12px 32px rgba(0,0,0,0.35); text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import { t } from '../i18n'
|
||||
|
||||
export let error = ''
|
||||
export let onDismiss = () => {}
|
||||
|
||||
function translateError(msg) {
|
||||
const map = {
|
||||
'vault not open': t('error.vaultNotOpen'),
|
||||
}
|
||||
return map[msg] || msg
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="button" tabindex="0" on:click={onDismiss} on:keydown={handleKeydown}>
|
||||
{translateError(error)}
|
||||
<button class="dismiss-btn" on:click|stopPropagation={onDismiss} aria-label="Dismiss">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
|
||||
.dismiss-btn:hover { color: #ff4444; }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import { t } from '../i18n'
|
||||
import { actionIcon } from '../actionIcons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let node = null
|
||||
export let notes = []
|
||||
export let worklog = []
|
||||
export let nodeKindLabel = (type) => type || ''
|
||||
export let formatDate = (d) => d || ''
|
||||
|
||||
function goNotesCreate() {
|
||||
dispatch('goTab', 'notes')
|
||||
dispatch('createNote')
|
||||
}
|
||||
|
||||
function goFilesAdd() {
|
||||
dispatch('goTab', 'files')
|
||||
dispatch('addFile')
|
||||
}
|
||||
|
||||
function onKeyActivate(fn) {
|
||||
return (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overview">
|
||||
<h2 class="node-title">{node.title}</h2>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(node.type)}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{node.section || '—'}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(node.createdAt)}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn" on:click={goNotesCreate}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
{t('overview.newNote')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={goFilesAdd}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
{t('overview.addFile')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={() => dispatch('createAction')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
{t('overview.addAction')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={() => dispatch('goTab', 'worklog')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{t('overview.logTime')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if notes.length > 0}
|
||||
<div class="recent-section">
|
||||
<h3>{t('overview.recentNotes')}</h3>
|
||||
{#each notes.slice(0, 5) as note}
|
||||
<div class="recent-note" role="button" tabindex="0" on:click={() => dispatch('openNote', { note })} on:keydown={onKeyActivate(() => dispatch('openNote', { note }))}>
|
||||
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if worklog.length > 0}
|
||||
<div class="recent-section">
|
||||
<h3>{t('overview.recentEntries')}</h3>
|
||||
{#each worklog.slice(0, 3) as e}
|
||||
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overview { padding: 24px; }
|
||||
.node-title { font-size: 24px; margin-bottom: 16px; color: #e4e4ef; }
|
||||
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
|
||||
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||||
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.qa-btn:hover { background: #222233; }
|
||||
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.recent-section { margin-bottom: 24px; }
|
||||
.recent-section h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.recent-note { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
|
||||
.recent-note:hover { background: #1a1a28; }
|
||||
.recent-date { font-size: 11px; color: #555; }
|
||||
.recent-entry { padding: 6px 0; font-size: 13px; color: #888; border-bottom: 1px solid #1a1a28; }
|
||||
</style>
|
||||
@@ -0,0 +1,616 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { t } from '../../i18n'
|
||||
import { isMarkdownFile } from '../../fileUtils.js'
|
||||
import FileTreeRow from '../../../FileTreeRow.svelte'
|
||||
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
|
||||
import FilePreviewModal from '../../FilePreviewModal.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Props
|
||||
export let selectedNode = null
|
||||
|
||||
// Internal state
|
||||
let loadingFiles = false
|
||||
let currentFolderId = null
|
||||
let folderStack = []
|
||||
let fileItems = []
|
||||
let previewItem = null
|
||||
let previewContent = ''
|
||||
let previewLoading = false
|
||||
let previewError = ''
|
||||
let clipboard = { items: [], mode: 'copy' }
|
||||
let selectedIds = []
|
||||
let dragIds = []
|
||||
let importing = false
|
||||
let importSummary = null
|
||||
let showImportDialog = false
|
||||
let pendingImportPath = ''
|
||||
let pendingImportParent = ''
|
||||
|
||||
// ===== Wails call helper =====
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
const fn = window['go']['main']['App'][method]
|
||||
if (typeof fn === 'function') {
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Wails call error:', method, e)
|
||||
}
|
||||
return Promise.reject(new Error('Wails not connected: ' + method))
|
||||
}
|
||||
|
||||
// ===== Public API for keyboard handler =====
|
||||
export function filesHandleKeydown(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openSelected()
|
||||
} else if (e.key === 'Delete') {
|
||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||
} else if (e.key === 'Escape') {
|
||||
if (previewItem) { closePreview(); return }
|
||||
if (selectedIds.length > 0) { clearSelection(); return }
|
||||
} else if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
openRenameForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Folder navigation =====
|
||||
export async function loadFolder(folderId) {
|
||||
loadingFiles = true
|
||||
try {
|
||||
let items = await wailsCall('ListItems', folderId) || []
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
|
||||
return (a.name || '').localeCompare(b.name || '')
|
||||
})
|
||||
fileItems = items
|
||||
} catch (e) {
|
||||
fileItems = []
|
||||
}
|
||||
loadingFiles = false
|
||||
}
|
||||
|
||||
async function navigateToFolder(folderId) {
|
||||
if (!selectedNode) return
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', folderId)
|
||||
if (node) {
|
||||
folderStack = [...folderStack, { id: folderId, name: node.title }]
|
||||
}
|
||||
} catch (e) {
|
||||
folderStack = [...folderStack, { id: folderId, name: '...' }]
|
||||
}
|
||||
currentFolderId = folderId
|
||||
await loadFolder(folderId)
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
if (folderStack.length < 2) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
if (selectedNode) loadFolder(selectedNode.id)
|
||||
} else {
|
||||
const target = folderStack[folderStack.length - 2]
|
||||
folderStack = folderStack.slice(0, -1)
|
||||
currentFolderId = target.id
|
||||
loadFolder(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index) {
|
||||
const target = folderStack[index]
|
||||
folderStack = folderStack.slice(0, index + 1)
|
||||
currentFolderId = target.id
|
||||
loadFolder(target.id)
|
||||
}
|
||||
|
||||
// ===== File preview =====
|
||||
async function openPreview(item) {
|
||||
if (item && item.fileId && isMarkdownFile(item)) {
|
||||
try {
|
||||
const action = await wailsCall('CheckFileAction', item.fileId)
|
||||
if (action.action === 'note') {
|
||||
dispatch('openNote', { note: { id: action.noteId, title: action.noteTitle } })
|
||||
return
|
||||
}
|
||||
if (action.action === 'external') {
|
||||
await wailsCall('OpenFile', item.fileId)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('CheckFileAction failed, falling back to preview:', e)
|
||||
}
|
||||
}
|
||||
|
||||
previewItem = item
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
previewLoading = true
|
||||
|
||||
try {
|
||||
if (needsBase64Preview(item)) {
|
||||
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
|
||||
} else if (needsTextPreview(item)) {
|
||||
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
|
||||
}
|
||||
} catch (e) {
|
||||
previewError = String(e)
|
||||
}
|
||||
previewLoading = false
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
}
|
||||
|
||||
// ===== Helpers for preview =====
|
||||
function needsBase64Preview(item) {
|
||||
if (!item) return false
|
||||
const ext = (item.name || '').toLowerCase()
|
||||
return ext.endsWith('.png') || ext.endsWith('.jpg') || ext.endsWith('.jpeg') ||
|
||||
ext.endsWith('.gif') || ext.endsWith('.webp') || ext.endsWith('.svg') ||
|
||||
ext.endsWith('.bmp') || ext.endsWith('.ico') || ext.endsWith('.pdf')
|
||||
}
|
||||
|
||||
function needsTextPreview(item) {
|
||||
if (!item) return false
|
||||
const ext = (item.name || '').toLowerCase()
|
||||
return ext.endsWith('.txt') || ext.endsWith('.log') || ext.endsWith('.json') ||
|
||||
ext.endsWith('.xml') || ext.endsWith('.csv') || ext.endsWith('.md') ||
|
||||
ext.endsWith('.js') || ext.endsWith('.ts') || ext.endsWith('.css') ||
|
||||
ext.endsWith('.html') || ext.endsWith('.yaml') || ext.endsWith('.yml')
|
||||
}
|
||||
|
||||
// ===== File operations =====
|
||||
async function createFile() {
|
||||
const name = prompt(t('file.namePrompt'))
|
||||
if (!name || !name.trim()) return
|
||||
try {
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await wailsCall('CreateEmptyFile', parentId, name.trim())
|
||||
await loadFolder(parentId)
|
||||
dispatch('refreshParent', { nodeId: parentId })
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateItem(id) {
|
||||
try {
|
||||
await wailsCall('DuplicateNode', id)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
dispatch('refreshParent', { nodeId: parentId })
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) {
|
||||
dispatch('rename', { id, name: item.name })
|
||||
}
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
clipboard = { items: [id], mode: 'cut' }
|
||||
}
|
||||
|
||||
function copyItem(id) {
|
||||
clipboard = { items: [id], mode: 'copy' }
|
||||
}
|
||||
|
||||
async function pasteItem() {
|
||||
if (clipboard.items.length === 0) return
|
||||
const targetId = currentFolderId || selectedNode.id
|
||||
try {
|
||||
if (clipboard.mode === 'copy') {
|
||||
for (const id of clipboard.items) {
|
||||
await wailsCall('DuplicateNode', id)
|
||||
}
|
||||
} else {
|
||||
for (const id of clipboard.items) {
|
||||
await wailsCall('MoveNode', id, targetId)
|
||||
}
|
||||
}
|
||||
clipboard = { items: [], mode: 'copy' }
|
||||
await loadFolder(targetId)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Selection =====
|
||||
function getTargetIds(ids) {
|
||||
return ids.length > 0 ? ids : fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (selectedIds.includes(id)) {
|
||||
selectedIds = selectedIds.filter(x => x !== id)
|
||||
} else {
|
||||
selectedIds = [...selectedIds, id]
|
||||
}
|
||||
}
|
||||
|
||||
function selectOne(id) {
|
||||
selectedIds = [id]
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function rangeSelect(id) {
|
||||
if (fileItems.length === 0) return
|
||||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||
if (lastIdx === -1 || curIdx === -1) return
|
||||
const start = Math.min(lastIdx, curIdx)
|
||||
const end = Math.max(lastIdx, curIdx)
|
||||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||
const set = new Set(selectedIds)
|
||||
range.forEach(x => set.add(x))
|
||||
selectedIds = [...set]
|
||||
}
|
||||
|
||||
function cutSelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
clipboard = { items: ids, mode: 'cut' }
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
clipboard = { items: ids, mode: 'copy' }
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.id)
|
||||
} else {
|
||||
openPreview(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
dispatch('rename', { id: item.id, name: item.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
const item = fileItems.find(x => x.id === ids[0])
|
||||
let label
|
||||
if (ids.length === 1 && item?.type === 'folder') {
|
||||
label = t('delete.folder')
|
||||
} else if (ids.length === 1) {
|
||||
label = t('delete.file')
|
||||
} else {
|
||||
label = t('delete.files', { count: ids.length })
|
||||
}
|
||||
dispatch('confirm', {
|
||||
title: t('delete.confirmTitle'),
|
||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
||||
confirmText: t('common.delete'),
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await wailsCall('DeleteFileOrFolder', id)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
selectedIds = []
|
||||
const reloadId = currentFolderId || selectedNode.id
|
||||
await loadFolder(reloadId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Drag-and-drop =====
|
||||
function onDragStart(e, id) {
|
||||
e.stopPropagation()
|
||||
const ids = selectedIds.includes(id) ? selectedIds : [id]
|
||||
dragIds = ids
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', ids.join(','))
|
||||
}
|
||||
|
||||
function onDragOver(e, folderId) {
|
||||
const item = fileItems.find(x => x.id === folderId)
|
||||
if (item && item.type === 'folder') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(e, folderId) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (dragIds.length === 0) return
|
||||
for (const id of dragIds) {
|
||||
try {
|
||||
await wailsCall('MoveNode', id, folderId)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
dragIds = []
|
||||
selectedIds = []
|
||||
await loadFolder(currentFolderId || selectedNode.id)
|
||||
}
|
||||
|
||||
// ===== Import =====
|
||||
async function addFile() {
|
||||
const path = await wailsCall('PickFile')
|
||||
if (!path) return
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await startImport(parentId, path)
|
||||
}
|
||||
|
||||
async function addFolder() {
|
||||
const path = await wailsCall('PickDirectory')
|
||||
if (!path) return
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await startImport(parentId, path)
|
||||
}
|
||||
|
||||
async function startImport(parentID, sourcePath) {
|
||||
importing = true
|
||||
try {
|
||||
const summary = await wailsCall('PreviewImport', sourcePath)
|
||||
importSummary = summary
|
||||
pendingImportPath = sourcePath
|
||||
pendingImportParent = parentID
|
||||
showImportDialog = true
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
importing = false
|
||||
}
|
||||
|
||||
async function confirmImport(mode) {
|
||||
try {
|
||||
const parentId = pendingImportParent || selectedNode.id
|
||||
const result = mode === 'copy'
|
||||
? await wailsCall('AddPathCopy', parentId, pendingImportPath)
|
||||
: await wailsCall('AddPathLink', parentId, pendingImportPath)
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
await loadFolder(parentId)
|
||||
dispatch('refreshParent', { nodeId: parentId })
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport() {
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
}
|
||||
|
||||
async function deleteFile({ id, type }) {
|
||||
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
|
||||
dispatch('confirm', {
|
||||
title: t('delete.confirmTitle'),
|
||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
||||
confirmText: t('common.delete'),
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await wailsCall('DeleteFileOrFolder', id)
|
||||
const reloadId = currentFolderId || selectedNode.id
|
||||
await loadFolder(reloadId)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Public API: called from parent when node changes =====
|
||||
export function resetToNode(nodeId) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
clipboard = { items: [], mode: 'copy' }
|
||||
fileItems = []
|
||||
loadingFiles = false
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
importing = false
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
if (nodeId) loadFolder(nodeId)
|
||||
}
|
||||
|
||||
// Load folder when selectedNode changes
|
||||
$: if (selectedNode && selectedNode.id && !currentFolderId && fileItems.length === 0 && !loadingFiles) {
|
||||
loadFolder(selectedNode.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="files-tab">
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
|
||||
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
|
||||
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
|
||||
{#if clipboard.items.length > 0}
|
||||
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingFiles}
|
||||
<div class="empty-state">
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
if (selectedNode) loadFolder(selectedNode.id)
|
||||
} else {
|
||||
navigateToBreadcrumb(i - 1)
|
||||
}
|
||||
}}/>
|
||||
<button class="btn btn-sm back-btn" on:click={navigateBack}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
{t('common.backLabel')}
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
|
||||
<p class="hint">{t('file.hint')}</p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
|
||||
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-list">
|
||||
{#each fileItems as item (item.id)}
|
||||
<FileTreeRow
|
||||
{item}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
{onDragStart}
|
||||
{onDragOver}
|
||||
{onDrop}
|
||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
on:cut={(e) => cutItem(e.detail)}
|
||||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if importing && !showImportDialog}
|
||||
<div class="empty-state"><p>{t('file.scanning')}</p></div>
|
||||
{/if}
|
||||
|
||||
<!-- Import dialog -->
|
||||
{#if showImportDialog && importSummary}
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('file.importTitle')} on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
|
||||
<div class="modal-box" role="document">
|
||||
<h3>{t('file.importTitle')}</h3>
|
||||
<div class="import-summary">
|
||||
<p><strong>{t('file.path')}:</strong> {importSummary.path || pendingImportPath}</p>
|
||||
{#if importSummary.files}
|
||||
<p>{t('file.files')}: {importSummary.files.length}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
|
||||
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
|
||||
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File preview modal -->
|
||||
{#if previewItem}
|
||||
<FilePreviewModal
|
||||
item={previewItem}
|
||||
content={previewContent}
|
||||
loading={previewLoading}
|
||||
error={previewError}
|
||||
{wailsCall}
|
||||
on:close={closePreview}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.files-tab { padding: 20px; }
|
||||
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
|
||||
.file-list { display: flex; flex-direction: column; }
|
||||
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||
.empty-state p { margin: 0; font-size: 14px; color: #666; }
|
||||
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
||||
.empty-icon { margin-bottom: 12px; color: #333; }
|
||||
.empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
||||
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.btn:hover { background: #222233; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||||
.btn-primary:hover { background: #4f46e5; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; }
|
||||
.modal-box { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; min-width: 320px; max-width: 560px; width: 90vw; }
|
||||
.modal-box h3 { margin: 0 0 16px; font-size: 16px; }
|
||||
.import-summary { margin-bottom: 16px; font-size: 13px; }
|
||||
.import-summary p { margin: 4px 0; }
|
||||
.form-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { escapeMarkdownLabel } from '../../markdown/markdown';
|
||||
import { expandKeyboardVariants } from '../../util/keyboardLayout';
|
||||
|
||||
export let visible = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Filter types
|
||||
const types = [
|
||||
{ id: 'case', label: 'Дело' },
|
||||
{ id: 'note', label: 'Заметка' },
|
||||
{ id: 'file', label: 'Файл' },
|
||||
{ id: 'secret', label: 'Секрет' },
|
||||
];
|
||||
|
||||
let activeType = 'note';
|
||||
let query = '';
|
||||
let rawResults = []; // all search results (unfiltered)
|
||||
let results = []; // filtered by activeType
|
||||
let selectedIndex = 0;
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let inputRef;
|
||||
|
||||
// Map node types from backend to our filter types
|
||||
function nodeTypeToFilter(type) {
|
||||
if (type === 'note') return 'note';
|
||||
if (type === 'file') return 'file';
|
||||
if (type === 'secret') return 'secret';
|
||||
return 'case';
|
||||
}
|
||||
|
||||
// Filter rawResults by activeType into displayed results
|
||||
function applyTypeFilter() {
|
||||
results = rawResults.filter(n => nodeTypeToFilter(n.type) === activeType);
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!query.trim() || query.trim().length < 2) {
|
||||
rawResults = [];
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
// Expand query with keyboard layout variants for tolerant search
|
||||
const variants = expandKeyboardVariants(query.trim());
|
||||
const seen = new Set();
|
||||
const queries = [];
|
||||
for (const v of variants) {
|
||||
if (!seen.has(v)) {
|
||||
seen.add(v);
|
||||
queries.push(v);
|
||||
}
|
||||
}
|
||||
// Execute all variant queries in parallel
|
||||
const allResults = await Promise.all(
|
||||
queries.map(q => window['go']['main']['App']['SearchNodes'](q).catch(() => []))
|
||||
);
|
||||
// Merge and deduplicate by node ID
|
||||
const merged = new Map();
|
||||
for (const arr of allResults) {
|
||||
for (const n of (arr || [])) {
|
||||
if (!merged.has(n.id)) {
|
||||
merged.set(n.id, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
rawResults = Array.from(merged.values());
|
||||
applyTypeFilter();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
rawResults = [];
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(search, 250);
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
selectResult(results[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(item) {
|
||||
const label = escapeMarkdownLabel(item.title || item.id);
|
||||
const md = `[${label}](verstak://${nodeTypeToFilter(item.type)}/${item.id})`;
|
||||
dispatch('insert', { markdown: md });
|
||||
}
|
||||
|
||||
function handleTypeChange(typeId) {
|
||||
activeType = typeId;
|
||||
applyTypeFilter();
|
||||
if (inputRef) inputRef.focus();
|
||||
}
|
||||
|
||||
let searchTimer;
|
||||
$: if (visible && inputRef) {
|
||||
setTimeout(() => inputRef.focus(), 50);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="picker-modal-overlay" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
|
||||
<div class="picker-modal" role="dialog" aria-modal="true" aria-label="Выбрать объект">
|
||||
<div class="picker-header">
|
||||
<input
|
||||
type="text"
|
||||
bind:this={inputRef}
|
||||
bind:value={query}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeydown}
|
||||
placeholder="Поиск..."
|
||||
class="picker-search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="picker-types" role="tablist" aria-label="Тип объекта">
|
||||
{#each types as type}
|
||||
<button
|
||||
type="button"
|
||||
class="picker-type-btn"
|
||||
class:active={activeType === type.id}
|
||||
class:disabled={type.id === 'secret'}
|
||||
role="tab"
|
||||
aria-selected={activeType === type.id}
|
||||
on:click={() => type.id !== 'secret' && handleTypeChange(type.id)}
|
||||
disabled={type.id === 'secret'}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeType === 'secret'}
|
||||
<div class="picker-secret-placeholder">
|
||||
<p>🔒 Сейф доступов ещё не реализован</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="picker-results" role="listbox">
|
||||
{#if loading}
|
||||
<div class="picker-loading">Загрузка...</div>
|
||||
{:else if error}
|
||||
<div class="picker-error">{error}</div>
|
||||
{:else if query.trim().length >= 2 && results.length === 0 && !loading}
|
||||
<div class="picker-empty">
|
||||
{rawResults.length > 0 ? 'Нет результатов для этого типа' : 'Ничего не найдено'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each results as item, i}
|
||||
<button
|
||||
type="button"
|
||||
class="picker-result"
|
||||
class:selected={i === selectedIndex}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
on:click={() => selectResult(item)}
|
||||
>
|
||||
<span class="picker-result-title">{item.title}</span>
|
||||
{#if item.path}
|
||||
<span class="picker-result-path">{item.path}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="picker-footer">
|
||||
<button type="button" class="btn" on:click={() => dispatch('close')}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.picker-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.picker-modal {
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 12px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
}
|
||||
|
||||
.picker-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
background: #13131f;
|
||||
color: #e4e4ef;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-search-input:focus {
|
||||
border-color: #818cf8;
|
||||
}
|
||||
|
||||
.picker-types {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
}
|
||||
|
||||
.picker-type-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 4px;
|
||||
background: #13131f;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picker-type-btn:hover:not(:disabled) {
|
||||
background: #1e1e38;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.picker-type-btn.active {
|
||||
background: #2a2a4a;
|
||||
color: #e4e4ef;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.picker-type-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.picker-secret-placeholder {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.picker-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.picker-loading,
|
||||
.picker-error,
|
||||
.picker-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picker-error {
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.picker-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1e1e30;
|
||||
background: transparent;
|
||||
color: #e4e4ef;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picker-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.picker-result:hover,
|
||||
.picker-result.selected {
|
||||
background: #1e1e38;
|
||||
}
|
||||
|
||||
.picker-result-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.picker-result-path {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #2a2a3c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,390 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let content = '';
|
||||
export let viewMode = 'edit';
|
||||
export let placeholder = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let textareaRef;
|
||||
|
||||
// ─── Public API: insert text at cursor ──────────────────────────
|
||||
export function insertText(text) {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const before = content.substring(0, start);
|
||||
const after = content.substring(end);
|
||||
const newContent = before + text + after;
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = start + text.length;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Core insert helper ──────────────────────────────────────────
|
||||
function insertAtCursor(before, after = '', placeholder = '') {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = content.substring(start, end);
|
||||
const insertText = selected || placeholder;
|
||||
|
||||
const replacement = before + insertText + after;
|
||||
const newContent = content.substring(0, start) + replacement + content.substring(end);
|
||||
content = newContent;
|
||||
|
||||
// Dispatch change
|
||||
dispatch('content-change', { content: newContent });
|
||||
|
||||
// Restore focus and set cursor position
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
if (selected) {
|
||||
// Wrap mode: select the inserted content (excluding markers)
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + selected.length;
|
||||
} else {
|
||||
// No selection: place cursor inside the wrapper
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + placeholder.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertLinePrefix(prefix) {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
|
||||
// Find the start of the line(s) covered by selection
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
|
||||
let lineEnd = end;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
|
||||
const selectedLines = content.substring(lineStart, lineEnd);
|
||||
const lines = selectedLines.split('\n');
|
||||
const prefixedLines = lines.map(l => {
|
||||
// Don't double-prefix
|
||||
if (l.startsWith(prefix)) return l;
|
||||
return prefix + l;
|
||||
});
|
||||
const replacement = prefixedLines.join('\n');
|
||||
|
||||
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + replacement.length;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Toolbar handlers ────────────────────────────────────────────
|
||||
function handleBold() { insertAtCursor('**', '**', 'bold text'); }
|
||||
function handleItalic() { insertAtCursor('*', '*', 'italic text'); }
|
||||
function handleInlineCode() { insertAtCursor('`', '`', 'code'); }
|
||||
function handleLink() { dispatch('insert-link'); }
|
||||
function handleHeading() { insertLinePrefix('## '); }
|
||||
function handleQuote() { insertLinePrefix('> '); }
|
||||
function handleBulletList() { insertLinePrefix('- '); }
|
||||
function handleNumberedList() {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
let lineEnd = ta.selectionEnd;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
const selectedLines = content.substring(lineStart, lineEnd);
|
||||
const lines = selectedLines.split('\n');
|
||||
let counter = 1;
|
||||
const prefixedLines = lines.map(l => {
|
||||
const trimmed = l.trim();
|
||||
// Remove existing numbered list prefix
|
||||
const cleaned = l.replace(/^\s*\d+\.\s*/, '');
|
||||
return `${counter++}. ${trimmed || 'item'}`;
|
||||
});
|
||||
const replacement = prefixedLines.join('\n');
|
||||
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => { ta.focus(); ta.selectionStart = lineStart; ta.selectionEnd = lineStart + replacement.length; });
|
||||
}
|
||||
function handleCheckbox() { insertLinePrefix('- [ ] '); }
|
||||
function handleCodeBlock() {
|
||||
const ta = textareaRef;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = content.substring(start, end);
|
||||
const replacement = selected
|
||||
? '```\n' + selected + '\n```'
|
||||
: '```\ncode here\n```';
|
||||
const newContent = content.substring(0, start) + replacement + content.substring(end);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
if (selected) {
|
||||
ta.selectionStart = start + 4;
|
||||
ta.selectionEnd = start + 4 + selected.length;
|
||||
} else {
|
||||
ta.selectionStart = start + 4;
|
||||
ta.selectionEnd = start + 4 + 'code here'.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
function handleInternalLink() { dispatch('insert-internal-link'); }
|
||||
|
||||
// ─── Textarea events ─────────────────────────────────────────────
|
||||
function handleInput(e) {
|
||||
content = e.target.value;
|
||||
dispatch('content-change', { content });
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
dispatch('save');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const ta = e.target;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Unindent: remove up to 2 spaces at start of selected lines
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
let lineEnd = end;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
const block = content.substring(lineStart, lineEnd);
|
||||
const dedented = block.replace(/^ {1,2}/gm, '');
|
||||
const newContent = content.substring(0, lineStart) + dedented + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + dedented.length;
|
||||
});
|
||||
} else {
|
||||
// Indent: insert 2 spaces at cursor or start of each selected line
|
||||
if (start === end) {
|
||||
// No selection: insert 2 spaces at cursor
|
||||
const newContent = content.substring(0, start) + ' ' + content.substring(end);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||
});
|
||||
} else {
|
||||
// Selection: indent each line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
|
||||
let lineEnd = end;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
|
||||
const block = content.substring(lineStart, lineEnd);
|
||||
const indented = block.replace(/^/gm, ' ');
|
||||
const newContent = content.substring(0, lineStart) + indented + content.substring(lineEnd);
|
||||
content = newContent;
|
||||
dispatch('content-change', { content: newContent });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + indented.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="md-editor" class:mode-edit={viewMode === 'edit' || viewMode === 'split'}>
|
||||
<!-- Toolbar -->
|
||||
<div class="md-toolbar" role="toolbar" aria-label="Markdown formatting">
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleBold} title={t('note.toolbar.bold')} aria-label={t('note.toolbar.bold')}>
|
||||
<span class="md-btn-label md-btn-bold">B</span>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleItalic} title={t('note.toolbar.italic')} aria-label={t('note.toolbar.italic')}>
|
||||
<span class="md-btn-label md-btn-italic">I</span>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleHeading} title={t('note.toolbar.heading')} aria-label={t('note.toolbar.heading')}>
|
||||
<span class="md-btn-label">H</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-toolbar-sep"></div>
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleLink} title={t('note.toolbar.link')} aria-label={t('note.toolbar.link')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleInlineCode} title={t('note.toolbar.code')} aria-label={t('note.toolbar.code')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleCodeBlock} title={t('note.toolbar.codeBlock')} aria-label={t('note.toolbar.codeBlock')}>
|
||||
<span class="md-btn-label md-btn-sm">{'</>'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-toolbar-sep"></div>
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleBulletList} title={t('note.toolbar.bulletList')} aria-label={t('note.toolbar.bulletList')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleNumberedList} title={t('note.toolbar.numberedList')} aria-label={t('note.toolbar.numberedList')}>
|
||||
<span class="md-btn-label md-btn-sm">1.</span>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleCheckbox} title={t('note.toolbar.checkbox')} aria-label={t('note.toolbar.checkbox')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</button>
|
||||
<button type="button" class="md-toolbar-btn" on:click={handleQuote} title={t('note.toolbar.quote')} aria-label={t('note.toolbar.quote')}>
|
||||
<span class="md-btn-label md-btn-sm md-btn-quote">"</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-toolbar-sep"></div>
|
||||
<div class="md-toolbar-group">
|
||||
<button type="button" class="md-toolbar-btn md-toolbar-btn--internal" on:click={handleInternalLink} title={t('note.toolbar.internalLink')} aria-label={t('note.toolbar.internalLink')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M4.93 4.93l1.41 1.41"/><path d="M17.66 17.66l1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="M6.34 17.66l-1.41 1.41"/><path d="M19.07 4.93l-1.41 1.41"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor textarea -->
|
||||
<textarea
|
||||
class="md-textarea"
|
||||
bind:value={content}
|
||||
bind:this={textareaRef}
|
||||
{placeholder}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeydown}
|
||||
spellcheck="true"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.md-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.md-editor.mode-edit {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
background: #16161f;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.md-toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.md-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #2a2a3c;
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.md-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.md-toolbar-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #e4e4ef;
|
||||
}
|
||||
|
||||
.md-toolbar-btn:active {
|
||||
background: #333350;
|
||||
}
|
||||
|
||||
.md-toolbar-btn:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.md-toolbar-btn--internal {
|
||||
color: #67e8f9;
|
||||
}
|
||||
.md-toolbar-btn--internal:hover {
|
||||
background: rgba(103, 232, 249, 0.1);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.md-btn-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.md-btn-bold { font-weight: 900; }
|
||||
.md-btn-italic { font-style: italic; font-family: Georgia, serif; }
|
||||
.md-btn-sm { font-size: 10px; }
|
||||
.md-btn-quote { font-size: 16px; font-family: Georgia, serif; }
|
||||
|
||||
/* Textarea */
|
||||
.md-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #13131f;
|
||||
color: #e4e4ef;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
padding: 16px 20px;
|
||||
resize: none;
|
||||
tab-size: 2;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.md-textarea::placeholder {
|
||||
color: #444;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { renderMarkdown, isInternalLink, parseVerstakUrl, isAllowedVerstakType } from '../../markdown/markdown';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let content = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let html = '';
|
||||
let error = '';
|
||||
|
||||
$: {
|
||||
try {
|
||||
html = renderMarkdown(content || '');
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
html = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
const link = e.target.closest('[data-verstak-href]');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('data-verstak-href');
|
||||
if (!href) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const parsed = parseVerstakUrl(href);
|
||||
if (!parsed || !isAllowedVerstakType(parsed.type)) return;
|
||||
|
||||
dispatch('verstak-link', { type: parsed.type, id: parsed.id, href });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="markdown-body" on:click={handleClick} role="article">
|
||||
{#if error}
|
||||
<div class="md-error">
|
||||
<p>⚠️ {t('note.preview.error')}</p>
|
||||
<p class="md-error-detail">{error}</p>
|
||||
</div>
|
||||
{:else if !content?.trim()}
|
||||
<div class="md-empty">{t('note.preview.empty')}</div>
|
||||
{:else}
|
||||
{@html html}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-body {
|
||||
color: #e4e4ef;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.md-empty {
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.md-error {
|
||||
background: #3a2222;
|
||||
border: 1px solid #5a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.md-error-detail {
|
||||
font-size: 12px;
|
||||
color: #cc6666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
:global(.markdown-body h1) {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 32px 0 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
color: #f0f0f8;
|
||||
}
|
||||
:global(.markdown-body h2) {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 28px 0 14px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #22223a;
|
||||
color: #e8e8f0;
|
||||
}
|
||||
:global(.markdown-body h3) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
:global(.markdown-body h4) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 10px;
|
||||
color: #ccc;
|
||||
}
|
||||
:global(.markdown-body h5),
|
||||
:global(.markdown-body h6) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
:global(.markdown-body p) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
:global(.markdown-body .md-link) {
|
||||
color: #818cf8;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.markdown-body .md-link:hover) {
|
||||
color: #a5b4fc;
|
||||
border-bottom-color: #818cf8;
|
||||
}
|
||||
:global(.markdown-body .md-link--internal) {
|
||||
color: #67e8f9;
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(.markdown-body .md-link--internal:hover) {
|
||||
color: #a5f3fc;
|
||||
border-bottom-color: #67e8f9;
|
||||
}
|
||||
:global(.markdown-body .md-link--blocked) {
|
||||
color: #666;
|
||||
text-decoration: line-through;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bold / Italic / Strikethrough */
|
||||
:global(.markdown-body strong) { font-weight: 700; color: #f0f0f8; }
|
||||
:global(.markdown-body em) { font-style: italic; }
|
||||
:global(.markdown-body del) { text-decoration: line-through; color: #888; }
|
||||
|
||||
/* Lists */
|
||||
:global(.markdown-body ul) {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
list-style-type: disc;
|
||||
}
|
||||
:global(.markdown-body ol) {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
:global(.markdown-body li) {
|
||||
margin: 4px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
:global(.markdown-body li > ul),
|
||||
:global(.markdown-body li > ol) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Task lists */
|
||||
:global(.markdown-body .md-task-item) {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
:global(.markdown-body .md-task-checkbox) {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
accent-color: #818cf8;
|
||||
}
|
||||
:global(.markdown-body .md-task-text) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
:global(.markdown-body blockquote) {
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid #818cf8;
|
||||
background: rgba(129, 140, 248, 0.06);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
}
|
||||
:global(.markdown-body blockquote p) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:global(.markdown-body .md-code-inline) {
|
||||
background: #1e1e38;
|
||||
color: #f59e0b;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Code blocks — dark theme */
|
||||
:global(.markdown-body .md-code-block) {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
:global(.markdown-body .md-code-block code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* highlight.js dark theme (GitHub Dark) */
|
||||
:global(.markdown-body .hljs) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-comment) { color: #8b949e; }
|
||||
:global(.markdown-body .hljs-keyword) { color: #ff7b72; }
|
||||
:global(.markdown-body .hljs-string) { color: #a5d6ff; }
|
||||
:global(.markdown-body .hljs-number) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-function) { color: #d2a8ff; }
|
||||
:global(.markdown-body .hljs-class) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-variable) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-operator) { color: #ff7b72; }
|
||||
:global(.markdown-body .hljs-punctuation) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-tag) { color: #7ee787; }
|
||||
:global(.markdown-body .hljs-attr) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-title) { color: #d2a8ff; }
|
||||
:global(.markdown-body .hljs-built_in) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-literal) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-type) { color: #ffa657; }
|
||||
:global(.markdown-body .hljs-params) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-property) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-regexp) { color: #a5d6ff; }
|
||||
:global(.markdown-body .hljs-meta) { color: #8b949e; }
|
||||
:global(.markdown-body .hljs-doctag) { color: #ff7b72; }
|
||||
:global(.markdown-body .hljs-name) { color: #7ee787; }
|
||||
:global(.markdown-body .hljs-section) { color: #1f6feb; font-weight: 700; }
|
||||
:global(.markdown-body .hljs-symbol) { color: #79c0ff; }
|
||||
:global(.markdown-body .hljs-bullet) { color: #f2cc60; }
|
||||
:global(.markdown-body .hljs-code) { color: #c9d1d9; }
|
||||
:global(.markdown-body .hljs-emphasis) { font-style: italic; }
|
||||
:global(.markdown-body .hljs-strong) { font-weight: 700; }
|
||||
:global(.markdown-body .hljs-addition) { color: #aff5b4; background: #033a16; }
|
||||
:global(.markdown-body .hljs-deletion) { color: #ffdcd7; background: #67060c; }
|
||||
|
||||
/* Tables */
|
||||
:global(.markdown-body table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
:global(.markdown-body thead) {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
:global(.markdown-body th) {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #c4c4d4;
|
||||
border-bottom: 2px solid #2a2a3c;
|
||||
}
|
||||
:global(.markdown-body td) {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #1e1e30;
|
||||
}
|
||||
:global(.markdown-body tbody tr:hover) {
|
||||
background: rgba(129, 140, 248, 0.04);
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
:global(.markdown-body hr) {
|
||||
border: none;
|
||||
border-top: 1px solid #2a2a3c;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* Blocked images placeholder */
|
||||
:global(.markdown-body .md-image-blocked) {
|
||||
display: inline-block;
|
||||
background: #1a1a2e;
|
||||
border: 1px dashed #3a3a5c;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import MarkdownPreview from './MarkdownPreview.svelte';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let content = '';
|
||||
export let viewMode = 'edit';
|
||||
export let placeholder = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor
|
||||
|
||||
function setMode(mode) {
|
||||
dispatch('mode-change', { mode });
|
||||
}
|
||||
|
||||
function handleContentChange(e) {
|
||||
content = e.detail.content;
|
||||
dispatch('content-change', e.detail);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
dispatch('save');
|
||||
}
|
||||
|
||||
function handleInsertLink() {
|
||||
dispatch('insert-link');
|
||||
}
|
||||
|
||||
function handleInsertInternalLink() {
|
||||
dispatch('insert-internal-link');
|
||||
}
|
||||
|
||||
function handleVerstakLink(e) {
|
||||
dispatch('verstak-link', e.detail);
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────
|
||||
export function insertText(text) {
|
||||
if (activeEditor && typeof activeEditor.insertText === 'function') {
|
||||
activeEditor.insertText(text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
|
||||
<!-- Mode switcher -->
|
||||
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
|
||||
{t('note.mode.edit')}
|
||||
</button>
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
|
||||
{t('note.mode.preview')}
|
||||
</button>
|
||||
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
|
||||
{t('note.mode.split')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="panel-content">
|
||||
{#if viewMode === 'edit'}
|
||||
<MarkdownEditor
|
||||
bind:this={activeEditor}
|
||||
{content}
|
||||
{placeholder}
|
||||
viewMode="edit"
|
||||
on:content-change={handleContentChange}
|
||||
on:save={handleSave}
|
||||
on:insert-link={handleInsertLink}
|
||||
on:insert-internal-link={handleInsertInternalLink}
|
||||
/>
|
||||
{:else if viewMode === 'preview'}
|
||||
<div class="preview-pane">
|
||||
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
|
||||
</div>
|
||||
{:else if viewMode === 'split'}
|
||||
<div class="split-pane">
|
||||
<div class="split-editor">
|
||||
<MarkdownEditor
|
||||
bind:this={activeEditor}
|
||||
{content}
|
||||
{placeholder}
|
||||
viewMode="split"
|
||||
on:content-change={handleContentChange}
|
||||
on:save={handleSave}
|
||||
on:insert-link={handleInsertLink}
|
||||
on:insert-internal-link={handleInsertInternalLink}
|
||||
/>
|
||||
</div>
|
||||
<div class="split-preview">
|
||||
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
background: #14141f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: #ccc;
|
||||
background: #1e1e30;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
color: #e4e4ef;
|
||||
background: #22223a;
|
||||
border-color: #333350;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mode-btn:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mode-preview .panel-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #2a2a3c;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
background: #11111c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script>
|
||||
import { t } from '../../i18n'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let notes = []
|
||||
export let showCreateNote = false
|
||||
export let formatDate = (d) => d || ''
|
||||
|
||||
let newNoteTitle = ''
|
||||
|
||||
function handleCreateNote() {
|
||||
dispatch('createNote')
|
||||
}
|
||||
|
||||
function handleSubmitCreateNote() {
|
||||
if (newNoteTitle.trim()) {
|
||||
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
|
||||
newNoteTitle = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelCreateNote() {
|
||||
dispatch('cancelCreateNote')
|
||||
}
|
||||
|
||||
function handleOpenNote(note) {
|
||||
dispatch('openNote', { note })
|
||||
}
|
||||
|
||||
function handleStartRename(note) {
|
||||
dispatch('startRename', { note })
|
||||
}
|
||||
|
||||
function handleDelete(note) {
|
||||
dispatch('delete', { note })
|
||||
}
|
||||
|
||||
function onKeyActivate(fn) {
|
||||
return (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="notes-tab">
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={handleCreateNote}>{t('note.add')}</button>
|
||||
</div>
|
||||
{#if showCreateNote}
|
||||
<div class="create-form">
|
||||
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleSubmitCreateNote()} />
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" on:click={handleSubmitCreateNote}>{t('common.create')}</button>
|
||||
<button class="btn" on:click={handleCancelCreateNote}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if notes.length === 0 && !showCreateNote}
|
||||
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
|
||||
{:else}
|
||||
<div class="notes-list">
|
||||
{#each notes as note}
|
||||
<div class="note-card" role="button" tabindex="0" on:click={() => handleOpenNote(note)} on:keydown={onKeyActivate(() => handleOpenNote(note))}>
|
||||
<div class="note-card-info">
|
||||
<div class="note-card-title">{note.title}</div>
|
||||
<div class="note-card-date">{formatDate(note.createdAt)}</div>
|
||||
</div>
|
||||
<div class="note-card-actions" on:click|stopPropagation>
|
||||
<button class="note-action-btn" on:click={() => handleStartRename(note)} title={t('common.rename')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
||||
</button>
|
||||
<button class="note-action-btn note-action-danger" on:click={() => handleDelete(note)} title={t('common.delete')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notes-tab { padding: 24px; }
|
||||
.tab-toolbar { margin-bottom: 16px; }
|
||||
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; outline: none; }
|
||||
.create-form input:focus { border-color: #6366f1; }
|
||||
.form-actions { display: flex; gap: 8px; }
|
||||
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
|
||||
.note-card:hover { border-color: #3a3a5c; }
|
||||
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||
.note-card-date { font-size: 11px; color: #555; }
|
||||
.note-card-info { flex: 1; min-width: 0; }
|
||||
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
|
||||
.note-card:hover .note-card-actions { opacity: 1; }
|
||||
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
|
||||
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
|
||||
.note-action-danger:hover { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||
.empty-state p { margin: 0; font-size: 14px; color: #666; }
|
||||
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
||||
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.btn:hover { background: #222233; }
|
||||
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||||
.btn-primary:hover { background: #4f46e5; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export let visible = false;
|
||||
export let objectType = 'note'; // 'case' | 'note' | 'file' | 'secret'
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let searchQuery = '';
|
||||
let results = [];
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let selectedIndex = 0;
|
||||
let inputRef;
|
||||
|
||||
const objectTypes = [
|
||||
{ id: 'case', label: 'Дело', searchable: true },
|
||||
{ id: 'note', label: 'Заметка', searchable: true },
|
||||
{ id: 'file', label: 'Файл', searchable: true },
|
||||
{ id: 'secret', label: 'Секрет', searchable: false },
|
||||
];
|
||||
|
||||
$: currentTypeInfo = objectTypes.find(o => o.id === objectType) || objectTypes[0];
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
const fn = window['go']['main']['App'][method];
|
||||
if (typeof fn === 'function') {
|
||||
return fn(...args);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Wails call error:', method, e);
|
||||
}
|
||||
return Promise.reject(new Error('Wails not connected: ' + method));
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!searchQuery.trim() || !currentTypeInfo.searchable) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
let items = [];
|
||||
if (objectType === 'note') {
|
||||
// Search across all notes
|
||||
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
|
||||
items = (searchResults || []).filter(n => n.type === 'note' || n.type === 'case');
|
||||
} else if (objectType === 'case') {
|
||||
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
|
||||
items = (searchResults || []).filter(n => n.type === 'case' || n.type === 'project' || n.type === 'client');
|
||||
} else if (objectType === 'file') {
|
||||
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
|
||||
items = (searchResults || []).filter(n => n.type === 'file');
|
||||
}
|
||||
results = items;
|
||||
selectedIndex = 0;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTypeChange(newType) {
|
||||
objectType = newType;
|
||||
searchQuery = '';
|
||||
results = [];
|
||||
error = '';
|
||||
if (inputRef) inputRef.focus();
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
dispatch('select', { type: objectType, id: item.id, title: item.title, path: item.path || '' });
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
search();
|
||||
}
|
||||
|
||||
$: if (visible && inputRef) {
|
||||
setTimeout(() => inputRef.focus(), 50);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label="Select object" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
|
||||
<div class="modal-box object-picker-modal" role="document">
|
||||
<h3>Вставить внутреннюю ссылку</h3>
|
||||
|
||||
<!-- Object type selector -->
|
||||
<div class="type-selector" role="tablist" aria-label="Object type">
|
||||
{#each objectTypes as type}
|
||||
<button
|
||||
type="button"
|
||||
class="type-btn"
|
||||
class:active={objectType === type.id}
|
||||
class:disabled={!type.searchable}
|
||||
role="tab"
|
||||
aria-selected={objectType === type.id}
|
||||
on:click={() => type.searchable && handleTypeChange(type.id)}
|
||||
disabled={!type.searchable}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !currentTypeInfo.searchable}
|
||||
<div class="coming-soon">
|
||||
<p>{t('note.internal.secretNotImplemented')}</p>
|
||||
<p class="hint">Object picker for this type is coming soon.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Search input -->
|
||||
<div class="search-wrapper">
|
||||
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:this={inputRef}
|
||||
bind:value={searchQuery}
|
||||
on:input={handleInput}
|
||||
on:keydown={handleKeydown}
|
||||
placeholder="Поиск..."
|
||||
class="search-input"
|
||||
aria-label="Search objects"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="object-picker-results"
|
||||
aria-activedescendant={results[selectedIndex] ? `result-${selectedIndex}` : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="results-list" id="object-picker-results" role="listbox">
|
||||
{#if loading}
|
||||
<div class="results-loading">{t('common.loading')}</div>
|
||||
{:else if error}
|
||||
<div class="results-error">{error}</div>
|
||||
{:else if searchQuery.trim() && results.length === 0}
|
||||
<div class="results-empty">{t('search.noResults')}</div>
|
||||
{:else}
|
||||
{#each results as item, i}
|
||||
<button
|
||||
type="button"
|
||||
class="result-item"
|
||||
class:selected={i === selectedIndex}
|
||||
id="result-{i}"
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
on:click={() => handleSelect(item)}
|
||||
>
|
||||
<span class="result-title">{item.title}</span>
|
||||
{#if item.path}
|
||||
<span class="result-path">{item.path}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" on:click={() => dispatch('close')}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.object-picker-modal {
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 4px;
|
||||
background: #1a1a28;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.type-btn:hover:not(:disabled) {
|
||||
background: #22223a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: #2a2a4a;
|
||||
color: #e4e4ef;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.type-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
background: #13131f;
|
||||
color: #e4e4ef;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #818cf8;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
background: #11111c;
|
||||
}
|
||||
|
||||
.results-loading,
|
||||
.results-error,
|
||||
.results-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.results-error {
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1a1a28;
|
||||
background: transparent;
|
||||
color: #e4e4ef;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item:hover,
|
||||
.result-item.selected {
|
||||
background: #1e1e38;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-path {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.coming-soon .hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -45,6 +45,7 @@ export default {
|
||||
'inbox.assignTitle': 'Assign material',
|
||||
'inbox.assignTarget': 'Case',
|
||||
'inbox.assignSearchPlaceholder': 'Find case',
|
||||
'inbox.assignSearchHint': 'Start typing a Case name',
|
||||
'inbox.deleteTitle': 'Delete material',
|
||||
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
|
||||
'capture.kind.text': 'Text',
|
||||
@@ -107,6 +108,8 @@ export default {
|
||||
'common.created': 'Created',
|
||||
'common.empty': 'None',
|
||||
'common.newName': 'New name',
|
||||
'search.placeholder': 'Search Verstak...',
|
||||
'search.noResults': 'No results found',
|
||||
'welcome.title': 'Verstak',
|
||||
'welcome.selectSection': 'Select a section in the sidebar.',
|
||||
'welcome.createCase': 'Or create a new case with the + button.',
|
||||
@@ -139,6 +142,7 @@ export default {
|
||||
'kind.archive': 'Archive',
|
||||
'kind.case': 'Case',
|
||||
'kind.link': 'Link',
|
||||
'kind.action': 'Action',
|
||||
'links.empty': 'No links yet',
|
||||
'links.editTitle': 'Edit link',
|
||||
'links.note': 'Note',
|
||||
@@ -174,6 +178,46 @@ export default {
|
||||
'note.unsavedTitle': 'Unsaved changes',
|
||||
'note.unsavedMessage': 'Close the editor? All unsaved changes will be lost.',
|
||||
'note.unsavedClose': 'Close',
|
||||
|
||||
'note.mode.edit': 'Edit',
|
||||
'note.mode.preview': 'Preview',
|
||||
'note.mode.split': 'Split',
|
||||
|
||||
'note.toolbar.bold': 'Bold (Ctrl+B)',
|
||||
'note.toolbar.italic': 'Italic (Ctrl+I)',
|
||||
'note.toolbar.heading': 'Heading',
|
||||
'note.toolbar.link': 'Link',
|
||||
'note.toolbar.code': 'Code',
|
||||
'note.toolbar.codeBlock': 'Code block',
|
||||
'note.toolbar.bulletList': 'Bullet list',
|
||||
'note.toolbar.numberedList': 'Numbered list',
|
||||
'note.toolbar.checkbox': 'Checkbox',
|
||||
'note.toolbar.quote': 'Quote',
|
||||
'note.toolbar.internalLink': 'Verstak internal link',
|
||||
|
||||
'note.internal.secretNotImplemented': 'Secrets vault is not yet implemented',
|
||||
'note.internal.caseNotImplemented': 'Case navigation is not yet implemented',
|
||||
'note.internal.noteNotImplemented': 'Note navigation is not yet implemented',
|
||||
'note.internal.fileNotImplemented': 'File navigation is not yet implemented',
|
||||
|
||||
'note.rename': 'Rename note',
|
||||
'note.deleteConfirm': 'Delete note "{title}"?',
|
||||
'note.renameError': 'Failed to rename note',
|
||||
|
||||
'note.internalLink.title': 'Insert internal link',
|
||||
'note.internalLink.label': 'Link text',
|
||||
'note.internalLink.url': 'Link address',
|
||||
'note.internalLink.labelPlaceholder': 'e.g. Admin panel',
|
||||
'note.internalLink.urlPlaceholder': 'verstak://secret/sec_...',
|
||||
|
||||
'note.preview.empty': 'Empty note. Switch to edit mode to start writing.',
|
||||
'note.preview.error': 'Markdown render error',
|
||||
|
||||
'link.label': 'Link text',
|
||||
'link.url': 'URL or verstak:// link',
|
||||
'link.urlPlaceholder': 'https://... or verstak://secret/sec_...',
|
||||
'link.insert': 'Insert',
|
||||
|
||||
'file.addFile': '+ Add file',
|
||||
'file.addFolder': '+ Add folder',
|
||||
'file.newFile': '+ New file',
|
||||
@@ -278,6 +322,7 @@ export default {
|
||||
'today.sortAsc': 'ascending',
|
||||
'today.sortDesc': 'descending',
|
||||
'journal.title': 'Work Log',
|
||||
'journal.worklogTab': 'Work Log',
|
||||
'journal.empty': 'No entries for the selected period',
|
||||
'journal.dateFrom': 'From',
|
||||
'journal.dateTo': 'To',
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
'inbox.assignTitle': 'Разложить материал',
|
||||
'inbox.assignTarget': 'Дело',
|
||||
'inbox.assignSearchPlaceholder': 'Найти дело',
|
||||
'inbox.assignSearchHint': 'Начните набирать название Дела',
|
||||
'inbox.deleteTitle': 'Удалить материал',
|
||||
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
|
||||
|
||||
@@ -112,6 +113,8 @@ export default {
|
||||
'common.created': 'Создано',
|
||||
'common.empty': 'Нет',
|
||||
'common.newName': 'Новое имя',
|
||||
'search.placeholder': 'Поиск по Верстаку...',
|
||||
'search.noResults': 'Ничего не найдено',
|
||||
|
||||
'welcome.title': 'Верстак',
|
||||
'welcome.selectSection': 'Выберите раздел в боковой панели.',
|
||||
@@ -147,6 +150,7 @@ export default {
|
||||
'kind.archive': 'Архив',
|
||||
'kind.case': 'Дело',
|
||||
'kind.link': 'Ссылка',
|
||||
'kind.action': 'Действие',
|
||||
|
||||
'links.empty': 'Ссылок пока нет',
|
||||
'links.editTitle': 'Редактировать ссылку',
|
||||
@@ -186,6 +190,46 @@ export default {
|
||||
'note.unsavedMessage': 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||
'note.unsavedClose': 'Закрыть',
|
||||
|
||||
'note.mode.edit': 'Редактор',
|
||||
'note.mode.preview': 'Просмотр',
|
||||
'note.mode.split': 'Разделённый',
|
||||
|
||||
'note.toolbar.bold': 'Жирный (Ctrl+B)',
|
||||
'note.toolbar.italic': 'Курсив (Ctrl+I)',
|
||||
'note.toolbar.heading': 'Заголовок',
|
||||
'note.toolbar.link': 'Ссылка',
|
||||
'note.toolbar.code': 'Код',
|
||||
'note.toolbar.codeBlock': 'Блок кода',
|
||||
'note.toolbar.bulletList': 'Маркированный список',
|
||||
'note.toolbar.numberedList': 'Нумерованный список',
|
||||
'note.toolbar.checkbox': 'Чекбокс',
|
||||
'note.toolbar.quote': 'Цитата',
|
||||
'note.toolbar.internalLink': 'Внутренняя ссылка Verstak',
|
||||
|
||||
'note.internal.secretNotImplemented': 'Сейф доступов ещё не реализован',
|
||||
'note.internal.caseNotFound': 'Дело не найдено',
|
||||
'note.internal.noteNotFound': 'Заметка не найдена',
|
||||
'note.internal.fileNotFound': 'Файл не найден',
|
||||
'note.internal.fileFound': 'Файл найден: {title}',
|
||||
|
||||
'note.rename': 'Переименовать заметку',
|
||||
'note.deleteConfirm': 'Удалить заметку «{title}»?',
|
||||
'note.renameError': 'Не удалось переименовать заметку',
|
||||
|
||||
'note.internalLink.title': 'Вставить внутреннюю ссылку',
|
||||
'note.internalLink.label': 'Текст ссылки',
|
||||
'note.internalLink.url': 'Адрес ссылки',
|
||||
'note.internalLink.labelPlaceholder': 'Например: Админка',
|
||||
'note.internalLink.urlPlaceholder': 'verstak://secret/sec_...',
|
||||
|
||||
'note.preview.empty': 'Пустая заметка. Переключитесь в режим редактора, чтобы начать писать.',
|
||||
'note.preview.error': 'Ошибка рендеринга markdown',
|
||||
|
||||
'link.label': 'Текст ссылки',
|
||||
'link.url': 'URL или verstak:// ссылка',
|
||||
'link.urlPlaceholder': 'https://... или verstak://secret/sec_...',
|
||||
'link.insert': 'Вставить',
|
||||
|
||||
'file.addFile': '+ Добавить файл',
|
||||
'file.addFolder': '+ Добавить папку',
|
||||
'file.newFile': '+ Новый файл',
|
||||
@@ -294,6 +338,7 @@ export default {
|
||||
'today.sortDesc': 'по убыванию',
|
||||
|
||||
'journal.title': 'Журнал работы',
|
||||
'journal.worklogTab': 'Журнал работы',
|
||||
'journal.empty': 'Нет записей за выбранный период',
|
||||
'journal.dateFrom': 'От',
|
||||
'journal.dateTo': 'До',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseVerstakUrl } from './markdown';
|
||||
|
||||
export type InternalLinkType = 'case' | 'note' | 'file' | 'secret' | 'unknown';
|
||||
|
||||
export interface InternalLinkInfo {
|
||||
type: InternalLinkType;
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a verstak:// href into structured info.
|
||||
*/
|
||||
export function getInternalLinkInfo(href: string): InternalLinkInfo | null {
|
||||
const parsed = parseVerstakUrl(href);
|
||||
if (!parsed) return null;
|
||||
|
||||
const map: Record<string, { type: InternalLinkType; icon: string }> = {
|
||||
case: { type: 'case', icon: '📁' },
|
||||
note: { type: 'note', icon: '📄' },
|
||||
file: { type: 'file', icon: '📎' },
|
||||
secret: { type: 'secret', icon: '🔐' },
|
||||
};
|
||||
|
||||
const entry = map[parsed.type];
|
||||
if (!entry) {
|
||||
return { type: 'unknown', id: parsed.id, label: parsed.id, icon: '🔗' };
|
||||
}
|
||||
|
||||
return {
|
||||
type: entry.type,
|
||||
id: parsed.id,
|
||||
label: parsed.id,
|
||||
icon: entry.icon,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for an internal link type.
|
||||
* These should be overridden via i18n in the UI layer.
|
||||
*/
|
||||
export function getDefaultLabel(type: InternalLinkType, id: string): string {
|
||||
switch (type) {
|
||||
case 'secret': return 'Секрет';
|
||||
case 'case': return 'Дело';
|
||||
case 'note': return 'Заметка';
|
||||
case 'file': return 'Файл';
|
||||
default: return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
|
||||
// Register only the languages we need
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
|
||||
// ─── Allowed URL schemes ───────────────────────────────────────────
|
||||
const ALLOWED_SCHEMES = /^(https?|mailto|verstak):/i;
|
||||
const ALLOWED_VERSTAK_TYPES = new Set(['case', 'note', 'file', 'secret']);
|
||||
|
||||
// ─── DOMPurify hooks ───────────────────────────────────────────────
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
const href = (node.getAttribute('href') || '').trim();
|
||||
// Internal verstak links use hash-based href (e.g. #verstak-file-abc)
|
||||
// and store the real URL in data-verstak-href. Don't block them.
|
||||
if (node.hasAttribute('data-verstak-href')) {
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_SCHEMES.test(href)) {
|
||||
node.removeAttribute('href');
|
||||
node.setAttribute('data-blocked-href', href);
|
||||
node.classList.add('md-link--blocked');
|
||||
node.setAttribute('role', 'link');
|
||||
node.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
}
|
||||
// Remove all images for now — no internal asset mechanism yet
|
||||
if (node.tagName === 'IMG') {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Marked renderer ───────────────────────────────────────────────
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
// Links: route verstak:// through internal handler, block unknown schemes
|
||||
renderer.link = function ({ href, title, text }) {
|
||||
const trimmedHref = (href || '').trim();
|
||||
|
||||
// Block dangerous schemes
|
||||
if (!ALLOWED_SCHEMES.test(trimmedHref)) {
|
||||
const escapedText = escapeHtml(text);
|
||||
const escapedHref = escapeAttr(trimmedHref);
|
||||
return `<span class="md-link--blocked" data-blocked-href="${escapedHref}" role="link" aria-disabled="true">${escapedText}</span>`;
|
||||
}
|
||||
|
||||
// Internal verstak:// links
|
||||
if (trimmedHref.startsWith('verstak://')) {
|
||||
const parsed = parseVerstakUrl(trimmedHref);
|
||||
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
|
||||
const escapedHref = escapeAttr(trimmedHref);
|
||||
const escapedText = escapeHtml(text);
|
||||
// Use href="#" as a safe placeholder — DOMPurify won't strip it.
|
||||
// The actual navigation is handled by data-verstak-href + click handler.
|
||||
// e.preventDefault() in the click handler prevents scrolling to top.
|
||||
return `<a href="#" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
|
||||
}
|
||||
// Unknown verstak type — render as blocked
|
||||
const escapedText = escapeHtml(text);
|
||||
const escapedHref = escapeAttr(trimmedHref);
|
||||
return `<span class="md-link--blocked" data-blocked-href="${escapedHref}">${escapedText}</span>`;
|
||||
}
|
||||
|
||||
// External links
|
||||
const escapedHref = escapeAttr(trimmedHref);
|
||||
const escapedText = escapeHtml(text);
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<a href="${escapedHref}" class="md-link md-link--external" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapedText}</a>`;
|
||||
};
|
||||
|
||||
// Images: disabled — no internal asset mechanism
|
||||
renderer.image = function ({ href, title, text }) {
|
||||
const escapedAlt = escapeHtml(text);
|
||||
return `<span class="md-image-blocked" title="Images are not supported yet">[Image: ${escapedAlt}]</span>`;
|
||||
};
|
||||
|
||||
// Code blocks with highlight.js
|
||||
renderer.code = function ({ text, lang }) {
|
||||
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
let highlighted;
|
||||
try {
|
||||
highlighted = hljs.highlight(text, { language }).value;
|
||||
} catch {
|
||||
highlighted = escapeHtml(text);
|
||||
}
|
||||
const escapedLang = escapeAttr(language);
|
||||
return `<pre class="md-code-block"><code class="hljs language-${escapedLang}">${highlighted}</code></pre>`;
|
||||
};
|
||||
|
||||
// Inline code
|
||||
renderer.codespan = function ({ text }) {
|
||||
return `<code class="md-code-inline">${escapeHtml(text)}</code>`;
|
||||
};
|
||||
|
||||
// Task lists
|
||||
renderer.listitem = function ({ text, task, checked }) {
|
||||
if (task) {
|
||||
const checkedAttr = checked ? 'checked' : '';
|
||||
return `<li class="md-task-item"><input type="checkbox" disabled ${checkedAttr} class="md-task-checkbox"/><span class="md-task-text">${text}</span></li>`;
|
||||
}
|
||||
return `<li>${text}</li>`;
|
||||
};
|
||||
|
||||
// ─── Marked options ────────────────────────────────────────────────
|
||||
marked.setOptions({
|
||||
renderer,
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
});
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render markdown string to sanitized HTML string.
|
||||
*/
|
||||
export function renderMarkdown(md) {
|
||||
const raw = marked.parse(md);
|
||||
return DOMPurify.sanitize(raw, {
|
||||
ADD_ATTR: ['target', 'class', 'data-verstak-href', 'data-verstak-type', 'data-verstak-id', 'data-blocked-href', 'role', 'aria-disabled', 'checked'],
|
||||
ADD_TAGS: ['input', 'span'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an internal verstak:// link.
|
||||
*/
|
||||
export function isInternalLink(href) {
|
||||
return href.startsWith('verstak://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a verstak:// URL into its components.
|
||||
* Returns { type, id } or null if invalid.
|
||||
*/
|
||||
export function parseVerstakUrl(href) {
|
||||
const match = href.match(/^verstak:\/\/([a-zA-Z_]+)\/(.+)$/);
|
||||
if (!match) return null;
|
||||
return { type: match[1], id: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape markdown special characters in link label text.
|
||||
* Escapes [ ] ( ) so they don't break markdown inline link syntax.
|
||||
*/
|
||||
export function escapeMarkdownLabel(s) {
|
||||
return s.replace(/([[\]()])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a verstak:// type is supported.
|
||||
*/
|
||||
export function isAllowedVerstakType(type) {
|
||||
return ALLOWED_VERSTAK_TYPES.has(type);
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return s.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Actions API — actions (quick commands) on nodes.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listActions = createApi('ListActions')
|
||||
export const createAction = createApi('CreateAction')
|
||||
export const runAction = createApi('RunAction')
|
||||
export const deleteAction = createApi('DeleteAction')
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Activity API — activity feed and per-node activity.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listActivityFeed = createApi('ListActivityFeed')
|
||||
export const listActivityByNode = createApi('ListActivityByNode')
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Browser Events API — browser extension events.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listBrowserEvents = createApi('ListBrowserEvents')
|
||||
export const acceptBrowserEvent = createApi('AcceptBrowserEvent')
|
||||
export const dismissBrowserEvent = createApi('DismissBrowserEvent')
|
||||
export const attachBrowserEventToNode = createApi('AttachBrowserEventToNode')
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Files API — all file/folder related Wails calls.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listItems = createApi('ListItems')
|
||||
export const listFiles = createApi('ListFiles')
|
||||
export const createEmptyFile = createApi('CreateEmptyFile')
|
||||
export const deleteFileOrFolder = createApi('DeleteFileOrFolder')
|
||||
export const openFile = createApi('OpenFile')
|
||||
export const openFolder = createApi('OpenFolder')
|
||||
export const pickFile = createApi('PickFile')
|
||||
export const pickDirectory = createApi('PickDirectory')
|
||||
export const previewImport = createApi('PreviewImport')
|
||||
export const addPathCopy = createApi('AddPathCopy')
|
||||
export const addPathLink = createApi('AddPathLink')
|
||||
export const checkFileAction = createApi('CheckFileAction')
|
||||
export const getFileBase64 = createApi('GetFileBase64')
|
||||
export const readFileText = createApi('ReadFileText')
|
||||
export const showInFolder = createApi('OpenFolder')
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Inbox API — capture and inbox management.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listInboxNodes = createApi('ListInboxNodes')
|
||||
export const listInboxNodesForTarget = createApi('ListInboxNodesForTarget')
|
||||
export const captureClipboardTextWithContext = createApi('CaptureClipboardTextWithContext')
|
||||
export const captureTextWithContext = createApi('CaptureTextWithContext')
|
||||
export const captureURLWithContext = createApi('CaptureURLWithContext')
|
||||
export const capturePathWithContext = createApi('CapturePathWithContext')
|
||||
export const captureFileDataWithContext = createApi('CaptureFileDataWithContext')
|
||||
export const resolveInboxNode = createApi('ResolveInboxNode')
|
||||
export const deleteInboxNode = createApi('DeleteInboxNode')
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Journal & Worklog API — time tracking and reporting.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listWorklog = createApi('ListWorklog')
|
||||
export const createWorklogFull = createApi('CreateWorklogFull')
|
||||
export const updateWorklogEntry = createApi('UpdateWorklogEntry')
|
||||
export const deleteWorklogEntry = createApi('DeleteWorklogEntry')
|
||||
export const getWorklogEntryEvents = createApi('GetWorklogEntryEvents')
|
||||
export const listWorklogReport = createApi('ListWorklogReport')
|
||||
export const worklogReportSummary = createApi('WorklogReportSummary')
|
||||
export const saveWorklogReport = createApi('SaveWorklogReport')
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Links API — external URL links management.
|
||||
*/
|
||||
import { createApi } from './wails.js'
|
||||
|
||||
export const listLinks = createApi('ListLinks')
|
||||
export const updateLink = createApi('UpdateLink')
|
||||
export const deleteLink = createApi('DeleteLink')
|
||||
export const openLink = createApi('OpenLink')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user