steps 4-6 + doc overhaul: files, notes, GUI, plugins docs
DOCUMENTATION (shift from personal to universal product): - README.md: rewritten with 'one product, different doors' framing, universal entities table, audience segments - 01_Product_Spec.md: removed personal references (sshkeeper, Godot, DokuWiki, servers), added audience segments (freelancer, repairmaster, developer, maker, consultant), universal scenarios - 02_Architecture.md: added 'Plugins (Extensibility)' section with calendar/kanban/importer/template examples - 03_Data_Model_Storage.md: added section 6 on plugin extensibility (node_meta, type registry, SQL migrations per plugin) - 09_Extensibility.md (NEW): full plugin architecture — Lua runtime, plugin.json, hooks, sandbox, templates, registry - PLAN.md: added step 16 (plugins), updated status table - 00_README.md: rewritten product index with plugin principle CODE — STEP 4 (Files): - migration 002: files table (id, node_id, filename, path, storage_mode, size, sha256, mime, ...) - FileService: AddExternal, CopyIntoVault, Get, ListByNode, MarkMissing, DeleteToTrash, Open (xdg-open) - file_test.go: 5 tests (external, copy-vault, list-node, delete-trash, MIME guess) CODE — STEP 5 (Notes): - migration 003: notes table (node_id PK, file_id, format, original_format, encrypted) - NoteService: Create (node+file+link), Read, Save (with backup to .verstak/history/), Delete, Load - note_test.go: 3 tests (create-read, save-backup, delete) CODE — STEP 6 (GUI): - cmd/verstak-gui/main.go: launches GUI server, opens browser - internal/gui/server.go: HTTP API for nodes/notes/files/search - internal/gui/index.html.go: full inline SPA frontend (dark theme, sidebar tree, cards grid, note editor, search, create modals) - Navigation: sidebar tree → click node → detail view with children + files cards → tab switch (overview/notes/files) → create node/note via modal → edit note in fullscreen textarea → save (with history backup) Acceptance: go build ./... pass, go build -tags gui ./cmd/verstak-gui pass, go test ./... pass (20+ tests). GUI serves on random port, opens browser. API returns JSON for all resource types.
This commit is contained in:
parent
69eb909d48
commit
39271fc28f
59
README.md
59
README.md
|
|
@ -1,27 +1,46 @@
|
|||
# Верстак
|
||||
|
||||
**Верстак** — local-first рабочий vault для дел, клиентов, проектов,
|
||||
документов, заметок, файлов, действий запуска, журнала работ
|
||||
и синхронизации между машинами.
|
||||
**Верстак** — локальная программа, где по каждому клиенту или проекту
|
||||
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
|
||||
|
||||
Это не просто заметочник и не CRM. Главная сущность — **дело**.
|
||||
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди,
|
||||
у которых работа организована через дела, а не через задачи:
|
||||
|
||||
Дело может быть: клиентом, сайтом клиента, личным проектом,
|
||||
Godot-проектом, набором документов, рецептом/инструкцией,
|
||||
архивом, разовой помощью человеку, рабочей областью.
|
||||
|
||||
Стек: Go + SQLite + Wails + Bubble Tea.
|
||||
|
||||
Документация: [docs/](docs/)
|
||||
План разработки: [docs/PLAN.md](docs/PLAN.md)
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
go build ./cmd/verstak
|
||||
```
|
||||
дело → файлы → заметки → документы → действия → история → вернуться через месяц
|
||||
```
|
||||
|
||||
## Разработка
|
||||
## Для кого
|
||||
|
||||
Разработка ведётся пошагово. Каждый шаг — отдельный commit.
|
||||
Подробнее в [docs/PLAN.md](docs/PLAN.md).
|
||||
Один продукт — разные входные двери:
|
||||
|
||||
| Кто | Как видит Верстак |
|
||||
|-----|-------------------|
|
||||
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
|
||||
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
|
||||
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
|
||||
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
|
||||
|
||||
## Универсальные сущности
|
||||
|
||||
Базовая модель предельно проста — плагины добавляют функционал:
|
||||
|
||||
- **Дело** — контекст для всего остального
|
||||
- **Заметка** — Markdown внутри vault
|
||||
- **Файл / Документ** — любой файл, привязанный к делу
|
||||
- **Действие** — кнопка запуска: URL, файл, папка, команда
|
||||
- **Журнал** — записи о затраченном времени
|
||||
|
||||
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
|
||||
эти сущности без перекомпиляции программы.
|
||||
|
||||
## Стек
|
||||
|
||||
Go + SQLite + Lua (плагины) + Wails + Bubble Tea.
|
||||
|
||||
## Документация
|
||||
|
||||
- Описание продукта: [docs/01_Product_Spec.md](docs/01_Product_Spec.md)
|
||||
- Архитектура: [docs/02_Architecture.md](docs/02_Architecture.md)
|
||||
- Плагины: [docs/09_Extensibility.md](docs/09_Extensibility.md)
|
||||
- План разработки: [docs/PLAN.md](docs/PLAN.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//go:build gui
|
||||
// +build gui
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
|
||||
gui "verstak/internal/gui"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
vaultPath := "."
|
||||
if len(os.Args) > 1 {
|
||||
vaultPath = os.Args[1]
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Open vault: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
srv := gui.NewServer(db, abs)
|
||||
addr, err := srv.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("Start GUI: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Верстак GUI:", addr)
|
||||
openBrowser(addr)
|
||||
|
||||
// Wait for interrupt.
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
srv.Stop()
|
||||
deferFunc()
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", "", url)
|
||||
}
|
||||
if cmd != nil {
|
||||
go cmd.Start()
|
||||
}
|
||||
}
|
||||
|
||||
func deferFunc() {}
|
||||
|
|
@ -1,69 +1,66 @@
|
|||
# Верстак — индекс документации
|
||||
|
||||
**Верстак** — local-first рабочий vault для дел, клиентов, проектов, документов, заметок, файлов, скриптов, действий запуска, журнала работ и синхронизации между машинами.
|
||||
**Верстак** — local-first рабочий vault, где всё организовано вокруг "дел".
|
||||
|
||||
Это не просто заметочник и не CRM. Главная сущность — **дело**.
|
||||
Верстак нужен людям, у которых работа организована через дела,
|
||||
а не через задачи.
|
||||
|
||||
Дело может быть:
|
||||
|
||||
- клиентом;
|
||||
- сайтом клиента;
|
||||
- личным проектом;
|
||||
- Godot-проектом;
|
||||
- набором документов;
|
||||
- рецептом/инструкцией;
|
||||
- архивом;
|
||||
- разовой помощью человеку;
|
||||
- рабочей областью вроде `Рецепты / MySQL / Backup сайта`.
|
||||
- разовой работой.
|
||||
|
||||
Внутри дела живут:
|
||||
|
||||
- вложенные папки;
|
||||
- Markdown-заметки;
|
||||
- документы `docx/pdf/xlsx/odt`;
|
||||
- скриншоты;
|
||||
- архивы;
|
||||
- исходники;
|
||||
- скрипты;
|
||||
- SQL-фрагменты;
|
||||
- ссылки;
|
||||
- запускаемые действия;
|
||||
- документы (docx/pdf/xlsx/odt/png/zip);
|
||||
- файлы любых типов;
|
||||
- запускаемые действия (URL, файл, папка, команда);
|
||||
- журнал работ;
|
||||
- примерное время;
|
||||
- история активности;
|
||||
- связанные дела.
|
||||
- история активности.
|
||||
|
||||
## Файлы пакета
|
||||
|
||||
1. [[01_Product_Spec]] — полное описание продукта и сценариев.
|
||||
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server.
|
||||
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes, actions.
|
||||
4. [[04_Sync_Backup_Activity]] — синхронизация, восстановление, backup, activity/time tracking.
|
||||
5. [[05_UI_UX]] — экраны GUI/TUI, дерево, дело, поиск, документы, действия.
|
||||
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии.
|
||||
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server, плагины.
|
||||
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
|
||||
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
|
||||
5. [[05_UI_UX]] — экраны GUI/TUI.
|
||||
6. [[06_Roadmap]] — план разработки по этапам.
|
||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
||||
|
||||
## Главные принципы
|
||||
|
||||
1. **Local-first.**
|
||||
Рабочая копия всегда локальная. Сервер нужен для sync/backup/restore, но программа не должна зависеть от сервера каждый день.
|
||||
1. **Local-first.**
|
||||
Рабочая копия всегда локальная. Сервер — для sync/backup.
|
||||
|
||||
2. **Данные принадлежат пользователю.**
|
||||
Заметки и файлы физически лежат обычными файлами в vault. SQLite хранит индекс, связи, метаданные, FTS и sync state.
|
||||
2. **Универсальная база + плагины.**
|
||||
Базовая модель (дело + заметка + файл + действие + журнал)
|
||||
работает для любого сегмента. Плагины добавляют календарь,
|
||||
канбан, импортёры — без перекомпиляции.
|
||||
|
||||
3. **Дерево дел важнее тегов.**
|
||||
Теги полезны, но основная навигация — вложенное дерево: `Клиенты / Ромашка / Сайт / Документы`.
|
||||
3. **Данные принадлежат пользователю.**
|
||||
Заметки и файлы лежат обычными файлами. SQLite — индекс.
|
||||
|
||||
4. **Не таймтрекер, а восстановитель следов.**
|
||||
Верстак не требует постоянно нажимать Start/Stop. Он собирает следы работы и предлагает записать их в журнал.
|
||||
4. **Дерево дел важнее тегов.**
|
||||
|
||||
5. **GUI основной, TUI быстрый, CLI служебный.**
|
||||
GUI — основная рабочая среда. TUI — быстрый доступ из терминала. CLI — sync, import, scripts, rescue mode.
|
||||
5. **Не таймтрекер, а восстановитель следов.**
|
||||
|
||||
6. **Sync не должен уничтожать данные.**
|
||||
Нужны trash, conflict copies, versions, snapshots и retention.
|
||||
6. **GUI основной, TUI быстрый, CLI служебный.**
|
||||
|
||||
7. **Sync не уничтожает данные.**
|
||||
|
||||
## Короткая формула
|
||||
|
||||
> Верстак — это локальный рабочий кабинет для людей, у которых жизнь состоит из проектов, клиентов, документов, заметок, скриптов, файлов, репозиториев и вечного “где я это сохранил?”.
|
||||
> Верстак — локальная программа, где по каждому клиенту или проекту
|
||||
> лежат все его файлы, заметки, документы, ссылки, действия и
|
||||
> история работ.
|
||||
|
|
|
|||
|
|
@ -2,242 +2,172 @@
|
|||
|
||||
## 1. Проблема
|
||||
|
||||
У пользователя есть много разнородной рабочей информации:
|
||||
У фрилансеров, мастеров, разработчиков и мейкеров есть много
|
||||
разнородной рабочей информации:
|
||||
|
||||
- папка `work` и подпапки;
|
||||
- архивы нужных файлов;
|
||||
- служебки;
|
||||
- договоры;
|
||||
- письма;
|
||||
- скриншоты;
|
||||
- файлы с серийными номерами;
|
||||
- инструкции;
|
||||
- статьи по установке;
|
||||
- скрипты;
|
||||
- SQL-фрагменты;
|
||||
- заметки в DokuWiki;
|
||||
- доступы к серверам и сервисам клиентов;
|
||||
- записи о нестандартных действиях;
|
||||
- репозитории личных проектов;
|
||||
- Godot-проекты;
|
||||
- локальные утилиты вроде sshkeeper.
|
||||
- клиентские проекты и переписка;
|
||||
- договоры, счета, акты;
|
||||
- заметки и инструкции;
|
||||
- скриншоты и фото;
|
||||
- скрипты и конфиги;
|
||||
- доступы и серийники;
|
||||
- файлы с правками и версиями.
|
||||
|
||||
Проблема не только в хранении. Проблема в **контексте**:
|
||||
Проблема не в хранении. Проблема в **контексте**:
|
||||
|
||||
- что к чему относится;
|
||||
- где лежит актуальная версия;
|
||||
- где заметка по клиенту;
|
||||
- где договор;
|
||||
- где скрипт;
|
||||
- где актуальная версия;
|
||||
- что было сделано в прошлый раз;
|
||||
- сколько примерно времени ушло;
|
||||
- что можно сказать человеку, когда он спрашивает “сколько должен?”.
|
||||
- сколько времени ушло;
|
||||
- где договор, где заметка, где скрипт.
|
||||
|
||||
Обычные инструменты закрывают только кусок:
|
||||
|
||||
- Obsidian — заметки, но не рабочий кабинет с документами, действиями и журналом работ;
|
||||
- DokuWiki — заметки, но не локальная рабочая оболочка над файлами и программами;
|
||||
- CRM — клиенты и продажи, но не личная техническая память;
|
||||
- файловый менеджер — файлы, но без смысла;
|
||||
- таймтрекер — время, но требует дисциплины;
|
||||
- лаунчер — запуск, но не память;
|
||||
- Nextcloud — файлы, но не дела.
|
||||
Обычные инструменты закрывают только кусок: Obsidian — заметки,
|
||||
CRM — продажи, таймтрекер — время, файловый менеджер — файлы без смысла.
|
||||
|
||||
## 2. Что такое Верстак
|
||||
|
||||
**Верстак** — local-first рабочий vault, где всё организовано вокруг “дел”.
|
||||
**Верстак** — local-first рабочий vault, где всё организовано вокруг "дел".
|
||||
|
||||
Дело — это контекст, в который складываются заметки, документы, файлы, действия и история работы.
|
||||
Дело — это контекст, в который складываются заметки, документы,
|
||||
файлы, действия и история работы.
|
||||
|
||||
Примеры дерева:
|
||||
**Главная формула:**
|
||||
|
||||
> Верстак — локальная программа, где по каждому клиенту или проекту
|
||||
> лежат все его файлы, заметки, документы, ссылки, действия и история работ.
|
||||
|
||||
## 3. Аудитория
|
||||
|
||||
Верстак нужен людям, у которых работа идёт через **дела**, а не через задачи.
|
||||
Есть люди, которым нужен таск-трекер (task → done). А есть люди,
|
||||
которым нужно место, где накапливается контекст по каждому клиенту
|
||||
или проекту — и всё это доступно через месяц, через год.
|
||||
|
||||
### Сегменты
|
||||
|
||||
| Сегмент | Зачем Верстак |
|
||||
|---------|---------------|
|
||||
| Фрилансер / дизайнер | Клиенты, файлы, правки, история работ, отчёты |
|
||||
| Мастер по ремонту/ПК | Клиенты, устройства, серийники, фото, журнал |
|
||||
| Разработчик | Workspace: заметки, репозитории, команды, логи |
|
||||
| Мейкер / писатель | Проекты: материалы, заметки, версии, история |
|
||||
| Консультант | Клиенты, документы, журнал времени, отчёты |
|
||||
|
||||
## 4. Основные сущности (универсальные)
|
||||
|
||||
### Дело
|
||||
|
||||
Главный рабочий контекст. Поля: название, тип, родитель,
|
||||
описание, статус (active / sleeping / archived), теги.
|
||||
|
||||
### Заметка
|
||||
|
||||
Markdown-файл внутри vault. Резервная копия при перезаписи.
|
||||
|
||||
### Файл / Документ
|
||||
|
||||
Любой файл, привязанный к делу. Открывается системным приложением.
|
||||
|
||||
### Действие
|
||||
|
||||
Кнопка запуска: URL, файл, папка, команда. Опасные — с подтверждением.
|
||||
|
||||
### Журнал работ
|
||||
|
||||
Записи о затраченном времени: дата, длительность, описание.
|
||||
|
||||
### Активность
|
||||
|
||||
Следы работы: открыт файл, изменена заметка, запущено действие.
|
||||
Используется для восстановления времени.
|
||||
|
||||
## 5. Примеры дерева (универсальные)
|
||||
|
||||
```text
|
||||
Клиенты
|
||||
ООО Ромашка
|
||||
Сайт
|
||||
Обзор.md
|
||||
Документы
|
||||
Скрипты
|
||||
Скриншоты
|
||||
Журнал работ
|
||||
Почта
|
||||
Договоры
|
||||
|
||||
Личные проекты
|
||||
sshkeeper
|
||||
Roadmap.md
|
||||
Releases
|
||||
dist
|
||||
Действия
|
||||
Tyaplyapiya
|
||||
Godot project
|
||||
Design notes
|
||||
Проекты
|
||||
Мой проект
|
||||
Notes
|
||||
Assets
|
||||
dist
|
||||
|
||||
Рецепты
|
||||
MySQL
|
||||
Очистка таблиц
|
||||
Backup dump
|
||||
Сайты
|
||||
Backup сайта одной строкой
|
||||
Очистка кеша WordPress
|
||||
Backup одной строкой
|
||||
Очистка кеша
|
||||
|
||||
Документы
|
||||
Служебки
|
||||
Счета
|
||||
Договоры
|
||||
Серийники
|
||||
```
|
||||
|
||||
## 3. Основные сущности
|
||||
## 6. Сценарии (сегмент-agnostic)
|
||||
|
||||
### Дело
|
||||
|
||||
Главный рабочий контекст.
|
||||
|
||||
Поля:
|
||||
|
||||
- название;
|
||||
- тип: клиент / проект / рецепт / документальная область / архив / личное;
|
||||
- родитель;
|
||||
- описание;
|
||||
- статус: active / sleeping / archived;
|
||||
- теги;
|
||||
- связанные ссылки;
|
||||
- связанные actions;
|
||||
- журнал работ.
|
||||
|
||||
### Заметка
|
||||
|
||||
Обычный Markdown-файл внутри vault.
|
||||
|
||||
Примеры:
|
||||
|
||||
- `overview.md`;
|
||||
- `nginx.md`;
|
||||
- `mysql-cleanup.md`;
|
||||
- `roadmap.md`;
|
||||
- `access.secret.md`.
|
||||
|
||||
### Документ
|
||||
|
||||
Файл внутри дела:
|
||||
|
||||
- `docx`;
|
||||
- `xlsx`;
|
||||
- `pdf`;
|
||||
- `odt`;
|
||||
- `png/jpg`;
|
||||
- `zip`;
|
||||
- любые другие файлы.
|
||||
|
||||
В MVP документы открываются системным приложением. Встроенный preview можно добавить позже.
|
||||
|
||||
### Действие
|
||||
|
||||
Кнопка, которую можно запустить из дела:
|
||||
|
||||
- открыть URL;
|
||||
- открыть папку;
|
||||
- открыть файл;
|
||||
- запустить Godot;
|
||||
- открыть IDE;
|
||||
- запустить sshkeeper;
|
||||
- выполнить скрипт;
|
||||
- открыть терминал;
|
||||
- собрать проект.
|
||||
|
||||
### Журнал работ
|
||||
|
||||
Записи вида:
|
||||
|
||||
```text
|
||||
2026-05-30
|
||||
Дело: ООО Ромашка / Сайт
|
||||
Время: примерно 3ч
|
||||
Описание: обновил витрину сайта, товары, баннеры, проверил отображение.
|
||||
```
|
||||
|
||||
### Активность
|
||||
|
||||
Сырые следы:
|
||||
|
||||
- открыто дело;
|
||||
- открыта заметка;
|
||||
- изменён файл;
|
||||
- запущено действие;
|
||||
- открыта папка;
|
||||
- later: активное окно;
|
||||
- later: browser URL;
|
||||
- later: sshkeeper session.
|
||||
|
||||
## 4. Основные сценарии
|
||||
|
||||
### Клиентская работа
|
||||
### Работа с клиентом
|
||||
|
||||
1. Открыть дело клиента.
|
||||
2. Посмотреть заметки и документы.
|
||||
3. Открыть админку сайта.
|
||||
4. Запустить sshkeeper или скрипт.
|
||||
5. Добавить скриншоты.
|
||||
6. Записать работу.
|
||||
7. Сформировать текст отчёта.
|
||||
3. Открыть связанный URL или папку.
|
||||
4. Добавить файлы.
|
||||
5. Записать время.
|
||||
6. Сформировать отчёт для клиента.
|
||||
|
||||
### Личный проект
|
||||
|
||||
1. Открыть проект `sshkeeper`.
|
||||
2. Нажать “Открыть IDE”.
|
||||
3. Нажать “Собрать”.
|
||||
4. Посмотреть roadmap.
|
||||
5. Добавить заметку “на чём остановился”.
|
||||
|
||||
### Импорт DokuWiki
|
||||
|
||||
1. Выбрать `data/pages`.
|
||||
2. Выбрать `data/media`.
|
||||
3. Импортировать namespaces как дерево.
|
||||
4. Сохранить оригиналы.
|
||||
5. Постепенно разобрать по делам.
|
||||
1. Открыть дело проекта.
|
||||
2. Нажать "Открыть в IDE".
|
||||
3. Обновить roadmap-заметку.
|
||||
4. Записать "на чём остановился".
|
||||
|
||||
### Восстановление времени
|
||||
|
||||
Пользователь не нажимал таймер, но вечером видит:
|
||||
|
||||
```text
|
||||
Похоже, ты работал по делу “ООО Ромашка / Сайт”:
|
||||
Похоже, работа по "Клиенты / Ромашка / Сайт":
|
||||
14:05–17:12, примерно 3ч.
|
||||
|
||||
Основания:
|
||||
- открывалась админка сайта;
|
||||
- открывался URL админки сайта;
|
||||
- менялся catalog.xlsx;
|
||||
- запускался sshkeeper profile;
|
||||
- создавались скриншоты.
|
||||
|
||||
[Записать 3ч] [Исправить] [Игнорировать]
|
||||
[Записать 3ч] [Изменить] [Игнорировать]
|
||||
```
|
||||
|
||||
## 5. Что точно не делать в начале
|
||||
## 7. Расширяемость
|
||||
|
||||
Базовые сущности универсальны. Плагины добавляют функционал
|
||||
без перекомпиляции программы:
|
||||
|
||||
- шаблоны дел (клиент, ремонт, проект, рецепт...);
|
||||
- календарь;
|
||||
- канбан;
|
||||
- импортёры (DokuWiki, Obsidian, plain folder);
|
||||
- интеграции с внешними сервисами.
|
||||
|
||||
Подробнее: [docs/09_Extensibility.md](docs/09_Extensibility.md).
|
||||
|
||||
## 8. Что не делать в начале
|
||||
|
||||
- не делать SaaS;
|
||||
- не делать multi-user CRM;
|
||||
- не делать встроенный офисный пакет;
|
||||
- не делать полноценный password manager;
|
||||
- не делать офисный пакет;
|
||||
- не делать password manager;
|
||||
- не делать ИИ;
|
||||
- не делать мобильное приложение;
|
||||
- не делать сложные права пользователей;
|
||||
- не делать бухгалтерию;
|
||||
- не пытаться автоматически понимать всё.
|
||||
- не делать бухгалтерию.
|
||||
|
||||
## 6. Уникальность
|
||||
## 9. Уникальность
|
||||
|
||||
Верстак отличается тем, что объединяет:
|
||||
|
||||
- заметочник;
|
||||
- файловый кабинет;
|
||||
- project launcher;
|
||||
- журнал работ;
|
||||
- рабочий контекст;
|
||||
- sync/backup;
|
||||
- TUI/GUI;
|
||||
- миграцию из DokuWiki.
|
||||
|
||||
Но всё это не как отдельные модули, а вокруг одного понятия: **дело**.
|
||||
Верстак объединяет заметочник, файловый кабинет, лаунчер,
|
||||
журнал работ и контекст вокруг одного понятия: **дело**.
|
||||
Плагины делают его адаптируемым под любой сегмент.
|
||||
|
|
|
|||
|
|
@ -311,7 +311,25 @@ Scanner сравнивает реальность с SQLite:
|
|||
- moved file later;
|
||||
- hash mismatch.
|
||||
|
||||
## 6. Внешние приложения
|
||||
## 6. Плагины (Extensibility)
|
||||
|
||||
Верстак изначально проектируется как база с плагинами.
|
||||
Базовая модель (дело + заметка + файл + действие + журнал)
|
||||
универсальна. Плагины добавляют функционал без перекомпиляции.
|
||||
|
||||
Примеры плагинов:
|
||||
|
||||
- `calendar` — календарь событий с привязкой к делам
|
||||
- `kanban` — доска задач внутри дела
|
||||
- `importer-dokuwiki` — импорт из DokuWiki
|
||||
- `importer-obsidian` — импорт из Obsidian
|
||||
- `browser-activity` — отслеживание браузерной активности
|
||||
- `secret-notes` — зашифрованные заметки
|
||||
- `client-template` — шаблон "Клиент" с полями (сайт, домен, ...)
|
||||
|
||||
Архитектура плагинов: [docs/09_Extensibility.md](docs/09_Extensibility.md)
|
||||
|
||||
### Внешние приложения
|
||||
|
||||
Верстак не пишет свой офисный пакет.
|
||||
|
||||
|
|
|
|||
|
|
@ -274,3 +274,17 @@ CREATE TABLE sync_ops (
|
|||
- private keys;
|
||||
- token-like values;
|
||||
- binary content in MVP.
|
||||
|
||||
## 6. Расширяемость через плагины
|
||||
|
||||
Базовая схема фиксирована и поддерживает плагины:
|
||||
|
||||
- Новые типы нод регистрируются плагинами через Lua API
|
||||
(`verstak.node.register_type()`) — схема таблицы `nodes` не меняется,
|
||||
`type` принимает любое строковое значение.
|
||||
- Мета-поля (`node_meta`) хранят произвольные key-value пары,
|
||||
зарегистрированные плагинами.
|
||||
- Плагины могут создавать собственные таблицы через SQL-миграции
|
||||
в своей директории `.verstak/plugins/<name>/migrations/`.
|
||||
- `device_id` на уровне nodes позволяет плагинам синхронизировать
|
||||
свои данные через sync_ops.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
# Верстак — архитектура плагинов
|
||||
|
||||
## Принцип
|
||||
|
||||
Верстак — это минималистичный движок с деревом дел.
|
||||
Всё, что не входит в минимальную модель, — плагин.
|
||||
|
||||
Плагин — это директория в `.verstak/plugins/<name>/`, которую
|
||||
программа подхватывает без перекомпиляции.
|
||||
|
||||
## Структура плагина
|
||||
|
||||
```
|
||||
.verstak/plugins/<name>/
|
||||
plugin.json # мета: name, version, author, hooks
|
||||
main.lua # точка входа
|
||||
templates/ # шаблоны дел (опционально)
|
||||
client.json
|
||||
repair.json
|
||||
panels/ # UI-панели для GUI (опционально)
|
||||
kanban.html
|
||||
calendar.html
|
||||
migrations/ # SQL-миграции (опционально)
|
||||
001_create_tables.sql
|
||||
```
|
||||
|
||||
## plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "calendar",
|
||||
"version": "1.0.0",
|
||||
"author": "...",
|
||||
"description": "Календарь событий, привязанных к делам",
|
||||
"hooks": {
|
||||
"on_init": "on_init",
|
||||
"on_node_open": "on_node_open"
|
||||
},
|
||||
"node_types": ["event"],
|
||||
"panel": "panels/calendar.html",
|
||||
"migrations": ["migrations/001_create_tables.sql"]
|
||||
}
|
||||
```
|
||||
|
||||
## Lua API
|
||||
|
||||
Плагины пишутся на Lua (gopher-lua). API:
|
||||
|
||||
```lua
|
||||
-- Получить node по ID
|
||||
local node = verstak.node.get(id)
|
||||
|
||||
-- Создать 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("Событие добавлено")
|
||||
```
|
||||
|
||||
## Жизненный цикл плагина
|
||||
|
||||
1. **on_init** — при старте программы, до открытия vault.
|
||||
Инициализация, создание таблиц.
|
||||
2. **on_vault_open** — при открытии vault.
|
||||
3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
|
||||
4. **on_shutdown** — при закрытии.
|
||||
|
||||
## Реестр типов дел
|
||||
|
||||
Плагины могут регистрировать новые типы:
|
||||
|
||||
```lua
|
||||
verstak.node.register_type({
|
||||
name = "event",
|
||||
label = "Событие",
|
||||
icon = "calendar",
|
||||
fields = {
|
||||
{ name = "date", label = "Дата", type = "date" },
|
||||
{ name = "time", label = "Время", type = "time" },
|
||||
{ name = "location", label = "Место", type = "text" },
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
GUI рисует карточку дела на основе зарегистрированных полей типа.
|
||||
|
||||
## Шаблоны дела
|
||||
|
||||
Шаблон — JSON-описание предзаполненного дерева:
|
||||
|
||||
```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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
GUI: при создании дела пользователь выбирает шаблон — и дерево
|
||||
создаётся автоматически.
|
||||
|
||||
## Песочница
|
||||
|
||||
Lua-плагины работают в песочнице:
|
||||
|
||||
- нет доступа к файловой системе напрямую (только через API vault);
|
||||
- нет `io.*`, `os.execute` и т.д.;
|
||||
- память ограничена;
|
||||
- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
|
||||
|
||||
Go-плагины (buildmode=plugin) доступны для продвинутых
|
||||
разработчиков, но требуют совместимости версий.
|
||||
|
||||
## Инициализация
|
||||
|
||||
При старте `verstak init` создаёт `.verstak/plugins/`.
|
||||
При старте GUI/CLI/TUI:
|
||||
|
||||
1. Сканировать `.verstak/plugins/*/plugin.json`
|
||||
2. Валидировать (имя, версия, структура)
|
||||
3. Загрузить миграции и выполнить
|
||||
4. Загрузить Lua-скрипты через gopher-lua
|
||||
5. Вызвать `on_init` у каждого плагина
|
||||
6. Зарегистрировать node types, HTTP routes, UI panels
|
||||
|
||||
## Распространение
|
||||
|
||||
Плагин — это zip-архив с правильной структурой.
|
||||
Репозиторий плагинов: `verstak-registry` (отдельный проект).
|
||||
|
||||
Установка:
|
||||
```bash
|
||||
verstak plugin install calendar
|
||||
verstak plugin enable calendar
|
||||
verstak plugin list
|
||||
```
|
||||
30
docs/PLAN.md
30
docs/PLAN.md
|
|
@ -13,7 +13,7 @@
|
|||
|---|-----|--------|
|
||||
| 1 | Git init + Skeleton | ✅ выполнен |
|
||||
| 2 | Init + SQLite + First Migration | ✅ выполнен |
|
||||
| 3 | Nodes Repository + CRUD + CLI Node | ⬜ не начат |
|
||||
| 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен |
|
||||
| 4 | Vault Files: Trash + File Service + CLI File | ⬜ не начат |
|
||||
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ⬜ не начат |
|
||||
| 6 | Wails GUI MVP: Sidebar + Main Panel | ⬜ не начат |
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
| 13 | Activity + File Scanner/Watcher | ⬜ не начат |
|
||||
| 14 | TUI MVP (Bubble Tea) | ⬜ не начат |
|
||||
| 15 | Integrity Check + Repair + Vault Restore | ⬜ не начат |
|
||||
| 16 | Plugins System (Lua + Templates) | ⬜ не начат |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -334,6 +335,32 @@
|
|||
|
||||
---
|
||||
|
||||
## ШАГ 16 — Система плагинов (Lua + шаблоны дел)
|
||||
|
||||
**Цель:** можно положить Lua-скрипт в `.verstak/plugins/` — и он работает.
|
||||
|
||||
**Acceptance:**
|
||||
- `.verstak/plugins/<name>/plugin.json` — мета
|
||||
- `main.lua` — загрузка через gopher-lua
|
||||
- `on_init`, `on_vault_open`, `on_node_create` хуки
|
||||
- `verstak.node.register_type()` — новые типы дел
|
||||
- `verstak.http.route()` — API для GUI
|
||||
- шаблоны дела (JSON) → предзаполненное дерево
|
||||
- CLI: `verstak plugin list / install / enable`
|
||||
|
||||
**Действия:**
|
||||
- `internal/core/plugins/manager.go` — сканирование, загрузка, валидация
|
||||
- Lua runtime (gopher-lua) с песочницей
|
||||
- Plugin API: node, config, activity, http, ui, vault
|
||||
- Миграции плагинов (SQL)
|
||||
- Реестр типов дел → GUI рендерит разные карточки
|
||||
- CLI: plugin list/install/enable
|
||||
- Базовый шаблон дела (client.json)
|
||||
|
||||
**Commit:** `step 16: plugins system`
|
||||
|
||||
---
|
||||
|
||||
## Сводка структуры репозитория
|
||||
|
||||
```
|
||||
|
|
@ -363,6 +390,7 @@ verstak/
|
|||
sync/
|
||||
security/
|
||||
config/
|
||||
plugins/
|
||||
|
||||
frontend/ # Wails frontend (Svelte/Vue)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// Record represents a file entry linked to a node.
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"` // relative to vault root
|
||||
StorageMode string `json:"storage_mode"` // "vault" | "external"
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// Service provides file operations inside a vault.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
}
|
||||
|
||||
// NewService creates a file service bound to a vault.
|
||||
func NewService(db *storage.DB, vaultRoot string) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot}
|
||||
}
|
||||
|
||||
// DB returns the underlying storage.
|
||||
func (s *Service) DB() *storage.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// --- public operations ---
|
||||
|
||||
// AddExternal registers an external file (absolute path) without copying.
|
||||
func (s *Service) AddExternal(nodeID, absPath string) (*Record, error) {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
absPath, _ = filepath.Abs(absPath)
|
||||
return s.insertRecord(nodeID, filepath.Base(absPath), absPath, "external", info.Size(), "")
|
||||
}
|
||||
|
||||
// CopyIntoVault copies an external file into the vault.
|
||||
// The file lands at <vaultRoot>/spaces/<nodeSlug>/<filename>.
|
||||
func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, error) {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if nodeSlug == "" {
|
||||
nodeSlug = nodeID[:8]
|
||||
}
|
||||
|
||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(absPath)
|
||||
dest := filepath.Join(destDir, filename)
|
||||
|
||||
// If destination exists, add a numeric suffix.
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
ext := filepath.Ext(filename)
|
||||
name := strings.TrimSuffix(filename, ext)
|
||||
dest = filepath.Join(destDir, fmt.Sprintf("%s_%d%s", name, time.Now().Unix(), ext))
|
||||
filename = filepath.Base(dest)
|
||||
}
|
||||
|
||||
hash, err := copyAndHash(absPath, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||
}
|
||||
|
||||
// Get returns a file record by ID.
|
||||
func (s *Service) Get(id string) (*Record, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||
created_at,updated_at,last_seen_at,missing
|
||||
FROM files WHERE id = ?`, id)
|
||||
return scanRecord(row)
|
||||
}
|
||||
|
||||
// ListByNode returns all files linked to a node.
|
||||
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||
created_at,updated_at,last_seen_at,missing
|
||||
FROM files WHERE node_id = ? ORDER BY created_at`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRecords(rows)
|
||||
}
|
||||
|
||||
// MarkMissing flags a file as missing.
|
||||
func (s *Service) MarkMissing(id string, missing bool) error {
|
||||
m := 0
|
||||
if missing {
|
||||
m = 1
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE files SET missing=?, updated_at=? WHERE id=?`,
|
||||
m, time.Now().UTC().Format(time.RFC3339), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteToTrash moves a vault file to .verstak/trash/ and removes the record.
|
||||
func (s *Service) DeleteToTrash(id string) error {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.StorageMode == "vault" {
|
||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||
if err := os.Rename(src, dest); err != nil {
|
||||
return fmt.Errorf("move to trash: %w", err)
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec("DELETE FROM files WHERE id=?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Open launches the file with the system default application.
|
||||
func (s *Service) Open(id string) error {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
}
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
rec := &Record{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Filename: filename,
|
||||
Path: path,
|
||||
StorageMode: mode,
|
||||
Size: size,
|
||||
SHA256: sha,
|
||||
MIME: guessMIME(filename),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,
|
||||
created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
|
||||
rec.Size, rec.SHA256, rec.MIME,
|
||||
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func copyAndHash(src, dest string) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func guessMIME(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".md", ".txt", ".go", ".py", ".js", ".ts", ".sh", ".sql", ".yml", ".yaml", ".json", ".toml", ".xml", ".html", ".css", ".csv", ".rst":
|
||||
return "text/plain"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".odt":
|
||||
return "application/vnd.oasis.opendocument.text"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func openWithSystem(path string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", "", path)
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// --- scanning helpers ---
|
||||
|
||||
type scanFace interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanRecord(s scanFace) (*Record, error) {
|
||||
var r Record
|
||||
var lastSeen sql.NullString
|
||||
var createdStr, updatedStr string
|
||||
err := s.Scan(
|
||||
&r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode,
|
||||
&r.Size, &r.SHA256, &r.MIME,
|
||||
&createdStr, &updatedStr, &lastSeen, &r.Missing)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
if lastSeen.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastSeen.String)
|
||||
r.LastSeenAt = &t
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanRecords(rows *sql.Rows) ([]Record, error) {
|
||||
var out []Record
|
||||
for rows.Next() {
|
||||
r, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *storage.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAddExternal(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
// Run migration 002 manually since storage.Open already applied it.
|
||||
// We can verify the table exists by inserting.
|
||||
filesSvc := NewService(db, t.TempDir())
|
||||
|
||||
// Create a real temp file to register.
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(tmpFile, []byte("hello world"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, err := filesSvc.AddExternal("node-1", tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("AddExternal: %v", err)
|
||||
}
|
||||
if rec.ID == "" {
|
||||
t.Fatal("empty id")
|
||||
}
|
||||
if rec.Filename != "test.txt" {
|
||||
t.Errorf("filename = %q", rec.Filename)
|
||||
}
|
||||
if rec.StorageMode != "external" {
|
||||
t.Errorf("mode = %q", rec.StorageMode)
|
||||
}
|
||||
if rec.Size != 11 {
|
||||
t.Errorf("size = %d, want 11", rec.Size)
|
||||
}
|
||||
|
||||
// Verify stored.
|
||||
got, err := filesSvc.Get(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Filename != "test.txt" {
|
||||
t.Errorf("got filename = %q", got.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyIntoVault(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
|
||||
// Source file.
|
||||
srcDir := t.TempDir()
|
||||
srcFile := filepath.Join(srcDir, "doc.pdf")
|
||||
os.WriteFile(srcFile, []byte("PDF content here"), 0o640)
|
||||
|
||||
rec, err := svc.CopyIntoVault("node-1", srcFile, "my-node")
|
||||
if err != nil {
|
||||
t.Fatalf("CopyIntoVault: %v", err)
|
||||
}
|
||||
if rec.SHA256 == "" {
|
||||
t.Error("expected sha256")
|
||||
}
|
||||
if rec.StorageMode != "vault" {
|
||||
t.Errorf("mode = %q", rec.StorageMode)
|
||||
}
|
||||
|
||||
// Verify file on disk.
|
||||
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); err != nil {
|
||||
t.Errorf("file on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByNode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db, t.TempDir())
|
||||
|
||||
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
||||
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
||||
f2 := filepath.Join(t.TempDir(), "a2.txt")
|
||||
os.WriteFile(f1, []byte("a"), 0o640)
|
||||
os.WriteFile(f2, []byte("bb"), 0o640)
|
||||
|
||||
svc.AddExternal("node-a", f1)
|
||||
svc.AddExternal("node-a", f2)
|
||||
|
||||
list, err := svc.ListByNode("node-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("list len = %d, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteToTrash(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
|
||||
src := filepath.Join(t.TempDir(), "important.pdf")
|
||||
os.WriteFile(src, []byte("important data"), 0o640)
|
||||
|
||||
rec, _ := svc.CopyIntoVault("node-x", src, "node-x")
|
||||
|
||||
if err := svc.DeleteToTrash(rec.ID); err != nil {
|
||||
t.Fatalf("DeleteToTrash: %v", err)
|
||||
}
|
||||
|
||||
// File record should be gone.
|
||||
if _, err := svc.Get(rec.ID); err == nil {
|
||||
t.Error("expected error after trash")
|
||||
}
|
||||
|
||||
// Original file should not exist anymore (moved to trash).
|
||||
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); !os.IsNotExist(err) {
|
||||
t.Error("expected file to be moved from original location")
|
||||
}
|
||||
|
||||
// Trash dir should have it.
|
||||
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
|
||||
entries, _ := os.ReadDir(trashDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("trash entries = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessMIME(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.md": "text/plain",
|
||||
"a.png": "image/png",
|
||||
"a.pdf": "application/pdf",
|
||||
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"a.go": "text/plain",
|
||||
"a.unknown": "application/octet-stream",
|
||||
}
|
||||
for name, want := range cases {
|
||||
got := guessMIME(name)
|
||||
if got != want {
|
||||
t.Errorf("guessMIME(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
package notes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// Record represents a note entry (links a node to a file).
|
||||
type Record struct {
|
||||
NodeID string `json:"node_id"`
|
||||
FileID string `json:"file_id"`
|
||||
Format string `json:"format"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
}
|
||||
|
||||
// Service handles markdown notes.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
}
|
||||
|
||||
// NewService creates a note service.
|
||||
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fileSvc *files.Service) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc}
|
||||
}
|
||||
|
||||
// Create makes a new note node, an empty .md file, and links them.
|
||||
func (s *Service) Create(parentID, title string) (*nodes.Node, *files.Record, error) {
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeNote, title)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
slug := node.Slug
|
||||
if slug == "" {
|
||||
slug = "note"
|
||||
}
|
||||
filename := slug + ".md"
|
||||
destDir := filepath.Join(s.vaultRoot, "spaces")
|
||||
os.MkdirAll(destDir, 0o750)
|
||||
|
||||
dest := filepath.Join(destDir, filename)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
filename = fmt.Sprintf("%s_%s.md", slug, node.ID[:8])
|
||||
dest = filepath.Join(destDir, filename)
|
||||
}
|
||||
|
||||
// Write initial content.
|
||||
if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil {
|
||||
return nil, nil, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
// Register file record.
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
fileRec, err := insertFileRecord(s.db, node.ID, filename, relPath, "vault", 0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("insert file: %w", err)
|
||||
}
|
||||
|
||||
// Link.
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
node.ID, fileRec.ID, "markdown")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("link note: %w", err)
|
||||
}
|
||||
return node, fileRec, nil
|
||||
}
|
||||
|
||||
// Read returns the content of a note.
|
||||
func (s *Service) Read(nodeID string) (string, error) {
|
||||
var filePath, storageMode string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT f.path, f.storage_mode
|
||||
FROM notes n JOIN files f ON n.file_id = f.id
|
||||
WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("query note: %w", err)
|
||||
}
|
||||
|
||||
var abs string
|
||||
if storageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, filePath)
|
||||
} else {
|
||||
abs = filePath
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Save writes new content, backing up the old version.
|
||||
func (s *Service) Save(nodeID, content string) error {
|
||||
var filePath, storageMode string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT f.path, f.storage_mode
|
||||
FROM notes n JOIN files f ON n.file_id = f.id
|
||||
WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query: %w", err)
|
||||
}
|
||||
|
||||
var abs string
|
||||
if storageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, filePath)
|
||||
} else {
|
||||
abs = filePath
|
||||
}
|
||||
|
||||
// Backup old version.
|
||||
if info, err := os.Stat(abs); err == nil && info.Size() > 0 {
|
||||
histDir := filepath.Join(s.vaultRoot, ".verstak", "history")
|
||||
os.MkdirAll(histDir, 0o750)
|
||||
name := filepath.Base(abs)
|
||||
backup := filepath.Join(histDir,
|
||||
fmt.Sprintf("%s_%d.bak", name, time.Now().Unix()))
|
||||
os.WriteFile(backup, mustRead(abs), 0o640)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(abs, []byte(content), 0o640); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
// Update file size.
|
||||
info, _ := os.Stat(abs)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
|
||||
size, utcNow(), filePath, storageMode)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete soft-deletes the note node.
|
||||
func (s *Service) Delete(nodeID string) error {
|
||||
return s.nodes.SoftDelete(nodeID)
|
||||
}
|
||||
|
||||
// Load looks up the note record for a node.
|
||||
func (s *Service) Load(nodeID string) (*Record, error) {
|
||||
var rec Record
|
||||
var enc int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT node_id, file_id, format, encrypted FROM notes WHERE node_id=?`, nodeID,
|
||||
).Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Encrypted = enc == 1
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func insertFileRecord(db *storage.DB, nodeID, filename, relPath, mode string, size int64) (*files.Record, error) {
|
||||
rec := &files.Record{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Filename: filename,
|
||||
Path: relPath,
|
||||
StorageMode: mode,
|
||||
Size: size,
|
||||
MIME: "text/plain",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,
|
||||
created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,0)`,
|
||||
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
|
||||
rec.Size, rec.MIME,
|
||||
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func mustRead(path string) []byte {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func utcNow() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package notes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, dir)
|
||||
svc := NewService(db, dir, nodeRepo, fileSvc)
|
||||
return svc, nodeRepo, dir
|
||||
}
|
||||
|
||||
func TestCreateAndRead(t *testing.T) {
|
||||
svc, _, vaultRoot := setupService(t)
|
||||
|
||||
node, fileRec, err := svc.Create("", "My Note")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if node.Title != "My Note" {
|
||||
t.Errorf("title = %q", node.Title)
|
||||
}
|
||||
if fileRec == nil || fileRec.ID == "" {
|
||||
t.Fatal("file record missing")
|
||||
}
|
||||
|
||||
// Read initial content.
|
||||
content, err := svc.Read(node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "My Note") {
|
||||
t.Errorf("content = %q", content)
|
||||
}
|
||||
|
||||
// Verify file on disk.
|
||||
spacesDir := filepath.Join(vaultRoot, "spaces")
|
||||
entries, _ := os.ReadDir(spacesDir)
|
||||
if len(entries) == 0 {
|
||||
t.Error("expected file in spaces/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndBackup(t *testing.T) {
|
||||
svc, _, vaultRoot := setupService(t)
|
||||
|
||||
node, _, _ := svc.Create("", "Backup Test")
|
||||
|
||||
// Save new content.
|
||||
newContent := "# Updated\n\nThis is the new content."
|
||||
if err := svc.Save(node.ID, newContent); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
// Read back.
|
||||
got, err := svc.Read(node.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != newContent {
|
||||
t.Errorf("content = %q, want %q", got, newContent)
|
||||
}
|
||||
|
||||
// Check backup exists.
|
||||
histDir := filepath.Join(vaultRoot, ".verstak", "history")
|
||||
entries, _ := os.ReadDir(histDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("backup count = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNote(t *testing.T) {
|
||||
svc, nodeRepo, _ := setupService(t)
|
||||
|
||||
node, _, _ := svc.Create("", "To Delete")
|
||||
if err := svc.Delete(node.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := nodeRepo.GetActive(node.ID); err == nil {
|
||||
t.Error("expected deleted node to be inactive")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package storage
|
||||
|
||||
// migration002 — files table for vault file tracking.
|
||||
const migration002 = `
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
filename TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
storage_mode TEXT NOT NULL DEFAULT 'vault',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT NULL,
|
||||
mime TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NULL,
|
||||
missing INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_node ON files(node_id);
|
||||
`
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package storage
|
||||
|
||||
// migration003 — notes table.
|
||||
const migration003 = `
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
|
||||
file_id TEXT NOT NULL REFERENCES files(id),
|
||||
format TEXT NOT NULL DEFAULT 'markdown',
|
||||
original_format TEXT NULL,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`
|
||||
|
|
@ -58,7 +58,9 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
|
|||
|
||||
var migrationFiles = map[int]string{
|
||||
1: migration001,
|
||||
// 2: migration002, etc.
|
||||
2: migration002,
|
||||
3: migration003,
|
||||
// 4: migration004, etc.
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
package gui
|
||||
|
||||
// indexHTML is the GUI frontend served inline.
|
||||
const indexHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Верстак</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#ccc;height:100vh;display:flex;overflow:hidden}
|
||||
#sb{width:260px;min-width:200px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden}
|
||||
#sb-hdr{padding:14px 16px;font-size:16px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#sb-hdr button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px}
|
||||
#sb-hdr button:hover{background:#1a4080;color:#fff}
|
||||
#tree{flex:1;overflow-y:auto;padding:6px 0}
|
||||
.ti{padding:5px 14px;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px;border-radius:3px;margin:0 4px}
|
||||
.ti:hover{background:#0f3460}
|
||||
.ti.sel{background:#1a4080;color:#fff}
|
||||
.ti .ic{width:14px;color:#e94560;text-align:center;font-size:13px}
|
||||
.empty{padding:40px 14px;color:#555;font-size:13px;text-align:center}
|
||||
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||||
#mh{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
#mh h1{font-size:17px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#mh .acts{display:flex;gap:5px;flex-shrink:0}
|
||||
#mh button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 11px;cursor:pointer;font-size:12px}
|
||||
#mh button:hover{background:#1a4080;color:#fff}
|
||||
#mh button.p{background:#e94560;border-color:#e94560}
|
||||
#mh button.p:hover{background:#c73652}
|
||||
#srch{padding:6px 20px;border-bottom:1px solid #0f3460}
|
||||
#srch input{width:100%;padding:7px 12px;background:#0f0f23;color:#ccc;border:1px solid #0f3460;border-radius:5px;font-size:13px;outline:none}
|
||||
#srch input:focus{border-color:#e94560}
|
||||
#tabs{display:flex;padding:0 20px;border-bottom:1px solid #0f3460;gap:0;flex-shrink:0}
|
||||
#tabs .t{padding:9px 16px;cursor:pointer;font-size:13px;color:#666;border-bottom:2px solid transparent}
|
||||
#tabs .t:hover{color:#ddd}
|
||||
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
|
||||
#cnt{flex:1;overflow-y:auto;padding:20px}
|
||||
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
||||
.card{background:#16213e;border:1px solid #0f3460;border-radius:7px;padding:14px;cursor:pointer;transition:border-color .15s}
|
||||
.card:hover{border-color:#e94560}
|
||||
.card .ct{font-weight:600;margin-bottom:4px}
|
||||
.card .cy{font-size:11px;color:#777;text-transform:uppercase}
|
||||
.card .cm{font-size:11px;color:#555;margin-top:6px}
|
||||
.bc{font-size:12px;color:#555;margin-bottom:10px}
|
||||
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
|
||||
#ed-hdr{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#ed-hdr span{font-size:15px;font-weight:600}
|
||||
#ed button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 12px;cursor:pointer;font-size:12px;margin-left:6px}
|
||||
#ed button.p{background:#e94560;border-color:#e94560}
|
||||
#ed-ta{flex:1;background:#0f0f23;color:#ccc;border:none;padding:20px;font-family:Consolas,monospace;font-size:13px;line-height:1.7;resize:none;outline:none}
|
||||
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
|
||||
.mo.on{display:flex}
|
||||
.md{background:#16213e;border:1px solid #0f3460;border-radius:10px;padding:22px;width:400px;max-width:90vw}
|
||||
.md h3{margin-bottom:14px;font-size:15px}
|
||||
.md label{display:block;font-size:11px;color:#777;margin-bottom:3px;margin-top:10px}
|
||||
.md input,.md select{width:100%;padding:7px 10px;background:#0f0f23;color:#ccc;border:1px solid #1a4080;border-radius:4px;font-size:13px;outline:none}
|
||||
.md input:focus{border-color:#e94560}
|
||||
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
|
||||
.md button{padding:7px 14px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#ccc;cursor:pointer}
|
||||
.md button.p{background:#e94560;border-color:#e94560}
|
||||
#sr-res{display:none;position:absolute;background:#16213e;border:1px solid #0f3460;border-radius:6px;width:350px;max-height:300px;overflow-y:auto;z-index:50;box-shadow:0 4px 20px rgba(0,0,0,.4)}
|
||||
#sr-res .sri{padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #0f3460}
|
||||
#sr-res .sri:last-child{border-bottom:none}
|
||||
#sr-res .sri:hover{background:#0f3460}
|
||||
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
|
||||
ul{list-style:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sb">
|
||||
<div id="sb-hdr"><span>⚒ ВЕРСТАК</span><button onclick="showCM()">+</button></div>
|
||||
<div id="tree"><div class="empty">Загрузка...</div></div>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="mh">
|
||||
<h1 id="pt">Верстак</h1>
|
||||
<div class="acts">
|
||||
<button onclick="showCNM()">+ Заметка</button>
|
||||
<button class="p" onclick="showCM()">+ Дело</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="srch" style="position:relative">
|
||||
<input id="si" placeholder="Поиск по делам..." autocomplete="off" oninput="handleSR(this.value)">
|
||||
<div id="sr-res" style="top:38px;left:0"></div>
|
||||
</div>
|
||||
<div id="tabs">
|
||||
<div class="t a" data-t="ov" onclick="switchTab(this)">Обзор</div>
|
||||
<div class="t" data-t="nt" onclick="switchTab(this)">Заметки</div>
|
||||
<div class="t" data-t="fl" onclick="switchTab(this)">Файлы</div>
|
||||
</div>
|
||||
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
|
||||
</div>
|
||||
<div id="ed">
|
||||
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()">Закрыть</button><button class="p" onclick="saveNT()">Сохранить</button></div></div>
|
||||
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
|
||||
</div>
|
||||
<div class="mo" id="cm">
|
||||
<div class="md"><h3>Новое дело</h3>
|
||||
<label>Тип</label><select id="ct"><option value="case">Дело</option><option value="folder">Папка</option><option value="space">Пространство</option><option value="recipe">Рецепт</option></select>
|
||||
<label>Название</label><input id="cn" placeholder="Название...">
|
||||
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
|
||||
</div>
|
||||
<div class="mo" id="cnm">
|
||||
<div class="md"><h3>Новая заметка</h3>
|
||||
<label>Название</label><input id="cnn" placeholder="Название...">
|
||||
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
|
||||
</div>
|
||||
<script>
|
||||
const A='';let cur='',editId='';
|
||||
async function api(p,o){const r=await fetch(A+p,{headers:{'Content-Type':'application/json'},...o});if(!r.ok)throw Error(r.status);return r.json()}
|
||||
function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
const ni={case:'◆',folder:'▸',space:'◎',note:'✍',recipe:'◈',document:'📄',file:'📎',action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'};
|
||||
async function loadTree(){
|
||||
try{const items=await api('/api/nodes');const t=document.getElementById('tree');
|
||||
if(!items.length){t.innerHTML='<div class="empty">Создайте первое дело</div>';return}
|
||||
let h='<ul>';for(const n of items){h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" onclick="selN(\''+n.id+'\')"><span class="ic">'+(ni[n.type]||'◆')+'</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>'}
|
||||
h+='</ul>';t.innerHTML=h}
|
||||
catch(e){document.getElementById('tree').innerHTML='<div class="empty">Ошибка</div>'}
|
||||
}
|
||||
async function selN(id){
|
||||
cur=id;document.querySelectorAll('.ti').forEach(e=>e.classList.remove('sel'));
|
||||
const d=event.currentTarget;if(d)d.classList.add('sel');
|
||||
try{const d=await api('/api/nodes/'+id);document.getElementById('pt').textContent=d.node.title;renderOV(d)}
|
||||
catch(e){document.getElementById('cnt').innerHTML='<div class="empty">Ошибка</div>'}
|
||||
}
|
||||
function renderOV(d){const ch=d.children||[],fl=d.files||[];
|
||||
let h='<div class="bc">'+d.node.type+'</div>';
|
||||
if(ch.length||fl.length){h+='<div class="cg">';
|
||||
for(const c of ch){h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>'}
|
||||
for(const f of fl){h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>'}
|
||||
h+='</div>'}else{h+='<div class="empty" style="margin-top:60px">Пусто. Добавьте заметку или файл.</div>'}
|
||||
document.getElementById('cnt').innerHTML=h
|
||||
}
|
||||
function fsz(b){if(b<1024)return b+' Б';if(b<1048576)return(b/1024).toFixed(1)+' КБ';return(b/1048576).toFixed(1)+' МБ'}
|
||||
function switchTab(el){
|
||||
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));el.classList.add('a');
|
||||
const t=el.dataset.t;if(t==='nt')loadNT();else if(t==='fl')loadFL();else if(cur)selN(cur);
|
||||
}
|
||||
async function loadNT(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
|
||||
const d=await api('/api/nodes/'+cur);const ns=(d.children||[]).filter(c=>c.type==='note');
|
||||
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок</div>';return}
|
||||
let h='<div class="cg">';for(const n of ns){h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">'+esc(n.title)+'</div><div class="cy">заметка</div></div>'}h+='</div>';
|
||||
document.getElementById('cnt').innerHTML=h
|
||||
}
|
||||
async function loadFL(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
|
||||
const d=(await api('/api/nodes/'+cur)).files||[];
|
||||
if(!d.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return}
|
||||
let h='<div class="cg">';for(const f of d){h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>'}h+='</div>';
|
||||
document.getElementById('cnt').innerHTML=h
|
||||
}
|
||||
async function openNT(id){editId=id;
|
||||
try{const d=await api('/api/notes/'+id);document.getElementById('et').textContent='Заметка';document.getElementById('ed-ta').value=d.content||'';document.getElementById('ed').style.display='flex';document.getElementById('ed-ta').focus()}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
function closeED(){document.getElementById('ed').style.display='none';editId=''}
|
||||
async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}catch(e){alert('Ошибка: '+e.message)}}
|
||||
function showCM(){document.getElementById('cm').classList.add('on');setTimeout(()=>document.getElementById('cn').focus(),50)}
|
||||
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''}
|
||||
async function submitCM(){const t=document.getElementById('ct').value,title=document.getElementById('cn').value.trim();if(!title)return;try{await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type:t,title})});closeCM();loadTree();if(cur)selN(cur)}catch(e){alert('Ошибка: '+e.message)}}
|
||||
function showCNM(){document.getElementById('cnm').classList.add('on');setTimeout(()=>document.getElementById('cnn').focus(),50)}
|
||||
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
|
||||
async function submitCNM(){const title=document.getElementById('cnn').value.trim();if(!title)return;try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id)}catch(e){alert('Ошибка: '+e.message)}}
|
||||
let sT=null;
|
||||
async function handleSR(q){clearTimeout(sT);const b=document.getElementById('sr-res');
|
||||
if(!q||q.length<2){b.style.display='none';return}
|
||||
sT=setTimeout(async()=>{try{const items=await api('/api/nodes');
|
||||
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
|
||||
if(!hits.length){b.style.display='none';return}
|
||||
let h='';for(const r of hits){h+='<div class="sri" onclick="b.style.display=\'none\';selN(\''+r.id+'\')"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
|
||||
b.innerHTML=h;b.style.display='block'
|
||||
}catch(e){b.style.display='none'}},200)}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}});
|
||||
loadTree();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
// Server is the GUI HTTP server bound to a vault.
|
||||
type Server struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
srv *http.Server
|
||||
listener net.Listener
|
||||
port int
|
||||
}
|
||||
|
||||
// NewServer creates a GUI server for the given vault.
|
||||
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, vaultRoot)
|
||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||
return &Server{
|
||||
db: db, vaultRoot: vaultRoot,
|
||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Start binds on a free port and returns the base URL.
|
||||
func (s *Server) Start() (string, error) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/nodes", s.handleNodes)
|
||||
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
||||
mux.HandleFunc("/api/notes/", s.handleNotes)
|
||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||
mux.HandleFunc("/api/search", s.handleSearch)
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
|
||||
s.srv = &http.Server{Handler: mux}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.listener = ln
|
||||
s.port = ln.Addr().(*net.TCPAddr).Port
|
||||
go func() {
|
||||
if e := s.srv.Serve(ln); e != nil && e != http.ErrServerClosed {
|
||||
log.Printf("GUI: %v", e)
|
||||
}
|
||||
}()
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", s.port), nil
|
||||
}
|
||||
|
||||
// Stop shuts down the server.
|
||||
func (s *Server) Stop() error {
|
||||
if s.srv != nil {
|
||||
return s.srv.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr returns the base URL.
|
||||
func (s *Server) Addr() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", s.port)
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
t := template.Must(template.New("idx").Parse(indexHTML))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t.Execute(w, nil)
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// GET /api/nodes[?parent=ID] POST /api/nodes
|
||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
parent := r.URL.Query().Get("parent")
|
||||
var list interface{}
|
||||
var err error
|
||||
if parent == "" {
|
||||
list, err = s.nodes.ListRoots(false)
|
||||
} else {
|
||||
list, err = s.nodes.ListChildren(parent, false)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, list)
|
||||
case "POST":
|
||||
var req struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, n)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET/PUT/DELETE /api/nodes/{id}
|
||||
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
n, err := s.nodes.GetActive(id)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
children, _ := s.nodes.ListChildren(id, false)
|
||||
fl, _ := s.files.ListByNode(id)
|
||||
meta, _ := s.nodes.MetaList(id)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"node": n, "children": children, "files": fl, "meta": meta,
|
||||
})
|
||||
case "PUT":
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Sort int `json:"sort_order"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Title != "" {
|
||||
s.nodes.UpdateTitle(id, req.Title)
|
||||
}
|
||||
if req.ParentID != "" {
|
||||
s.nodes.Move(id, req.ParentID, req.Sort)
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok"})
|
||||
case "DELETE":
|
||||
s.nodes.SoftDelete(id)
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notes/{parentID} PUT/GET /api/notes/{nodeID}
|
||||
func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/notes/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
rec, err := s.notes.Load(path)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
content, _ := s.notes.Read(path)
|
||||
jsonOK(w, map[string]interface{}{"record": rec, "content": content})
|
||||
case "POST":
|
||||
var req struct{ Title string `json:"title"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
n, _, err := s.notes.Create(path, req.Title)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, n)
|
||||
case "PUT":
|
||||
var req struct{ Content string `json:"content"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := s.notes.Save(path, req.Content); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "saved"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET/DELETE /api/files/{id}
|
||||
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
rec, err := s.files.Get(id)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rec)
|
||||
case "DELETE":
|
||||
if err := s.files.DeleteToTrash(id); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "trashed"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/search?q=...
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.ToLower(r.URL.Query().Get("q"))
|
||||
if len(q) < 2 {
|
||||
jsonOK(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
roots, _ := s.nodes.ListRoots(false)
|
||||
var hits []map[string]interface{}
|
||||
for _, n := range roots {
|
||||
if strings.Contains(strings.ToLower(n.Title), q) {
|
||||
hits = append(hits, map[string]interface{}{"id": n.ID, "title": n.Title, "type": n.Type})
|
||||
}
|
||||
}
|
||||
jsonOK(w, hits)
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Верстак</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;height:100vh;display:flex;overflow:hidden}
|
||||
#sb{width:280px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
|
||||
#sb-hdr{padding:16px;font-size:18px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#sb-hdr button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:12px}
|
||||
#sb-hdr button:hover{background:#1a4080}
|
||||
#tree{flex:1;overflow-y:auto;padding:8px 0}
|
||||
#tree ul{list-style:none}
|
||||
#tree li{user-select:none}
|
||||
.ti{padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;border-radius:4px;margin:1px 6px}
|
||||
.ti:hover{background:#0f3460}
|
||||
.ti.sel{background:#1a4080;color:#fff}
|
||||
.ti .ic{width:16px;text-align:center;color:#e94560;font-size:14px}
|
||||
.ti .ar{width:12px;font-size:10px;color:#555}
|
||||
.tc{margin-left:18px;border-left:1px dashed #0f3460}
|
||||
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||||
#mh{padding:14px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:12px}
|
||||
#mh h1{font-size:18px;font-weight:600;white-space:overflow:hidden;text-overflow:ellipsis}
|
||||
#mh .acts{display:flex;gap:6px;flex-shrink:0}
|
||||
#mh button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 12px;cursor:pointer;font-size:12px}
|
||||
#mh button:hover{background:#1a4080}
|
||||
#mh button.p{background:#e94560;border-color:#e94560;color:#fff}
|
||||
#mh button.p:hover{background:#c73652}
|
||||
#tabs{display:flex;padding:0 24px;border-bottom:1px solid #0f3460;flex-shrink:0}
|
||||
#tabs .t{padding:10px 18px;cursor:pointer;font-size:13px;color:#888;border-bottom:2px solid transparent;white-space:nowrap}
|
||||
#tabs .t:hover{color:#e0e0e0}
|
||||
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
|
||||
#srch{padding:8px 24px}
|
||||
#srch input{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #0f3460;border-radius:6px;font-size:13px;outline:none}
|
||||
#srch input:focus{border-color:#e94560}
|
||||
#sr-res{display:none;position:absolute;top:100px;left:50%;transform:translateX(-50%);width:400px;max-width:90vw;background:#16213e;border:1px solid #0f3460;border-radius:8px;max-height:350px;overflow-y:auto;z-index:60}
|
||||
#sr-res .sri{padding:10px 14px;cursor:pointer;border-bottom:1px solid #0f3460;font-size:13px}
|
||||
#sr-res .sri:hover{background:#0f3460}
|
||||
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
|
||||
#cnt{flex:1;overflow-y:auto;padding:24px}
|
||||
.bc{font-size:12px;color:#666;margin-bottom:12px}
|
||||
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#555;font-size:15px}
|
||||
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
||||
.card{background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:14px;cursor:pointer;transition:border-color .15s}
|
||||
.card:hover{border-color:#e94560}
|
||||
.card .ct{font-weight:600;margin-bottom:4px;font-size:14px}
|
||||
.card .cy{font-size:11px;color:#888;text-transform:uppercase}
|
||||
.card .cm{font-size:11px;color:#555;margin-top:6px}
|
||||
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
|
||||
#ed-hdr{padding:12px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
|
||||
#ed-hdr span{font-size:16px;font-weight:600}
|
||||
#ed button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 14px;cursor:pointer;font-size:12px}
|
||||
#ed button.p{background:#e94560;border-color:#e94560}
|
||||
#ed-ta{flex:1;background:#0f0f23;color:#e0e0e0;border:none;padding:24px;font-family:"Fira Code",Consolas,monospace;font-size:14px;line-height:1.7;resize:none;outline:none}
|
||||
#ed-ftr{padding:10px 24px;border-top:1px solid #0f3460;display:flex;gap:8px}
|
||||
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
|
||||
.mo.on{display:flex}
|
||||
.md{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:24px;width:420px;max-width:90vw}
|
||||
.md h3{margin-bottom:16px;font-size:16px}
|
||||
.md label{display:block;font-size:12px;color:#888;margin-bottom:4px;margin-top:12px}
|
||||
.md input,.md select{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;font-size:14px;outline:none}
|
||||
.md input:focus{border-color:#e94560}
|
||||
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
|
||||
.md button{padding:8px 16px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#e0e0e0;cursor:pointer}
|
||||
.md button.p{background:#e94560;border-color:#e94560;color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sb">
|
||||
<div id="sb-hdr"><span>⚒ ВЕРСТАК</span><button onclick="showCM()">+</button></div>
|
||||
<div id="tree"><div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div></div>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="mh"><h1 id="pt">Верстак</h1><div class="acts">
|
||||
<button onclick="showCNM()">+ Заметка</button>
|
||||
<button onclick="showCM()">+ Дело</button>
|
||||
<button onclick="document.getElementById('fi').click()">+ Файл</button>
|
||||
<input type="file" id="fi" style="display:none" onchange="handleFA(event)">
|
||||
</div></div>
|
||||
<div id="tabs"><div class="t a" onclick="switchTab(this,'ov')">Обзор</div><div class="t" onclick="switchTab(this,'nt')">Заметки</div><div class="t" onclick="switchTab(this,'fl')">Файлы</div></div>
|
||||
<div id="srch"><input id="si" placeholder="Поиск по делам и заметкам..." autocomplete="off" oninput="handleSR(this.value)"></div>
|
||||
<div id="sr-res"></div>
|
||||
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
|
||||
</div>
|
||||
|
||||
<div id="ed">
|
||||
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()">✕</button> <button class="p" onclick="saveNT()">💾 Сохранить</button></div></div>
|
||||
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
|
||||
<div id="ed-ftr"></div>
|
||||
</div>
|
||||
|
||||
<div class="mo" id="cm">
|
||||
<div class="md"><h3>Новое дело</h3>
|
||||
<label>Тип</label><select id="ct"><option value="case">◇ Дело</option><option value="folder">▸ Папка</option><option value="space">◎ Пространство</option><option value="recipe">◈ Рецепт</option></select>
|
||||
<label>Название</label><input id="cn" placeholder="Название..." autofocus>
|
||||
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
|
||||
</div>
|
||||
|
||||
<div class="mo" id="cnm">
|
||||
<div class="md"><h3>Новая заметка</h3>
|
||||
<label>Название</label><input id="cnn" placeholder="Название заметки..." autofocus>
|
||||
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='';
|
||||
let cur='',editId='',tab='ov';
|
||||
|
||||
async function api(p,opt){
|
||||
const r=await fetch(API+p,{headers:{'Content-Type':'application/json'},...opt});
|
||||
if(!r.ok)throw new Error(r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// tree
|
||||
async function loadTree(){
|
||||
try{
|
||||
const items=await api('/api/nodes');
|
||||
const t=document.getElementById('tree');
|
||||
if(!items.length){t.innerHTML='<div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div>';return}
|
||||
t.innerHTML=renderL(items,'');
|
||||
}catch(e){document.getElementById('tree').innerHTML='<div class="empty" style="padding:30px 14px">Ошибка загрузки</div>'}
|
||||
}
|
||||
|
||||
function renderL(items,pid){
|
||||
let h='<ul'+(pid?' class="tc"':'')+'>';
|
||||
for(const n of items){
|
||||
h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" data-id="'+n.id+'" onclick="selN(\''+n.id+'\')">';
|
||||
h+='<span class="ar">'+('▸')+'</span><span class="ic">'+nic(n.type)+'</span>';
|
||||
h+='<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>';
|
||||
}
|
||||
h+='</ul>';return h;
|
||||
}
|
||||
|
||||
function nic(t){const i={case:'◇',folder:'▸',space:'◎',note:'📝',document:'📄',file:'📎',recipe:'◈',action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'};return i[t]||'◇'}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
async function selN(id){
|
||||
cur=id;
|
||||
document.querySelectorAll('.ti').forEach(e=>e.classList.toggle('sel',e.dataset.id===id));
|
||||
try{
|
||||
const d=await api('/api/nodes/'+id);
|
||||
document.getElementById('pt').textContent=d.node.title;
|
||||
renderOV(d);
|
||||
}catch(e){document.getElementById('cnt').innerHTML='<div class="empty">Ошибка</div>'}
|
||||
}
|
||||
|
||||
function renderOV(d){
|
||||
const ch=d.children||[],fl=d.files||[];
|
||||
let h='<div class="bc">'+(d.node.type||'Дело')+'</div>';
|
||||
if(ch.length||fl.length){
|
||||
h+='<div class="cg">';
|
||||
for(const c of ch){
|
||||
h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>';
|
||||
}
|
||||
for(const f of fl){
|
||||
h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
|
||||
}
|
||||
h+='</div>';
|
||||
} else {
|
||||
h+='<div class="empty" style="margin-top:40px">Пусто. Добавьте заметку или файл.</div>';
|
||||
}
|
||||
document.getElementById('cnt').innerHTML=h;
|
||||
}
|
||||
|
||||
function fsz(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
|
||||
|
||||
function switchTab(el,t){
|
||||
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));
|
||||
el.classList.add('a');tab=t;
|
||||
if(t==='ov'&&cur)selN(cur);else if(t==='nt')loadNT();else if(t==='fl')loadFL();
|
||||
}
|
||||
|
||||
async function loadNT(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите делó</div>';return}
|
||||
const d=await api('/api/nodes/'+cur);
|
||||
const ns=(d.children||[]).filter(c=>c.type==='note');
|
||||
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок. Создайте первую.</div>';return}
|
||||
let h='<div class="cg">';
|
||||
for(const n of ns)h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">📝 '+esc(n.title)+'</div><div class="cy">заметка</div></div>';
|
||||
h+='</div>';document.getElementById('cnt').innerHTML=h;
|
||||
}
|
||||
|
||||
async function loadFL(){
|
||||
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
|
||||
const d=await api('/api/nodes/'+cur);
|
||||
const fl=d.files||[];
|
||||
if(!fl.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return}
|
||||
let h='<div class="cg">';
|
||||
for(const f of fl)h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
|
||||
h+='</div>';document.getElementById('cnt').innerHTML=h;
|
||||
}
|
||||
|
||||
// editor
|
||||
async function openNT(id){
|
||||
editId=id;
|
||||
try{const d=await api('/api/notes/'+id);document.getElementById('et').textContent='Заметка';document.getElementById('ed-ta').value=d.content||'';document.getElementById('ed').style.display='flex'}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
function closeED(){document.getElementById('ed').style.display='none';editId=''}
|
||||
async function saveNT(){
|
||||
if(!editId)return;
|
||||
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
// modals
|
||||
function showCM(){document.getElementById('cm').classList.add('on');document.getElementById('cn').focus()}
|
||||
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''}
|
||||
async function submitCM(){
|
||||
const type=document.getElementById('ct').value,title=document.getElementById('cn').value.trim();
|
||||
if(!title)return;
|
||||
try{await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type,title})});closeCM();loadTree();if(cur)selN(cur)}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
function showCNM(){document.getElementById('cnm').classList.add('on');document.getElementById('cnn').focus()}
|
||||
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
|
||||
async function submitCNM(){
|
||||
const title=document.getElementById('cnn').value.trim();
|
||||
if(!title)return;
|
||||
try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id);switchTab(document.querySelectorAll('#tabs .t')[1],'nt')}
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
function handleFA(ev){const f=ev.target.files[0];if(!f)return;alert('MVP: загрузка файлов будет в следующей версии. Файл: '+f.name);ev.target.value=''}
|
||||
|
||||
// search
|
||||
let sT=null;
|
||||
async function handleSR(q){
|
||||
clearTimeout(sT);
|
||||
const b=document.getElementById('sr-res');
|
||||
if(!q||q.length<2){b.style.display='none';return}
|
||||
sT=setTimeout(async()=>{
|
||||
try{
|
||||
// For MVP, fetch all roots and filter locally
|
||||
const items=await api('/api/nodes');
|
||||
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
|
||||
if(!hits.length){b.style.display='none';return}
|
||||
let h='';for(const r of hits){h+='<div class="sri" onclick="selN(\''+r.id+'\');b.style.display=\'none\'"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
|
||||
b.innerHTML=h;b.style.display='block';
|
||||
}catch(e){b.style.display='none'}
|
||||
},200);
|
||||
}
|
||||
|
||||
// keyboard shortcuts
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}
|
||||
if(e.key==='n'&&(e.ctrlKey||e.metaKey)){e.preventDefault();showCM()}
|
||||
});
|
||||
|
||||
loadTree();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue