Add OnlyOfficeTemplates module

This commit is contained in:
Fedor
2026-02-16 09:27:19 +03:00
parent fd54177ada
commit 2bb56342f4
29 changed files with 2173 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
<?php
$languageStrings = [
'LBL_OOT_TEMPLATES' => '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',
];

View File

@@ -0,0 +1,24 @@
<?php
$languageStrings = [
'LBL_OOT_TEMPLATES' => 'Шаблоны документов',
'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' => 'Загрузить файл',
];

View File

@@ -0,0 +1,54 @@
{strip}
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h4 class="pull-left">{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}</h4>
<div class="clearfix"></div>
</div>
</div>
<div class="row">
<div class="col-sm-8">
{if $ERROR_MSG}
<div class="alert alert-danger">{$ERROR_MSG|escape}</div>
{/if}
<form action="index.php" method="post" enctype="multipart/form-data" class="form-horizontal">
<input type="hidden" name="module" value="OnlyOfficeTemplates" />
<input type="hidden" name="action" value="UploadTemplate" />
<input type="hidden" name="redirect" value="List" />
<div class="form-group">
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_NAME', $MODULE_NAME)} <span class="redColor">*</span></label>
<div class="col-sm-9">
<input type="text" name="name" class="form-control" required="required" />
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)} <span class="redColor">*</span></label>
<div class="col-sm-9">
<select name="module_name" class="form-control" required="required">
<option value="">-- {vtranslate('LBL_SELECT_OPTION','Vtiger')} --</option>
{foreach from=$MODULES key=modName item=modLabel}
<option value="{$modName}">{$modLabel}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_FILE', $MODULE_NAME)} (DOCX) <span class="redColor">*</span></label>
<div class="col-sm-9">
<input type="file" name="file" accept=".docx" required="required" />
<span class="help-block">{vtranslate('LBL_OOT_FILE_HINT', $MODULE_NAME)}</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-success">{vtranslate('LBL_SAVE', $MODULE_NAME)}</button>
<a href="index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS" class="btn btn-default">{vtranslate('LBL_CANCEL', $MODULE_NAME)}</a>
</div>
</div>
</form>
</div>
</div>
</div>
{/strip}

View File

@@ -0,0 +1,76 @@
{strip}
<div class="contents tabbable">
<div class="row">
<div class="col-xs-4 left-block" style="border-right: 1px solid #ddd;">
<h4>{if $TEMPLATE.id gt 0}{vtranslate('LBL_OOT_EDIT_TEMPLATE', $MODULE_NAME)}{else}{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}{/if}</h4>
{if $ERROR_MSG}<div class="alert alert-danger">{$ERROR_MSG|escape}</div>{/if}
<form id="ootMetadataForm" action="index.php" method="post" class="form-horizontal">
<input type="hidden" name="module" value="OnlyOfficeTemplates" />
<input type="hidden" name="action" value="SaveMetadata" />
<input type="hidden" name="templateid" value="{$TEMPLATE.id}" />
<input type="hidden" name="redirect" value="Edit" />
<div class="form-group">
<label class="control-label col-sm-4">{vtranslate('LBL_OOT_NAME', $MODULE_NAME)}</label>
<div class="col-sm-8">
<input type="text" name="name" class="form-control" value="{$TEMPLATE.name|escape}" required />
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-4">{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}</label>
<div class="col-sm-8">
<select name="module_name" class="form-control" required>
<option value="">-- {vtranslate('LBL_SELECT_OPTION','Vtiger')} --</option>
{foreach from=$MODULES key=modName item=modLabel}
<option value="{$modName}" {if $TEMPLATE.module eq $modName}selected{/if}>{$modLabel}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-4 col-sm-8">
<button type="submit" class="btn btn-success">{vtranslate('LBL_SAVE', $MODULE_NAME)}</button>
<a href="index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS" class="btn btn-default">{vtranslate('LBL_CANCEL', $MODULE_NAME)}</a>
</div>
</div>
</form>
</div>
<div class="col-xs-8" style="min-height: 600px;">
{if $OOT_EDITOR_AVAILABLE}
<p class="text-muted">{vtranslate('LBL_OOT_EDITOR_HINT', $MODULE_NAME)}</p>
<div id="ootOnlyOfficeEditor" style="width:100%; height:700px;"></div>
<script src="{$OOT_DOCUMENT_SERVER}/web-apps/apps/api/documents/api.js"></script>
<script>
(function() {
var docKey = "{$OOT_DOC_KEY|escape:'javascript'}";
var config = {
document: {
fileType: "docx",
key: docKey,
title: "{$OOT_DOC_TITLE|escape:'javascript'}",
url: "{$OOT_DOCUMENT_URL|escape:'javascript'}"
},
documentType: "word",
editorConfig: {
callbackUrl: "{$OOT_CALLBACK_URL|escape:'javascript'}",
mode: "edit",
lang: "ru"
},
width: "100%",
height: "100%"
};
if (typeof DocsAPI !== "undefined") {
new DocsAPI.DocEditor("ootOnlyOfficeEditor", config);
} else {
document.getElementById("ootOnlyOfficeEditor").innerHTML = "<div class=\"alert alert-warning\">Не удалось загрузить OnlyOffice. Проверьте ONLYOFFICE_DOCUMENT_SERVER.</div>";
}
})();
</script>
{else}
<div class="alert alert-info">{$OOT_EDITOR_MESSAGE|escape}</div>
<p>{vtranslate('LBL_OOT_EDITOR_FALLBACK', $MODULE_NAME)}</p>
<a href="index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS" class="btn btn-primary">{vtranslate('LBL_OOT_ADD_VIA_UPLOAD', $MODULE_NAME)}</a>
{/if}
</div>
</div>
</div>
{/strip}

