Feature: allow to convert to PDF from Chill and group action button on document

BREAKING CHANGE: avoid using the macro for download button. To keep the UI clean, use always the new "group of action buttons".
This commit is contained in:
Julien Fastré 2023-01-31 16:30:19 +00:00
parent e5bc74d11d
commit 9f5b11e6cc
30 changed files with 770 additions and 161 deletions

View File

@ -168,7 +168,7 @@
{% if entity.documents|length > 0 %}
<ul>
{% for d in entity.documents %}
<li>{{ d.title }}{{ m.download_button(d) }}</li>
<li>{{ d.title }} {{ d|chill_document_button_group() }}</li>
{% endfor %}
</ul>
{% else %}

View File

@ -8,12 +8,14 @@
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %}

View File

@ -7,13 +7,13 @@
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %}

View File

@ -0,0 +1,35 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {createApp} from "vue";
import {StoredObject} from "../../types";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll<HTMLDivElement>('div[data-download-buttons]').forEach((el) => {
const app = createApp({
components: {DocumentActionButtonsGroup},
data() {
const datasets = el.dataset as {
filename: string,
canEdit: string,
storedObject: string,
small: string,
};
const
storedObject = JSON.parse(datasets.storedObject),
filename = datasets.filename,
canEdit = datasets.canEdit === '1',
small = datasets.small === '1'
;
return { storedObject, filename, canEdit, small };
},
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small"></document-action-buttons-group>',
});
app.use(i18n).mount(el);
})
});

View File

@ -0,0 +1,25 @@
import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
export interface StoredObject {
id: number,
/**
* filename of the object in the object storage
*/
filename: string,
creationDate: DateTime,
datas: object,
iv: number[],
keyInfos: object,
title: string,
type: string,
uuid: string
}
/**
* Function executed by the WopiEditButton component.
*/
export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void>
}

View File

@ -0,0 +1,63 @@
<template>
<div class="dropdown">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, small: props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type)">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && props.canConvertPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
<li v-if="props.canDownload">
<download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable} from "./StoredObjectButton/helpers";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../types";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
small?: boolean,
canEdit?: boolean,
canDownload?: boolean,
canConvertPdf?: boolean,
returnPath?: string,
/**
* Will be the filename displayed to the user when he·she download the document
* (the document will be saved on his disk with this name)
*
* If not set, 'document' will be used.
*/
filename?: string,
/**
* If set, will execute this function before leaving to the editor
*/
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
}
const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
small: false,
canEdit: true,
canDownload: true,
canConvertPdf: true,
returnPath: window.location.pathname + window.location.search + window.location.hash,
});
</script>
<style scoped>
</style>

View File

@ -0,0 +1,5 @@
# About buttons and components available
## DocumentActionButtonsGroup
This is an component to use to render a group of button with actions linked to a document.

View File

@ -0,0 +1,46 @@
<template>
<a :class="props.classes" @click="download_and_open($event)">
<i class="fa fa-file-pdf-o"></i>
Télécharger en pdf
</a>
</template>
<script lang="ts" setup>
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive} from "vue";
import {StoredObject} from "../../types";
interface ConvertButtonConfig {
storedObject: StoredObject,
classes: { [key: string]: boolean},
filename?: string,
};
interface DownloadButtonState {
content: null|string
}
const props = defineProps<ConvertButtonConfig>();
const state: DownloadButtonState = reactive({content: null});
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
if (null === state.content) {
event.preventDefault();
const raw = await download_doc(build_convert_link(props.storedObject.uuid));
state.content = window.URL.createObjectURL(raw);
button.href = window.URL.createObjectURL(raw);
button.type = 'application/pdf';
button.download = (props.filename + '.pdf') || 'document.pdf';
}
button.click();
}
</script>

View File

