Compare commits

...

7 Commits

Author SHA1 Message Date
mirivlad 03f5299209 Плагин verstak.workspace-folders: иконки, цвета, drag-and-drop
- Заменяет дерево Дел через workspaceTree contribution
- IconPicker: 1703 иконки Lucide с typeahead-поиском
- ColorPicker: 12 предустановленных цветов
- Drag-and-drop перемещение Дел между папками
- Создание и редактирование папок с иконкой/цветом
- Создание Дела с выбором родительской папки
- Локализация: EN + RU, release notes v0.1.0-alpha.5
2026-07-18 18:54:42 +08:00
mirivlad 1a201bed6d Документация: удалён черновик PLAN_FILES_IMPROVEMENTS, добавлен README.ru.md с шапкой 2026-07-18 18:06:44 +08:00
mirivlad 1bcad23b58 Localize sync server error codes 2026-07-17 05:09:59 +08:00
mirivlad 126d90cd68 Expose remote vault pairing in sync plugin 2026-07-17 04:11:12 +08:00
mirivlad 9904d4a962 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad 515dec6a7b docs: require human release notes 2026-07-15 07:42:02 +08:00
mirivlad c9c26faf56 fix: localize file type labels 2026-07-15 03:13:48 +08:00
22 changed files with 1095 additions and 177 deletions

View File