View File

@@ -0,0 +1,65 @@
{*
OnlyOfficeTemplates widget: template list, format (PDF/DOCX), Download / Save to Documents
*}
{if $CRM_TEMPLATES_EXIST eq 0}
<li class="dropdown-header">
<span class="fa fa-file-text-o"></span> {vtranslate('LBL_OOT_TEMPLATES','OnlyOfficeTemplates')}
</li>
<li class="oot-widget" data-record="{$ID}" data-module="{$MODULE}">
<div class="form-group">
<label class="control-label">{vtranslate('LBL_OOT_SELECT_TEMPLATE','OnlyOfficeTemplates')}</label>
<select class="oot-template-id form-control input-sm">
{foreach from=$CRM_TEMPLATES item=tpl}
<option value="{$tpl.id}">{$tpl.name}</option>
{/foreach}
</select>
</div>
<div class="form-group">
<label class="control-label">{vtranslate('LBL_OOT_FORMAT','OnlyOfficeTemplates')}</label>
<select class="oot-format form-control input-sm">
<option value="pdf">{vtranslate('LBL_OOT_FORMAT_PDF','OnlyOfficeTemplates')}</option>
<option value="docx">{vtranslate('LBL_OOT_FORMAT_DOCX','OnlyOfficeTemplates')}</option>
</select>
</div>
<div class="btn-group btn-group-justified">
<a href="javascript:;" class="oot-download"><i class="fa fa-download"></i> {vtranslate('LBL_OOT_DOWNLOAD','OnlyOfficeTemplates')}</a>
<a href="javascript:;" class="oot-save-to-docs"><i class="fa fa-save"></i> {vtranslate('LBL_OOT_SAVE_TO_DOCUMENTS','OnlyOfficeTemplates')}</a>
</div>
</li>
{else}
<li><span class="text-muted">{vtranslate('LBL_OOT_NO_TEMPLATES','OnlyOfficeTemplates')}</span></li>
{/if}
{if $CRM_TEMPLATES_EXIST eq 0}
<script type="text/javascript">
(function() {
var container = document.querySelector('.oot-widget');
if (!container) return;
container.addEventListener('click', function(e) {
var t = e.target.closest('.oot-download, .oot-save-to-docs');
if (!t) return;
e.preventDefault();
var record = container.getAttribute('data-record'), module = container.getAttribute('data-module');
var sel = container.querySelector('.oot-template-id'), fmt = container.querySelector('.oot-format');
var templateId = sel ? sel.value : '', format = fmt ? fmt.value : 'pdf';
var mode = t.classList.contains('oot-download') ? 'download' : 'save_to_documents';
var url = 'index.php?module=OnlyOfficeTemplates&action=CreateFromTemplate&record=' + encodeURIComponent(record) + '&source_module=' + encodeURIComponent(module) + '&template_id=' + encodeURIComponent(templateId) + '&format=' + encodeURIComponent(format) + '&mode=' + encodeURIComponent(mode);
if (mode === 'download') {
window.location.href = url;
} else {
var xhr = new XMLHttpRequest();
xhr.open('GET', url + '&ajax=1');
xhr.onload = function() {
var res = {};
try { res = JSON.parse(xhr.responseText); } catch (err) {}
if (res.success) {
alert(res.message || 'Сохранено в Документы');
} else {
alert(res.error || 'Ошибка');
}
};
xhr.send();
}
});
})();
</script>
{/if}

