diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c1ad767e45..c935d72524 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -168,6 +168,8 @@ * }, * customSettings: ?array{ * width: int, + * showPreview?: bool, + * imagePreviewSize?: int, * }, * } * @@ -209,6 +211,8 @@ * }, * customSettings: ?array{ * width: int, + * showPreview?: bool, + * imagePreviewSize?: int, * }, * } * diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 52a84b03c1..51632a537b 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -482,6 +482,29 @@ private function validateCustomSettings(?string $customSettings): void { ); } } + + if (isset($settings['showPreview']) && !is_bool($settings['showPreview'])) { + $translatedMessage = $this->l->t('Show preview must be a boolean value.'); + throw new BadRequestError( + $translatedMessage, + 0, + null, + $translatedMessage + ); + } + + if (isset($settings['imagePreviewSize'])) { + $size = $settings['imagePreviewSize']; + if (!is_numeric($size) || $size < 32 || $size > 192) { + $translatedMessage = $this->l->t('Image preview size must be between %1$s and %2$s.', [32, 192]); + throw new BadRequestError( + $translatedMessage, + 0, + null, + $translatedMessage + ); + } + } } private function normalizeTitle(?string $title, bool $required): ?string { diff --git a/lib/Service/ColumnTypes/TextLinkBusiness.php b/lib/Service/ColumnTypes/TextLinkBusiness.php index ca5ece0eb2..5ed74f2857 100644 --- a/lib/Service/ColumnTypes/TextLinkBusiness.php +++ b/lib/Service/ColumnTypes/TextLinkBusiness.php @@ -48,6 +48,7 @@ public function parseValue($value, Column $column): string|false { $data = json_decode((string)$value, true); if ($data !== null) { if (isset($data['resourceUrl'])) { + // Only persist server-trusted fields return json_encode(json_encode([ 'title' => $data['title'] ?? $data['resourceUrl'], 'value' => $data['resourceUrl'], diff --git a/openapi.json b/openapi.json index 554bc92033..80e327a7a7 100644 --- a/openapi.json +++ b/openapi.json @@ -259,6 +259,13 @@ "width": { "type": "integer", "format": "int64" + }, + "showPreview": { + "type": "boolean" + }, + "imagePreviewSize": { + "type": "integer", + "format": "int64" } } } @@ -610,6 +617,13 @@ "width": { "type": "integer", "format": "int64" + }, + "showPreview": { + "type": "boolean" + }, + "imagePreviewSize": { + "type": "integer", + "format": "int64" } } } diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index b6095ffd7f..ae8c311e98 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -130,8 +130,12 @@ import RelationForm from '../../shared/components/ncTable/partials/columnTypePar import { useTablesStore } from '../../store/store.js' import { useDataStore } from '../../store/data.js' import { mapActions } from 'pinia' -import { COLUMN_WIDTH_MAX, COLUMN_WIDTH_MIN } from '../../shared/constants.js' +import { + COLUMN_WIDTH_MAX, + COLUMN_WIDTH_MIN, +} from '../../shared/constants.js' import { normalizeTechnicalName, isTechnicalNameValid } from '../../shared/utils/columnUtils.js' +import { normalizeImagePreviewSize } from '../../shared/utils/imagePreviewSize.js' export default { name: 'CreateColumn', @@ -382,6 +386,10 @@ export default { data.textDefault = this.column.textDefault } else if (this.combinedType === ColumnTypes.TextLink) { data.textAllowedPattern = this.column.textAllowedPattern + data.customSettings.showPreview = !!this.column.customSettings?.showPreview + if (data.customSettings.showPreview || this.column.customSettings?.imagePreviewSize !== undefined) { + data.customSettings.imagePreviewSize = normalizeImagePreviewSize(this.column.customSettings?.imagePreviewSize) + } } else if (this.column.type === 'selection') { data.selectionDefault = typeof this.column.selectionDefault !== 'string' ? JSON.stringify(this.column.selectionDefault) : this.column.selectionDefault if (this.column.subtype !== 'check') { diff --git a/src/modules/modals/EditColumn.vue b/src/modules/modals/EditColumn.vue index da4d90ade9..6b6fcf007f 100644 --- a/src/modules/modals/EditColumn.vue +++ b/src/modules/modals/EditColumn.vue @@ -75,8 +75,12 @@ import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandle import moment from '@nextcloud/moment' import { mapActions } from 'pinia' import { useDataStore } from '../../store/data.js' -import { COLUMN_WIDTH_MAX, COLUMN_WIDTH_MIN } from '../../shared/constants.js' +import { + COLUMN_WIDTH_MAX, + COLUMN_WIDTH_MIN, +} from '../../shared/constants.js' import { normalizeTechnicalName, isTechnicalNameValid } from '../../shared/utils/columnUtils.js' +import { normalizeImagePreviewSize } from '../../shared/utils/imagePreviewSize.js' export default { name: 'EditColumn', @@ -221,6 +225,10 @@ export default { data.technicalName = this.normalizeTechnicalName(data.technicalName) data.customSettings = { ...data.customSettings, width: data.customSettings.width } + if (this.isTextLinkColumn(this.column) + && (data.customSettings.showPreview || data.customSettings.imagePreviewSize !== undefined)) { + data.customSettings.imagePreviewSize = normalizeImagePreviewSize(data.customSettings.imagePreviewSize) + } const res = await this.updateColumn({ id: this.editColumn.id, isView: this.isView, @@ -238,6 +246,9 @@ export default { isTechnicalNameValid() { return isTechnicalNameValid(this.editColumn.technicalName) }, + isTextLinkColumn(column) { + return column?.type === ColumnTypes.TextLink || (column?.type === 'text' && column?.subtype === 'link') + }, }, } diff --git a/src/shared/components/ncTable/partials/TableCellLink.vue b/src/shared/components/ncTable/partials/TableCellLink.vue index 5784713a1c..a183aeca4d 100644 --- a/src/shared/components/ncTable/partials/TableCellLink.vue +++ b/src/shared/components/ncTable/partials/TableCellLink.vue @@ -8,8 +8,21 @@ @click="handleStartEditing" @keydown.enter="handleStartEditing" @keydown.space.prevent="handleStartEditing"> - + + + import { NcTextField, NcSelect } from '@nextcloud/vue' import axios from '@nextcloud/axios' -import { generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { translate as t } from '@nextcloud/l10n' import debounce from 'debounce' import generalHelper from '../../../mixins/generalHelper.js' @@ -68,6 +81,7 @@ import displayError from '../../../utils/displayError.js' import { showError } from '@nextcloud/dialogs' import LinkWidget from './LinkWidget.vue' import { ALLOWED_PROTOCOLS } from '../../../constants.ts' +import { normalizeImagePreviewSize } from '../../../utils/imagePreviewSize.js' export default { name: 'TableCellLink', @@ -103,10 +117,59 @@ export default { providerLoading: {}, isInitialEditClick: false, allowedProtocols: ALLOWED_PROTOCOLS, + imagePreviewFailed: false, } }, computed: { + fileId() { + const valueObject = this.getValueObject + if (valueObject?.providerId !== 'files') { + return null + } + + if (valueObject?.attributes?.fileId) { + return valueObject.attributes.fileId + } + + const url = valueObject?.resourceUrl || valueObject?.value || '' + const match = url.match(/\/f\/(\d+)(?:[/?#]|$)/) || url.match(/[?&]fileid=(\d+)/) || url.match(/[?&]openfile=(\d+)/) + return match ? match[1] : null + }, + showImagePreview() { + return !!this.imagePreviewSrc && !this.imagePreviewFailed + }, + imagePreviewSize() { + return normalizeImagePreviewSize(this.column?.customSettings?.imagePreviewSize) + }, + imagePreviewStyle() { + return { + '--image-preview-size': this.imagePreviewSize + 'px', + } + }, + imagePreviewSrc() { + if (!this.column?.customSettings?.showPreview || !this.isFilesPreviewLink) { + return null + } + + const previewParameters = new URLSearchParams({ + fileId: String(this.fileId), + x: String(this.imagePreviewSize), + y: String(this.imagePreviewSize), + a: '1', + }) + return generateUrl('/core/preview') + '?' + previewParameters.toString() + }, + imagePreviewLink() { + return this.getValueObject?.resourceUrl || this.getValueObject?.value + }, + imagePreviewLabel() { + return this.getValueObject?.title || t('tables', 'Preview') + }, + isFilesPreviewLink() { + // Derived at render time from the Files file id + return this.getValueObject?.providerId === 'files' && !!this.fileId + }, getValueObject() { if (this.hasJsonStructure(this.value)) { const valueObject = JSON.parse(this.value) @@ -194,6 +257,9 @@ export default { this.isInitialEditClick = false } }, + imagePreviewSrc() { + this.imagePreviewFailed = false + }, }, mounted() { @@ -331,6 +397,10 @@ export default { this.results = this.results.filter(item => item.providerId !== providerId) }, + handlePreviewError() { + this.imagePreviewFailed = true + }, + async saveChanges() { if (this.localLoading) { return @@ -377,6 +447,23 @@ export default {