diff --git a/README.md b/README.md index 2ff2761..7ba7a41 100644 --- a/README.md +++ b/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) diff --git a/cmd/verstak-gui/main.go b/cmd/verstak-gui/main.go new file mode 100644 index 0000000..a914178 --- /dev/null +++ b/cmd/verstak-gui/main.go @@ -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() {} diff --git a/docs/00_README.md b/docs/00_README.md index 6233016..9d60a5c 100644 --- a/docs/00_README.md +++ b/docs/00_README.md @@ -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 не уничтожает данные.** ## Короткая формула -> Верстак — это локальный рабочий кабинет для людей, у которых жизнь состоит из проектов, клиентов, документов, заметок, скриптов, файлов, репозиториев и вечного “где я это сохранил?”. +> Верстак — локальная программа, где по каждому клиенту или проекту +> лежат все его файлы, заметки, документы, ссылки, действия и +> история работ. diff --git a/docs/01_Product_Spec.md b/docs/01_Product_Spec.md index 341dedc..7853c92 100644 --- a/docs/01_Product_Spec.md +++ b/docs/01_Product_Spec.md @@ -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. - -Но всё это не как отдельные модули, а вокруг одного понятия: **дело**. +Верстак объединяет заметочник, файловый кабинет, лаунчер, +журнал работ и контекст вокруг одного понятия: **дело**. +Плагины делают его адаптируемым под любой сегмент. diff --git a/docs/02_Architecture.md b/docs/02_Architecture.md index ff3e261..1076888 100644 --- a/docs/02_Architecture.md +++ b/docs/02_Architecture.md @@ -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) + +### Внешние приложения Верстак не пишет свой офисный пакет. diff --git a/docs/03_Data_Model_Storage.md b/docs/03_Data_Model_Storage.md index 6194aa1..42b88f2 100644 --- a/docs/03_Data_Model_Storage.md +++ b/docs/03_Data_Model_Storage.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//migrations/`. +- `device_id` на уровне nodes позволяет плагинам синхронизировать + свои данные через sync_ops. diff --git a/docs/09_Extensibility.md b/docs/09_Extensibility.md new file mode 100644 index 0000000..cfee6ec --- /dev/null +++ b/docs/09_Extensibility.md @@ -0,0 +1,159 @@ +# Верстак — архитектура плагинов + +## Принцип + +Верстак — это минималистичный движок с деревом дел. +Всё, что не входит в минимальную модель, — плагин. + +Плагин — это директория в `.verstak/plugins//`, которую +программа подхватывает без перекомпиляции. + +## Структура плагина + +``` +.verstak/plugins// + 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 +``` diff --git a/docs/PLAN.md b/docs/PLAN.md index afe59bd..92a1bb1 100644 --- a/docs/PLAN.md +++ b/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//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) diff --git a/internal/core/files/file.go b/internal/core/files/file.go new file mode 100644 index 0000000..01bc1b5 --- /dev/null +++ b/internal/core/files/file.go @@ -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 /spaces//. +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() +} diff --git a/internal/core/files/file_test.go b/internal/core/files/file_test.go new file mode 100644 index 0000000..de3ea13 --- /dev/null +++ b/internal/core/files/file_test.go @@ -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) + } + } +} diff --git a/internal/core/notes/note.go b/internal/core/notes/note.go new file mode 100644 index 0000000..d9efcf4 --- /dev/null +++ b/internal/core/notes/note.go @@ -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) +} diff --git a/internal/core/notes/note_test.go b/internal/core/notes/note_test.go new file mode 100644 index 0000000..a8246da --- /dev/null +++ b/internal/core/notes/note_test.go @@ -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") + } +} diff --git a/internal/core/storage/migrations_002.sql.go b/internal/core/storage/migrations_002.sql.go new file mode 100644 index 0000000..399d982 --- /dev/null +++ b/internal/core/storage/migrations_002.sql.go @@ -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); +` diff --git a/internal/core/storage/migrations_003.sql.go b/internal/core/storage/migrations_003.sql.go new file mode 100644 index 0000000..63b2ea4 --- /dev/null +++ b/internal/core/storage/migrations_003.sql.go @@ -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 +); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index 8620d80..46b9610 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -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 { diff --git a/internal/gui/index.html.go b/internal/gui/index.html.go new file mode 100644 index 0000000..9ddb769 --- /dev/null +++ b/internal/gui/index.html.go @@ -0,0 +1,179 @@ +package gui + +// indexHTML is the GUI frontend served inline. +const indexHTML = ` + + + + +Верстак + + + +
+
⚒ ВЕРСТАК
+
Загрузка...
+
+
+
+

Верстак

+
+ + +
+
+
+ +
+
+
+
Обзор
+
Заметки
+
Файлы
+
+
Выберите дело или создайте новое
+
+
+
Редактор
+ +
+
+

Новое дело

+ + +
+
+
+

Новая заметка

+ +
+
+ + +` diff --git a/internal/gui/server.go b/internal/gui/server.go new file mode 100644 index 0000000..a0acbdc --- /dev/null +++ b/internal/gui/server.go @@ -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) +} diff --git a/internal/gui/static/index.html b/internal/gui/static/index.html new file mode 100644 index 0000000..961f873 --- /dev/null +++ b/internal/gui/static/index.html @@ -0,0 +1,259 @@ + + + + + +Верстак + + + + +
+
⚒ ВЕРСТАК
+
Пусто. Создайте первое дело.
+
+ +
+

Верстак

+ + + + +
+
Обзор
Заметки
Файлы
+
+
+
Выберите дело или создайте новое
+
+ +
+
Редактор
+ +
+
+ +
+

Новое дело

+ + +
+
+ +
+

Новая заметка

+ +
+
+ + + + diff --git a/migrations/.gitkeep b/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000