View File

@@ -0,0 +1,47 @@
{strip}
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h4 class="pull-left">{vtranslate('LBL_OOT_TEMPLATES', $MODULE_NAME)}</h4>
<a href="index.php?module=OnlyOfficeTemplates&action=CreateDraft&app=TOOLS" class="btn btn-success pull-right">
<i class="fa fa-plus"></i> {vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}
</a>
<a href="index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS" class="btn btn-default pull-right" style="margin-right:8px;">
{vtranslate('LBL_OOT_UPLOAD_FILE', $MODULE_NAME)}
</a>
<div class="clearfix"></div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
{if empty($TEMPLATES)}
<div class="alert alert-info">{vtranslate('LBL_OOT_EMPTY_LIST', $MODULE_NAME)}</div>
{else}
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>{vtranslate('LBL_OOT_NAME', $MODULE_NAME)}</th>
<th>{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}</th>
<th>{vtranslate('LBL_OOT_FILE', $MODULE_NAME)}</th>
<th>{vtranslate('LBL_OOT_CREATED_AT', $MODULE_NAME)}</th>
</tr>
</thead>
<tbody>
{foreach from=$TEMPLATES item=TPL}
<tr>
<td>
<a href="index.php?module=OnlyOfficeTemplates&view=Edit&templateid={$TPL.id}&app=TOOLS">{$TPL.name|escape}</a>
</td>
<td>{$TPL.module|escape}</td>
<td>{$TPL.file_name|escape}</td>
<td>{$TPL.created_at|escape}</td>
</tr>
{/foreach}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{/strip}

View File

@@ -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 или внешний конфиг; модуль портативный.

View File

@@ -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 | Префикс пути при сохранении в Документы |

View File

@@ -0,0 +1,107 @@
<?php
/**
* OnlyOfficeTemplates module - DOCX template merge and PDF/DOCX export.
* Portable: config via .env or optional crm_extensions config.
*/
class OnlyOfficeTemplates
{
public $db;
public $moduleName = 'OnlyOfficeTemplates';
public $parentName = 'Tools';
public function __construct()
{
global $log;
$this->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');
}
}
}
}

View File

@@ -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 или импорт пакета).

View File

@@ -0,0 +1,40 @@
<?php
/**
* Create a draft template record and redirect to Edit (OnlyOffice editor).
*/
class OnlyOfficeTemplates_CreateDraft_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->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');
}
}

View File

