init electron

This commit is contained in:
Alex Ant
2025-06-03 12:11:23 +05:00
parent 6f682eda75
commit 3df3c31067
25 changed files with 8887 additions and 323 deletions
-4
View File
@@ -1,4 +0,0 @@
# Путь к папке с шаблонами registry (относительно drafter)
TEMPLATES_REGISTRY_PATH=../registry
# Путь к папке cooptypes registry для синхронизации
COOPTYPES_REGISTRY_PATH=../cooptypes/src/cooperative/registry
-4
View File
@@ -1,4 +0,0 @@
# Путь к папке с шаблонами registry (относительно drafter)
TEMPLATES_REGISTRY_PATH=../registry
# Путь к папке cooptypes registry для синхронизации
COOPTYPES_REGISTRY_PATH=../cooptypes/src/cooperative/registry
+1 -1
View File
@@ -83,6 +83,6 @@ module.exports = {
// '@typescript-eslint/no-explicit-any': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};
+140
View File
@@ -0,0 +1,140 @@
name: Build and Release Electron App
on:
push:
tags:
- 'v*'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint code
run: pnpm lint
build:
needs: lint
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Get package info
id: package
run: |
echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
echo "NAME=$(node -p "require('./package.json').name")" >> $GITHUB_OUTPUT
shell: bash
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron App for Windows
if: matrix.os == 'windows-latest'
run: pnpm build:electron
env:
PLATFORM: win32
- name: Build Electron App for macOS
if: matrix.os == 'macos-latest'
run: pnpm build:electron
env:
PLATFORM: darwin
- name: Build Electron App for Linux
if: matrix.os == 'ubuntu-latest'
run: pnpm build:electron
env:
PLATFORM: linux
- name: Prepare for artifact upload
id: prepare_artifacts
run: |
APP_NAME=${{ steps.package.outputs.NAME }}
APP_VERSION=${{ steps.package.outputs.VERSION }}
if [ "${{ matrix.os }}" == "windows-latest" ]; then
echo "ARTIFACT_PATHS=dist/electron/*.exe" >> $GITHUB_OUTPUT
echo "ARTIFACT_NAME=$APP_NAME-windows-$APP_VERSION" >> $GITHUB_OUTPUT
elif [ "${{ matrix.os }}" == "macos-latest" ]; then
echo "ARTIFACT_PATHS=dist/electron/*.dmg" >> $GITHUB_OUTPUT
echo "ARTIFACT_NAME=$APP_NAME-macos-$APP_VERSION" >> $GITHUB_OUTPUT
else
echo "ARTIFACT_PATHS=dist/electron/*.AppImage" >> $GITHUB_OUTPUT
echo "ARTIFACT_NAME=$APP_NAME-linux-$APP_VERSION" >> $GITHUB_OUTPUT
fi
shell: bash
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ steps.prepare_artifacts.outputs.ARTIFACT_NAME }}
path: ${{ steps.prepare_artifacts.outputs.ARTIFACT_PATHS }}
if-no-files-found: warn
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get package info
id: package
run: |
echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
echo "NAME=$(node -p "require('./package.json').name")" >> $GITHUB_OUTPUT
shell: bash
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
with:
name: ${{ steps.package.outputs.NAME }} v${{ steps.package.outputs.VERSION }}
files: artifacts/**/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+14
View File
@@ -0,0 +1,14 @@
{
"types": [
{ "type": "feat", "section": "Новые функции" },
{ "type": "fix", "section": "Исправления ошибок" },
{ "type": "chore", "hidden": true },
{ "type": "docs", "section": "Документация" },
{ "type": "style", "section": "Стилевые изменения" },
{ "type": "refactor", "section": "Рефакторинг кода" },
{ "type": "perf", "section": "Оптимизация производительности" },
{ "type": "test", "section": "Тесты" }
],
"commitUrlFormat": "https://github.com/your-username/drafter/commit/{{hash}}",
"compareUrlFormat": "https://github.com/your-username/drafter/compare/{{previousTag}}...{{currentTag}}"
}
+95 -22
View File
@@ -1,41 +1,114 @@
# Drafter (drafter)
# Drafter - Редактор шаблонов документов
Drafter
Drafter - это Electron приложение для редактирования шаблонов документов кооперативов. Приложение работает напрямую с файлами cooptypes, позволяя редактировать context, translations и exampleData.
## Возможности
- 📝 Редактирование HTML шаблонов с поддержкой Nunjucks
- 🌐 Управление переводами (i18n)
- 📊 Редактирование примеров данных (object_model)
- 💾 Автоматическое сохранение в файлы cooptypes
- 🔍 Поиск по шаблонам
- 👀 Предварительный просмотр в реальном времени
## Установка и запуск
1. Установите зависимости:
## Install the dependencies
```bash
yarn
# or
npm install
pnpm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
2. Настройте путь в `.env` файле:
```bash
quasar dev
cp .env-example .env
```
Отредактируйте `.env`:
### Lint the files
```bash
yarn lint
# or
npm run lint
```
COOPTYPES_REGISTRY_PATH=../cooptypes/src/cooperative/registry
```
3. Запустите в режиме разработки:
### Format the files
```bash
yarn format
# or
npm run format
pnpm dev
```
4. Или соберите Electron приложение:
### Build the app for production
```bash
quasar build
pnpm build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
## Структура проекта
```
drafter/
├── src/
│ ├── components/
│ │ ├── TemplatesList.vue # Список шаблонов в левом drawer
│ │ ├── htmlEditor.vue # Редактор HTML
│ │ ├── modelViewer.vue # Редактор данных
│ │ └── htmlPreview.vue # Предварительный просмотр
│ ├── services/
│ │ └── TemplateService.ts # Сервис для работы с cooptypes файлами
│ ├── stores/
│ │ └── store.ts # Pinia store
│ └── layouts/
│ └── MainLayout.vue # Главный layout с drawer
├── src-electron/
│ ├── electron-main.ts # Главный процесс Electron
│ └── electron-preload.ts # Preload скрипт
└── .env # Конфигурация путей
```
## Как использовать
1. **Выбор шаблона**: Используйте левое меню для выбора шаблона из списка
2. **Редактирование**:
- HTML шаблон редактируется в левой колонке
- Данные и переводы - в центральной колонке
- Предварительный просмотр - в правой колонке
3. **Сохранение**: Нажмите кнопку "Сохранить" рядом с шаблоном в списке
4. **Автосохранение**: Изменения сохраняются в `cooptypes/src/cooperative/registry/[template]/index.ts`
## Формат шаблонов
Шаблоны используют Nunjucks синтаксис:
```html
<div class="digital-document">
<h3>{% trans 'TITLE' %}</h3>
<p>{{ coop.city }}, {{ meet.created_at_day }}</p>
{% for question in questions %}
<p>{{ question.number }}. {{ question.title }}</p>
{% endfor %}
</div>
```
## Структура cooptypes файлов
Каждый файл шаблона содержит:
- `context` - HTML шаблон с Nunjucks разметкой
- `translations` - Объект с переводами (экспорт `translations`)
- `exampleData` - Пример данных для рендеринга (экспорт `exampleData`)
## Разработка
Для добавления нового шаблона:
1. Создайте папку `[id].[Name]/` в `cooptypes/src/cooperative/registry/`
2. Добавьте `index.ts` файл с типами и экспортом `context`, `translations`, `exampleData`
## Технологии
- **Vue 3** + **TypeScript** - фронтенд
- **Quasar Framework** - UI компоненты
- **Electron** - десктопное приложение
- **Pinia** - управление состоянием
- **Nunjucks** - шаблонизатор
+75
View File
@@ -0,0 +1,75 @@
# Управление версиями в Drafter
В этом проекте используется [standard-version](https://github.com/conventional-changelog/standard-version) для автоматического управления версиями и создания тегов Git.
## Конвенция коммитов
Для правильной работы standard-version используйте следующий формат сообщений коммитов:
```
<тип>(<область>): <описание>
[опциональное тело]
[опциональный footer]
```
Допустимые типы коммитов:
- **feat**: новая функциональность
- **fix**: исправление ошибки
- **docs**: изменения в документации
- **style**: форматирование, отсутствие semi-colons и т.д.
- **refactor**: рефакторинг кода
- **perf**: улучшения производительности
- **test**: добавление тестов
- **chore**: изменения в процессе сборки или вспомогательных инструментах
## Команды для создания релизов
Проект имеет следующие скрипты для управления версиями:
```bash
# Стандартный релиз (увеличение патч-версии)
pnpm release
# Увеличение минорной версии (0.1.0 -> 0.2.0)
pnpm release:minor
# Увеличение мажорной версии (1.0.0 -> 2.0.0)
pnpm release:major
# Увеличение патч-версии (1.0.0 -> 1.0.1)
pnpm release:patch
# Создание альфа-версии (1.0.0 -> 1.0.1-alpha.0)
pnpm release:alpha
# Создание бета-версии (1.0.0 -> 1.0.1-beta.0)
pnpm release:beta
```
## Процесс релиза
1. Сделайте необходимые изменения в коде
2. Зафиксируйте изменения с помощью правильно оформленных коммитов
3. Запустите команду релиза (например, `pnpm release`)
4. Пакет автоматически:
- Увеличит версию в package.json
- Создаст тег в Git
- Обновит CHANGELOG.md
- Зафиксирует изменения
5. Отправьте изменения и теги в репозиторий:
```bash
git push --follow-tags origin main
```
6. GitHub Actions автоматически запустит сборку и создаст релиз для всех платформ
## Ручное создание тега
Если вам нужно создать тег вручную:
```bash
git tag v1.0.0
git push origin v1.0.0
```
+11 -1
View File
@@ -10,12 +10,21 @@
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build"
"build": "quasar build",
"dev:electron": "quasar dev -m electron",
"build:electron": "quasar build -m electron",
"release": "standard-version",
"release:minor": "standard-version --release-as minor",
"release:major": "standard-version --release-as major",
"release:patch": "standard-version --release-as patch",
"release:alpha": "standard-version --prerelease alpha",
"release:beta": "standard-version --prerelease beta"
},
"dependencies": {
"@matpool/vue-json-view": "^0.1.8",
"@quasar/extras": "^1.16.4",
"coopdoc-generator-ts": "^1.0.17",
"dotenv": "^16.5.0",
"inline-css": "^4.0.2",
"jspdf": "^2.5.1",
"nunjucks": "^3.2.4",
@@ -38,6 +47,7 @@
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^9.0.0",
"prettier": "^2.5.1",
"standard-version": "^9.5.0",
"typescript": "^4.5.4",
"vite-plugin-checker": "^0.6.4",
"vue-tsc": "^1.8.22"
+7062
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -5,6 +5,9 @@
* the ES6 features that are supported by your Node version. https://node.green/
*/
// Загружаем переменные окружения из .env файла
require('dotenv').config();
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
@@ -53,7 +56,6 @@ module.exports = configure(function (/* ctx */) {
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
@@ -100,7 +102,7 @@ module.exports = configure(function (/* ctx */) {
// directives: [],
// Quasar plugins
plugins: [],
plugins: ['Notify'],
},
// animations: 'all', // --- includes all animations
@@ -166,7 +168,10 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf)
extendElectronMainConf(esbuildConf) {
// Убираем зависимость от переменной окружения COOPTYPES_REGISTRY_PATH
esbuildConf.define = esbuildConf.define || {};
},
// extendElectronPreloadConf (esbuildConf)
inspectPort: 5858,
+21 -1
View File
@@ -2,8 +2,28 @@
declare namespace NodeJS {
interface ProcessEnv {
QUASAR_PUBLIC_FOLDER: string;
QUASAR_ELECTRON_PRELOAD: string;
APP_URL: string;
DEBUGGING?: string;
COOPTYPES_REGISTRY_PATH?: string;
}
}
interface ElectronAPI {
listCooptypeDirectories: () => Promise<string[]>;
readCooptypeFile: (templateId: string) => Promise<string>;
writeCooptypeFile: (templateId: string, content: string) => Promise<boolean>;
// Новые методы для работы с путем к реестру
setRegistryPath: (path: string) => Promise<boolean>;
getRegistryPath: () => Promise<string>;
selectDirectory: () => Promise<string | null>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};
+84 -5
View File
@@ -1,29 +1,38 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import path from 'path';
import os from 'os';
const fs = require('fs').promises;
// needed in case process is undefined under Linux
const platform = process.platform || os.platform();
let mainWindow: BrowserWindow | undefined;
// Переменная для хранения пути к реестру
let registryPath = process.env.COOPTYPES_REGISTRY_PATH || '';
function createWindow() {
/**
* Initial window options
*/
mainWindow = new BrowserWindow({
icon: path.resolve(__dirname, 'icons/icon.png'), // tray icon
width: 1000,
height: 600,
width: 1200,
height: 800,
useContentSize: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// More info: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/electron-preload-script
preload: path.resolve(__dirname, process.env.QUASAR_ELECTRON_PRELOAD),
preload: path.resolve(
__dirname,
process.env.QUASAR_ELECTRON_PRELOAD || ''
),
},
});
mainWindow.loadURL(process.env.APP_URL);
mainWindow.loadURL(process.env.APP_URL || '');
if (process.env.DEBUGGING) {
// if on DEV or Production with debug enabled
@@ -40,6 +49,76 @@ function createWindow() {
});
}
// Обработчик для установки пути к реестру
ipcMain.handle('set-registry-path', async (event, path: string) => {
registryPath = path;
return true;
});
// Обработчик для получения текущего пути к реестру
ipcMain.handle('get-registry-path', async () => {
return registryPath;
});
// Обработчик для выбора директории
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
// IPC обработчики для работы с cooptypes файлами
ipcMain.handle('list-cooptype-directories', async () => {
try {
if (!registryPath) {
throw new Error('Registry path not set');
}
const items = await fs.readdir(registryPath, { withFileTypes: true });
return items
.filter((item: any) => item.isDirectory())
.map((item: any) => item.name);
} catch (error) {
console.error('Error listing cooptype directories:', error);
return [];
}
});
ipcMain.handle('read-cooptype-file', async (event, templateId: string) => {
try {
if (!registryPath) {
throw new Error('Registry path not set');
}
const filePath = path.join(registryPath, templateId, 'index.ts');
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch (error) {
console.error('Error reading cooptype file:', error);
throw error;
}
});
ipcMain.handle(
'write-cooptype-file',
async (event, templateId: string, content: string) => {
try {
if (!registryPath) {
throw new Error('Registry path not set');
}
const filePath = path.join(registryPath, templateId, 'index.ts');
await fs.writeFile(filePath, content, 'utf-8');
return true;
} catch (error) {
console.error('Error writing cooptype file:', error);
throw error;
}
}
);
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
+27 -1
View File
@@ -27,4 +27,30 @@
* }
* }
*/
export {}
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
// Файловые операции для шаблонов
readTemplateFile: (fileName: string) =>
ipcRenderer.invoke('read-template-file', fileName),
writeTemplateFile: (fileName: string, content: string) =>
ipcRenderer.invoke('write-template-file', fileName, content),
listTemplateFiles: () => ipcRenderer.invoke('list-template-files'),
// Операции с cooptypes
listCooptypeDirectories: () =>
ipcRenderer.invoke('list-cooptype-directories'),
readCooptypeFile: (templateId: string) =>
ipcRenderer.invoke('read-cooptype-file', templateId),
writeCooptypeFile: (templateId: string, content: string) =>
ipcRenderer.invoke('write-cooptype-file', templateId, content),
// Операции с путем к реестру
setRegistryPath: (path: string) =>
ipcRenderer.invoke('set-registry-path', path),
getRegistryPath: () => ipcRenderer.invoke('get-registry-path'),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
});
export {};
+119
View File
@@ -0,0 +1,119 @@
<template>
<q-dialog
v-model="isDialogOpen"
persistent
transition-show="scale"
transition-hide="scale"
>
<q-card style="min-width: 500px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Выбор пути к реестру</div>
<q-space />
</q-card-section>
<q-card-section>
<p>Укажите путь к директории реестра документов</p>
<q-input
v-model="registryPathInput"
outlined
label="Путь к реестру"
class="q-mb-md"
>
<template #after>
<q-btn flat icon="folder" @click="selectDirectory" />
</template>
</q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
label="Отмена"
color="primary"
@click="closeDialog"
v-if="!isFirstLaunch"
/>
<q-btn
flat
label="Подтвердить"
color="primary"
@click="confirmPath"
:disable="!registryPathInput"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script lang="ts">
import { defineComponent, ref, computed, onMounted } from 'vue';
import { useSettingsStore } from 'src/stores/settings';
export default defineComponent({
name: 'RegistryPathDialog',
props: {
modelValue: {
type: Boolean,
default: false,
},
isFirstLaunch: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const settingsStore = useSettingsStore();
const registryPathInput = ref(settingsStore.registryPath);
// Вычисляемое свойство для двустороннего связывания диалога
const isDialogOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
// При первом запуске получаем путь из electron
onMounted(async () => {
if (settingsStore.registryPath === '') {
const path = await window.electronAPI.getRegistryPath();
if (path) {
registryPathInput.value = path;
}
}
});
// Выбор директории через диалог
const selectDirectory = async () => {
const selectedPath = await window.electronAPI.selectDirectory();
if (selectedPath) {
registryPathInput.value = selectedPath;
}
};
// Подтверждение выбранного пути
const confirmPath = async () => {
if (registryPathInput.value) {
await window.electronAPI.setRegistryPath(registryPathInput.value);
settingsStore.setRegistryPath(registryPathInput.value);
isDialogOpen.value = false;
}
};
// Закрытие диалога
const closeDialog = () => {
isDialogOpen.value = false;
};
return {
isDialogOpen,
registryPathInput,
selectDirectory,
confirmPath,
closeDialog,
};
},
});
</script>
+77
View File
@@ -0,0 +1,77 @@
<template>
<q-btn-dropdown
flat
no-caps
:label="shortPath"
icon="folder"
class="registry-path-dropdown"
>
<q-card class="no-shadow">
<q-card-section>
<div class="text-subtitle2">Текущий путь к реестру</div>
<div class="registry-path">{{ registryPath }}</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Изменить" color="primary" @click="openDialog" />
</q-card-actions>
</q-card>
</q-btn-dropdown>
<registry-path-dialog v-model="showDialog" :is-first-launch="false" />
</template>
<script lang="ts">
import { defineComponent, ref, computed } from 'vue';
import { useSettingsStore } from 'src/stores/settings';
import RegistryPathDialog from './RegistryPathDialog.vue';
export default defineComponent({
name: 'RegistryPathSetter',
components: {
RegistryPathDialog,
},
setup() {
const settingsStore = useSettingsStore();
const showDialog = ref(false);
const registryPath = computed(() => settingsStore.registryPath);
// Сокращенный путь для отображения в кнопке
const shortPath = computed(() => {
const path = registryPath.value;
if (!path) return 'Путь не выбран';
const parts = path.split('/');
if (parts.length <= 2) return path;
return `.../${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
});
const openDialog = () => {
showDialog.value = true;
};
return {
registryPath,
shortPath,
showDialog,
openDialog,
};
},
});
</script>
<style scoped>
.registry-path {
word-break: break-all;
max-width: 300px;
}
.registry-path-dropdown {
max-width: 300px;
text-align: left;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="templates-list">
<div class="q-pa-md">
<q-input
v-model="searchQuery"
placeholder="Поиск шаблонов..."
outlined
dense
clearable
@input="filterTemplates"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
<q-list>
<q-item-label header class="text-grey-8">
Шаблоны ({{ filteredTemplates.length }})
</q-item-label>
<q-item
v-for="template in sortedTemplates"
:key="template.id"
clickable
v-ripple
:active="currentTemplate?.id === template.id"
@click="selectTemplate(template)"
class="template-item"
>
<q-item-section>
<q-item-label class="text-weight-medium">
{{ template.name }}
</q-item-label>
<q-item-label
caption
class="text-grey-6"
v-if="loadedTitles[template.id]"
>
{{ loadedTitles[template.id] }}
</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="filteredTemplates.length === 0">
<q-item-section>
<q-item-label class="text-grey-6"> Шаблоны не найдены </q-item-label>
</q-item-section>
</q-item>
</q-list>
<div class="q-pa-md" v-if="isLoading">
<q-skeleton height="50px" class="q-mb-sm" v-for="n in 5" :key="n" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue';
import { useAppState } from 'src/stores/store';
import {
TemplateService,
type TemplateFile,
} from 'src/services/TemplateService';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const appState = useAppState();
const templateService = new TemplateService();
const searchQuery = ref('');
const isLoading = ref(false);
const loadedTitles = reactive<Record<string, string>>({});
const currentTemplate = computed(() => appState.currentTemplate);
const templates = computed(() => appState.templates);
// Определяем события, которые компонент может испускать
const emit = defineEmits(['template-selected']);
const filteredTemplates = computed(() => {
if (!searchQuery.value) {
return templates.value;
}
const query = searchQuery.value.toLowerCase();
return templates.value.filter(
(template) =>
template.name.toLowerCase().includes(query) ||
template.fileName.toLowerCase().includes(query) ||
(loadedTitles[template.id] &&
loadedTitles[template.id].toLowerCase().includes(query))
);
});
// Сортировка шаблонов по порядковому номеру
const sortedTemplates = computed(() => {
return [...filteredTemplates.value].sort((a, b) => {
// Извлекаем числовой номер из начала названия
const getNumber = (name: string) => {
const match = name.match(/^(\d+)\./);
return match ? parseInt(match[1], 10) : 0;
};
const numA = getNumber(a.name);
const numB = getNumber(b.name);
return numA - numB;
});
});
const selectTemplate = async (template: TemplateFile) => {
try {
isLoading.value = true;
// Загружаем полные данные шаблона
const fullTemplate = await templateService.loadTemplate(template.fileName);
if (fullTemplate) {
// Сохраняем title шаблона в нашем локальном хранилище
if (fullTemplate.title && fullTemplate.title !== template.name) {
loadedTitles[template.id] = fullTemplate.title;
}
appState.setCurrentTemplate(fullTemplate);
appState.parseContext();
// Испускаем событие выбора шаблона
emit('template-selected');
} else {
throw new Error('Failed to load template');
}
} catch (error) {
console.error('Error selecting template:', error);
$q.notify({
type: 'negative',
message: 'Ошибка при загрузке шаблона',
position: 'top',
});
} finally {
isLoading.value = false;
}
};
const filterTemplates = () => {
// Логика фильтрации реализована через computed свойство
};
const loadTemplates = async () => {
try {
isLoading.value = true;
const templatesList = await templateService.loadTemplatesList();
appState.setTemplates(templatesList);
// Загружаем titles для всех шаблонов в фоновом режиме
setTimeout(() => {
loadTemplateTitles(templatesList);
}, 100);
} catch (error) {
console.error('Error loading templates:', error);
$q.notify({
type: 'negative',
message: 'Ошибка при загрузке списка шаблонов',
position: 'top',
});
} finally {
isLoading.value = false;
}
};
// Загрузка titles шаблонов в фоновом режиме
const loadTemplateTitles = async (templatesList: TemplateFile[]) => {
for (const template of templatesList) {
try {
const fullTemplate = await templateService.loadTemplate(
template.fileName
);
if (
fullTemplate &&
fullTemplate.title &&
fullTemplate.title !== template.name
) {
loadedTitles[template.id] = fullTemplate.title;
}
} catch (error) {
console.error(`Error loading title for template ${template.id}:`, error);
}
}
};
onMounted(async () => {
if (!appState.isTemplateListLoaded) {
await loadTemplates();
}
});
</script>
<style scoped>
.templates-list {
height: 100%;
overflow-y: auto;
}
.template-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.template-item.q-item--active {
background-color: rgba(25, 118, 210, 0.12);
}
</style>
+61 -12
View File
@@ -1,21 +1,42 @@
<template>
<q-card flat class="q-pa-sm">
<p class="text-center">Редактор</p>
<q-input
@change="state.parseContext"
v-model="state.context"
type="textarea"
rows="50"
class="q-pa-xs"
style="word-break: break-all"
></q-input>
<q-card flat class="editor-card" ref="cardEl">
<q-banner class="bg-primary text-white no-border-radius q-px-md q-py-sm">
<template v-slot:avatar>
<q-icon name="code" />
</template>
<q-badge class="q-ml-md" color="white" text-color="primary">
Шаблон
</q-badge>
</q-banner>
<div
class="editor-content"
contenteditable="plaintext-only"
@input="updateContent"
ref="contentEl"
></div>
</q-card>
</template>
<style scoped>
.line-wrap {
.editor-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
}
.editor-content {
flex: 1;
font-family: monospace;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
min-height: 70vh;
outline: none;
padding: 0.5rem;
}
.no-border-radius {
border-radius: 0;
}
</style>
@@ -25,5 +46,33 @@ defineOptions({
});
import { useAppState } from 'src/stores/store';
import { ref, onMounted, watch } from 'vue';
const state = useAppState();
const contentEl = ref<HTMLDivElement | null>(null);
const cardEl = ref<HTMLDivElement | null>(null);
const updateContent = () => {
if (contentEl.value) {
state.context = contentEl.value.innerText;
state.parseContext();
}
};
// Обновляем содержимое редактора при изменении state.context извне
watch(
() => state.context,
(newValue) => {
if (contentEl.value && contentEl.value.innerText !== newValue) {
contentEl.value.innerText = newValue;
}
}
);
// Инициализация редактора при монтировании
onMounted(() => {
if (contentEl.value && state.context) {
contentEl.value.innerText = state.context;
}
});
</script>
+26 -7
View File
@@ -1,15 +1,35 @@
<template>
<q-card flat>
<p class="text-center">Предпросмотр</p>
<pre v-html="state.html" class="line-wrap"></pre>
<q-card flat class="preview-card">
<q-banner class="bg-green text-white no-border-radius q-px-md q-py-sm">
<template v-slot:avatar>
<q-icon name="visibility" />
</template>
<q-badge class="q-ml-md" color="white" text-color="green">
Предпросмотр
</q-badge>
</q-banner>
<div class="preview-content" v-html="state.html"></div>
</q-card>
</template>
<style scoped>
.line-wrap {
.preview-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
}
.preview-content {
flex: 1;
padding: 0.5rem;
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
}
.no-border-radius {
border-radius: 0;
}
</style>
@@ -19,6 +39,5 @@ defineOptions({
});
import { useAppState } from 'src/stores/store';
const state = useAppState()
const state = useAppState();
</script>
+95 -65
View File
@@ -1,80 +1,91 @@
<!-- eslint-disable @typescript-eslint/no-explicit-any -->
<template>
<q-card flat>
<p class="text-center">Переменные и переводы</p>
<q-tree
:nodes="nodes"
node-key="id"
:label-key="labelKey"
:children-key="childrenKey"
:default-expanded="defaultExpanded"
>
<template v-slot:default-body="{ node }">
<div v-if="node.isAddButton" class="q-pa-xs">
<q-btn
size="sm"
color="primary"
icon="add"
:label="node.label"
@click="addArrayItemByPath(node.arrayPath)"
/>
</div>
<div v-else-if="!node.children" class="q-pa-xs">
<q-input
type="textarea"
rows="3"
dense
filled
v-model="node[displayKey]"
@update:model-value="updateValue(node, $event)"
/>
</div>
<div v-else-if="node.isArray" class="q-pa-xs">
<div class="row items-center q-mb-sm">
<div class="col">
<q-btn
size="sm"
color="primary"
icon="add"
label="Добавить элемент"
@click="addArrayItem(node)"
/>
</div>
<q-card flat class="model-card">
<q-banner class="bg-secondary text-white no-border-radius q-px-md q-py-sm">
<template v-slot:avatar>
<q-icon name="data_object" />
</template>
<q-badge class="q-ml-md" color="white" text-color="secondary">
Данные
</q-badge>
</q-banner>
<div class="model-content">
<q-tree
:nodes="nodes"
node-key="id"
:label-key="labelKey"
:children-key="childrenKey"
:default-expanded="defaultExpanded"
>
<template v-slot:default-body="{ node }">
<div v-if="node.isAddButton" class="q-pa-xs">
<q-btn
size="sm"
color="primary"
icon="add"
:label="node.label"
@click="addArrayItemByPath(node.arrayPath)"
/>
</div>
<div
v-for="(item, index) in node.arrayItems"
:key="index"
class="q-mb-md q-pa-sm"
style="border: 1px solid #e0e0e0; border-radius: 4px"
>
<div v-else-if="!node.children" class="q-pa-xs">
<q-input
type="textarea"
rows="3"
dense
filled
v-model="node[displayKey]"
@update:model-value="updateValue(node, $event)"
/>
</div>
<div v-else-if="node.isArray" class="q-pa-xs">
<div class="row items-center q-mb-sm">
<div class="col">
<strong>{{ node.label }} [{{ index }}]</strong>
</div>
<div class="col-auto">
<q-btn
size="sm"
color="negative"
icon="delete"
@click="removeArrayItem(node, index)"
color="primary"
icon="add"
label="Добавить элемент"
@click="addArrayItem(node)"
/>
</div>
</div>
<div v-for="(value, key) in item" :key="key" class="q-mb-sm">
<q-input
:label="String(key)"
type="textarea"
rows="2"
dense
filled
v-model="item[key]"
@change="updateArrayValue(node, index, String(key), item[key])"
/>
<div
v-for="(item, index) in node.arrayItems"
:key="index"
class="q-mb-md q-pa-sm"
style="border: 1px solid #e0e0e0; border-radius: 4px"
>
<div class="row items-center q-mb-sm">
<div class="col">
<strong>{{ node.label }} [{{ index }}]</strong>
</div>
<div class="col-auto">
<q-btn
size="sm"
color="negative"
icon="delete"
@click="removeArrayItem(node, index)"
/>
</div>
</div>
<div v-for="(value, key) in item" :key="key" class="q-mb-sm">
<q-input
:label="String(key)"
type="textarea"
rows="2"
dense
filled
v-model="item[key]"
@change="
updateArrayValue(node, index, String(key), item[key])
"
/>
</div>
</div>
</div>
</div>
</template>
</q-tree>
</template>
</q-tree>
</div>
</q-card>
</template>
@@ -419,3 +430,22 @@ watchEffect(() => {
nodes.value = generateTree();
});
</script>
<style scoped>
.model-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.model-content {
flex: 1;
overflow: auto;
padding: 0.25rem;
}
.no-border-radius {
border-radius: 0;
}
</style>
+129 -3
View File
@@ -1,12 +1,138 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-page-container>
<router-view />
</q-page-container>
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
{{ currentTemplate ? currentTemplate.name : 'Редактор шаблонов' }}
</q-toolbar-title>
<q-space />
<!-- Компонент для отображения и изменения пути к реестру -->
<registry-path-setter />
<q-btn
v-if="currentTemplate"
flat
dense
round
icon="save"
aria-label="Сохранить"
color="positive"
@click="saveCurrentTemplate"
>
<q-tooltip>Сохранить шаблон</q-tooltip>
</q-btn>
</q-toolbar>
</q-header>
<q-splitter v-model="splitterModel" :limits="[0, 50]" class="full-height">
<template v-slot:before>
<div v-if="leftDrawerOpen" class="bg-grey-1 full-height q-pa-sm">
<TemplatesList @template-selected="closeDrawer" />
</div>
</template>
<template v-slot:after>
<q-page-container>
<router-view />
</q-page-container>
</template>
</q-splitter>
<!-- Диалог выбора пути при первом запуске -->
<registry-path-dialog v-model="showPathDialog" :is-first-launch="true" />
</q-layout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useAppState } from 'src/stores/store';
import { useSettingsStore } from 'src/stores/settings';
import { useQuasar } from 'quasar';
import TemplatesList from 'src/components/TemplatesList.vue';
import RegistryPathSetter from 'src/components/RegistryPathSetter.vue';
import RegistryPathDialog from 'src/components/RegistryPathDialog.vue';
import { TemplateService } from 'src/services/TemplateService';
const $q = useQuasar();
const appState = useAppState();
const settingsStore = useSettingsStore();
const templateService = new TemplateService();
const leftDrawerOpen = ref(false);
const showPathDialog = ref(false);
const splitterModel = ref(0); // Начальное значение 0 (закрыто)
const savedSplitterWidth = ref(20); // Сохраняем ширину для восстановления
const currentTemplate = computed(() => appState.currentTemplate);
// Проверка при запуске, нужно ли показать диалог выбора пути
onMounted(() => {
if (settingsStore.isFirstLaunch || !settingsStore.registryPath) {
showPathDialog.value = true;
}
});
// Отслеживаем изменение состояния бокового меню
watch(leftDrawerOpen, (isOpen) => {
if (isOpen) {
// Открываем боковую панель
splitterModel.value = savedSplitterWidth.value;
} else {
// Сохраняем текущую ширину перед закрытием
if (splitterModel.value > 0) {
savedSplitterWidth.value = splitterModel.value;
}
// Закрываем боковую панель
splitterModel.value = 0;
}
});
const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value;
};
const closeDrawer = () => {
leftDrawerOpen.value = false;
};
const saveCurrentTemplate = async () => {
if (!currentTemplate.value) return;
try {
// Обновляем данные шаблона из store
appState.updateCurrentTemplate();
const success = await templateService.saveTemplate(currentTemplate.value);
if (success) {
$q.notify({
type: 'positive',
message: `Шаблон "${currentTemplate.value.name}" сохранен`,
position: 'top',
});
} else {
throw new Error('Save failed');
}
} catch (error) {
console.error('Error saving template:', error);
$q.notify({
type: 'negative',
message: 'Ошибка при сохранении шаблона',
position: 'top',
});
}
};
defineOptions({
name: 'MainLayout',
});
+50 -20
View File
@@ -1,39 +1,69 @@
<template>
<q-page>
<div class="row">
<updateButton class="col-4"/>
<templateUploader class="col-4"/>
<downloadButton class="col-4"/>
<div v-if="!currentTemplate" class="full-height flex flex-center">
<div class="text-center">
<q-icon name="description" size="4rem" color="grey-5" />
<h5 class="text-grey-6 q-mt-md q-mb-md">
Выберите шаблон для редактирования
</h5>
<p class="text-grey-5">
Используйте меню слева для выбора шаблона из списка
</p>
</div>
</div>
<div class="row" >
<htmlEditor class="col-4 q-pa-md scrolled" />
<modelViewer class="col-4 q-pa-md scrolled"/>
<htmlPreview class="col-4 q-pa-md scrolled"/>
<div v-else class="q-pa-sm">
<div class="editor-container">
<q-splitter v-model="editorSplit" class="full-height">
<template v-slot:before>
<htmlEditor class="q-pa-md" />
</template>
<template v-slot:after>
<q-splitter v-model="rightSplit" class="full-height">
<template v-slot:before>
<modelViewer class="q-pa-md" />
</template>
<template v-slot:after>
<htmlPreview class="q-pa-md" />
</template>
</q-splitter>
</template>
</q-splitter>
</div>
</div>
</q-page>
</template>
<script setup lang="ts">
// import { ref } from 'vue';
// import { Meta } from 'components/models';
import { computed, ref } from 'vue';
import { useAppState } from 'src/stores/store';
import htmlPreview from 'src/components/htmlPreview.vue';
import updateButton from 'src/components/updateButton.vue';
import downloadButton from 'src/components/downloadButton.vue';
import templateUploader from 'src/components/templateUploader.vue';
import htmlEditor from 'src/components/htmlEditor.vue';
import modelViewer from 'src/components/modelViewer.vue';
const appState = useAppState();
const editorSplit = ref(33); // 33% для левой панели
const rightSplit = ref(50); // 50% для средней панели от оставшегося пространства
const currentTemplate = computed(() => appState.currentTemplate);
defineOptions({
name: 'IndexPage',
});
// const meta = ref<Meta>({
// totalCount: 1200,
// });
</script>
<style>
.scrolled{
max-height: 90vh;
overflow-y: scroll;
.editor-container {
height: calc(100vh - 100px);
}
.full-height {
height: 100%;
}
.q-splitter :deep(.q-splitter__panel) {
overflow: auto;
}
</style>
+479 -92
View File
@@ -9,55 +9,493 @@ export interface TemplateFile {
}
export class TemplateService {
private registryPath: string;
private cooptypesPath: string;
constructor() {
this.registryPath =
import.meta.env.VITE_TEMPLATES_REGISTRY_PATH || '../registry';
this.cooptypesPath =
import.meta.env.VITE_COOPTYPES_REGISTRY_PATH ||
'../cooptypes/src/cooperative/registry';
}
async loadTemplatesList(): Promise<TemplateFile[]> {
// В реальном приложении здесь будет запрос к API
// Пока что заглушка для тестирования
const templates = [
'1.walletProgramAgreement.json',
'100.participantApplication.json',
'1000.investAgreement.json',
'1001.investByResultStatement.json',
'1002.investByResultAct.json',
'1005.investByMoneyStatement.json',
'101.selectBranchStatement.json',
'1010.investMembershipConvertation.json',
'2.regulationElectronicSignature.json',
'3.privacyPolicy.json',
'300.annualGeneralMeetingAgenda.json',
'301.annualGeneralMeetingSovietDecision.json',
'302.annualGeneralMeetingNotification.json',
'303.annualGeneralMeetingVotingBallot.json',
'304.annualGeneralMeetingDecision.json',
'4.userAgreement.json',
'50.CoopenomicsAgreement.json',
'501.decisionOfParticipantApplication.json',
'599.projectFreeDecision.json',
'600.freeDecision.json',
'700.assetContributionStatement.json',
'701.assetContributionDecision.json',
'702.assetContributionAct.json',
'800.returnByAssetStatement.json',
'801.returnByAssetDecision.json',
'802.returnByAssetAct.json',
];
try {
// Проверяем, доступен ли Electron API
if (window.electronAPI) {
const templateDirs = await window.electronAPI.listCooptypeDirectories();
return templateDirs.map((dirName) => {
const id = dirName;
const name = this.formatTemplateName(dirName);
const fileName = `${dirName}.json`; // Для совместимости
return templates.map((fileName) => {
const id = fileName.replace('.json', '');
const name = this.formatTemplateName(fileName);
return {
id,
name,
title: name,
fileName,
context: '',
translation: {},
exampleData: {},
};
});
} else {
// Fallback для веб-версии
return this.getHardcodedTemplatesList();
}
} catch (error) {
console.error('Error loading templates list:', error);
return this.getHardcodedTemplatesList();
}
}
async loadTemplate(fileName: string): Promise<TemplateFile | null> {
try {
const templateId = fileName.replace('.json', '');
console.log('🔄 Loading template:', { fileName, templateId });
// Проверяем, доступен ли Electron API
if (window.electronAPI) {
// Загружаем данные из cooptypes
console.log('📁 Reading cooptype file via Electron API...');
const cooptypeContent = await window.electronAPI.readCooptypeFile(
templateId
);
console.log(
'📄 Raw cooptype content preview:',
cooptypeContent.substring(0, 300) + '...'
);
const cooptypeData = this.parseCooptypeFile(cooptypeContent);
console.log('🔍 Parsed cooptype data:', {
contextLength: cooptypeData.context?.length || 0,
translationsKeys: Object.keys(cooptypeData.translations || {}),
exampleDataKeys: Object.keys(cooptypeData.exampleData || {}),
hasExampleData: !!(
cooptypeData.exampleData &&
Object.keys(cooptypeData.exampleData).length > 0
),
});
const context = cooptypeData.context || '';
const translation = cooptypeData.translations?.ru || {};
const exampleData = cooptypeData.exampleData || {};
// Используем извлеченный title или формируем из имени шаблона
const title = cooptypeData.title || this.formatTemplateName(templateId);
console.log('✅ Final template data:', {
id: templateId,
contextLength: context.length,
translationKeys: Object.keys(translation),
exampleDataKeys: Object.keys(exampleData),
exampleDataSample:
JSON.stringify(exampleData).substring(0, 200) + '...',
});
return {
id: templateId,
name: this.formatTemplateName(templateId),
title: title,
fileName,
context,
translation,
exampleData,
};
} else {
// Fallback для веб-версии - используем fetch
console.log('🌐 Fallback: using fetch for web version...');
const response = await fetch(`/api/cooptypes/${templateId}`);
if (!response.ok) {
throw new Error('Template not found');
}
const data = await response.text();
const cooptypeData = this.parseCooptypeFile(data);
const title = cooptypeData.title || this.formatTemplateName(templateId);
return {
id: templateId,
name: this.formatTemplateName(templateId),
title: title,
fileName,
context: cooptypeData.context || '',
translation: cooptypeData.translations?.ru || {},
exampleData: cooptypeData.exampleData || {},
};
}
} catch (error) {
console.error('❌ Error loading template:', error);
return null;
}
}
async saveTemplate(template: TemplateFile): Promise<boolean> {
try {
// Проверяем, доступен ли Electron API
if (window.electronAPI) {
// Обновляем только файл в cooptypes
await this.updateCooptypeFile(template);
return true;
} else {
// Fallback для веб-версии
const response = await fetch(`/api/cooptypes/${template.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
context: template.context,
translation: template.translation,
exampleData: template.exampleData,
}),
});
return response.ok;
}
} catch (error) {
console.error('Error saving template:', error);
return false;
}
}
private parseCooptypeFile(content: string): any {
try {
// Извлекаем title из файла
const titleMatch = content.match(/export const title = ['"`](.*?)['"`]/);
let title = '';
if (titleMatch) {
title = titleMatch[1]
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
// Извлекаем context с учетом экранированных символов и переносов строк
const contextMatch = content.match(
/export const context = ['"`]([\s\S]*?)['"`](?=\s*export|\s*$)/
);
let context = '';
if (contextMatch) {
// Правильно обрабатываем экранированные символы
context = contextMatch[1]
.replace(/\\n/g, '\n') // Заменяем \\n на реальные переносы строк
.replace(/\\t/g, '\t') // Заменяем \\t на табуляцию
.replace(/\\'/g, "'") // Заменяем \\' на одинарные кавычки
.replace(/\\"/g, '"') // Заменяем \\" на двойные кавычки
.replace(/\\\\/g, '\\'); // Заменяем \\\\ на обратные слеши
}
// Функция для извлечения содержимого объекта с учетом вложенных скобок
const extractObjectContent = (
text: string,
startPattern: string
): string | null => {
const startIndex = text.indexOf(startPattern);
if (startIndex === -1) return null;
const objectStart = text.indexOf('{', startIndex);
if (objectStart === -1) return null;
let braceCount = 0;
let current = objectStart;
while (current < text.length) {
const char = text[current];
if (char === '{') braceCount++;
if (char === '}') braceCount--;
if (braceCount === 0) {
// Найдена закрывающая скобка объекта
return text.substring(objectStart + 1, current);
}
current++;
}
return null;
};
// Извлекаем translations с более надежным подходом
let translations = {};
const translationsContent = extractObjectContent(
content,
'export const translations = {'
);
if (translationsContent) {
try {
// Создаем временную функцию для безопасного выполнения кода
const translationsCode = `return {${translationsContent}}`;
const func = new Function(translationsCode);
translations = func();
} catch (e) {
console.warn(
'Could not parse translations, trying alternative method:',
e
);
// Альтернативный метод - ищем ru секцию
const ruMatch = translationsContent.match(
/ru:\s*\{([\s\S]*?)\}(?=\s*,?\s*[a-zA-Z_]|\s*$)/
);
if (ruMatch) {
try {
const ruCode = `return {${ruMatch[1]}}`;
const ruFunc = new Function(ruCode);
translations = { ru: ruFunc() };
} catch (e2) {
console.warn('Alternative parsing also failed:', e2);
console.log('Failed ru content:', ruMatch[1]);
}
}
}
}
// Извлекаем exampleData если есть
let exampleData = {};
const exampleDataContent = extractObjectContent(
content,
'export const exampleData = {'
);
if (exampleDataContent) {
try {
const exampleDataCode = `return {${exampleDataContent}}`;
const func = new Function(exampleDataCode);
exampleData = func();
} catch (e) {
console.warn('Could not parse exampleData:', e);
console.log(
'Failed exampleData content:',
exampleDataContent.substring(0, 200) + '...'
);
}
}
console.log('Parsed cooptype file:', {
contextLength: context.length,
translationsKeys: Object.keys(translations),
exampleDataKeys: Object.keys(exampleData),
contextPreview:
context.substring(0, 200) + (context.length > 200 ? '...' : ''),
});
return {
id,
title,
context,
translations,
exampleData,
};
} catch (error) {
console.error('Error parsing cooptype file:', error);
return {
title: '',
context: '',
translations: {},
exampleData: {},
};
}
}
private async updateCooptypeFile(template: TemplateFile): Promise<void> {
try {
if (!window.electronAPI) return;
// Читаем существующий файл cooptype
let existingContent = await window.electronAPI.readCooptypeFile(
template.id
);
// Максимально простой и надежный подход - используем грубую замену текста
// на основе идентификации строк начала и конца блока контекста
// Находим начало блока контекста
const contextStartPattern = 'export const context =';
const contextStartIndex = existingContent.indexOf(contextStartPattern);
if (contextStartIndex === -1) {
console.error('Блок context не найден в файле');
return;
}
// Ищем первый символ после "export const context ="
let quoteStartIndex = contextStartIndex + contextStartPattern.length;
// Пропускаем пробелы
while (existingContent[quoteStartIndex] === ' ') {
quoteStartIndex++;
}
// Определяем тип кавычки
const quoteChar = existingContent[quoteStartIndex];
if (quoteChar !== "'" && quoteChar !== '"' && quoteChar !== '`') {
console.error('Не удалось определить тип кавычки в блоке context');
return;
}
// Ищем конец блока (закрывающую кавычку с учетом экранирования)
let quoteEndIndex = quoteStartIndex + 1;
let escaped = false;
// Поиск закрывающей кавычки с учетом вложенных кавычек и экранирования
while (quoteEndIndex < existingContent.length) {
const char = existingContent[quoteEndIndex];
if (char === '\\') {
escaped = !escaped; // Переключаем состояние экранирования
} else if (char === quoteChar && !escaped) {
break; // Нашли закрывающую кавычку
} else {
escaped = false; // Сбрасываем состояние экранирования для других символов
}
quoteEndIndex++;
}
if (quoteEndIndex >= existingContent.length) {
console.error('Не удалось найти конец блока context');
return;
}
// Формируем новый блок контекста
const newContextBlock = `${contextStartPattern} \`${template.context}\``;
// Заменяем старый блок контекста на новый
const beforeContext = existingContent.substring(0, contextStartIndex);
const afterContext = existingContent.substring(quoteEndIndex + 1);
// Собираем обновленное содержимое
existingContent = beforeContext + newContextBlock + afterContext;
// Обновляем translations и exampleData (используя простой подход с поиском маркеров)
// Обновляем translations
const translationsStartPattern = 'export const translations =';
const translationsStartIndex = existingContent.indexOf(
translationsStartPattern
);
if (translationsStartIndex !== -1) {
const translationsBracketIndex = existingContent.indexOf(
'{',
translationsStartIndex
);
if (translationsBracketIndex !== -1) {
// Ищем закрывающую фигурную скобку с учетом вложенности
let bracketCount = 1;
let translationsEndIndex = translationsBracketIndex + 1;
while (
translationsEndIndex < existingContent.length &&
bracketCount > 0
) {
if (existingContent[translationsEndIndex] === '{') {
bracketCount++;
} else if (existingContent[translationsEndIndex] === '}') {
bracketCount--;
}
translationsEndIndex++;
}
if (bracketCount === 0) {
const newTranslations = `${translationsStartPattern} {
ru: ${JSON.stringify(template.translation, null, 4).replace(/^/gm, ' ')},
}`;
existingContent =
existingContent.substring(0, translationsStartIndex) +
newTranslations +
existingContent.substring(translationsEndIndex);
}
}
}
// Обновляем exampleData
const exampleDataStartPattern = 'export const exampleData =';
const exampleDataStartIndex = existingContent.indexOf(
exampleDataStartPattern
);
const newExampleData = `${exampleDataStartPattern} ${JSON.stringify(
template.exampleData,
null,
2
)}`;
if (exampleDataStartIndex !== -1) {
const exampleDataBracketIndex = existingContent.indexOf(
'{',
exampleDataStartIndex
);
if (exampleDataBracketIndex !== -1) {
// Ищем закрывающую фигурную скобку с учетом вложенности
let bracketCount = 1;
let exampleDataEndIndex = exampleDataBracketIndex + 1;
while (
exampleDataEndIndex < existingContent.length &&
bracketCount > 0
) {
if (existingContent[exampleDataEndIndex] === '{') {
bracketCount++;
} else if (existingContent[exampleDataEndIndex] === '}') {
bracketCount--;
}
exampleDataEndIndex++;
}
if (bracketCount === 0) {
existingContent =
existingContent.substring(0, exampleDataStartIndex) +
newExampleData +
existingContent.substring(exampleDataEndIndex);
}
}
} else {
// Добавляем exampleData в конец файла
existingContent += `\n\n// Пример данных для редактора шаблонов\n${newExampleData}\n`;
}
// Сохраняем обновленный файл
await window.electronAPI.writeCooptypeFile(template.id, existingContent);
console.log('Файл шаблона успешно обновлен');
} catch (error) {
console.error('Ошибка при обновлении файла шаблона:', error);
}
}
private getHardcodedTemplatesList(): TemplateFile[] {
const templates = [
'1.WalletAgreement',
'100.ParticipantApplication',
'1000.InvestmentAgreement',
'1001.InvestByResultStatement',
'1002.InvestByResultAct',
'1005.InvestByMoneyStatement',
'101.SelectBranchStatement',
'1010.InvestMembershipConvertation',
'2.RegulationElectronicSignature',
'3.PrivacyPolicy',
'300.AnnualGeneralMeetingAgenda',
'301.AnnualGeneralMeetingSovietDecision',
'302.AnnualGeneralMeetingNotification',
'303.AnnualGeneralMeetingVotingBallot',
'304.AnnualGeneralMeetingDecision',
'4.UserAgreement',
'50.CoopenomicsAgreement',
'501.DecisionOfParticipantApplication',
'599.ProjectFreeDecision',
'600.FreeDecision',
'700.AssetContributionStatement',
'701.AssetContributionDecision',
'702.AssetContributionAct',
'800.ReturnByAssetStatement',
'801.ReturnByAssetDecision',
'802.ReturnByAssetAct',
];
return templates.map((templateId) => {
const name = this.formatTemplateName(templateId);
const fileName = `${templateId}.json`;
return {
id: templateId,
name,
title: name,
fileName,
@@ -68,64 +506,13 @@ export class TemplateService {
});
}
async loadTemplate(fileName: string): Promise<TemplateFile | null> {
try {
// В реальном приложении здесь будет запрос к серверу
// Пока что возвращаем заглушку
const response = await fetch(`/api/templates/${fileName}`);
if (!response.ok) {
throw new Error('Template not found');
}
const data = await response.json();
return {
id: fileName.replace('.json', ''),
name: this.formatTemplateName(fileName),
title: this.formatTemplateName(fileName),
fileName,
context: data.context || '',
translation: data.translation || {},
exampleData: data.object_model || data.model || {},
};
} catch (error) {
console.error('Error loading template:', error);
return null;
}
}
async saveTemplate(template: TemplateFile): Promise<boolean> {
try {
// В реальном приложении здесь будет запрос к серверу для сохранения
const data = {
context: template.context,
translation: template.translation,
exampleData: template.exampleData,
};
const response = await fetch(`/api/templates/${template.fileName}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
return response.ok;
} catch (error) {
console.error('Error saving template:', error);
return false;
}
}
private formatTemplateName(fileName: string): string {
private formatTemplateName(templateId: string): string {
// Форматируем имя файла для отображения
const name = fileName.replace('.json', '');
const parts = name.split('.');
const parts = templateId.split('.');
if (parts.length === 2) {
const [id, namepart] = parts;
// Преобразуем camelCase в читаемый формат
// Преобразуем PascalCase в читаемый формат
const formatted = namepart
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
@@ -134,6 +521,6 @@ export class TemplateService {
return `${id}. ${formatted}`;
}
return name;
return templateId;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineStore } from 'pinia';
export const useSettingsStore = defineStore('settings', {
state: () => ({
registryPath: '',
isFirstLaunch: true,
}),
actions: {
setRegistryPath(path: string) {
this.registryPath = path;
this.isFirstLaunch = false;
},
resetFirstLaunch() {
this.isFirstLaunch = false;
},
},
persist: {
enabled: true,
strategies: [
{
key: 'app-settings',
storage: localStorage,
},
],
},
});
+74 -7
View File
@@ -25,12 +25,30 @@ export const useAppState = defineStore('app', {
}),
actions: {
setContext(context: string, model: any) {
console.log('🔧 setContext called with:', {
contextLength: context?.length || 0,
modelVarsKeys: Object.keys(model.vars || {}),
modelTranslationKeys: Object.keys(model.translation || {}),
modelObjectModelKeys: Object.keys(model.object_model || {}),
modelObjectModelSample:
JSON.stringify(model.object_model || {}).substring(0, 200) + '...',
});
this.context = context;
this.model = {
vars: model.vars || {},
translation: model.translation || {},
object_model: model.object_model || {},
};
console.log('✅ Context and model set:', {
contextLength: this.context.length,
modelKeys: {
vars: Object.keys(this.model.vars),
translation: Object.keys(this.model.translation),
object_model: Object.keys(this.model.object_model),
},
});
},
// Новые actions для работы с множественными шаблонами
@@ -40,12 +58,35 @@ export const useAppState = defineStore('app', {
},
setCurrentTemplate(template: TemplateFile) {
console.log('🎯 Setting current template:', {
templateId: template.id,
templateName: template.name,
hasExampleData: !!(
template.exampleData && Object.keys(template.exampleData).length > 0
),
exampleDataKeys: Object.keys(template.exampleData || {}),
exampleDataSample:
JSON.stringify(template.exampleData || {}).substring(0, 200) + '...',
contextLength: template.context?.length || 0,
});
this.currentTemplate = template;
this.setContext(template.context, {
// Полностью перезаписываем данные новым шаблоном, не сохраняя старые
const modelData = {
vars: {},
translation: template.translation,
object_model: template.exampleData,
object_model: template.exampleData || {}, // Если нет exampleData, используем пустой объект
};
console.log('📝 Setting context with model:', {
contextLength: template.context?.length || 0,
translationKeys: Object.keys(modelData.translation || {}),
objectModelKeys: Object.keys(modelData.object_model || {}),
objectModelSample:
JSON.stringify(modelData.object_model).substring(0, 200) + '...',
});
this.setContext(template.context, modelData);
},
updateCurrentTemplate() {
@@ -75,6 +116,16 @@ export const useAppState = defineStore('app', {
},
parseContext() {
console.log('🔍 parseContext started with current state:', {
currentTemplateId: this.currentTemplate?.id || 'none',
currentTemplateHasExampleData: !!(
this.currentTemplate?.exampleData &&
Object.keys(this.currentTemplate.exampleData).length > 0
),
currentModelObjectModelKeys: Object.keys(this.model.object_model || {}),
contextLength: this.context?.length || 0,
});
const variableRegex = /{{\s*([\w\.]+)(?:\s*\|\s*\w+)?\s*}}/g;
// Обновлённый translationRegex для работы с переменными без именования
const translationRegex = /{% trans '([^']+)'(?:,\s*([\w\.]+))* %}/g;
@@ -194,7 +245,7 @@ export const useAppState = defineStore('app', {
});
}
// Мержим существующие данные
// Мержим существующие данные только для текущего шаблона
const mergeWithPreservation = (
target: Record<string, any>,
source: Record<string, any>
@@ -223,13 +274,29 @@ export const useAppState = defineStore('app', {
mergeWithPreservation(this.model.vars, variables);
mergeWithPreservation(this.model.translation, translations);
// Объединяем object_model
mergeWithPreservation(this.model.object_model, objectModel);
// Если есть exampleData в текущем шаблоне, используем его как базу
if (
this.currentTemplate &&
this.currentTemplate.exampleData &&
Object.keys(this.currentTemplate.exampleData).length > 0
) {
// Мержим с exampleData как базой
const baseObjectModel = { ...this.currentTemplate.exampleData };
mergeWithPreservation(baseObjectModel, objectModel);
this.model.object_model = baseObjectModel;
} else {
// Если нет exampleData, используем только парсинг из контекста
this.model.object_model = objectModel;
}
console.log('После парсинга (исключены переменные циклов):', {
console.log('После парсинга:', {
loopVariables: Array.from(loopVariables),
vars: this.model.vars,
object_model: this.model.object_model,
hasExampleData: !!(
this.currentTemplate &&
Object.keys(this.currentTemplate.exampleData || {}).length > 0
),
});
} else {
this.model = {
@@ -262,7 +329,7 @@ export const useAppState = defineStore('app', {
{
key: 'app',
storage: localStorage,
paths: ['context', 'model', 'html', 'currentTemplate'],
paths: ['context', 'html', 'currentTemplate'],
},
],
},
-75
View File
@@ -1,75 +0,0 @@
{
"context": "<div class=\"digital-document\"><p style=\"text-align: center;\"><h3>{% trans 'AGENDA_PROPOSAL_TITLE' %}</h3></p><p style=\"text-align: center;\">{% if meet.type == 'regular' %}{% trans 'ANNUAL_REGULAR' %}{% else %}{% trans 'ANNUAL_EXTRAORDINARY' %}{% endif %} {% trans 'GENERAL_MEETING_SHAREHOLDERS' %}</p><p style=\"text-align: center;\">{{vars.full_abbr_genitive}} «{{vars.name}}»</p><p style=\"text-align: right;\">{{ coop.city }}, {{ meet.created_at_day }} {{ meet.created_at_month }} {{ meet.created_at_year }} г.</p><p>{% trans 'MEETING_DATE_LABEL' %}: {{ meet.open_at_date }}</p><p>{% trans 'MEETING_TIME_LABEL' %}: {{ meet.open_at_time }}</p><p>{% trans 'MEETING_FORMAT_LABEL' %}: {% trans 'MEETING_FORMAT_VALUE' %}</p><p>{% trans 'REGISTRATION_DATETIME' %}: {{ meet.registration_datetime }}</p><p>{% trans 'VOTING_DEADLINE' %}: {% trans 'NO_LATER_THAN' %} {{ meet.close_at_datetime }}</p><h3>{% trans 'AGENDA_QUESTIONS' %}:</h3>{% for question in questions %}<p>{{ question.number }}. {{ question.title }}</p>{% if question.context %}<p>{{ question.context }}</p>{% endif %}{% endfor %}<h3>{% trans 'PROJECT_DECISIONS' %}:</h3>{% for question in questions %}<p>{% trans 'PROJECT_DECISION_LABEL' %} {{ question.number }}: {{ question.decision }}</p>{% endfor %}<p>{{ meet.presider_last_name }} {{ meet.presider_first_name }} {{ meet.presider_middle_name }}</p></div>\n\n<style>\n .digital-document {\n padding: 20px;\n white-space: pre-wrap;\n }\n</style>",
"model": {
"coop.city": "Москва",
"meet.type": "regular",
"meet.created_at_day": "12",
"meet.created_at_month": "февраля",
"meet.created_at_year": "2024",
"meet.open_at_date": "12.03.2024",
"meet.open_at_time": "10:00",
"meet.registration_datetime": "12.03.2024, 09:30",
"meet.close_at_datetime": "15.03.2024",
"meet.presider_last_name": "Иванов",
"meet.presider_first_name": "Петр",
"meet.presider_middle_name": "Сидорович",
"question.number": "1",
"question.title": "Отчет Председателя Потребительского Кооператива о проделанной работе за период с 01.01.2023 г. по 31.12.2023 г.",
"question.context": "Подробная информация об отчете приведена в документе...",
"question.decision": "Принять Отчет Председателя Совета и признать результаты деятельности удовлетворительными",
"vars.full_abbr_genitive": "Потребительского Кооператива",
"vars.name": "ВОСХОД"
},
"translation": {
"AGENDA_PROPOSAL_TITLE": "ПРЕДЛОЖЕНИЕ ПОВЕСТКИ",
"ANNUAL_REGULAR": "ОЧЕРЕДНОГО",
"ANNUAL_EXTRAORDINARY": "ВНЕОЧЕРЕДНОГО",
"GENERAL_MEETING_SHAREHOLDERS": "СОБРАНИЯ ПАЙЩИКОВ",
"MEETING_DATE_LABEL": "Дата проведения Собрания",
"MEETING_TIME_LABEL": "Время проведения Собрания",
"MEETING_FORMAT_LABEL": "Форма проведения собрания",
"MEETING_FORMAT_VALUE": "заочное",
"REGISTRATION_DATETIME": "Дата и время регистрации участников Собрания",
"VOTING_DEADLINE": "Дата и время сбора заявлений пайщиков с результатом голосования",
"NO_LATER_THAN": "не позднее",
"AGENDA_QUESTIONS": "ВОПРОСЫ ПОВЕСТКИ ДНЯ",
"PROJECT_DECISIONS": "ПРОЕКТЫ РЕШЕНИЙ",
"PROJECT_DECISION_LABEL": "ПРОЕКТ РЕШЕНИЯ по вопросу"
},
"object_model": {
"coop": {
"city": "Москва"
},
"meet": {
"type": "regular",
"created_at_day": "12",
"created_at_month": "февраля",
"created_at_year": "2024",
"open_at_date": "12.03.2024",
"open_at_time": "10:00",
"registration_datetime": "12.03.2024, 09:30",
"close_at_datetime": "15.03.2024",
"presider_last_name": "Иванов",
"presider_first_name": "Петр",
"presider_middle_name": "Сидорович"
},
"questions": [
{
"number": "1",
"title": "Отчет Председателя Потребительского Кооператива о проделанной работе за период с 01.01.2023 г. по 31.12.2023 г.",
"context": "Подробная информация об отчете приведена в документе...",
"decision": "Принять Отчет Председателя Совета и признать результаты деятельности удовлетворительными"
},
{
"number": "2",
"title": "Утверждение Заключения Ревизионной Комиссии о ревизии деятельности за период с 01.01.2023 г. по 31.12.2023 г.",
"context": "Заключение Ревизионной Комиссии содержит...",
"decision": "Утвердить Заключение Ревизионной Комиссии о ревизии деятельности"
}
],
"vars": {
"full_abbr_genitive": "Потребительского Кооператива",
"name": "ВОСХОД"
}
}
}