diff --git a/languages/en_us/OnlyOfficeTemplates.php b/languages/en_us/OnlyOfficeTemplates.php new file mode 100644 index 00000000..1eae169d --- /dev/null +++ b/languages/en_us/OnlyOfficeTemplates.php @@ -0,0 +1,24 @@ + 'Document templates', + 'LBL_OOT_SELECT_TEMPLATE' => 'Template', + 'LBL_OOT_FORMAT' => 'Format', + 'LBL_OOT_FORMAT_PDF' => 'PDF', + 'LBL_OOT_FORMAT_DOCX' => 'DOCX', + 'LBL_OOT_DOWNLOAD' => 'Download', + 'LBL_OOT_SAVE_TO_DOCUMENTS' => 'Save to Documents', + 'LBL_OOT_NO_TEMPLATES' => 'No templates for this module', + 'LBL_OOT_EMPTY_LIST' => 'No templates found', + 'LBL_OOT_NAME' => 'Name', + 'LBL_OOT_MODULE' => 'Module', + 'LBL_OOT_FILE' => 'File', + 'LBL_OOT_CREATED_AT' => 'Created', + 'LBL_OOT_ADD_TEMPLATE' => 'Add template', + 'LBL_OOT_FILE_HINT' => 'DOCX files only', + 'LBL_OOT_EDIT_TEMPLATE' => 'Edit template', + 'LBL_OOT_NEW_TEMPLATE' => 'New template', + 'LBL_OOT_EDITOR_HINT' => 'Edit the document on the right. Saving to S3 happens automatically when closing or when clicking Save in the editor.', + 'LBL_OOT_EDITOR_FALLBACK' => 'If OnlyOffice is not configured, you can upload a DOCX file instead.', + 'LBL_OOT_ADD_VIA_UPLOAD' => 'Upload DOCX file', + 'LBL_OOT_UPLOAD_FILE' => 'Upload file', +]; diff --git a/languages/ru_ru/OnlyOfficeTemplates.php b/languages/ru_ru/OnlyOfficeTemplates.php new file mode 100644 index 00000000..318218f9 --- /dev/null +++ b/languages/ru_ru/OnlyOfficeTemplates.php @@ -0,0 +1,24 @@ + 'Шаблоны документов', + 'LBL_OOT_SELECT_TEMPLATE' => 'Шаблон', + 'LBL_OOT_FORMAT' => 'Формат', + 'LBL_OOT_FORMAT_PDF' => 'PDF', + 'LBL_OOT_FORMAT_DOCX' => 'DOCX', + 'LBL_OOT_DOWNLOAD' => 'Скачать', + 'LBL_OOT_SAVE_TO_DOCUMENTS' => 'Сохранить в Документы', + 'LBL_OOT_NO_TEMPLATES' => 'Нет шаблонов для этого модуля', + 'LBL_OOT_EMPTY_LIST' => 'Шаблоны не найдены', + 'LBL_OOT_NAME' => 'Название', + 'LBL_OOT_MODULE' => 'Модуль', + 'LBL_OOT_FILE' => 'Файл', + 'LBL_OOT_CREATED_AT' => 'Создан', + 'LBL_OOT_ADD_TEMPLATE' => 'Добавить шаблон', + 'LBL_OOT_FILE_HINT' => 'Только файлы DOCX', + 'LBL_OOT_EDIT_TEMPLATE' => 'Редактирование шаблона', + 'LBL_OOT_NEW_TEMPLATE' => 'Новый шаблон', + 'LBL_OOT_EDITOR_HINT' => 'Редактируйте документ справа. Сохранение в S3 происходит автоматически при закрытии или по кнопке «Сохранить» в редакторе.', + 'LBL_OOT_EDITOR_FALLBACK' => 'Если OnlyOffice не настроен, можно загрузить готовый DOCX-файл.', + 'LBL_OOT_ADD_VIA_UPLOAD' => 'Загрузить DOCX-файл', + 'LBL_OOT_UPLOAD_FILE' => 'Загрузить файл', +]; diff --git a/layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl b/layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl new file mode 100644 index 00000000..958bfd85 --- /dev/null +++ b/layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl @@ -0,0 +1,54 @@ +{strip} +
+
+
+

{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}

+
+
+
+ +
+
+ {if $ERROR_MSG} +
{$ERROR_MSG|escape}
+ {/if} +
+ + + + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + {vtranslate('LBL_OOT_FILE_HINT', $MODULE_NAME)} +
+
+
+
+ + {vtranslate('LBL_CANCEL', $MODULE_NAME)} +
+
+
+
+
+
+{/strip} diff --git a/layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl b/layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl new file mode 100644 index 00000000..30021093 --- /dev/null +++ b/layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl @@ -0,0 +1,76 @@ +{strip} +
+
+
+

{if $TEMPLATE.id gt 0}{vtranslate('LBL_OOT_EDIT_TEMPLATE', $MODULE_NAME)}{else}{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}{/if}

+ {if $ERROR_MSG}
{$ERROR_MSG|escape}
{/if} +
+ + + + +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + {vtranslate('LBL_CANCEL', $MODULE_NAME)} +
+
+
+
+
+ {if $OOT_EDITOR_AVAILABLE} +

{vtranslate('LBL_OOT_EDITOR_HINT', $MODULE_NAME)}

+
+ + + {else} +
{$OOT_EDITOR_MESSAGE|escape}
+

{vtranslate('LBL_OOT_EDITOR_FALLBACK', $MODULE_NAME)}