@@ -0,0 +1,185 @@
<?php
/**
* Generate document from template: merge placeholders, optional PDF conversion, download or save to Documents.
*/
class OnlyOfficeTemplates_CreateFromTemplate_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
$record = $request->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);
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* Serve DOCX for OnlyOffice Document Server: from S3 or empty document for new template.
* Document Server requests this URL; it must be publicly reachable.
*/
class OnlyOfficeTemplates_GetDocument_Action extends Vtiger_Action_Controller
{
/** Document Server requests this URL without session; we verify token if OOT_DOCUMENT_SECRET is set. */
public function checkPermission(Vtiger_Request $request)
{
require_once dirname(__DIR__) . '/config.php';
$config = OnlyOfficeTemplates_getConfig();
$secret = $config['document_secret'] ?? '';
if ($secret !== '') {
$templateId = (int)$request->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);
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* Install OnlyOfficeTemplates: register in vtiger_tab, create tables, add widget links.
* Open in browser (as admin): index.php?module=OnlyOfficeTemplates&action=Install
*/
class OnlyOfficeTemplates_Install_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
if (!isPermitted('Settings', 'Edit', '')) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$adb = PearDatabase::getInstance();
$r = $adb->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;
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* OnlyOffice Document Server callback: receive saved document and store in S3.
* Expects JSON body: status, key, url (when status 2,3,6,7). Returns {"error":0}.
*/
class OnlyOfficeTemplates_OnlyOfficeCallback_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
// Callback is called by Document Server, not by user. We validate by key matching template.
}
public function process(Vtiger_Request $request)
{
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (!$data || !isset($data['key']) || !isset($data['status'])) {
echo json_encode(['error' => 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]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Save template name and module (metadata). Redirect back to Edit.
*/
class OnlyOfficeTemplates_SaveMetadata_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->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');
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* Upload DOCX template to S3 and save metadata in vtiger_oot_templates.
*/
class OnlyOfficeTemplates_UploadTemplate_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
if (!isPermitted('Settings', 'Edit', '')) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$name = $request->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]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* OnlyOfficeTemplates module configuration.
* Reads from environment (.env) or optional external config (crm_extensions/file_storage/config.php).
* Portable: no hardcoded paths; set S3_*, ONLYOFFICE_* in .env or your config.
*/
$OnlyOfficeTemplatesConfig = null;
/**
* Read env var: getenv() then $_ENV then EnvLoader::get() (EnvLoader does not use putenv).
*/
function OnlyOfficeTemplates_env($key, $default = '') {
$v = getenv($key);
if ($v !== false && $v !== '') {
return $v;
}
$v = $_ENV[$key] ?? null;
if ($v !== null && $v !== '') {
return $v;
}
if (class_exists('EnvLoader')) {
$v = EnvLoader::get($key, $default);
return $v !== null ? $v : $default;
}
return $default;
}
function OnlyOfficeTemplates_getConfig() {
global $OnlyOfficeTemplatesConfig;
if ($OnlyOfficeTemplatesConfig !== null) {
return $OnlyOfficeTemplatesConfig;
}
$baseDir = dirname(__DIR__);
$rootDir = dirname(dirname($baseDir));
// Load .env (EnvLoader uses $_ENV, not getenv)
if (file_exists($rootDir . '/crm_extensions/shared/EnvLoader.php')) {
require_once $rootDir . '/crm_extensions/shared/EnvLoader.php';
$envPath = $rootDir . '/crm_extensions/.env';
if (file_exists($envPath)) {
EnvLoader::load($envPath);
}
}
// 1) Try external config (crm_extensions) if present
$extConfigPath = $rootDir . '/crm_extensions/file_storage/config.php';
if (file_exists($extConfigPath)) {
try {
$ext = require $extConfigPath;
if (is_array($ext) && isset($ext['s3'])) {
$s3 = $ext['s3'];
$bucket = $s3['bucket'] ?? $ext['bucket'] ?? $ext['s3_bucket'] ?? OnlyOfficeTemplates_env('S3_BUCKET', '');
$OnlyOfficeTemplatesConfig = [
's3' => $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;
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* One-time install: register OnlyOfficeTemplates in vtiger_tab and run schema + links.
* Run from browser: index.php?module=OnlyOfficeTemplates&action=Install
* Or from CLI: php modules/OnlyOfficeTemplates/install.php
* Requires admin user when run from browser.
*/
$rootDir = dirname(dirname(__DIR__));
if (php_sapi_name() === 'cli') {
require_once $rootDir . '/config.inc.php';
require_once $rootDir . '/vtlib/Vtiger/Module.php';
require_once $rootDir . '/includes/utils/utils.php';
$current_user = Users::getActiveAdminUser();
if (!$current_user) {
die("Admin user not found.\n");
}
} else {
require_once $rootDir . '/config.inc.php';
require_once $rootDir . '/vtlib/Vtiger/Module.php';
require_once $rootDir . '/includes/utils/utils.php';
if (!isPermitted('Settings', 'Edit', '')) {
die('Access denied.');
}
}
require_once __DIR__ . '/OnlyOfficeTemplates.php';
$adb = PearDatabase::getInstance();
// Check if already registered
$r = $adb->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;
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<module>
<type>extension</type>
<name>OnlyOfficeTemplates</name>
<label>OnlyOffice Templates</label>
<parent>Tools</parent>
<version>1.0</version>
<dependencies>
<vtiger_version>7.*</vtiger_version>
</dependencies>
<tables>
<table>
<name>vtiger_oot_templates</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates (
id INT(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
module VARCHAR(100) NOT NULL,
s3_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
owner INT(11) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
settings TEXT NULL,
PRIMARY KEY (id),
KEY idx_oot_module (module),
KEY idx_oot_owner (owner)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
]]></sql>
</table>
<table>
<name>vtiger_oot_templates_seq</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates_seq (
id INT(11) NOT NULL DEFAULT 1
) ENGINE=InnoDB
]]></sql>
</table>
</tables>
</module>

View File

@@ -0,0 +1,124 @@
<?php
/**
* OnlyOfficeTemplates model: list templates by module, get from S3, merge, convert.
*/
class OnlyOfficeTemplates_Model
{
protected $db;
protected $config;
public function __construct()
{
$this->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');
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* OnlyOffice Conversion API: DOCX -> 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];
}
}

View File

@@ -0,0 +1,172 @@
<?php
/**
* Merge DOCX template: load from S3, substitute {{field}} and {{ModuleName__field}}, output DOCX.
*/
class OnlyOfficeTemplates_MergeService
{
protected $s3;
protected $config;
public function __construct(OnlyOfficeTemplates_S3Helper $s3, array $config)
{
$this->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;
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* Portable S3 helper for OnlyOfficeTemplates (get/put using module config).
*/
class OnlyOfficeTemplates_S3Helper
{
protected $client;
protected $bucket;
protected $prefix;
public function __construct(array $config)
{
$this->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);
}
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0"?>
<schema>
<tables>
<table>
<name>vtiger_oot_templates</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates (
id INT(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
module VARCHAR(100) NOT NULL,
s3_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
owner INT(11) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
settings TEXT NULL,
PRIMARY KEY (id),
KEY idx_oot_module (module),
KEY idx_oot_owner (owner)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
]]></sql>
</table>
<table>
<name>vtiger_oot_templates_seq</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates_seq (
id INT(11) NOT NULL DEFAULT 1
) ENGINE=InnoDB
]]></sql>
</table>
</tables>
</schema>

View File

@@ -0,0 +1,39 @@
<?php
/**
* Form to add a new DOCX template (name, module, file upload).
*/
class OnlyOfficeTemplates_AddTemplate_View extends Vtiger_Index_View
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->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);
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* Edit/Create template: left = metadata (name, module), right = OnlyOffice Document Editor in iframe.
* Document is loaded from GetDocument and saved to S3 via OnlyOffice callback.
*/
class OnlyOfficeTemplates_Edit_View extends Vtiger_Index_View
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->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;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* Widget view: list OnlyOffice templates for the record's module and actions (Download / Save to Documents) with format (PDF/DOCX).
*/
class OnlyOfficeTemplates_GetTemplateActions_View extends Vtiger_BasicAjax_View
{
public function checkPermission(Vtiger_Request $request)
{
}
public function process(Vtiger_Request $request)
{
$module = false;
if ($request->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');
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* Simple list view for OnlyOfficeTemplates (non-entity module).
*/
class OnlyOfficeTemplates_List_View extends Vtiger_Index_View
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->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);
}
}