@ -1,13 +1,26 @@
<div align="center">
# Verstak Official Plugins # Verstak Official Plugins
The first-party plugin set for Verstak Desktop: Files, Trash, Notes, editor and ### The first-party plugin set — Files, Notes, Editor, Activity, Journal and more.
preview providers, Browser Inbox, Activity, Journal, Todo, Search, Secrets and
Templates. Plugin bundles are built into independent directories that the **English** · [Русский](README.ru.md)
desktop host loads from its `plugins/` folder.
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak-official-plugins?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-official-plugins/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
[![License](https://img.shields.io/github/license/mirivlad/verstak-official-plugins)](LICENSE)
</div>
> **Alpha software.** Build this repository together with compatible > **Alpha software.** Build this repository together with compatible
> `verstak-desktop` and `verstak-sdk` checkouts. > `verstak-desktop` and `verstak-sdk` checkouts.
Verstak Official Plugins is the first-party plugin set for Verstak Desktop:
Files, Trash, Notes, Default Editor, File Preview, Browser Inbox, Activity,
Journal, Todo, Search, Secrets, Sync, and Templates. Plugin bundles are built
into independent directories that the desktop host loads from its `plugins/`
folder.
## Build ## Build
Requirements: Node.js with npm, Go and Python 3. Keep this repository next to Requirements: Node.js with npm, Go and Python 3. Keep this repository next to

91
README.ru.md Normal file
View File

@ -0,0 +1,91 @@
<div align="center">
# Официальные плагины Верстака
### Базовый набор плагинов — Файлы, Заметки, Редактор, Активность, Журнал и другие.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak-official-plugins?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-official-plugins/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak-official-plugins)](LICENSE)
</div>
> **Alpha-версия.** Собирайте этот репозиторий вместе с совместимыми
> версиями `verstak-desktop` и `verstak-sdk`.
Официальные плагины — базовый набор инструментов Верстака: Файлы, Корзина,
Заметки, Редактор, Предпросмотр, Входящие из браузера, Активность, Журнал,
Задачи, Поиск, Секреты, Синхронизация и Шаблоны. Плагины собираются в
независимые пакеты, которые desktop-приложение загружает из папки `plugins/`.
## Состав
| Плагин | Назначение |
|--------|------------|
| `verstak.files` | Файловый менеджер внутри дела |
| `verstak.trash` | Корзина: восстановление и удаление |
| `verstak.notes` | Заметки в формате Markdown |
| `verstak.default-editor` | Редактор текста и Markdown |
| `verstak.file-preview` | Предпросмотр изображений |
| `verstak.activity` | История активности и реконструкция работы |
| `verstak.journal` | Журнал работ |
| `verstak.browser-inbox` | Приём материалов из браузера |
| `verstak.search` | Поиск по файлам и заметкам |
| `verstak.secrets` | Защищённое хранилище доступов |
| `verstak.todo` | Списки задач |
| `verstak.sync` | Синхронизация между устройствами |
| `verstak.templates` | Шаблоны дел |
Все плагины используют один и тот же runtime, что и сторонние.
## Сборка
Требования: Node.js с npm, Go и Python 3. Репозиторий должен находиться рядом
с `verstak-sdk`, чтобы проверка manifest использовала SDK-схему.
```bash
npm --version
go version
./scripts/check.sh
./scripts/build.sh
```
Сборка создаёт пакеты в `dist/<plugin-id>/`. Чтобы установить их в соседний
desktop-репозиторий:
```bash
cd ../verstak-desktop
./scripts/install-dev-plugins.sh
```
## Портативные архивы плагинов
Сборка архивов под конкретную платформу (Linux):
```bash
sudo apt install gcc-mingw-w64-x86-64
./scripts/package-portable.sh v0.1.0-alpha.1
```
Команда создаёт `release/verstak-official-plugins-linux-amd64-<version>.tar.gz`
и `release/verstak-official-plugins-windows-amd64-<version>.zip`, а также
`SHA256SUMS`. Каждый архив распаковывается прямо в `plugins/` desktop-приложения.
Frontend и manifest общие; нативные Go sidecar собираются под целевую ОС.
Это локальная операция упаковки, она не создаёт GitHub Release.
## Публикация GitHub Release
```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1
```
Требуется авторизованный [`gh`](https://cli.github.com/) CLI и чистый
актуальный `main`. Создаёт и отправляет аннотированный тег, затем создаёт
или обновляет GitHub Release с Linux-архивом, Windows-архивом и `SHA256SUMS`.
## Лицензия
Copyright © 2026 Verstak contributors. Распространяется на условиях
[GNU AGPLv3 или новее](LICENSE).

View File

@ -1,162 +0,0 @@
# План улучшений файлового менеджера Verstak2
> **Архитектура:** Плагин `verstak.files` в монорепозитории `verstak-official-plugins`.
> Код: `plugins/files/frontend/src/index.js` (vanilla JS, VerstakPluginRegister).
> API: `api.files.*`, `api.workbench.openResource()`, `api.settings`, `api.events`, `api.commands`.
## Порядок реализации
| # | Feature | Приоритет | Зависит от |
|---|---------|-----------|------------|
| 1 | Контекстное меню (правый клик) | 🔥 High | — |
| 2 | Open External / Show in Explorer | ✅ Done | `files.openExternal` |
| 3 | Кастомный ConfirmModal | 🔥 High | — |
| 4 | Duplicate | 🔥 High | — |
| 5 | Cut/Copy/Paste | 🔥 High | — |
| 6 | Множественное выделение | 🔥 High | — |
| 7 | Drag-and-Drop | 🔥 High | — |
| 8 | Клавиатурная навигация (Ctrl+A, Escape, стрелки) | Medium | #6 |
| 9 | Богатые иконки (как в v1) | Medium | — |
| 10 | История навигации | Medium | — |
| 11 | Rename с валидацией | Medium | — |
---
### Feature 1: Контекстное меню (правый клик)
**Описание:** При правом клике на файле/папке показывать меню с действиями.
**Пункты меню:** Open, Open External, Show in Explorer, Rename, Duplicate, Cut, Copy, Delete — с разделителями.
**Архитектура:**
- Событие `contextmenu` на `.files-item`
- Позиционирование у курсора (event.clientX/Y)
- Кастомный `<div>` с пунктами, закрывается по клику вне или Escape
- Класс `.files-context-menu` для стилей
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 2: Open External / Show in Explorer
**Описание:** Пункты меню для открытия файла во внешнем приложении и показа
файла/папки в системном файловом менеджере.
**Текущий v2 статус:** плагин использует публичные методы
`api.files.openExternal(relativePath)` и `api.files.showInFolder(relativePath)`,
guarded by `files.openExternal`. Fallback modal с vault-relative path и кнопкой
`Copy Path` показывается только если API недоступен или вернул ошибку.
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 3: Кастомный ConfirmModal
**Описание:** Вместо `window.confirm()` — стилизованный modal с кнопками Отмена/Удалить, опасным стилем (красная кнопка).
**Архитектура:**
- Функция `confirmModal(message, options)` возвращает Promise
- Создаёт overlay + modal в DOM
- Закрывается по Escape или кнопке Отмена
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 4: Duplicate
**Описание:** Копирование файла/папки в той же папке с добавлением суффикса (напр. "filename_copy.ext", "filename_2.ext").
**Архитектура:**
- Для файла: `api.files.readText()` + `api.files.writeText()` с новым именем
- Для папки: рекурсивное копирование (сложнее, можно defer)
- Пункт в контекстном меню: "Duplicate"
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 5: Cut/Copy/Paste
**Описание:** Буфер обмена внутри файлового менеджера. Cut → перенос. Copy → дублирование. Paste в текущую папку.
**Архитектура:**
- `clipboard: { items: [...], mode: 'cut'|'copy' }`
- Клавиши Ctrl+X/C/V
- Пункты в контекстном меню
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 6: Множественное выделение
**Описание:** Ctrl+click (toggle), Shift+click (range), Ctrl+A (select all). Массовые операции (Trash, Copy, Paste).
**Архитектура:**
- `selectedPaths: Set<string>` вместо `selectedPath: string`
- Ctrl+click → toggle entry в Set
- Shift+click → range select от последнего клика
- Действия применяются ко всем выделенным
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 7: Drag-and-Drop
**Описание:** Перемещение файлов/папок между папками через drag-and-drop.
**Архитектура:**
- HTML5 DnD API: `dragstart`, `dragover`, `drop` на `.files-item`
- При drop на папку → `api.files.move()`
- Визуальная индикация (подсветка папки при наведении)
- Отмена по Escape
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 8: Клавиатурная навигация
**Описание:** Escape (снять выделение), Ctrl+A (select all), стрелки (навигация по строкам).
**Зависит от:** Feature 6 (множественное выделение)
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 9: Богатые иконки
**Описание:** Как в v1 — отдельные SVG иконки для image, video, audio, pdf, document, spreadsheet, presentation, archive, code, text.
**Архитектура:** Расширить функцию `fileIcon()`.
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 10: История навигации
**Описание:** Кнопки Back/Forward в тулбаре, как в браузере. Хранение стека посещённых папок.
**Архитектура:**
- `historyStack: string[]` с индексом
- back() / forward() / push(path)
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 11: Rename с валидацией
**Описание:** Проверять имя через API до применения. Показывать ошибку под полем, а не в placeholder.
**Архитектура:**
- Проверка: не пустое, нет `/`, нет null byte, не `.verstak`
- API коллизии: `api.files.metadata(newPath)` — если существует, ошибка
**Файлы:** `plugins/files/frontend/src/index.js`

View File

@ -224,7 +224,7 @@
return EXT_MAP[ext] || 'generic'; return EXT_MAP[ext] || 'generic';
} }
function fileIconLabel(category) { function fileIconLabel(category, translate) {
var labels = { var labels = {
folder: 'Folder', folder: 'Folder',
markdown: 'Markdown file', markdown: 'Markdown file',
@ -244,7 +244,8 @@
json: 'JSON file', json: 'JSON file',
generic: 'File' generic: 'File'
}; };
return labels[category] || labels.generic; var label = labels[category] || labels.generic;
return typeof translate === 'function' ? translate('ui.icon.' + (labels[category] ? category : 'generic'), null, label) : label;
} }
function fileIcon(entry) { function fileIcon(entry) {
@ -267,8 +268,8 @@
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
function typeLabel(entry) { function typeLabel(entry, folderLabel) {
if (entry.type === 'folder') return 'folder'; if (entry.type === 'folder') return folderLabel || 'Folder';
return (entry.extension || extension(entry.name) || 'file').toLowerCase(); return (entry.extension || extension(entry.name) || 'file').toLowerCase();
} }
@ -383,6 +384,10 @@
return fallback || key; return fallback || key;
} }
function displayType(entry) {
return typeLabel(entry, tr('ui.folderType', null, 'Folder'));
}
function reportError(key, fallback, err) { function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') { if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.files] ' + key, err); console.warn('[verstak.files] ' + key, err);
@ -523,7 +528,7 @@
if (a.type !== 'folder' && b.type === 'folder') return 1; if (a.type !== 'folder' && b.type === 'folder') return 1;
} }
if (sortMode === 'type') { if (sortMode === 'type') {
var typeCmp = typeLabel(a).localeCompare(typeLabel(b)); var typeCmp = displayType(a).localeCompare(displayType(b));
if (typeCmp) return typeCmp; if (typeCmp) return typeCmp;
} }
if (sortMode === 'modified-desc') { if (sortMode === 'modified-desc') {
@ -645,7 +650,7 @@
shown.forEach(function (entry) { shown.forEach(function (entry) {
var iconCategory = fileIconCategory(entry); var iconCategory = fileIconCategory(entry);
var iconLabel = fileIconLabel(iconCategory); var iconLabel = fileIconLabel(iconCategory, tr);
var row = el('div', { var row = el('div', {
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''), className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
'data-file-name': entry.name, 'data-file-name': entry.name,
@ -678,7 +683,7 @@
el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }), el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }),
el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name }) el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name })
]), ]),
el('span', { className: 'files-item-meta' }, [typeLabel(entry)]), el('span', { className: 'files-item-meta' }, [displayType(entry)]),
el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]), el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]),
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]), el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
el('div', { className: 'files-row-actions' }, [ el('div', { className: 'files-row-actions' }, [

View File

@ -26,6 +26,24 @@
"ui.column.modified": "Modified", "ui.column.modified": "Modified",
"ui.column.size": "Size", "ui.column.size": "Size",
"ui.column.actions": "Actions", "ui.column.actions": "Actions",
"ui.folderType": "Folder",
"ui.icon.folder": "Folder",
"ui.icon.markdown": "Markdown file",
"ui.icon.image": "Image file",
"ui.icon.video": "Video file",
"ui.icon.audio": "Audio file",
"ui.icon.archive": "Archive file",
"ui.icon.pdf": "PDF file",
"ui.icon.text": "Text file",
"ui.icon.document": "Document file",
"ui.icon.spreadsheet": "Spreadsheet file",
"ui.icon.presentation": "Presentation file",
"ui.icon.code": "Code file",
"ui.icon.database": "Database file",
"ui.icon.font": "Font file",
"ui.icon.config": "Config file",
"ui.icon.json": "JSON file",
"ui.icon.generic": "File",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.createFolderTitle": "Create folder", "ui.createFolderTitle": "Create folder",

View File

@ -26,6 +26,24 @@
"ui.column.modified": "Изменён", "ui.column.modified": "Изменён",
"ui.column.size": "Размер", "ui.column.size": "Размер",
"ui.column.actions": "Действия", "ui.column.actions": "Действия",
"ui.folderType": "Папка",
"ui.icon.folder": "Папка",
"ui.icon.markdown": "Файл Markdown",
"ui.icon.image": "Изображение",
"ui.icon.video": "Видео",
"ui.icon.audio": "Аудиофайл",
"ui.icon.archive": "Архив",
"ui.icon.pdf": "PDF-файл",
"ui.icon.text": "Текстовый файл",
"ui.icon.document": "Документ",
"ui.icon.spreadsheet": "Таблица",
"ui.icon.presentation": "Презентация",
"ui.icon.code": "Исходный код",
"ui.icon.database": "База данных",
"ui.icon.font": "Шрифт",
"ui.icon.config": "Файл конфигурации",
"ui.icon.json": "JSON-файл",
"ui.icon.generic": "Файл",
"ui.create": "Создать", "ui.create": "Создать",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.createFolderTitle": "Создать папку", "ui.createFolderTitle": "Создать папку",

View File

@ -13,6 +13,7 @@
let connectionOk = null let connectionOk = null
let serverUrl = '' let serverUrl = ''
let vaultId = ''
let username = '' let username = ''
let password = '' let password = ''
let syncInterval = 5 let syncInterval = 5
@ -30,6 +31,9 @@
function reportError(key, fallback, error) { function reportError(key, fallback, error) {
console.warn('[verstak.sync] operation failed:', error) console.warn('[verstak.sync] operation failed:', error)
const code = String(error?.message || error || '').match(/sync-server:([a-z_]+)/)?.[1]
const localized = code ? tr(`ui.serverError.${code}`, null, '') : ''
if (localized && localized !== `ui.serverError.${code}`) return localized
return tr(key, null, fallback) return tr(key, null, fallback)
} }
@ -44,6 +48,7 @@
const saved = await api.settings.read() const saved = await api.settings.read()
if (saved) { if (saved) {
serverUrl = saved.serverUrl || '' serverUrl = saved.serverUrl || ''
vaultId = saved.vaultId || ''
username = saved.username || '' username = saved.username || ''
autoSync = !!saved.autoSync autoSync = !!saved.autoSync
syncInterval = saved.syncInterval || 5 syncInterval = saved.syncInterval || 5
@ -56,6 +61,7 @@
settings = await syncAPI().status() settings = await syncAPI().status()
if (settings) { if (settings) {
if (settings.serverUrl) serverUrl = settings.serverUrl if (settings.serverUrl) serverUrl = settings.serverUrl
if (settings.vaultId) vaultId = settings.vaultId
if (settings.syncInterval != null) syncInterval = settings.syncInterval if (settings.syncInterval != null) syncInterval = settings.syncInterval
if (settings.syncInterval > 0) autoSync = true if (settings.syncInterval > 0) autoSync = true
if (settings.lastError) console.warn('[verstak.sync] last sync failed:', settings.lastError) if (settings.lastError) console.warn('[verstak.sync] last sync failed:', settings.lastError)
@ -78,7 +84,7 @@
resultMsg = '' resultMsg = ''
try { try {
if (api?.settings?.writeAll) { if (api?.settings?.writeAll) {
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval }) await api.settings.writeAll({ serverUrl, vaultId, username, autoSync, syncInterval })
} }
await syncAPI().setInterval(autoSync ? syncInterval : 0) await syncAPI().setInterval(autoSync ? syncInterval : 0)
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.') resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
@ -112,7 +118,7 @@
errorMsg = '' errorMsg = ''
connectionResult = '' connectionResult = ''
try { try {
await syncAPI().configure(serverUrl, username, password) await syncAPI().configure(serverUrl, username, password, vaultId)
connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.') connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.')
connectionOk = true connectionOk = true
username = '' username = ''
@ -235,6 +241,11 @@
{tr('ui.lastSyncError', null, 'The last synchronization did not finish. Try again.')} {tr('ui.lastSyncError', null, 'The last synchronization did not finish. Try again.')}
</div> </div>
{/if} {/if}
{#if settings && settings.lastWarning && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:6px;color:#f59e0b;font-size:0.85rem;">
{tr('ui.unresolvedFilesWarning', null, 'Some files are not synchronized yet. Resolve the file warning and run sync again.')}
</div>
{/if}
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;"> <div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.server', null, 'Server')}</h3> <h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.server', null, 'Server')}</h3>
@ -244,6 +255,12 @@
<input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" /> <input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
</div> </div>
<div style="margin-bottom:0.75rem;">
<label for="sync-vault-id" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.remoteVaultId', null, 'Remote vault ID (optional)')}</label>
<input id="sync-vault-id" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={vaultId} />
<div style="color:#a0a0b8;font-size:0.78rem;margin-top:0.3rem;">{tr('ui.remoteVaultHint', null, 'Leave empty for this vault. Enter an existing remote vault ID to restore it on this device.')}</div>
</div>
<div style="margin-bottom:0.75rem;"> <div style="margin-bottom:0.75rem;">
<label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.username', null, 'Username')}</label> <label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.username', null, 'Username')}</label>
<input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} /> <input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />

View File

@ -24,9 +24,12 @@
"ui.keyReset": "Sync key reset. Connect again to pair this device.", "ui.keyReset": "Sync key reset. Connect again to pair this device.",
"ui.syncConflicts": "Sync conflicts", "ui.syncConflicts": "Sync conflicts",
"ui.lastSyncError": "The last synchronization did not finish. Try again.", "ui.lastSyncError": "The last synchronization did not finish. Try again.",
"ui.unresolvedFilesWarning": "Some files are not synchronized yet. Resolve the file warning and run sync again.",
"ui.syncConflictItem": "A synchronization conflict needs attention.", "ui.syncConflictItem": "A synchronization conflict needs attention.",
"ui.server": "Server", "ui.server": "Server",
"ui.serverUrl": "Server URL", "ui.serverUrl": "Server URL",
"ui.remoteVaultId": "Remote vault ID (optional)",
"ui.remoteVaultHint": "Leave empty for this vault. Enter an existing remote vault ID to restore it on this device.",
"ui.username": "Username", "ui.username": "Username",
"ui.password": "Password", "ui.password": "Password",
"ui.testConnection": "Test Connection", "ui.testConnection": "Test Connection",
@ -45,4 +48,14 @@
"ui.status.disabled": "Not configured", "ui.status.disabled": "Not configured",
"ui.status.error": "Error", "ui.status.error": "Error",
"ui.status.revoked": "Revoked" "ui.status.revoked": "Revoked"
,"ui.serverError.rate_limited": "Too many attempts. Wait a little and try again."
,"ui.serverError.request_too_large": "The synchronization request is too large."
,"ui.serverError.payload_too_large": "A file operation is too large to send inline."
,"ui.serverError.too_many_operations": "Too many pending changes were sent at once. Try synchronization again."
,"ui.serverError.blob_too_large": "This file exceeds the server blob size limit."
,"ui.serverError.quota_exceeded": "The server storage quota for this vault has been reached."
,"ui.serverError.blob_not_owned": "The file data must be uploaded before it can be synchronized."
,"ui.serverError.blob_not_found": "The required file data is unavailable on the server."
,"ui.serverError.unauthorized": "This device is no longer authorized. Connect it again."
,"ui.serverError.forbidden": "The server denied this synchronization request."
} }

View File

@ -24,9 +24,12 @@
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.", "ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации", "ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.", "ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.",
"ui.unresolvedFilesWarning": "Некоторые файлы ещё не синхронизированы. Устраните предупреждение по файлу и повторите синхронизацию.",
"ui.syncConflictItem": "Конфликт синхронизации требует внимания.", "ui.syncConflictItem": "Конфликт синхронизации требует внимания.",
"ui.server": "Сервер", "ui.server": "Сервер",
"ui.serverUrl": "URL сервера", "ui.serverUrl": "URL сервера",
"ui.remoteVaultId": "ID удалённого хранилища (необязательно)",
"ui.remoteVaultHint": "Оставьте пустым для этого хранилища. Укажите ID существующего удалённого хранилища, чтобы восстановить его на этом устройстве.",
"ui.username": "Имя пользователя", "ui.username": "Имя пользователя",
"ui.password": "Пароль", "ui.password": "Пароль",
"ui.testConnection": "Проверить подключение", "ui.testConnection": "Проверить подключение",
@ -45,4 +48,14 @@
"ui.status.disabled": "Не настроено", "ui.status.disabled": "Не настроено",
"ui.status.error": "Ошибка", "ui.status.error": "Ошибка",
"ui.status.revoked": "Доступ отозван" "ui.status.revoked": "Доступ отозван"
,"ui.serverError.rate_limited": "Слишком много попыток. Подождите и повторите попытку."
,"ui.serverError.request_too_large": "Запрос синхронизации слишком велик."
,"ui.serverError.payload_too_large": "Операция с файлом слишком велика для отправки в этом виде."
,"ui.serverError.too_many_operations": "Одновременно отправлено слишком много изменений. Повторите синхронизацию."
,"ui.serverError.blob_too_large": "Размер файла превышает разрешённый сервером лимит."
,"ui.serverError.quota_exceeded": "Достигнута квота серверного хранилища для этого vault."
,"ui.serverError.blob_not_owned": "Данные файла должны быть загружены до синхронизации операции."
,"ui.serverError.blob_not_found": "Необходимые данные файла недоступны на сервере."
,"ui.serverError.unauthorized": "Устройство больше не авторизовано. Подключите его заново."
,"ui.serverError.forbidden": "Сервер отклонил этот запрос синхронизации."
} }

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,30 @@
{
"manifest.name": "Workspace Folders",
"manifest.description": "Nested folders with icons, colors and drag-and-drop for the Deal tree.",
"common.close": "Close",
"common.cancel": "Cancel",
"common.save": "Save",
"common.create": "Create",
"common.select": "Select",
"workspaceTree.title": "Deals",
"workspaceTree.new": "New Deal",
"workspaceTree.nameRequired": "Name is required",
"workspaceTree.namePlaceholder": "Deal name",
"workspaceTree.template": "Template",
"workspaceTree.create": "Create Deal",
"workspaceTree.trash": "Move Deal to trash",
"pluginCard.name": "Name",
"folder.create": "Create Folder",
"folder.edit": "Edit Folder",
"folder.delete": "Delete Folder",
"folder.deleteConfirm": "Delete folder? Deals inside will move to the parent folder.",
"folder.namePlaceholder": "Folder name",
"folder.nameRequired": "Name is required",
"folder.icon": "Icon",
"folder.chooseIcon": "Choose...",
"folder.color": "Color",
"folder.chooseColor": "Choose...",
"folder.iconPickerTitle": "Choose Icon",
"folder.iconSearch": "Search icons...",
"folder.colorPickerTitle": "Choose Color"
}

View File

@ -0,0 +1,30 @@
{
"manifest.name": "Папки дел",
"manifest.description": "Вложенные папки с иконками, цветами и drag-and-drop для дерева Дел.",
"common.close": "Закрыть",
"common.cancel": "Отмена",
"common.save": "Сохранить",
"common.create": "Создать",
"common.select": "Выбрать",
"workspaceTree.title": "Дела",
"workspaceTree.new": "Новое дело",
"workspaceTree.nameRequired": "Имя обязательно",
"workspaceTree.namePlaceholder": "Название дела",
"workspaceTree.template": "Шаблон",
"workspaceTree.create": "Создать дело",
"workspaceTree.trash": "Переместить дело в корзину",
"pluginCard.name": "Имя",
"folder.create": "Создать папку",
"folder.edit": "Редактировать папку",
"folder.delete": "Удалить папку",
"folder.deleteConfirm": "Удалить папку? Дела внутри переместятся в родительскую папку.",
"folder.namePlaceholder": "Название папки",
"folder.nameRequired": "Имя обязательно",
"folder.icon": "Иконка",
"folder.chooseIcon": "Выбрать...",
"folder.color": "Цвет",
"folder.chooseColor": "Выбрать...",
"folder.iconPickerTitle": "Выбрать иконку",
"folder.iconSearch": "Поиск иконок...",
"folder.colorPickerTitle": "Выбрать цвет"
}

View File

@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"id": "verstak.workspace-folders",
"name": "Workspace Folders",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Nested folders with icons, colors and drag-and-drop for the Deal tree.",
"source": "official",
"icon": "folder",
"localization": {
"defaultLocale": "en",
"locales": {
"en": "locales/en.json",
"ru": "locales/ru.json"
}
},
"provides": ["workspace.folders"],
"requires": [
"verstak/core/files/v1",
"verstak/core/workspace/v1"
],
"optionalRequires": [],
"permissions": [
"files.read",
"files.write",
"ui.register",
"events.publish",
"events.subscribe",
"storage.namespace"
],
"frontend": {
"entry": "frontend/src/index.js",
"style": "frontend/src/style.css"
},
"contributes": {
"workspaceTree": {
"component": "FolderTreeView"
}
}
}

View File

@ -0,0 +1,7 @@
## Highlights
- First public bundle of Verstak's official plugins.
- Includes Files, Notes, Journal, Todo, Secrets, Browser Inbox, Activity, Search, Trash, Sync, and supporting editors and previews.
- Adds durable browser captures, archive handling, activity sessions, and localized plugin interfaces.
**Commit history**: https://github.com/mirivlad/verstak-official-plugins/commits/v0.1.0-alpha.1

View File

@ -0,0 +1,5 @@
## Highlights
- Todo tasks can schedule native reminders through the Desktop notification capability.
**Full Changelog**: https://github.com/mirivlad/verstak-official-plugins/compare/v0.1.0-alpha.1...v0.1.0-alpha.2

View File

@ -0,0 +1,7 @@
## Highlights
- Makes Todo reminder time selection, global Secrets, and global Journal entry creation usable.
- Localizes plugin errors, browser materials, activity review, files menus, and other key workflows.
- Replaces technical terminology with “Deal” and improves modal forms and secret safety.
**Full Changelog**: https://github.com/mirivlad/verstak-official-plugins/compare/v0.1.0-alpha.2...v0.1.0-alpha.3

View File

@ -0,0 +1,6 @@
## Highlights
- Folder rows in the Files plugin now use localized labels instead of the internal `folder` type.
- File icons have localized, accessible names in both supported languages.
Install the archive matching your platform into Verstak's plugins directory, replacing the previous official plugins bundle.

View File

@ -0,0 +1,11 @@
## Highlights
- **Workspace Folders plugin.** A new official plugin `verstak.workspace-folders`
replaces the sidebar Deal tree with a rich folder tree: icons (1703 Lucide
icons), 12 preset colors, draganddrop between folders, inline folder
creation and editing, and a Deal creation modal with parent folder selector.
## Install
Unpack the archive for your platform into Verstak's `plugins/` directory,
replacing the previous official plugins bundle.

View File

@ -6,6 +6,7 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak-official-plugins" REPOSITORY="mirivlad/verstak-official-plugins"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}" RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}" RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}" GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}" GH_BIN="${GH_BIN:-gh}"
@ -64,7 +65,17 @@ fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber "$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else else
RELEASE_OPTIONS=(--generate-notes --verify-tag) NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease) RELEASE_OPTIONS+=(--prerelease)
else else