+ {vtranslate('LBL_OOT_ADD_VIA_UPLOAD', $MODULE_NAME)} + {/if} +
+
+
+{/strip} diff --git a/layouts/v7/modules/OnlyOfficeTemplates/GetTemplateActions.tpl b/layouts/v7/modules/OnlyOfficeTemplates/GetTemplateActions.tpl new file mode 100644 index 00000000..f3582368 --- /dev/null +++ b/layouts/v7/modules/OnlyOfficeTemplates/GetTemplateActions.tpl @@ -0,0 +1,65 @@ +{* + OnlyOfficeTemplates widget: template list, format (PDF/DOCX), Download / Save to Documents +*} +{if $CRM_TEMPLATES_EXIST eq 0} + +
  • +
    + + +
    +
    + + +
    +
    + {vtranslate('LBL_OOT_DOWNLOAD','OnlyOfficeTemplates')} + {vtranslate('LBL_OOT_SAVE_TO_DOCUMENTS','OnlyOfficeTemplates')} +
    +
  • +{else} +
  • {vtranslate('LBL_OOT_NO_TEMPLATES','OnlyOfficeTemplates')}
  • +{/if} +{if $CRM_TEMPLATES_EXIST eq 0} + +{/if} diff --git a/layouts/v7/modules/OnlyOfficeTemplates/List.tpl b/layouts/v7/modules/OnlyOfficeTemplates/List.tpl new file mode 100644 index 00000000..5bee1030 --- /dev/null +++ b/layouts/v7/modules/OnlyOfficeTemplates/List.tpl @@ -0,0 +1,47 @@ +{strip} +
    +
    + +
    + +
    +
    + {if empty($TEMPLATES)} +
    {vtranslate('LBL_OOT_EMPTY_LIST', $MODULE_NAME)}
    + {else} + + + + + + + + + + + {foreach from=$TEMPLATES item=TPL} + + + + + + + {/foreach} + +
    {vtranslate('LBL_OOT_NAME', $MODULE_NAME)}{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}{vtranslate('LBL_OOT_FILE', $MODULE_NAME)}{vtranslate('LBL_OOT_CREATED_AT', $MODULE_NAME)}
    + {$TPL.name|escape} + {$TPL.module|escape}{$TPL.file_name|escape}{$TPL.created_at|escape}
    + {/if} +
    +
    +
    +{/strip} diff --git a/modules/OnlyOfficeTemplates/DESCRIPTION.md b/modules/OnlyOfficeTemplates/DESCRIPTION.md new file mode 100644 index 00000000..c30995be --- /dev/null +++ b/modules/OnlyOfficeTemplates/DESCRIPTION.md @@ -0,0 +1,158 @@ +# OnlyOfficeTemplates — подробное описание модуля + +## Назначение + +Модуль **OnlyOfficeTemplates** для ClientRight CRM (Vtiger-based) предназначен для: + +- создания и хранения DOCX-шаблонов документов в S3; +- редактирования шаблонов в веб-интерфейсе через OnlyOffice Document Editor (по аналогии с PDFMaker: слева метаданные, справа редактор); +- генерации документов по шаблону с подстановкой полей записи и связанных модулей (плейсхолдеры `{{field}}`, `{{ModuleName__field}}`); +- выдачи результата в формате **PDF** (через OnlyOffice Conversion API) или **DOCX**; +- скачивания сгенерированного файла или сохранения в модуль «Документы» CRM. + +Модуль портативный: конфигурация через переменные окружения или внешний конфиг, без жёсткой привязки к окружению. + +--- + +## Возможности + +### Хранение шаблонов + +- Шаблоны хранятся в **S3-совместимом хранилище** по пути: + `{OOT_S3_PREFIX}/templates/{template_id}/{имя_файла}.docx` + (по умолчанию `OOT_S3_PREFIX` = `crm2/OnlyOfficeTemplates`). +- Метаданные — в таблице БД `vtiger_oot_templates`: id, name, module, s3_key, file_name, owner, created_at. + +### Редактирование шаблонов (как в PDFMaker) + +- **Список шаблонов** (Инструменты → Шаблоны документов): таблица с именем (ссылка на редактирование), модулем, файлом, датой создания. +- **Добавить шаблон:** создаётся черновик, открывается экран редактирования: + - **Слева:** форма — название шаблона, выбор модуля CRM, кнопки «Сохранить» / «Отмена». + - **Справа:** OnlyOffice Document Editor в iframe; документ загружается с нашего сервера (экшен GetDocument), при сохранении/закрытии OnlyOffice Document Server отправляет файл на callback, мы сохраняем его в S3 и обновляем запись в БД. +- **Загрузить файл:** альтернативный способ — форма с полями «Название», «Модуль» и выбором DOCX-файла; отправка в экшен UploadTemplate (загрузка в S3 и запись в БД). +- Редактирование существующего шаблона: по клику на имя в списке открывается тот же экран с подставленными метаданными и документом из S3. + +### Генерация документов по шаблону + +- На **карточке записи** (любой entity-модуль) в боковой панели отображается виджет OnlyOfficeTemplates: + - выбор шаблона (по текущему модулю записи); + - выбор формата: PDF или DOCX; + - кнопки «Скачать» и «Сохранить в Документы». +- Подстановка в шаблоне: + - поля текущей записи: `{{fieldname}}`; + - поля связанных модулей: `{{ModuleName__fieldname}}` (например `{{Account__accountname}}`). +- Реализация: загрузка DOCX из S3, подстановка плейсхолдеров (PHPWord), при необходимости конвертация в PDF через OnlyOffice Conversion API, затем отдача файла или сохранение в Документы (в т.ч. в S3). + +--- + +## Требования + +- **PHP:** расширения zip, xml, curl (или allow_url_fopen для Conversion API). +- **Composer:** пакеты `phpoffice/phpword`, `aws/aws-sdk-php` (как правило, уже в корне проекта). +- **S3:** доступ к S3-совместимому хранилищу (ключ, секрет, endpoint, bucket). +- **OnlyOffice (опционально):** + - **Conversion API** — для выдачи PDF (URL в `OOT_ONLYOFFICE_CONVERT_URL` / `ONLYOFFICE_CONVERT_URL`). + - **Document Server** — для экрана редактирования шаблона (URL в `ONLYOFFICE_DOCUMENT_SERVER` / `OOT_ONLYOFFICE_DOCUMENT_SERVER`). Document Server должен иметь доступ по HTTP(S) к CRM (загрузка документа и callback). + +--- + +## Установка + +1. Скопировать в целевую CRM: + - `modules/OnlyOfficeTemplates/` (все файлы); + - `layouts/v7/modules/OnlyOfficeTemplates/` (все шаблоны и ресурсы). +2. Настроить конфигурацию (см. ниже). +3. Выполнить установку БД и виджетов: + - **Рекомендуется:** из корня CRM выполнить + `php modules/OnlyOfficeTemplates/install.php` + или открыть в браузере соответствующий URL с правами администратора. + - Альтернатива: упаковать модуль в zip с `manifest.xml` и импортировать через Module Manager. +4. При необходимости перегенерировать кэш меню (например `parent_tabdata.php`), чтобы пункт «Шаблоны документов» отображался в разделе «Инструменты». + +--- + +## Конфигурация + +Модуль читает настройки в следующем порядке. + +### 1. Внешний конфиг + +Если существует файл `crm_extensions/file_storage/config.php` и в нём возвращается массив с ключом `s3`, используются данные S3 оттуда. Имя бакета берётся из `s3['bucket']`, при отсутствии — из `bucket`, `s3_bucket` в корне массива или из переменной окружения `S3_BUCKET`. + +### 2. Переменные окружения + +Поддерживаются два способа загрузки переменных: + +- **EnvLoader** (если есть `crm_extensions/shared/EnvLoader.php`): загружается файл `crm_extensions/.env`. Переменные попадают в `$_ENV` и в массив EnvLoader (модуль читает их через вспомогательную функцию, а не только через `getenv()`). +- **getenv()** — если переменные заданы в окружении веб-сервера. + +Используемые переменные: + +| Переменная | Описание | +|------------|----------| +| `S3_ACCESS_KEY` | Ключ доступа S3 | +| `S3_SECRET_KEY` | Секретный ключ S3 | +| `S3_ENDPOINT` | URL эндпоинта S3 (напр. `https://s3.twcstorage.ru`) | +| `S3_BUCKET` | Имя бакета S3 | +| `S3_REGION` | Регион (по умолчанию `ru-1`) | +| `OOT_S3_PREFIX` | Префикс папки модуля в S3 (по умолчанию `crm2/OnlyOfficeTemplates`) | +| `OOT_ONLYOFFICE_CONVERT_URL` | URL OnlyOffice Conversion API (для PDF) | +| `ONLYOFFICE_CONVERT_URL` | То же, альтернативное имя | +| `OOT_ONLYOFFICE_DOCUMENT_SERVER` | URL OnlyOffice Document Server (для редактора шаблонов) | +| `ONLYOFFICE_DOCUMENT_SERVER` | То же, альтернативное имя | +| `OOT_DOCUMENT_SECRET` | Секрет для подписи URL документа (рекомендуется в продакшене) | +| `OOT_DOCUMENTS_S3_PREFIX` | Префикс в S3 для файлов, сохраняемых в Документы (по умолчанию `crm2/CRM_Active_Files/Documents`) | + +Без Conversion API генерация PDF недоступна (только DOCX). Без Document Server экран редактирования с OnlyOffice недоступен, но остаётся загрузка готового DOCX через «Загрузить файл». + +--- + +## Структура файлов модуля + +- **config.php** — загрузка конфигурации (внешний конфиг + .env), функция `OnlyOfficeTemplates_getConfig()` и вспомогательная `OnlyOfficeTemplates_env()` для чтения переменных из .env/EnvLoader. +- **OnlyOfficeTemplates.php** — обработчик vtlib (установка/удаление таблиц, добавление/удаление виджета на карточках). +- **schema.xml** — описание таблиц `vtiger_oot_templates`, `vtiger_oot_templates_seq`. +- **models/OnlyOfficeTemplates_Model.php** — список шаблонов по модулю, получение шаблона по id, конфиг. +- **resources/S3Helper.php** — работа с S3 (ключи шаблонов/temp, загрузка/скачивание). +- **resources/MergeService.php** — подстановка плейсхолдеров в DOCX (PHPWord). +- **resources/ConvertService.php** — конвертация DOCX → PDF через OnlyOffice Conversion API. +- **actions/Install.php** — установка через браузер. +- **actions/UploadTemplate.php** — загрузка DOCX (POST: name, module_name, file) и сохранение в S3 и БД. +- **actions/CreateDraft.php** — создание черновика шаблона и редирект на экран редактирования. +- **actions/SaveMetadata.php** — сохранение имени и модуля шаблона, редирект на Edit или List. +- **actions/GetDocument.php** — отдача DOCX для OnlyOffice Document Server (из S3 или пустой документ); опциональная проверка токена (OOT_DOCUMENT_SECRET). +- **actions/OnlyOfficeCallback.php** — приём callback от OnlyOffice Document Server при сохранении документа, скачивание файла по переданному URL и сохранение в S3, обновление записи в `vtiger_oot_templates`. +- **actions/CreateFromTemplate.php** — генерация документа по шаблону (merge → опционально PDF → скачать или сохранить в Документы). +- **views/List.php**, **views/Edit.php**, **views/AddTemplate.php**, **views/GetTemplateActions.php** — представления списка, редактирования (форма + OnlyOffice), загрузки файла и виджета на карточке. +- **layouts/v7/modules/OnlyOfficeTemplates/** — шаблоны Smarty (List.tpl, Edit.tpl, AddTemplate.tpl, GetTemplateActions.tpl). +- **languages/** — языковые строки (ru_ru, en_us). + +--- + +## База данных + +- **vtiger_oot_templates:** id, name, module, s3_key, file_name, owner, created_at (и при необходимости settings в формате JSON). +- **vtiger_oot_templates_seq:** служебная таблица для генерации id при необходимости. + +Регистрация модуля в меню: запись в `vtiger_tab` (например parent Tools), в `vtiger_parenttabrel`, в `vtiger_profile2tab` для прав доступа. Пункт меню кэшируется в `parent_tabdata.php` — при отсутствии пункта может потребоваться перегенерация кэша. + +--- + +## Безопасность + +- **GetDocument:** вызывается OnlyOffice Document Server без сессии пользователя. При заданном `OOT_DOCUMENT_SECRET` в URL документа добавляется подпись (HMAC), проверяемая в GetDocument; без секрета доступ по ссылке возможен для любого, кто знает template_id. +- **OnlyOfficeCallback:** вызывается только Document Server; проверка прав пользователя не выполняется, идентификация по ключу документа (template id). В продакшене целесообразно ограничить доступ к callback по сети (например, только с хоста Document Server). +- **UploadTemplate:** требуется право редактирования настроек (`Settings`, `Edit`) или иная выбранная при интеграции проверка. + +--- + +## Версии и совместимость + +- Модуль разработан для CRM на базе Vtiger (ClientRight), интерфейс v7. +- Зависимости: PHP 7.x+, PHPWord, AWS SDK для PHP, S3-совместимое хранилище; OnlyOffice — опционально. + +--- + +## Краткое описание для репозитория (Gitea) + +**OnlyOfficeTemplates** — модуль ClientRight CRM для создания и хранения DOCX-шаблонов в S3, редактирования их в OnlyOffice Document Editor (интерфейс как в PDFMaker: слева метаданные, справа редактор с сохранением в S3 по callback), генерации документов по шаблону с подстановкой полей записи и связанных модулей, выдачи в PDF (через OnlyOffice Conversion API) или DOCX и сохранения в Документы. Конфигурация через .env или внешний конфиг; модуль портативный. diff --git a/modules/OnlyOfficeTemplates/INSTALL.md b/modules/OnlyOfficeTemplates/INSTALL.md new file mode 100644 index 00000000..6e297ba6 --- /dev/null +++ b/modules/OnlyOfficeTemplates/INSTALL.md @@ -0,0 +1,38 @@ +# Установка OnlyOfficeTemplates + +## Шаги + +1. **Скопировать файлы** + - `modules/OnlyOfficeTemplates/` — целиком. + - `layouts/v7/modules/OnlyOfficeTemplates/` — целиком. + - Файлы языков из `languages/*/OnlyOfficeTemplates.php` (ru_ru, en_us и при необходимости другие). + +2. **Настроить конфигурацию** + - Либо используйте существующий `crm_extensions/file_storage/config.php` (S3 будет взят оттуда). + - Либо задайте в .env: `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_ENDPOINT`, `S3_BUCKET`. + - Для конвертации в PDF задайте `OOT_ONLYOFFICE_CONVERT_URL` (например `https://office.clientright.ru:9443/ConvertService.ashx`). + +3. **Установить модуль в CRM** + - Из корня CRM выполните: + ```bash + php modules/OnlyOfficeTemplates/install.php + ``` + - Либо зарегистрируйте модуль вручную в vtiger_tab и выполните SQL из `schema.xml`, затем вызовите `$mod = new OnlyOfficeTemplates(); $mod->executeSql(); $mod->addLinksToEntityModules();` + +4. **Добавить шаблоны** + - Через экшен UploadTemplate (POST с полями name, module_name и файлом file). + - Либо вручную: загрузить DOCX в S3 в `{OOT_S3_PREFIX}/templates/{id}/{filename}.docx` и вставить запись в `vtiger_oot_templates`. + +## Проверка + +- Откройте карточку любой записи модуля (например, Проект). В боковой панели должен появиться виджет «OnlyOffice Templates» со списком шаблонов (если они добавлены для этого модуля). +- Выберите шаблон, формат (PDF или DOCX), нажмите «Скачать» или «Сохранить в Документы». + +## Переменные окружения (кратко) + +| Переменная | Описание | +|------------|----------| +| S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, S3_BUCKET | Доступ к S3 | +| OOT_S3_PREFIX | Префикс папки модуля в S3 (по умолчанию crm2/OnlyOfficeTemplates) | +| OOT_ONLYOFFICE_CONVERT_URL | URL OnlyOffice Conversion API для DOCX→PDF | +| OOT_DOCUMENTS_S3_PREFIX | Префикс пути при сохранении в Документы | diff --git a/modules/OnlyOfficeTemplates/OnlyOfficeTemplates.php b/modules/OnlyOfficeTemplates/OnlyOfficeTemplates.php new file mode 100644 index 00000000..cfea35af --- /dev/null +++ b/modules/OnlyOfficeTemplates/OnlyOfficeTemplates.php @@ -0,0 +1,107 @@ +db = PearDatabase::getInstance(); + } + + public function vtlib_handler($modulename, $event_type) + { + switch ($event_type) { + case 'module.postinstall': + $this->executeSql(); + $this->addLinksToEntityModules(); + break; + case 'module.preupdate': + case 'module.disabled': + $this->removeLinksFromEntityModules(); + break; + case 'module.enabled': + case 'module.postupdate': + $this->executeSql(); + $this->removeLinksFromEntityModules(); + $this->addLinksToEntityModules(); + break; + case 'module.preuninstall': + $this->removeLinksFromEntityModules(); + break; + } + } + + /** + * Create tables from schema.xml + */ + public function executeSql() + { + $schemaPath = dirname(__FILE__) . '/schema.xml'; + if (!is_file($schemaPath)) { + return; + } + $xml = @simplexml_load_file($schemaPath); + if (!$xml || !isset($xml->tables->table)) { + return; + } + foreach ($xml->tables->table as $table) { + $name = (string)$table->name; + $sql = isset($table->sql) ? (string)$table->sql : ''; + if (empty($sql)) { + continue; + } + $this->db->pquery($sql, []); + } + // seq initial value + $this->db->pquery("INSERT IGNORE INTO vtiger_oot_templates_seq (id) VALUES (1)", []); + } + + /** + * Add DETAILVIEWSIDEBARWIDGET link to all entity modules (like PDFMaker). + */ + public function addLinksToEntityModules() + { + $result = $this->db->pquery( + "SELECT name FROM vtiger_tab WHERE isentitytype = ? AND presence = ?", + ['1', '0'] + ); + while ($row = $this->db->fetchByAssoc($result)) { + $moduleName = $row['name']; + $module = Vtiger_Module::getInstance($moduleName); + if (!$module) { + continue; + } + $module->deleteLink('DETAILVIEWSIDEBARWIDGET', 'OnlyOfficeTemplates'); + $module->addLink( + 'DETAILVIEWSIDEBARWIDGET', + 'OnlyOfficeTemplates', + 'module=OnlyOfficeTemplates&view=GetTemplateActions&record=$RECORD$' + ); + } + } + + /** + * Remove widget link from all entity modules. + */ + public function removeLinksFromEntityModules() + { + $result = $this->db->pquery( + "SELECT name FROM vtiger_tab WHERE isentitytype = ? AND presence = ?", + ['1', '0'] + ); + while ($row = $this->db->fetchByAssoc($result)) { + $module = Vtiger_Module::getInstance($row['name']); + if ($module) { + $module->deleteLink('DETAILVIEWSIDEBARWIDGET', 'OnlyOfficeTemplates'); + } + } + } +} diff --git a/modules/OnlyOfficeTemplates/README.md b/modules/OnlyOfficeTemplates/README.md new file mode 100644 index 00000000..49489bb3 --- /dev/null +++ b/modules/OnlyOfficeTemplates/README.md @@ -0,0 +1,59 @@ +# OnlyOfficeTemplates + +Модуль генерации документов из DOCX-шаблонов с подстановкой полей CRM (аналог PDFMaker). Результат — **PDF** (по умолчанию) или **DOCX**. При сохранении в Документы в формате DOCX документ можно редактировать через OnlyOffice (кнопка «Nextcloud» / open_file_v2). + +## Возможности + +- Шаблоны DOCX хранятся в **отдельной папке S3** (`crm2/OnlyOfficeTemplates/templates/`). +- **Редактирование по аналогии с PDFMaker:** слева — метаданные (имя, модуль), справа — OnlyOffice Document Editor; документ сохраняется в S3 через callback Document Server. +- Плейсхолдеры в шаблоне: `{{fieldname}}` для полей записи, `{{ModuleName__fieldname}}` для связанных модулей (Account, Contact и т.д.). +- В виджете карточки записи: выбор шаблона, формата (PDF/DOCX), действия «Скачать» и «Сохранить в Документы». +- При выборе PDF результат конвертируется через OnlyOffice Conversion API. +- Модуль **портативный**: можно развернуть в другом инстансе CRM без привязки к текущему `crm_extensions`. + +## Требования + +- PHP с расширениями: zip, xml, curl (или allow_url_fopen для Conversion API). +- Composer-зависимости: `phpoffice/phpword`, `aws/aws-sdk-php` (уже в корне проекта). +- Доступ к S3-совместимому хранилищу и (для PDF) к OnlyOffice Document Server (Conversion API). + +## Установка + +1. Скопируйте папку `modules/OnlyOfficeTemplates` и `layouts/v7/modules/OnlyOfficeTemplates` в целевой CRM. +2. Настройте переменные окружения или конфиг (см. раздел «Конфигурация»). +3. Выполните установку БД и виджетов одним из способов: + - **Через скрипт (рекомендуется):** + `php modules/OnlyOfficeTemplates/install.php` + из корня CRM (или откройте в браузере соответствующий URL с правами администратора). + - **Через Module Manager:** упакуйте модуль в zip с `manifest.xml` и импортируйте. +4. Добавьте шаблоны: загрузите DOCX в S3 в папку `{OOT_S3_PREFIX}/templates/{id}/{filename}.docx` и добавьте запись в `vtiger_oot_templates` (имя, модуль, s3_key, file_name, owner), либо используйте экшен UploadTemplate (см. ниже). + +## Конфигурация + +Модуль читает настройки из: + +1. **Внешний конфиг** (если есть): `crm_extensions/file_storage/config.php` — используются S3-данные оттуда. +2. **Переменные окружения** (.env в `crm_extensions` или в корне): + - `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_ENDPOINT`, `S3_BUCKET` — доступ к S3. + - `OOT_S3_PREFIX` — префикс папки модуля в S3 (по умолчанию `crm2/OnlyOfficeTemplates`). + - `OOT_ONLYOFFICE_CONVERT_URL` — URL Conversion API (например `https://office.example.com:9443/ConvertService.ashx` или `/converter`). + - `ONLYOFFICE_DOCUMENT_SERVER` или `OOT_ONLYOFFICE_DOCUMENT_SERVER` — URL OnlyOffice Document Server для редактора (например `https://documentserver`). Нужен для экрана редактирования шаблона (слева форма, справа OnlyOffice). Document Server должен иметь доступ по HTTP(S) к CRM (для загрузки документа и callback). + - `OOT_DOCUMENT_SECRET` — секрет для подписи URL документа (рекомендуется в продакшене). Если задан, в ссылку на документ добавляется токен; без него GetDocument доступен без проверки. + - `OOT_DOCUMENTS_S3_PREFIX` — префикс для файлов, сохраняемых в Документы (по умолчанию `crm2/CRM_Active_Files/Documents`). + +Без OnlyOffice Conversion API доступна только выдача DOCX (формат PDF не будет работать). Без Document Server редактирование шаблона в OnlyOffice недоступно, но можно загружать готовые DOCX через «Загрузить файл». + +## Редактирование и загрузка шаблонов + +- **Через OnlyOffice (как в PDFMaker):** «Добавить шаблон» → создаётся черновик → открывается экран: слева имя и модуль, справа OnlyOffice Document Editor. Документ по сохранению/закрытию отправляется в S3 через callback. Список шаблонов: имя — ссылка на редактирование. +- **Загрузить файл:** кнопка «Загрузить файл» открывает форму: имя, модуль, выбор DOCX; отправка в `UploadTemplate`. +- **Вручную:** загрузите DOCX в S3 по пути `{OOT_S3_PREFIX}/templates/{template_id}/{имя_файла}.docx` и вставьте запись в `vtiger_oot_templates`. + +## Структура БД + +- `vtiger_oot_templates` — id, name, module, s3_key, file_name, owner, created_at, settings (JSON, опционально). +- `vtiger_oot_templates_seq` — при необходимости для генерации id (опционально). + +## Портативность + +Модуль не изменяет ядро CRM и не зависит от наличия `crm_extensions`. Все пути и ключи задаются через конфиг/переменные окружения. В другом инстансе достаточно задать свои S3_*, OOT_* и (при необходимости) ONLYOFFICE_* и выполнить установку (install.php или импорт пакета). diff --git a/modules/OnlyOfficeTemplates/actions/CreateDraft.php b/modules/OnlyOfficeTemplates/actions/CreateDraft.php new file mode 100644 index 00000000..5716bd96 --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/CreateDraft.php @@ -0,0 +1,40 @@ +getModule(); + $tabId = getTabId($moduleName); + $privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel(); + if (!$privileges->hasModulePermission($tabId)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + + public function process(Vtiger_Request $request) + { + $adb = PearDatabase::getInstance(); + $currentUser = Users_Record_Model::getCurrentUserModel(); + $ownerId = $currentUser->getId(); + $name = $request->get('name') ?: vtranslate('LBL_OOT_NEW_TEMPLATE', $request->getModule()); + $module = $request->get('module_name'); + if (!$module) { + $r = $adb->pquery("SELECT name FROM vtiger_tab WHERE isentitytype = 1 AND presence = 0 ORDER BY name LIMIT 1", []); + $module = $adb->query_result($r, 0, 'name'); + } + $adb->pquery( + "INSERT INTO vtiger_oot_templates (name, module, s3_key, file_name, owner, created_at) VALUES (?, ?, '', '', ?, NOW())", + [$name, $module, $ownerId] + ); + $templateId = (int)$adb->getLastInsertID(); + if ($templateId <= 0) { + $r = $adb->pquery("SELECT MAX(id) AS m FROM vtiger_oot_templates", []); + $templateId = (int)$adb->query_result($r, 0, 'm'); + } + header('Location: index.php?module=OnlyOfficeTemplates&view=Edit&templateid=' . $templateId . '&app=TOOLS'); + } +} diff --git a/modules/OnlyOfficeTemplates/actions/CreateFromTemplate.php b/modules/OnlyOfficeTemplates/actions/CreateFromTemplate.php new file mode 100644 index 00000000..3210eb75 --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/CreateFromTemplate.php @@ -0,0 +1,185 @@ +get('record'); + $module = $request->get('source_module') ?: getSalesEntityType($record); + if (!isPermitted($module, 'DetailView', $record)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + + public function process(Vtiger_Request $request) + { + $recordId = (int)$request->get('record'); + $templateId = (int)$request->get('template_id'); + $format = strtolower($request->get('format') ?: 'pdf'); // pdf | docx + $mode = strtolower($request->get('mode') ?: 'download'); // download | save_to_documents + $module = $request->get('source_module') ?: getSalesEntityType($recordId); + + if (!$recordId || !$templateId) { + echo json_encode(['success' => false, 'error' => 'Missing record or template_id']); + return; + } + + require_once dirname(__DIR__) . '/models/OnlyOfficeTemplates_Model.php'; + require_once dirname(__DIR__) . '/resources/S3Helper.php'; + require_once dirname(__DIR__) . '/resources/MergeService.php'; + require_once dirname(__DIR__) . '/resources/ConvertService.php'; + + $model = new OnlyOfficeTemplates_Model(); + $template = $model->getTemplateById($templateId); + if (!$template) { + echo json_encode(['success' => false, 'error' => 'Template not found or access denied']); + return; + } + $config = $model->getConfig(); + $s3 = new OnlyOfficeTemplates_S3Helper($config); + $mergeService = new OnlyOfficeTemplates_MergeService($s3, $config); + $convertService = new OnlyOfficeTemplates_ConvertService($config); + + $placeholders = $mergeService->buildPlaceholders($module, $recordId); + $tempDir = null; + $docxPath = null; + $pdfPath = null; + + try { + $docxPath = $mergeService->mergeToFile($template['s3_key'], $placeholders); + $tempDir = dirname($docxPath); + $baseName = pathinfo($template['file_name'], PATHINFO_FILENAME); + $outExt = ($format === 'pdf') ? 'pdf' : 'docx'; + $outFileName = $baseName . '_' . $recordId . '.' . $outExt; + + if ($format === 'pdf') { + $docxUrl = $this->putMergedDocxForConversion($s3, $config, $docxPath, $recordId, $templateId); + if (!$docxUrl) { + echo json_encode(['success' => false, 'error' => 'Could not expose DOCX URL for conversion']); + return; + } + $result = $convertService->convertToPdf($docxUrl, $template['file_name']); + if (!$result['success']) { + echo json_encode(['success' => false, 'error' => $result['error']]); + return; + } + $pdfPath = $result['pdfPath']; + } + + if ($mode === 'save_to_documents') { + $fileToSave = ($format === 'pdf') ? $pdfPath : $docxPath; + $docId = $this->saveToDocuments($request, $module, $recordId, $fileToSave, $outFileName, $config); + $this->cleanupTemp($tempDir, $docxPath, $pdfPath); + echo json_encode(['success' => true, 'document_id' => $docId, 'message' => 'Saved to Documents']); + return; + } + + $downloadPath = ($format === 'pdf') ? $pdfPath : $docxPath; + $mime = ($format === 'pdf') ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + header('Content-Type: ' . $mime); + header('Content-Disposition: attachment; filename="' . basename($outFileName) . '"'); + header('Content-Length: ' . filesize($downloadPath)); + readfile($downloadPath); + $this->cleanupTemp($tempDir, $docxPath, $pdfPath); + } catch (Exception $e) { + $this->cleanupTemp($tempDir, $docxPath, $pdfPath); + if ($request->get('ajax')) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } else { + throw $e; + } + } + } + + /** + * Upload merged DOCX to S3 temp and return public URL for OnlyOffice converter. + */ + private function putMergedDocxForConversion(OnlyOfficeTemplates_S3Helper $s3, array $config, $localPath, $recordId, $templateId) + { + $key = $s3->getTempKey($recordId, $templateId, 'docx'); + $s3->uploadFile($localPath, $key, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + $bucket = $s3->getBucket(); + $endpoint = $config['s3']['endpoint'] ?? ''; + $base = preg_replace('#^https?://#', 'https://', $endpoint); + if (empty($base)) { + $base = 'https://s3.twcstorage.ru'; + } + return rtrim($base, '/') . '/' . $bucket . '/' . $key; + } + + /** + * Save file as Document record and link to parent. Use Documents S3 structure if FilePathManager available. + */ + private function saveToDocuments(Vtiger_Request $request, $module, $recordId, $localPath, $fileName, array $config) + { + $adb = PearDatabase::getInstance(); + $currentUser = Users_Record_Model::getCurrentUserModel(); + $ownerId = $currentUser->getId(); + $docPrefix = $config['documents_s3_prefix'] ?? 'crm2/CRM_Active_Files/Documents'; + $bucket = $config['s3_bucket'] ?? $config['s3']['bucket']; + + $notesId = $adb->getUniqueID('vtiger_crmentity'); + if (!$notesId) { + $r = $adb->pquery("SELECT MAX(crmid) AS m FROM vtiger_crmentity", []); + $notesId = (int)$adb->query_result($r, 0, 'm') + 1; + } + + $title = pathinfo($fileName, PATHINFO_FILENAME); + $now = date('Y-m-d H:i:s'); + $s3Key = $docPrefix . '/' . $module . '/' . $module . '_' . $recordId . '/' . $title . '_' . $notesId . '.' . pathinfo($fileName, PATHINFO_EXTENSION); + + $s3 = new OnlyOfficeTemplates_S3Helper($config); + $contentType = (pathinfo($fileName, PATHINFO_EXTENSION) === 'pdf') ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + $s3->uploadFile($localPath, $s3Key, $contentType); + + $fileUrl = 'https://' . ($config['s3']['endpoint'] ?? 's3.twcstorage.ru'); + $fileUrl = preg_replace('#^https?://#', '', $fileUrl); + $fileUrl = 'https://' . $fileUrl . '/' . $bucket . '/' . $s3Key; + $fileSize = filesize($localPath); + + $adb->pquery( + "INSERT INTO vtiger_crmentity (crmid, smownerid, smcreatorid, modifiedby, setype, description, createdtime, modifiedtime, presence, deleted) VALUES (?,?,?,?,?,?,?,?,?,?)", + [$notesId, $ownerId, $ownerId, $ownerId, 'Documents', '', $now, $now, 1, 0] + ); + $adb->pquery( + "INSERT INTO vtiger_notes (notesid, title, filename, filesize, filetype, filelocationtype, filedownloadcount, createdtime, modifiedtime, folderid, notecontent) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + [$notesId, $title, $fileUrl, $fileSize, $contentType, 'E', 0, $now, $now, 0, ''] + ); + if (method_exists($adb, 'pquery')) { + $adb->pquery("INSERT INTO vtiger_senotesrel (crmid, notesid) VALUES (?,?)", [$recordId, $notesId]); + } + $adb->pquery("INSERT INTO vtiger_notescf (notesid) VALUES (?)", [$notesId]); + + if ($this->hasS3Columns($adb)) { + $adb->pquery("UPDATE vtiger_notes SET s3_bucket = ?, s3_key = ? WHERE notesid = ?", [$bucket, $s3Key, $notesId]); + } + + return $notesId; + } + + private function hasS3Columns($adb) + { + static $has = null; + if ($has === null) { + $r = @$adb->pquery("SHOW COLUMNS FROM vtiger_notes LIKE 's3_key'", []); + $has = $r && $adb->num_rows($r) > 0; + } + return $has; + } + + private function cleanupTemp($tempDir, $docxPath, $pdfPath) + { + if ($pdfPath && is_file($pdfPath)) { + @unlink($pdfPath); + } + if ($docxPath && is_file($docxPath)) { + @unlink($docxPath); + } + if ($tempDir && is_dir($tempDir)) { + @rmdir($tempDir); + } + } +} diff --git a/modules/OnlyOfficeTemplates/actions/GetDocument.php b/modules/OnlyOfficeTemplates/actions/GetDocument.php new file mode 100644 index 00000000..fda2b09c --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/GetDocument.php @@ -0,0 +1,96 @@ +get('template_id'); + $token = $request->get('token'); + $expected = hash_hmac('sha256', (string)$templateId, $secret); + if ($token === '' || !hash_equals($expected, $token)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + } + + public function process(Vtiger_Request $request) + { + $templateId = (int)$request->get('template_id'); + if ($templateId <= 0) { + $this->outputEmptyDocx(); + return; + } + + $adb = PearDatabase::getInstance(); + $res = $adb->pquery( + "SELECT id, name, module, s3_key, file_name, owner FROM vtiger_oot_templates WHERE id = ?", + [$templateId] + ); + $row = $adb->fetchByAssoc($res); + if (!$row) { + $this->outputEmptyDocx(); + return; + } + if (empty($row['s3_key']) || empty($row['file_name'])) { + $this->outputEmptyDocx(); + return; + } + + require_once dirname(__DIR__) . '/config.php'; + require_once dirname(__DIR__) . '/resources/S3Helper.php'; + $config = OnlyOfficeTemplates_getConfig(); + $s3 = new OnlyOfficeTemplates_S3Helper($config); + $body = $s3->getObjectBody($row['s3_key']); + $fileName = $row['file_name']; + header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + header('Content-Disposition: attachment; filename="' . basename($fileName) . '"'); + header('Content-Length: ' . strlen($body)); + header('Cache-Control: no-cache'); + echo $body; + } + + protected function outputEmptyDocx() + { + $rootDir = dirname(dirname(dirname(__DIR__))); + $emptyPath = dirname(__DIR__) . '/resources/empty.docx'; + if (file_exists($emptyPath)) { + header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + header('Content-Disposition: attachment; filename="document.docx"'); + header('Content-Length: ' . filesize($emptyPath)); + header('Cache-Control: no-cache'); + readfile($emptyPath); + return; + } + if (is_file($rootDir . '/vendor/autoload.php')) { + require_once $rootDir . '/vendor/autoload.php'; + } + if (!class_exists('PhpOffice\PhpWord\PhpWord')) { + header('HTTP/1.1 500 Internal Server Error'); + echo 'PHPWord not found'; + return; + } + $phpWord = new \PhpOffice\PhpWord\PhpWord(); + $phpWord->addSection(); + $tmp = tempnam(sys_get_temp_dir(), 'oot_empty_') . '.docx'; + try { + $writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007'); + $writer->save($tmp); + header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + header('Content-Disposition: attachment; filename="document.docx"'); + header('Content-Length: ' . filesize($tmp)); + header('Cache-Control: no-cache'); + readfile($tmp); + } finally { + @unlink($tmp); + } + } +} diff --git a/modules/OnlyOfficeTemplates/actions/Install.php b/modules/OnlyOfficeTemplates/actions/Install.php new file mode 100644 index 00000000..a4162ce0 --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/Install.php @@ -0,0 +1,41 @@ +pquery("SELECT tabid FROM vtiger_tab WHERE name = ?", ['OnlyOfficeTemplates']); + if ($adb->num_rows($r) > 0) { + $msg = 'OnlyOfficeTemplates уже зарегистрирован. Таблицы и виджеты обновлены.'; + } else { + $maxId = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabid),0) AS m FROM vtiger_tab", []), 0, 'm'); + $tabid = $maxId + 1; + $maxSeq = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabsequence),0) AS m FROM vtiger_tab", []), 0, 'm'); + $adb->pquery( + "INSERT INTO vtiger_tab (tabid, name, presence, tabsequence, tablabel, modifiedby, modifiedtime, customized, ownedby, isentitytype, version, parent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + [$tabid, 'OnlyOfficeTemplates', 0, $maxSeq + 1, 'OnlyOffice Templates', null, null, 0, 0, 0, '1.0', 'Tools'] + ); + $msg = 'Модуль OnlyOfficeTemplates зарегистрирован (tabid=' . $tabid . '). Таблицы и виджеты созданы.'; + } + + $mod = new OnlyOfficeTemplates(); + $mod->executeSql(); + $mod->addLinksToEntityModules(); + + header('Location: index.php?module=Settings&parent=Settings&view=Index&install_oot=1&install_msg=' . urlencode($msg)); + exit; + } +} diff --git a/modules/OnlyOfficeTemplates/actions/OnlyOfficeCallback.php b/modules/OnlyOfficeTemplates/actions/OnlyOfficeCallback.php new file mode 100644 index 00000000..dc898379 --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/OnlyOfficeCallback.php @@ -0,0 +1,78 @@ + 1, 'message' => 'Invalid callback body']); + return; + } + $key = $data['key']; + $status = (int)$data['status']; + $templateId = (int)$key; + if ($templateId <= 0) { + echo json_encode(['error' => 0]); + return; + } + if (!in_array($status, [2, 3, 6, 7], true)) { + echo json_encode(['error' => 0]); + return; + } + $url = isset($data['url']) ? trim($data['url']) : ''; + if ($url === '') { + echo json_encode(['error' => 0]); + return; + } + $fileType = isset($data['filetype']) ? strtolower(trim($data['filetype'])) : 'docx'; + if ($fileType !== 'docx') { + $fileType = 'docx'; + } + + require_once dirname(__DIR__) . '/config.php'; + require_once dirname(__DIR__) . '/resources/S3Helper.php'; + $config = OnlyOfficeTemplates_getConfig(); + $adb = PearDatabase::getInstance(); + $res = $adb->pquery("SELECT id, file_name FROM vtiger_oot_templates WHERE id = ?", [$templateId]); + $row = $adb->fetchByAssoc($res); + if (!$row) { + echo json_encode(['error' => 0]); + return; + } + + $fileName = $row['file_name'] ?: ('template_' . $templateId . '.' . $fileType); + if (pathinfo($fileName, PATHINFO_EXTENSION) !== $fileType) { + $fileName = pathinfo($fileName, PATHINFO_FILENAME) . '.' . $fileType; + } + $s3 = new OnlyOfficeTemplates_S3Helper($config); + $s3Key = $s3->getTemplateKey($templateId, $fileName); + + $tmpFile = tempnam(sys_get_temp_dir(), 'oot_callback_') . '.' . $fileType; + try { + $ctx = stream_context_create(['http' => ['timeout' => 30]]); + $content = @file_get_contents($url, false, $ctx); + if ($content === false || strlen($content) === 0) { + echo json_encode(['error' => 1, 'message' => 'Failed to download document']); + return; + } + file_put_contents($tmpFile, $content); + $s3->uploadFile($tmpFile, $s3Key, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + $adb->pquery("UPDATE vtiger_oot_templates SET s3_key = ?, file_name = ? WHERE id = ?", [$s3Key, $fileName, $templateId]); + } finally { + @unlink($tmpFile); + } + echo json_encode(['error' => 0]); + } +} diff --git a/modules/OnlyOfficeTemplates/actions/SaveMetadata.php b/modules/OnlyOfficeTemplates/actions/SaveMetadata.php new file mode 100644 index 00000000..0df7b4f2 --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/SaveMetadata.php @@ -0,0 +1,50 @@ +getModule(); + $tabId = getTabId($moduleName); + $privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel(); + if (!$privileges->hasModulePermission($tabId)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + + public function process(Vtiger_Request $request) + { + $templateId = (int)$request->get('templateid'); + $name = $request->get('name'); + $moduleName = $request->get('module_name'); + if ($templateId <= 0 || $name === null || $moduleName === null) { + header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS'); + return; + } + $adb = PearDatabase::getInstance(); + $currentUser = Users_Record_Model::getCurrentUserModel(); + $userId = $currentUser->getId(); + $res = $adb->pquery("SELECT owner FROM vtiger_oot_templates WHERE id = ?", [$templateId]); + if ($adb->num_rows($res) === 0) { + header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS'); + return; + } + $owner = (int)$adb->query_result($res, 0, 'owner'); + if ($owner !== $userId) { + $gr = $adb->pquery("SELECT 1 FROM vtiger_users2group WHERE userid = ? AND groupid = ?", [$userId, $owner]); + if ($adb->num_rows($gr) === 0) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + $adb->pquery("UPDATE vtiger_oot_templates SET name = ?, module = ? WHERE id = ?", [$name, $moduleName, $templateId]); + $redirect = $request->get('redirect'); + if ($redirect === 'Edit') { + header('Location: index.php?module=OnlyOfficeTemplates&view=Edit&templateid=' . $templateId . '&app=TOOLS'); + } else { + header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS'); + } + } +} diff --git a/modules/OnlyOfficeTemplates/actions/UploadTemplate.php b/modules/OnlyOfficeTemplates/actions/UploadTemplate.php new file mode 100644 index 00000000..dcfde07d --- /dev/null +++ b/modules/OnlyOfficeTemplates/actions/UploadTemplate.php @@ -0,0 +1,81 @@ +get('name') ?: $request->get('template_name'); + $module = $request->get('module_name'); + $file = $_FILES['file'] ?? $_FILES['template_file'] ?? null; + + $redirect = $request->get('redirect'); + $doRedirect = ($redirect === 'List'); + + if (!$name || !$module || !$file || empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) { + if ($doRedirect) { + header('Location: index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS&error=' . urlencode('Укажите название, модуль и выберите файл DOCX')); + return; + } + echo json_encode(['success' => false, 'error' => 'Missing name, module_name, or valid file upload']); + return; + } + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if ($ext !== 'docx') { + if ($doRedirect) { + header('Location: index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS&error=' . urlencode('Допускаются только файлы DOCX')); + return; + } + echo json_encode(['success' => false, 'error' => 'Only DOCX files are allowed']); + return; + } + + require_once dirname(__DIR__) . '/models/OnlyOfficeTemplates_Model.php'; + require_once dirname(__DIR__) . '/resources/S3Helper.php'; + + $model = new OnlyOfficeTemplates_Model(); + $config = $model->getConfig(); + $s3 = new OnlyOfficeTemplates_S3Helper($config); + + $owner = Users_Record_Model::getCurrentUserModel()->getId(); + $adb = PearDatabase::getInstance(); + $adb->pquery("INSERT INTO vtiger_oot_templates (name, module, s3_key, file_name, owner, created_at) VALUES ('_pending', ?, '', '', ?, NOW())", [$module, $owner]); + $templateId = (int)$adb->getLastInsertID(); + if ($templateId <= 0) { + $r = $adb->pquery("SELECT MAX(id) AS m FROM vtiger_oot_templates", []); + $templateId = (int)$adb->query_result($r, 0, 'm'); + } + $fileName = basename($file['name']); + $s3Key = $s3->getTemplateKey($templateId, $fileName); + + try { + $s3->uploadFile($file['tmp_name'], $s3Key, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + } catch (Exception $e) { + $adb->pquery("DELETE FROM vtiger_oot_templates WHERE id = ?", [$templateId]); + if ($doRedirect) { + header('Location: index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS&error=' . urlencode('Ошибка загрузки: ' . $e->getMessage())); + return; + } + echo json_encode(['success' => false, 'error' => 'S3 upload failed: ' . $e->getMessage()]); + return; + } + + $adb->pquery("UPDATE vtiger_oot_templates SET name = ?, s3_key = ?, file_name = ? WHERE id = ?", [$name, $s3Key, $fileName, $templateId]); + $id = $templateId; + if ($doRedirect) { + header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS'); + return; + } + echo json_encode(['success' => true, 'id' => $id, 's3_key' => $s3Key]); + } +} diff --git a/modules/OnlyOfficeTemplates/config.php b/modules/OnlyOfficeTemplates/config.php new file mode 100644 index 00000000..6dbdeca7 --- /dev/null +++ b/modules/OnlyOfficeTemplates/config.php @@ -0,0 +1,91 @@ + $s3, + 's3_prefix' => OnlyOfficeTemplates_env('OOT_S3_PREFIX', 'crm2/OnlyOfficeTemplates'), + 'onlyoffice_convert_url' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_CONVERT_URL') ?: OnlyOfficeTemplates_env('ONLYOFFICE_CONVERT_URL', ''), + 'onlyoffice_document_server' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_DOCUMENT_SERVER') ?: OnlyOfficeTemplates_env('ONLYOFFICE_DOCUMENT_SERVER', ''), + 'documents_s3_prefix' => OnlyOfficeTemplates_env('OOT_DOCUMENTS_S3_PREFIX', 'crm2/CRM_Active_Files/Documents'), + 's3_bucket' => $bucket, + 'document_secret' => OnlyOfficeTemplates_env('OOT_DOCUMENT_SECRET', ''), + ]; + return $OnlyOfficeTemplatesConfig; + } + } catch (Exception $e) { + // fallback + } + } + + // 2) Build from environment + $OnlyOfficeTemplatesConfig = [ + 's3' => [ + 'key' => OnlyOfficeTemplates_env('S3_ACCESS_KEY', ''), + 'secret' => OnlyOfficeTemplates_env('S3_SECRET_KEY', ''), + 'endpoint' => OnlyOfficeTemplates_env('S3_ENDPOINT', ''), + 'bucket' => OnlyOfficeTemplates_env('S3_BUCKET', ''), + 'region' => OnlyOfficeTemplates_env('S3_REGION', 'ru-1'), + 'use_path_style_endpoint' => true, + 'version' => 'latest', + ], + 's3_prefix' => OnlyOfficeTemplates_env('OOT_S3_PREFIX', 'crm2/OnlyOfficeTemplates'), + 'onlyoffice_convert_url' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_CONVERT_URL') ?: OnlyOfficeTemplates_env('ONLYOFFICE_CONVERT_URL', ''), + 'onlyoffice_document_server' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_DOCUMENT_SERVER') ?: OnlyOfficeTemplates_env('ONLYOFFICE_DOCUMENT_SERVER', ''), + 'documents_s3_prefix' => OnlyOfficeTemplates_env('OOT_DOCUMENTS_S3_PREFIX', 'crm2/CRM_Active_Files/Documents'), + 's3_bucket' => OnlyOfficeTemplates_env('S3_BUCKET', ''), + 'document_secret' => OnlyOfficeTemplates_env('OOT_DOCUMENT_SECRET', ''), + ]; + + return $OnlyOfficeTemplatesConfig; +} diff --git a/modules/OnlyOfficeTemplates/install.php b/modules/OnlyOfficeTemplates/install.php new file mode 100644 index 00000000..98b076b9 --- /dev/null +++ b/modules/OnlyOfficeTemplates/install.php @@ -0,0 +1,58 @@ +pquery("SELECT tabid FROM vtiger_tab WHERE name = ?", ['OnlyOfficeTemplates']); +if ($adb->num_rows($r) > 0) { + if (php_sapi_name() === 'cli') { + echo "OnlyOfficeTemplates already registered. Running schema and links.\n"; + } +} else { + $maxId = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabid),0) AS m FROM vtiger_tab", []), 0, 'm'); + $tabid = $maxId + 1; + $maxSeq = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabsequence),0) AS m FROM vtiger_tab", []), 0, 'm'); + $adb->pquery( + "INSERT INTO vtiger_tab (tabid, name, presence, tabsequence, tablabel, modifiedby, modifiedtime, customized, ownedby, isentitytype, version, parent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + [$tabid, 'OnlyOfficeTemplates', 0, $maxSeq + 1, 'OnlyOffice Templates', null, null, 0, 0, 0, '1.0', 'Tools'] + ); + if (php_sapi_name() === 'cli') { + echo "Registered OnlyOfficeTemplates (tabid=$tabid).\n"; + } +} + +$mod = new OnlyOfficeTemplates(); +$mod->executeSql(); +$mod->addLinksToEntityModules(); + +if (php_sapi_name() === 'cli') { + echo "Done. Schema and widget links applied.\n"; +} else { + header('Location: index.php?module=Settings&parent=Settings&view=Index'); + exit; +} diff --git a/modules/OnlyOfficeTemplates/manifest.xml b/modules/OnlyOfficeTemplates/manifest.xml new file mode 100644 index 00000000..ba637977 --- /dev/null +++ b/modules/OnlyOfficeTemplates/manifest.xml @@ -0,0 +1,39 @@ + + + extension + OnlyOfficeTemplates + + Tools + 1.0 + + 7.* + + + + vtiger_oot_templates + +
    + + vtiger_oot_templates_seq + +
    +
    +
    diff --git a/modules/OnlyOfficeTemplates/models/OnlyOfficeTemplates_Model.php b/modules/OnlyOfficeTemplates/models/OnlyOfficeTemplates_Model.php new file mode 100644 index 00000000..0e81dcb4 --- /dev/null +++ b/modules/OnlyOfficeTemplates/models/OnlyOfficeTemplates_Model.php @@ -0,0 +1,124 @@ +db = PearDatabase::getInstance(); + require_once dirname(__DIR__) . '/config.php'; + $this->config = OnlyOfficeTemplates_getConfig(); + } + + /** + * List templates available for a module (and current user). + * + * @param string $module + * @return array [ ['id' =>, 'name' =>, 'file_name' =>, 'module' =>], ... ] + */ + public function getTemplatesByModule($module) + { + $userId = Users_Record_Model::getCurrentUserModel()->getId(); + $sql = "SELECT id, name, module, file_name, s3_key, owner, created_at + FROM vtiger_oot_templates + WHERE module = ? AND (owner = ? OR owner IN (SELECT groupid FROM vtiger_users2group WHERE userid = ?)) + ORDER BY name"; + $res = $this->db->pquery($sql, [$module, $userId, $userId]); + $list = []; + while ($row = $this->db->fetchByAssoc($res)) { + $list[] = [ + 'id' => (int)$row['id'], + 'name' => $row['name'], + 'module' => $row['module'], + 'file_name' => $row['file_name'], + 's3_key' => $row['s3_key'], + 'owner' => (int)$row['owner'], + 'created_at' => $row['created_at'], + ]; + } + return $list; + } + + /** + * Get one template by id (with permission check). + * + * @param int $templateId + * @return array|null + */ + public function getTemplateById($templateId) + { + $userId = Users_Record_Model::getCurrentUserModel()->getId(); + $res = $this->db->pquery( + "SELECT id, name, module, s3_key, file_name, owner FROM vtiger_oot_templates WHERE id = ? AND (owner = ? OR owner IN (SELECT groupid FROM vtiger_users2group WHERE userid = ?))", + [$templateId, $userId, $userId] + ); + if ($this->db->num_rows($res) === 0) { + return null; + } + return $this->db->fetchByAssoc($res); + } + + /** + * Save template metadata and S3 key (after upload). + * + * @param string $name + * @param string $module + * @param string $s3Key + * @param string $fileName + * @param int $owner + * @return int new template id + */ + public function saveTemplate($name, $module, $s3Key, $fileName, $owner = null) + { + if ($owner === null) { + $owner = Users_Record_Model::getCurrentUserModel()->getId(); + } + $this->db->pquery("INSERT INTO vtiger_oot_templates (name, module, s3_key, file_name, owner, created_at) VALUES (?,?,?,?,?,NOW())", + [$name, $module, $s3Key, $fileName, $owner]); + $id = $this->db->getLastInsertID(); + return $id ? (int)$id : (int)$this->db->query_result($this->db->pquery("SELECT MAX(id) AS n FROM vtiger_oot_templates", []), 0, 'n'); + } + + /** + * Delete template record and optionally S3 object (caller can delete object). + * + * @param int $templateId + * @return bool + */ + public function deleteTemplate($templateId) + { + $t = $this->getTemplateById($templateId); + if (!$t) { + return false; + } + $this->db->pquery("DELETE FROM vtiger_oot_templates WHERE id = ?", [$templateId]); + return true; + } + + /** + * Get config (S3 prefix, bucket, OnlyOffice URL). + * + * @return array + */ + public function getConfig() + { + return $this->config; + } + + /** + * Get next id for template (for S3 path). + * + * @return int + */ + public function getNextTemplateId() + { + $this->db->pquery("UPDATE vtiger_oot_templates_seq SET id = LAST_INSERT_ID(id + 1)", []); + $r = $this->db->pquery("SELECT LAST_INSERT_ID() AS n", []); + return (int)$this->db->query_result($r, 0, 'n'); + } +} diff --git a/modules/OnlyOfficeTemplates/resources/ConvertService.php b/modules/OnlyOfficeTemplates/resources/ConvertService.php new file mode 100644 index 00000000..3cdf6ffc --- /dev/null +++ b/modules/OnlyOfficeTemplates/resources/ConvertService.php @@ -0,0 +1,78 @@ + PDF. + * Requires onlyoffice_convert_url (e.g. https://office.clientright.ru:9443/ConvertService.ashx or /converter). + */ + +class OnlyOfficeTemplates_ConvertService +{ + protected $convertUrl; + protected $documentServerBase; + + public function __construct(array $config) + { + $this->convertUrl = rtrim($config['onlyoffice_convert_url'] ?? '', '/'); + if (strpos($this->convertUrl, '/converter') !== false) { + $this->documentServerBase = preg_replace('#/converter.*$#', '', $this->convertUrl); + } else { + $this->documentServerBase = preg_replace('#/ConvertService\.ashx.*$#', '', $this->convertUrl); + } + } + + /** + * Convert DOCX at given URL to PDF. + * + * @param string $docxUrl Absolute URL to DOCX (must be accessible by Document Server) + * @param string $title File name for display + * @return array [ 'success' => bool, 'pdfPath' => temp file path, 'error' => message ] + */ + public function convertToPdf($docxUrl, $title = 'document.docx') + { + if (empty($this->convertUrl)) { + return ['success' => false, 'error' => 'OnlyOffice conversion URL not configured (OOT_ONLYOFFICE_CONVERT_URL).']; + } + $key = md5($docxUrl . time()); + $body = [ + 'async' => false, + 'filetype' => 'docx', + 'key' => $key, + 'outputtype' => 'pdf', + 'title' => $title, + 'url' => $docxUrl, + ]; + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nAccept: application/json\r\n", + 'content' => json_encode($body), + 'timeout' => 120, + ], + 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false], + ]); + $response = @file_get_contents($this->convertUrl, false, $ctx); + if ($response === false) { + return ['success' => false, 'error' => 'Conversion request failed (connection or timeout).']; + } + $data = json_decode($response, true); + if (!$data) { + $data = ['error' => -1, 'endConvert' => false]; + } + if (!empty($data['error'])) { + return ['success' => false, 'error' => 'Conversion error code: ' . $data['error']]; + } + if (empty($data['endConvert']) || empty($data['fileUrl'])) { + return ['success' => false, 'error' => 'Conversion did not return file URL.']; + } + $fileUrl = $data['fileUrl']; + if (strpos($fileUrl, 'http') !== 0) { + $fileUrl = rtrim($this->documentServerBase, '/') . '/' . ltrim($fileUrl, '/'); + } + $tempPdf = sys_get_temp_dir() . '/oot_pdf_' . uniqid() . '.pdf'; + $pdfContent = @file_get_contents($fileUrl, false, stream_context_create(['http' => ['timeout' => 60], 'ssl' => ['verify_peer' => false]])); + if ($pdfContent === false || strlen($pdfContent) === 0) { + return ['success' => false, 'error' => 'Failed to download converted PDF from Document Server.']; + } + file_put_contents($tempPdf, $pdfContent); + return ['success' => true, 'pdfPath' => $tempPdf]; + } +} diff --git a/modules/OnlyOfficeTemplates/resources/MergeService.php b/modules/OnlyOfficeTemplates/resources/MergeService.php new file mode 100644 index 00000000..576eedd3 --- /dev/null +++ b/modules/OnlyOfficeTemplates/resources/MergeService.php @@ -0,0 +1,172 @@ +s3 = $s3; + $this->config = $config; + } + + /** + * Build placeholder map for a record: current module fields + related (Account, Contact, etc.). + * + * @param string $module + * @param int $recordId + * @return array [ 'fieldname' => value, 'Account__accountname' => value, ... ] + */ + public function buildPlaceholders($module, $recordId) + { + $adb = PearDatabase::getInstance(); + $focus = CRMEntity::getInstance($module); + $focus->id = $recordId; + $focus->retrieve_entity_info($recordId, $module); + $fields = $focus->column_fields; + + $map = []; + foreach ($fields as $k => $v) { + if ($v === null || $v === '') { + $v = ''; + } + $map[$k] = is_string($v) ? $v : (string)$v; + } + $map = array_merge($map, $this->getRelatedModuleFields($module, $recordId, $focus)); + return $map; + } + + /** + * Get related entity fields (Account, Contact, etc.) for placeholder {{ModuleName__fieldname}}. + */ + protected function getRelatedModuleFields($module, $recordId, CRMEntity $focus) + { + $map = []; + $relFields = $this->getRelationFieldNames($module); + foreach ($relFields as $relModule => $fieldName) { + $relId = isset($focus->column_fields[$fieldName]) ? $focus->column_fields[$fieldName] : null; + if (empty($relId)) { + continue; + } + $relFocus = CRMEntity::getInstance($relModule); + $relFocus->retrieve_entity_info($relId, $relModule); + foreach ($relFocus->column_fields as $k => $v) { + if ($v === null || $v === '') { + $v = ''; + } + $map[$relModule . '__' . $k] = is_string($v) ? $v : (string)$v; + } + } + return $map; + } + + /** + * Common relation field names per module (account_id, contact_id, related_to, parent_id, etc.). + */ + /** @return array [ 'RelatedModule' => 'local_field_name', ... ] */ + protected function getRelationFieldNames($module) + { + $known = [ + 'Project' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'], + 'Contacts' => ['Accounts' => 'account_id'], + 'Leads' => ['Accounts' => 'account_id'], + 'Potentials' => ['Accounts' => 'related_to', 'Contacts' => 'contact_id'], + 'Invoice' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'], + 'Quotes' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'], + 'SalesOrder' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'], + 'PurchaseOrder' => ['Vendors' => 'vendor_id', 'Contacts' => 'contact_id'], + 'HelpDesk' => ['Accounts' => 'parent_id', 'Contacts' => 'contact_id'], + 'Accounts' => [], + ]; + if (isset($known[$module])) { + return $known[$module]; + } + $out = []; + if (in_array($module, ['Contacts', 'Leads', 'Potentials', 'Invoice', 'Quotes', 'SalesOrder'])) { + $out['Accounts'] = 'account_id'; + if ($module !== 'Accounts') { + $out['Contacts'] = 'contact_id'; + } + } + return $out; + } + + /** + * Merge template: download from S3, replace placeholders, save to temp file. + * + * @param string $s3Key template S3 key + * @param array $placeholders [ 'field' => 'value', 'Account__name' => 'value' ] + * @return string path to merged DOCX file + */ + public function mergeToFile($s3Key, array $placeholders) + { + $path = dirname(dirname(dirname(__DIR__))); + if (!class_exists('PhpOffice\PhpWord\IOFactory')) { + if (is_file($path . '/vendor/autoload.php')) { + require_once $path . '/vendor/autoload.php'; + } + } + $tempDir = sys_get_temp_dir() . '/oot_' . uniqid(); + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + $templatePath = $tempDir . '/template.docx'; + $this->s3->downloadToFile($s3Key, $templatePath); + + $phpWord = \PhpOffice\PhpWord\IOFactory::load($templatePath); + $this->replaceInPhpWord($phpWord, $placeholders); + $outPath = $tempDir . '/merged.docx'; + $writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007'); + $writer->save($outPath); + @unlink($templatePath); + return $outPath; + } + + /** + * Replace {{placeholder}} in all text elements. + */ + protected function replaceInPhpWord(\PhpOffice\PhpWord\PhpWord $phpWord, array $placeholders) + { + foreach ($phpWord->getSections() as $section) { + foreach ($section->getElements() as $element) { + $this->replaceInElement($element, $placeholders); + } + } + } + + protected function replaceInElement($element, array $placeholders) + { + if ($element instanceof \PhpOffice\PhpWord\Element\Text) { + $text = $element->getText(); + $text = $this->replacePlaceholders($text, $placeholders); + $element->setText($text); + return; + } + if ($element instanceof \PhpOffice\PhpWord\Element\TextRun) { + foreach ($element->getElements() as $el) { + $this->replaceInElement($el, $placeholders); + } + return; + } + if ($element instanceof \PhpOffice\PhpWord\Element\TextBreak) { + return; + } + if (method_exists($element, 'getElements')) { + foreach ($element->getElements() as $el) { + $this->replaceInElement($el, $placeholders); + } + } + } + + protected function replacePlaceholders($text, array $placeholders) + { + foreach ($placeholders as $key => $value) { + $text = str_replace('{{' . $key . '}}', $value, $text); + } + return $text; + } +} diff --git a/modules/OnlyOfficeTemplates/resources/S3Helper.php b/modules/OnlyOfficeTemplates/resources/S3Helper.php new file mode 100644 index 00000000..9c5fb10a --- /dev/null +++ b/modules/OnlyOfficeTemplates/resources/S3Helper.php @@ -0,0 +1,120 @@ +bucket = $config['s3_bucket'] ?? ($config['s3']['bucket'] ?? ''); + $this->prefix = rtrim($config['s3_prefix'] ?? 'crm2/OnlyOfficeTemplates', '/'); + if ($this->bucket === null || $this->bucket === '') { + throw new Exception('OnlyOfficeTemplates: S3 bucket not configured. Set S3_BUCKET in .env or add bucket to crm_extensions/file_storage/config.php (s3.bucket or root bucket).'); + } + $s3 = $config['s3'] ?? []; + $path = dirname(dirname(dirname(__DIR__))); + if (!class_exists('Aws\S3\S3Client')) { + if (is_file($path . '/vendor/autoload.php')) { + require_once $path . '/vendor/autoload.php'; + } + } + $this->client = new Aws\S3\S3Client([ + 'version' => $s3['version'] ?? 'latest', + 'region' => $s3['region'] ?? 'ru-1', + 'endpoint' => $s3['endpoint'], + 'use_path_style_endpoint' => !empty($s3['use_path_style_endpoint']), + 'credentials' => [ + 'key' => $s3['key'], + 'secret' => $s3['secret'], + ], + ]); + } + + /** + * Template S3 key: {prefix}/templates/{templateId}/{fileName} + */ + public function getTemplateKey($templateId, $fileName) + { + return $this->prefix . '/templates/' . (int)$templateId . '/' . $fileName; + } + + /** + * Temp generated file key: {prefix}/temp/{recordId}_{templateId}_{timestamp}.ext + */ + public function getTempKey($recordId, $templateId, $extension) + { + return $this->prefix . '/temp/' . (int)$recordId . '_' . (int)$templateId . '_' . time() . '.' . $extension; + } + + public function getBucket() + { + return $this->bucket; + } + + public function getPrefix() + { + return $this->prefix; + } + + /** + * Download object to a local file; returns path. + */ + public function downloadToFile($s3Key, $localPath) + { + $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $s3Key, + 'SaveAs' => $localPath, + ]); + return $localPath; + } + + /** + * Get object body as string. + */ + public function getObjectBody($s3Key) + { + $result = $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $s3Key, + ]); + return (string)$result['Body']; + } + + /** + * Put string or file into S3. + */ + public function putObject($s3Key, $body, $contentType = null) + { + $params = [ + 'Bucket' => $this->bucket, + 'Key' => $s3Key, + 'Body' => $body, + ]; + if ($contentType) { + $params['ContentType'] = $contentType; + } + $this->client->putObject($params); + } + + /** + * Upload local file to S3. + */ + public function uploadFile($localPath, $s3Key, $contentType = null) + { + $params = [ + 'Bucket' => $this->bucket, + 'Key' => $s3Key, + 'SourceFile' => $localPath, + ]; + if ($contentType) { + $params['ContentType'] = $contentType; + } + $this->client->putObject($params); + } +} diff --git a/modules/OnlyOfficeTemplates/schema.xml b/modules/OnlyOfficeTemplates/schema.xml new file mode 100644 index 00000000..51b86566 --- /dev/null +++ b/modules/OnlyOfficeTemplates/schema.xml @@ -0,0 +1,31 @@ + + + + + vtiger_oot_templates + +
    + + vtiger_oot_templates_seq + +
    +
    +
    diff --git a/modules/OnlyOfficeTemplates/views/AddTemplate.php b/modules/OnlyOfficeTemplates/views/AddTemplate.php new file mode 100644 index 00000000..f73c786a --- /dev/null +++ b/modules/OnlyOfficeTemplates/views/AddTemplate.php @@ -0,0 +1,39 @@ +getModule(); + $tabId = getTabId($moduleName); + $privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel(); + if (!$privileges->hasModulePermission($tabId)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + + public function process(Vtiger_Request $request) + { + $moduleName = $request->getModule(); + $viewer = $this->getViewer($request); + + $db = PearDatabase::getInstance(); + $res = $db->pquery( + "SELECT name FROM vtiger_tab WHERE isentitytype = 1 AND presence = 0 ORDER BY name", + [] + ); + $modules = []; + while ($row = $db->fetchByAssoc($res)) { + $modules[$row['name']] = vtranslate($row['name'], $row['name']); + } + + $errorMsg = $request->get('error'); + $viewer->assign('MODULE_NAME', $moduleName); + $viewer->assign('MODULES', $modules); + $viewer->assign('ERROR_MSG', $errorMsg ?: ''); + $viewer->view('AddTemplate.tpl', $moduleName); + } +} diff --git a/modules/OnlyOfficeTemplates/views/Edit.php b/modules/OnlyOfficeTemplates/views/Edit.php new file mode 100644 index 00000000..7d76f6e9 --- /dev/null +++ b/modules/OnlyOfficeTemplates/views/Edit.php @@ -0,0 +1,109 @@ +getModule(); + $tabId = getTabId($moduleName); + $privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel(); + if (!$privileges->hasModulePermission($tabId)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + + public function process(Vtiger_Request $request) + { + $moduleName = $request->getModule(); + $templateId = (int)$request->get('templateid'); + $viewer = $this->getViewer($request); + $adb = PearDatabase::getInstance(); + $currentUser = Users_Record_Model::getCurrentUserModel(); + $userId = $currentUser->getId(); + + $template = null; + if ($templateId > 0) { + $res = $adb->pquery( + "SELECT id, name, module, file_name, owner FROM vtiger_oot_templates WHERE id = ?", + [$templateId] + ); + $template = $adb->fetchByAssoc($res); + if ($template) { + $owner = (int)$template['owner']; + if ($owner !== $userId) { + $gr = $adb->pquery("SELECT 1 FROM vtiger_users2group WHERE userid = ? AND groupid = ?", [$userId, $owner]); + if ($adb->num_rows($gr) === 0) { + $template = null; + } + } + } + } + + if (!$template) { + $template = [ + 'id' => 0, + 'name' => '', + 'module' => '', + 'file_name' => 'document.docx', + ]; + } + + $res = $adb->pquery( + "SELECT name FROM vtiger_tab WHERE isentitytype = 1 AND presence = 0 ORDER BY name", + [] + ); + $modules = []; + while ($row = $adb->fetchByAssoc($res)) { + $modules[$row['name']] = vtranslate($row['name'], $row['name']); + } + + require_once dirname(__DIR__) . '/config.php'; + $config = OnlyOfficeTemplates_getConfig(); + $docServer = rtrim($config['onlyoffice_document_server'] ?? '', '/'); + if ($docServer === '') { + $viewer->assign('OOT_EDITOR_AVAILABLE', false); + $viewer->assign('OOT_EDITOR_MESSAGE', 'OnlyOffice Document Server не настроен (ONLYOFFICE_DOCUMENT_SERVER).'); + } else { + $viewer->assign('OOT_EDITOR_AVAILABLE', true); + $baseUrl = $this->getBaseUrl(); + $tid = (int)$template['id']; + $documentUrl = $baseUrl . '/index.php?module=OnlyOfficeTemplates&action=GetDocument&template_id=' . $tid; + $secret = $config['document_secret'] ?? ''; + if ($secret !== '' && $tid > 0) { + $documentUrl .= '&token=' . rawurlencode(hash_hmac('sha256', (string)$tid, $secret)); + } + $callbackUrl = $baseUrl . '/index.php?module=OnlyOfficeTemplates&action=OnlyOfficeCallback'; + $docKey = $tid > 0 ? (string)$tid : ('new_' . $userId . '_' . time()); + $viewer->assign('OOT_DOCUMENT_SERVER', $docServer); + $viewer->assign('OOT_DOCUMENT_URL', $documentUrl); + $viewer->assign('OOT_CALLBACK_URL', $callbackUrl); + $viewer->assign('OOT_DOC_KEY', $docKey); + $viewer->assign('OOT_DOC_TITLE', $template['file_name'] ?: 'document.docx'); + } + + $viewer->assign('MODULE_NAME', $moduleName); + $viewer->assign('TEMPLATE', $template); + $viewer->assign('MODULES', $modules); + $viewer->assign('ERROR_MSG', $request->get('error') ?: ''); + $viewer->view('Edit.tpl', $moduleName); + } + + protected function getBaseUrl() + { + if (function_exists('vglobal') && (vglobal('site_URL') ?? '') !== '') { + return rtrim(vglobal('site_URL'), '/'); + } + $proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $path = dirname($_SERVER['SCRIPT_NAME'] ?? ''); + $path = str_replace('\\', '/', $path); + if ($path === '/' || $path === '') { + return $proto . '://' . $host; + } + return $proto . '://' . $host . $path; + } +} diff --git a/modules/OnlyOfficeTemplates/views/GetTemplateActions.php b/modules/OnlyOfficeTemplates/views/GetTemplateActions.php new file mode 100644 index 00000000..d8f13dcb --- /dev/null +++ b/modules/OnlyOfficeTemplates/views/GetTemplateActions.php @@ -0,0 +1,48 @@ +has('source_module') && !$request->isEmpty('source_module')) { + $source_module = $request->get('source_module'); + } elseif ($request->has('record') && !$request->isEmpty('record')) { + $source_module = $module = getSalesEntityType($request->get('record')); + } + $sourceModuleModel = Vtiger_Module_Model::getInstance($source_module); + if (!$sourceModuleModel || !$sourceModuleModel->isEntityModule()) { + return; + } + if (!$request->has('record') || $request->isEmpty('record')) { + return; + } + + $record = $request->get('record'); + if (!$module) { + $module = getSalesEntityType($record); + } + if ($module !== $source_module) { + return; + } + + require_once dirname(__DIR__) . '/models/OnlyOfficeTemplates_Model.php'; + $model = new OnlyOfficeTemplates_Model(); + $templates = $model->getTemplatesByModule($module); + + $viewer = $this->getViewer($request); + $viewer->assign('MODULE', $module); + $viewer->assign('ID', $record); + $viewer->assign('CRM_TEMPLATES', $templates); + $viewer->assign('CRM_TEMPLATES_EXIST', empty($templates) ? 1 : 0); + $viewer->assign('OOT_MOD', return_module_language(Vtiger_Language_Handler::getLanguage(), 'OnlyOfficeTemplates')); + $viewer->view('GetTemplateActions.tpl', 'OnlyOfficeTemplates'); + } +} diff --git a/modules/OnlyOfficeTemplates/views/List.php b/modules/OnlyOfficeTemplates/views/List.php new file mode 100644 index 00000000..550d969c --- /dev/null +++ b/modules/OnlyOfficeTemplates/views/List.php @@ -0,0 +1,41 @@ +getModule(); + $tabId = getTabId($moduleName); + $privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel(); + if (!$privileges->hasModulePermission($tabId)) { + throw new AppException('LBL_PERMISSION_DENIED'); + } + } + + public function process(Vtiger_Request $request) + { + $moduleName = $request->getModule(); + $viewer = $this->getViewer($request); + + $db = PearDatabase::getInstance(); + $userId = Users_Record_Model::getCurrentUserModel()->getId(); + $res = $db->pquery( + "SELECT id, name, module, file_name, owner, created_at + FROM vtiger_oot_templates + WHERE owner = ? OR owner IN (SELECT groupid FROM vtiger_users2group WHERE userid = ?) + ORDER BY created_at DESC", + [$userId, $userId] + ); + $templates = []; + while ($row = $db->fetchByAssoc($res)) { + $templates[] = $row; + } + + $viewer->assign('MODULE_NAME', $moduleName); + $viewer->assign('TEMPLATES', $templates); + $viewer->view('List.tpl', $moduleName); + } +}