@ -0,0 +1,53 @@
<template>
<a :class="props.classes" @click="download_and_open($event)">
<i class="fa fa-download"></i>
Télécharger
</a>
</template>
<script lang="ts" setup>
import {reactive} from "vue";
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
import mime from "mime";
import {StoredObject} from "../../types";
interface DownloadButtonConfig {
storedObject: StoredObject,
classes: {[k: string]: boolean},
filename?: string,
}
interface DownloadButtonState {
content: null|string
}
const props = defineProps<DownloadButtonConfig>();
const state: DownloadButtonState = reactive({content: null});
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
if (null === state.content) {
event.preventDefault();
const urlInfo = build_download_info_link(props.storedObject.filename);
const raw = await download_and_decrypt_doc(urlInfo, props.storedObject.keyInfos, new Uint8Array(props.storedObject.iv));
state.content = window.URL.createObjectURL(raw);
button.href = window.URL.createObjectURL(raw);
button.type = props.storedObject.type;
if (props.filename !== undefined) {
button.download = props.filename || 'document';
const ext = mime.getExtension(props.storedObject.type);
if (null !== ext) {
button.download = button.download + '.' + ext;
}
}
}
button.click();
}
</script>

View File

@ -0,0 +1,44 @@
<template>
<a :class="Object.assign(props.classes, {'btn': true})" @click="beforeLeave($event)" :href="build_wopi_editor_link(props.storedObject.uuid, props.returnPath)">
<i class="fa fa-paragraph"></i>
Editer en ligne
</a>
</template>
<script lang="ts" setup>
import WopiEditButton from "./WopiEditButton.vue";
import {build_wopi_editor_link} from "./helpers";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
interface WopiEditButtonConfig {
storedObject: StoredObject,
returnPath?: string,
classes: {[k: string] : boolean},
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
}
const props = defineProps<WopiEditButtonConfig>();
let executed = false;
async function beforeLeave(event: Event): Promise<true> {
console.log(executed);
if (props.executeBeforeLeave === undefined || executed === true) {
return Promise.resolve(true);
}
event.preventDefault();
await props.executeBeforeLeave();
executed = true;
const link = event.target as HTMLAnchorElement;
link.click();
return Promise.resolve(true);
}
</script>
<style scoped lang="sass">
</style>

View File