View File

@ -233,6 +233,15 @@ function makeApi(options = {}) {
size: 12, size: 12,
modifiedAt: '2026-06-27T00:00:00Z', modifiedAt: '2026-06-27T00:00:00Z',
}]; }];
if (options.includeFolder) {
entries.push({
name: 'Archive',
relativePath: 'Docs/Archive',
type: 'folder',
size: 0,
modifiedAt: '2026-06-27T00:00:00Z',
});
}
if (restored) { if (restored) {
entries.push({ entries.push({
name: 'deleted.md', name: 'deleted.md',
@ -456,6 +465,7 @@ async function flush() {
const russianDocument = makeDocument(); const russianDocument = makeDocument();
const { component: russianComponent } = loadFilesComponent(russianDocument); const { component: russianComponent } = loadFilesComponent(russianDocument);
const russianApi = makeApi({ const russianApi = makeApi({
includeFolder: true,
translations: { translations: {
'ui.open': 'Открыть', 'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении', 'ui.openExternal': 'Открыть во внешнем приложении',
@ -465,13 +475,22 @@ async function flush() {
'ui.cut': 'Вырезать', 'ui.cut': 'Вырезать',
'ui.copy': 'Копировать', 'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину', 'ui.trash': 'Переместить в корзину',
'ui.folderType': 'Папка',
'ui.icon.markdown': 'Файл Markdown',
}, },
}); });
const russianContainer = new FakeNode('div'); const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi); russianComponent.mount(russianContainer, {}, russianApi);
await flush(); await flush();
if (!russianContainer.textContent.includes('Папка') || russianContainer.textContent.includes('folder')) {
throw new Error(`folder type was not localized: ${russianContainer.textContent}`);
}
const russianList = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined); const russianList = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
const russianRow = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md'); const russianRow = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
const russianIcon = walk(russianRow, (node) => node.getAttribute && node.getAttribute('data-file-icon') === 'markdown');
if (!russianIcon || russianIcon.getAttribute('title') !== 'Файл Markdown' || russianIcon.getAttribute('aria-label') !== 'Файл Markdown') {
throw new Error(`file icon label was not localized: ${russianIcon && russianIcon.getAttribute('title')}`);
}
russianList.dispatchEvent('contextmenu', { target: russianRow, clientX: 20, clientY: 20 }); russianList.dispatchEvent('contextmenu', { target: russianRow, clientX: 20, clientY: 20 });
const russianMenuText = russianDocument.body.textContent; const russianMenuText = russianDocument.body.textContent;
['Открыть во внешнем приложении', 'Показать в папке', 'Переименовать', 'Создать копию', 'Вырезать', 'Копировать', 'Переместить в корзину'].forEach((label) => { ['Открыть во внешнем приложении', 'Показать в папке', 'Переименовать', 'Создать копию', 'Вырезать', 'Копировать', 'Переместить в корзину'].forEach((label) => {

View File

@ -27,5 +27,14 @@ if (!source.includes('conflictDetails')) {
if (!source.includes('Sync conflicts')) { if (!source.includes('Sync conflicts')) {
throw new Error('SyncSettings must label the conflict details section'); throw new Error('SyncSettings must label the conflict details section');
} }
if (!source.includes('syncAPI().configure(serverUrl, username, password, vaultId)')) {
throw new Error('SyncSettings must pass the optional remote vault ID during pairing');
}
if (!source.includes('settings.lastWarning')) {
throw new Error('SyncSettings must render unresolved scanner warnings');
}
if (!source.includes("tr('ui.remoteVaultHint'")) {
throw new Error('SyncSettings must explain remote vault restoration');
}
console.log('sync plugin smoke passed'); console.log('sync plugin smoke passed');

View File

@ -17,9 +17,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state" mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log" LOG="$WORK/log"
export LOG export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT' cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
@ -49,6 +50,7 @@ case "${1:-}" in
fi fi
echo test-commit echo test-commit
;; ;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;; tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;; push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;; *) echo "unexpected git invocation: $*" >&2; exit 1 ;;
@ -74,6 +76,7 @@ chmod +x "$WORK/bin/gh"
run_publisher() { run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \ VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \ VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \ GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \ GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \ EXPECTED_ROOT="$ROOT" \
@ -90,6 +93,9 @@ for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null grep -F "$asset" "$LOG" >/dev/null
done done
grep -F "SHA256SUMS" "$LOG" >/dev/null grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher run_publisher