@ -0,0 +1,164 @@
const SUPPORTED_MIMES = new Set([
'image/svg+xml',
'application/vnd.ms-powerpoint',
'application/vnd.ms-excel',
'application/vnd.sun.xml.writer',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.text-flat-xml',
'application/vnd.sun.xml.calc',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.spreadsheet-flat-xml',
'application/vnd.sun.xml.impress',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.presentation-flat-xml',
'application/vnd.sun.xml.draw',
'application/vnd.oasis.opendocument.graphics',
'application/vnd.oasis.opendocument.graphics-flat-xml',
'application/vnd.oasis.opendocument.chart',
'application/vnd.sun.xml.writer.global',
'application/vnd.oasis.opendocument.text-master',
'application/vnd.sun.xml.writer.template',
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text-master-template',
'application/vnd.sun.xml.calc.template',
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.sun.xml.impress.template',
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.sun.xml.draw.template',
'application/vnd.oasis.opendocument.graphics-template',
'application/msword',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-word.document.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.ms-word.template.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.wordperfect',
'application/x-aportisdoc',
'application/x-hwp',
'application/vnd.ms-works',
'application/x-mswrite',
'application/x-dif-document',
'text/spreadsheet',
'text/csv',
'application/x-dbase',
'application/vnd.lotus-1-2-3',
'image/cgm',
'image/vnd.dxf',
'image/x-emf',
'image/x-wmf',
'application/coreldraw',
'application/vnd.visio2013',
'application/vnd.visio',
'application/vnd.ms-visio.drawing',
'application/x-mspublisher',
'application/x-sony-bbeb',
'application/x-gnumeric',
'application/macwriteii',
'application/x-iwork-numbers-sffnumbers',
'application/vnd.oasis.opendocument.text-web',
'application/x-pagemaker',
'text/rtf',
'text/plain',
'application/x-fictionbook+xml',
'application/clarisworks',
'image/x-wpg',
'application/x-iwork-pages-sffpages',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/x-iwork-keynote-sffkey',
'application/x-abiword',
'image/x-freehand',
'application/vnd.sun.xml.chart',
'application/x-t602',
'image/bmp',
'image/png',
'image/gif',
'image/tiff',
'image/jpg',
'image/jpeg',
'application/pdf',
]);
function is_extension_editable(mimeType: string): boolean {
return SUPPORTED_MIMES.has(mimeType);
}
function build_convert_link(uuid: string) {
return `/chill/wopi/convert/${uuid}`;
}
function build_download_info_link(object_name: string) {
return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`;
}
function build_wopi_editor_link(uuid: string, returnPath?: string) {
if (returnPath === undefined) {
returnPath = window.location.pathname + window.location.search + window.location.hash;
}
return `/chill/wopi/edit/${uuid}?returnPath=` + encodeURIComponent(returnPath);
}
function download_doc(url: string): Promise<Blob> {
return window.fetch(url).then(r => {
if (r.ok) {
return r.blob()
}
throw new Error('Could not download document');
});
}
async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKey, iv: Uint8Array): Promise<Blob>
{
const algo = 'AES-CBC';
// get an url to download the object
const downloadInfoResponse = await window.fetch(urlGenerator);
if (!downloadInfoResponse.ok) {
throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText);
}
const downloadInfo = await downloadInfoResponse.json() as {url: string};
const rawResponse = await window.fetch(downloadInfo.url);
if (!rawResponse.ok) {
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
}
const rawBuffer = await rawResponse.arrayBuffer();
try {
const key = await window.crypto.subtle
.importKey('jwk', keyData, { name: algo }, false, ['decrypt']);
const decrypted = await window.crypto.subtle
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
return Promise.resolve(new Blob([decrypted]));
} catch (e) {
console.error('get error while keys and decrypt operations');
console.error(e);
throw e;
}
}
export {
build_convert_link,
build_download_info_link,
build_wopi_editor_link,
download_and_decrypt_doc,
download_doc,
is_extension_editable,
};

View File

@ -46,21 +46,8 @@
</li>
{% endif %}
<li>
{{ m.download_button(document.object, document.title) }}
{{ document.object|chill_document_button_group(document.title, not freezed) }}
</li>
{% if chill_document_is_editable(document.object) %}
{% if not freezed %}
<li>
{{ document.object|chill_document_edit_button({'title': document.title|e('html') }) }}
</li>
{% else %}
<li>
<a class="btn btn-wopilink disabled" href="#" title="{{ 'workflow.freezed document'|trans }}">
{{ 'Update document'|trans }}
</a>
</li>
{% endif %}
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE', document) and document.course != null %}
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': document.course.id, 'id': document.id}) }}" class="btn btn-show"></a>

View File

@ -8,16 +8,16 @@
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}

View File

@ -14,6 +14,7 @@
{{ parent() }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}
@ -61,13 +62,8 @@
</li>
{% endif %}
<li>
{{ m.download_button(document.object, document.title) }}
{{ document.object|chill_document_button_group(document.title) }}
</li>
{% if chill_document_is_editable(document.object) %}
<li>
{{ document.object|chill_document_edit_button }}
</li>
{% endif %}
{% set workflows_frame = chill_entity_workflow_list('Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument', document.id) %}
{% if workflows_frame is not empty %}
<li>
@ -86,4 +82,5 @@
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}

View File

@ -0,0 +1,7 @@
{%- import "@ChillDocStore/Macro/macro.html.twig" as m -%}
<div
data-download-buttons
data-stored-object="{{ document_json|json_encode|escape('html_attr') }}"
data-can-edit="{{ can_edit ? '1' : '0' }}"
{% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %}
{% if title|default(document.title)|default(null) is not null %}data-filename="{{ title|default(document.title)|escape('html_attr') }}"{% endif %}></div>

View File

@ -53,15 +53,10 @@
<li>
<a href="{{ path('accompanying_course_document_edit', {'course': accompanyingCourse.id, 'id': document.id }) }}" class="btn btn-update"></a>
</li>
{% if chill_document_is_editable(document.object) %}
<li>
{{ document.object|chill_document_edit_button }}
</li>
{% endif %}
{% endif %}
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ m.download_button(document.object, document.title) }}
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }}
</li>
<li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
@ -80,15 +75,10 @@
<li>
<a href="{{ path('person_document_edit', {'person': person.id, 'id': document.id}) }}" class="btn btn-update"></a>
</li>
{% if chill_document_is_editable(document.object) %}
<li>
{{ document.object|chill_document_edit_button }}
</li>
{% endif %}
{% endif %}
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ m.download_button(document.object, document.title) }}
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }}
</li>
<li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>

View File

@ -29,3 +29,20 @@
{{ 'Download'|trans }}</a>
{% endif %}
{% endmacro %}
{% macro download_button_group(storedObject, canEdit = true, filename = null, options = {}) %}
{% if storedObject is null %}
<!-- No document to download -->
{% else %}
<div
data-download-buttons
data-uuid="{{ storedObject.uuid|escape('html_attr') }}"
data-key="{{ storedObject.keyInfos|json_encode|escape('html_attr') }}"
data-iv="{{ storedObject.iv|json_encode|escape('html_attr') }}"
data-temp-url-generator="{{ storedObject|generate_url|escape('html_attr') }}"
data-mime-type="{{ storedObject.type|escape('html_attr') }}"
data-can-edit="{{ canEdit ? '1' : '0' }}"
{% if options['small'] is defined %}data-button-small="{{ options['small'] ? '1' : '0' }}"{% endif %}
{% if filename|default(storedObject.title)|default(null) is not null %}data-filename="{{ filename|default(storedObject.title)|escape('html_attr') }}"{% endif %}></div>
{% endif %}
{% endmacro %}

View File

@ -27,16 +27,16 @@
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}

View File

@ -24,7 +24,11 @@
{% block title %}{{ 'Detail of document of %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}{% endblock %}
{% block js %}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}
@ -70,6 +74,10 @@
</li>
{% endif %}
<li>
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }}
</li>
{% if is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('person_document_edit', {'id': document.id, 'person': person.id}) }}" class="btn btn-edit">
@ -77,16 +85,4 @@
</a>
</li>
{% endif %}
<li>
{{ m.download_button(document.object, document.title) }}
</li>
{% if chill_document_is_editable(document.object) %}
<li>
{{ document.object|chill_document_edit_button }}
</li>
{% endif %}
{# {{ include('ChillDocStoreBundle:PersonDocument:_delete_form.html.twig') }} #}
{% endblock %}

View File

@ -24,6 +24,10 @@ class WopiEditTwigExtension extends AbstractExtension
'needs_environment' => true,
'is_safe' => ['html'],
]),
new TwigFilter('chill_document_button_group', [WopiEditTwigExtensionRuntime::class, 'renderButtonGroup'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}

View File

@ -13,6 +13,8 @@ namespace Chill\DocStoreBundle\Templating;
use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
@ -112,20 +114,53 @@ final class WopiEditTwigExtensionRuntime implements RuntimeExtensionInterface
'application/pdf',
];
private const DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP = [
'small' => false,
];
private const TEMPLATE = '@ChillDocStore/Button/wopi_edit_document.html.twig';
private const TEMPLATE_BUTTON_GROUP = '@ChillDocStore/Button/button_group.html.twig';
private DiscoveryInterface $discovery;
public function __construct(DiscoveryInterface $discovery)
private NormalizerInterface $normalizer;
public function __construct(DiscoveryInterface $discovery, NormalizerInterface $normalizer)
{
$this->discovery = $discovery;
$this->normalizer = $normalizer;
}
/**
* return true if the document is editable.
*
* **NOTE**: as the Vue button does have similar test, this is not required if in use with
* the dedicated Vue component (GroupDownloadButton.vue, WopiEditButton.vue)
*/
public function isEditable(StoredObject $document): bool
{
return in_array($document->getType(), self::SUPPORTED_MIMES, true);
}
/**
* @param array{small: boolean} $options
*
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string
{
return $environment->render(self::TEMPLATE_BUTTON_GROUP, [
'document' => $document,
'document_json' => $this->normalizer->normalize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
'title' => $title,
'can_edit' => $canEdit,
'options' => array_merge($options, self::DEFAULT_OPTIONS_TEMPLATE_BUTTON_GROUP),
]);
}
public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string
{
return $environment->render(self::TEMPLATE, [

View File

@ -4,4 +4,5 @@ module.exports = function(encore)
ChillDocStoreAssets: __dirname + '/Resources/public'
});
encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.js');
encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index');
};

View File

@ -11,6 +11,7 @@
{{ encore_entry_script_tags('mod_entity_workflow_subscribe') }}
{{ encore_entry_script_tags('page_workflow_show') }}
{{ encore_entry_script_tags('mod_wopi_link') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
@ -19,6 +20,7 @@
{{ encore_entry_link_tags('mod_entity_workflow_subscribe') }}
{{ encore_entry_link_tags('page_workflow_show') }}
{{ encore_entry_link_tags('mod_wopi_link') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% import '@ChillMain/Workflow/macro_breadcrumb.html.twig' as macro %}

View File

@ -111,14 +111,12 @@
</add-async-upload>
</li>
<li>
<add-async-upload-downloader
:buttonTitle="$t('download')"
:storedObject="d.storedObject"
>
</add-async-upload-downloader>
</li>
<li v-if="canEditDocument(d)">
<a :href="buildEditLink(d.storedObject)" class="btn btn-wopilink"></a>
<document-action-buttons-group
:stored-object="d.storedObject"
:filename="d.title"
:can-edit="true"
:execute-before-leave="submitBeforeLeaveToEditor"
></document-action-buttons-group>
</li>
<li v-if="d.workflows.length === 0">
<a class="btn btn-delete" @click="removeDocument(d)">
@ -174,6 +172,7 @@ import AddAsyncUpload from 'ChillDocStoreAssets/vuejs/_components/AddAsyncUpload
import AddAsyncUploadDownloader from 'ChillDocStoreAssets/vuejs/_components/AddAsyncUploadDownloader.vue';
import ListWorkflowModal from 'ChillMainAssets/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue';
import {buildLinkCreate} from 'ChillMainAssets/lib/entity-workflow/api.js';
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
const i18n = {
messages: {
@ -212,6 +211,7 @@ export default {
AddAsyncUpload,
AddAsyncUploadDownloader,
ListWorkflowModal,
DocumentActionButtonsGroup,
},
i18n,
data() {
@ -223,78 +223,6 @@ export default {
maxPostSize: 15000000,
required: false,
},
mime: [
// TODO temporary hardcoded. to be replaced by twig extension or a collabora server query
'application/clarisworks',
'application/coreldraw',
'application/macwriteii',
'application/msword',
'application/vnd.lotus-1-2-3',
'application/vnd.ms-excel',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.ms-powerpoint',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.ms-visio.drawing',
'application/vnd.ms-word.document.macroEnabled.12',
'application/vnd.ms-word.template.macroEnabled.12',
'application/vnd.ms-works',
'application/vnd.oasis.opendocument.chart',
'application/vnd.oasis.opendocument.formula',
'application/vnd.oasis.opendocument.graphics',
'application/vnd.oasis.opendocument.graphics-flat-xml',
'application/vnd.oasis.opendocument.graphics-template',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.presentation-flat-xml',
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.spreadsheet-flat-xml',
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.text-flat-xml',
'application/vnd.oasis.opendocument.text-master',
'application/vnd.oasis.opendocument.text-master-template',
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text-web',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.sun.xml.calc',
'application/vnd.sun.xml.calc.template',
'application/vnd.sun.xml.chart',
'application/vnd.sun.xml.draw',
'application/vnd.sun.xml.draw.template',
'application/vnd.sun.xml.impress',
'application/vnd.sun.xml.impress.template',
'application/vnd.sun.xml.math',
'application/vnd.sun.xml.writer',
'application/vnd.sun.xml.writer.global',
'application/vnd.sun.xml.writer.template',
'application/vnd.visio',
'application/vnd.visio2013',
'application/vnd.wordperfect',
'application/x-abiword',
'application/x-aportisdoc',
'application/x-dbase',
'application/x-dif-document',
'application/x-fictionbook+xml',
'application/x-gnumeric',
'application/x-hwp',
'application/x-iwork-keynote-sffkey',
'application/x-iwork-numbers-sffnumbers',
'application/x-iwork-pages-sffpages',
'application/x-mspublisher',
'application/x-mswrite',
'application/x-pagemaker',
'application/x-sony-bbeb',
'application/x-t602',
]
}
},
computed: {
@ -343,10 +271,6 @@ export default {
},
methods: {
ISOToDatetime,
canEditDocument(document) {
return 'storedObject' in document ?
this.mime.includes(document.storedObject.type) : false;
},
listAllStatus() {
console.log('load all status');
let url = `/api/`;
@ -359,10 +283,25 @@ export default {
})
;
},
buildEditLink(storedObject) {
return `/chill/wopi/edit/${storedObject.uuid}?returnPath=` + encodeURIComponent(
buildEditLink(document) {
return `/chill/wopi/edit/${document.storedObject.uuid}?returnPath=` + encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash);
},
submitBeforeLeaveToEditor() {
console.log('submit beore edit 2');
// empty callback
const callback = () => null;
return this.$store.dispatch('submit', callback).catch(e => { console.log(e); throw e; });
},
submitBeforeEdit(storedObject) {
const callback = (data) => {
let evaluation = data.accompanyingPeriodWorkEvaluations.find(e => e.key === this.evaluation.key);
let document = evaluation.documents.find(d => d.storedObject.id === storedObject.id);
//console.log('=> document', document);
window.location.assign(this.buildEditLink(document));
};
return this.$store.dispatch('submit', callback).catch(e => { console.log(e); throw e; });
},
submitBeforeGenerate({template}) {
const callback = (data) => {
let evaluationId = data.accompanyingPeriodWorkEvaluations.find(e => e.key === this.evaluation.key).id;

View File

@ -142,7 +142,7 @@
{{ mm.mimeIcon(d.storedObject.type) }}
</div>
<div class="col col-lg-4 text-end">
{{ m.download_button_small(d.storedObject, d.title) }}
{{ d.storedObject|chill_document_button_group(d.title, is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', w), {'small': true}) }}
</div>
</div>
{% endfor %}

View File

@ -6,14 +6,14 @@
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block content %}

View File

@ -120,14 +120,8 @@
</div>
{% if display_action is defined and display_action == true %}
{% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', evaluation.accompanyingPeriodWork) %}
<ul class="record_actions">
<li>{{ m.download_button(doc.storedObject, doc.title) }}</li>
{% if chill_document_is_editable(doc.storedObject) %}
<li>
{{ doc.storedObject|chill_document_edit_button }}
</li>
{% endif %}
<li>{{ doc.storedObject|chill_document_button_group(doc.title, is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', evaluation.accompanyingPeriodWork)) }}</li>
<li>
<a class="btn btn-show" href="{{ path('chill_person_accompanying_period_work_edit', {'id': evaluation.accompanyingPeriodWork.id}) }}">
{{ 'Show'|trans }}
@ -136,4 +130,3 @@
</ul>
{% endif %}
{% endif %}
{% endif %}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\WopiBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class Convert
{
private const LOG_PREFIX = '[convert] ';
private string $collaboraDomain;
private HttpClientInterface $httpClient;
private LoggerInterface $logger;
private Security $security;
private StoredObjectManagerInterface $storedObjectManager;
/**
* @param StoredObjectManager $storedObjectManager
*/
public function __construct(
HttpClientInterface $httpClient,
Security $security,
StoredObjectManagerInterface $storedObjectManager,
LoggerInterface $logger,
ParameterBagInterface $parameters
) {
$this->httpClient = $httpClient;
$this->security = $security;
$this->storedObjectManager = $storedObjectManager;
$this->logger = $logger;
$this->collaboraDomain = $parameters->get('wopi')['server'];
}
public function __invoke(StoredObject $storedObject): Response
{
if (!$this->security->getUser() instanceof User) {
throw new AccessDeniedHttpException('User must be authenticated');
}
$content = $this->storedObjectManager->read($storedObject);
try {
$url = sprintf('%s/cool/convert-to/pdf', $this->collaboraDomain);
$form = new FormDataPart([
'data' => new DataPart($content, $storedObject->getUuid()->toString(), $storedObject->getType()),
]);
$response = $this->httpClient->request('POST', $url, [
'headers' => $form->getPreparedHeaders()->toArray(),
'body' => $form->bodyToString(),
'timeout' => 10,
]);
return new Response($response->getContent(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
]);
} catch (ClientExceptionInterface $exception) {
return $this->onConversionFailed($url, $response);
} catch (RedirectionExceptionInterface $e) {
return $this->onConversionFailed($url, $response);
} catch (ServerExceptionInterface $e) {
return $this->onConversionFailed($url, $response);
} catch (TransportExceptionInterface $e) {
return $this->onConversionFailed($url, $response);
}
}
private function onConversionFailed(string $url, ResponseInterface $response): JsonResponse
{
$this->logger->error(self::LOG_PREFIX . ' could not convert document', [
'response_status' => $response->getStatusCode(),
'message' => $response->getContent(false),
'server' => $this->collaboraDomain,
'url' => $url,
]);
return new JsonResponse(['message' => 'conversion failed : ' . $response->getContent(false)], Response::HTTP_SERVICE_UNAVAILABLE);
}
}

View File

@ -16,4 +16,8 @@ return static function (RoutingConfigurator $routes) {
$routes
->add('chill_wopi_file_edit', '/edit/{fileId}')
->controller(Editor::class);
$routes
->add('chill_wopi_object_convert', '/convert/{uuid}')
->controller(\Chill\WopiBundle\Controller\Convert::class);
};

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\WopiBundle\Tests\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Chill\WopiBundle\Controller\Convert;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
final class ConverTest extends TestCase
{
use ProphecyTrait;
public function testConversionFailed(): void
{
$storedObject = (new StoredObject())->setType('application/vnd.oasis.opendocument.text');
$httpClient = new MockHttpClient([
new MockResponse('not authorized', ['http_code' => 401]),
], 'http://collabora:9980');
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(new User());
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
$parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]);
$convert = new Convert(
$httpClient,
$security->reveal(),
$storeManager->reveal(),
new NullLogger(),
$parameterBag
);
$response = $convert($storedObject);
$this->assertNotEquals(200, $response->getStatusCode());
}
public function testEverythingWentFine(): void
{
$storedObject = (new StoredObject())->setType('application/vnd.oasis.opendocument.text');
$httpClient = new MockHttpClient([
new MockResponse('1234', ['http_code' => 200]),
], 'http://collabora:9980');
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(new User());
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
$parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]);
$convert = new Convert(
$httpClient,
$security->reveal(),
$storeManager->reveal(),
new NullLogger(),
$parameterBag
);
$response = $convert($storedObject);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('1234', $response->getContent());
}
}