CB-4681 fix: content downloading in value panel (#2417)

* CB-4681 fix: content downloading in value panel

* CB-4681 fix: review

* CB-4681 fix: review

---------

Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
This commit is contained in:
Alexey
2024-02-27 23:53:06 +03:00
committed by GitHub
co-authored by Daria Marutkina
parent e27d5d1de6
commit e4e8fb92ae
16 changed files with 270 additions and 232 deletions
@@ -8,7 +8,7 @@
import { observer } from 'mobx-react-lite';
import type { ObjectPropertyInfo } from '@cloudbeaver/core-sdk';
import { removeMetadataFromBase64 } from '@cloudbeaver/core-utils';
import { removeMetadataFromDataURL } from '@cloudbeaver/core-utils';
import { FieldCheckbox } from '../../FormControls/Checkboxes/FieldCheckbox';
import { Combobox } from '../../FormControls/Combobox';
@@ -187,7 +187,7 @@ export const RenderField = observer<RenderFieldProps>(function RenderField({
disabled={disabled}
fileName={passwordSavedMessage}
className={className}
mapValue={removeMetadataFromBase64}
mapValue={removeMetadataFromDataURL}
>
{property.displayName}
</InputFileTextContent>
@@ -0,0 +1,32 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export function downloadFromURL(url: string): Promise<Blob> {
const req = new XMLHttpRequest();
req.open('GET', url, true);
req.responseType = 'blob';
let resolve: (value: Blob) => void;
let reject: (reason?: any) => void;
const promise = new Promise<Blob>((res, rej) => {
resolve = res;
reject = rej;
});
req.onload = () => {
resolve(req.response);
};
req.onerror = e => {
reject(e);
};
req.send();
return promise;
}
@@ -1,8 +1,8 @@
import { getMIME } from './getMIME';
describe('getMIME', () => {
it('should return null if binary is empty', () => {
expect(getMIME('')).toBe(null);
it("should return 'application/octet-stream' if binary is empty", () => {
expect(getMIME('')).toBe('application/octet-stream');
});
it('should return image/jpeg if binary starts with /', () => {
@@ -26,7 +26,7 @@ describe('getMIME', () => {
expect(getMIME(webpBase64Image)).toBe('image/webp');
});
it('should return null if binary starts with anything else', () => {
expect(getMIME('aasdqwe')).toBe(null);
it("should return 'application/octet-stream' if binary starts with anything else", () => {
expect(getMIME('aasdqwe')).toBe('application/octet-stream');
});
});
+3 -3
View File
@@ -6,9 +6,9 @@
* you may not use this file except in compliance with the License.
*/
export function getMIME(binary: string): string | null {
export function getMIME(binary: string): string {
if (binary.length === 0) {
return null;
return 'application/octet-stream';
}
switch (binary[0]) {
@@ -21,7 +21,7 @@ export function getMIME(binary: string): string | null {
case 'U':
return 'image/webp';
default:
return null;
return 'application/octet-stream';
}
}
+2 -1
View File
@@ -56,6 +56,7 @@ export * from './isMapsEqual';
export * from './isObjectsEqual';
export * from './openCenteredPopup';
export * from './download';
export * from './downloadFromURL';
export * from './getTextFileReadingProcess';
export * from './getTextBetween';
export * from './timestampToDate';
@@ -70,7 +71,7 @@ export * from './TempMap';
export * from './uriToBlob';
export * from './utf8ToBase64';
export * from './createLastPromiseGetter';
export * from './removeMetadataFromBase64';
export * from './removeMetadataFromDataURL';
export * from './renamePathName';
export * from './removeLineBreak';
export * from './replaceSubstring';
@@ -5,7 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { removeMetadataFromBase64 } from './removeMetadataFromBase64';
import { removeMetadataFromDataURL } from './removeMetadataFromDataURL';
const BASE64 =
'data:application/zip;base64,UEsDBBQACAgIAIB8CVUAAAAAAAAAAAAAAAALAAAAZXdhbGxldC5wZW3Nmceyq0qyhuc8Rc+JExiBgEEP8CC8NzMBEt4j3NNf1trH7D59um/c7slVhCJEVVKUsjK//FP65ZfrxfCirP+N11k7Ml2e+5tpyz7t8n9T+Ohr+hdAk2X+XtEpk/djUZcitcEMR/OcRmM8a4R9yZnBGawCncsdTzsso1WSGXc6voSWMeQpMO3R8lwYheqHxvfostBL22Fpwy29OiJqPLhNgQvd6bgdg7dgPWBkDDOsZrbbZHYVLwPudK1k79VpirZlyROasi04dWTJOr7fzNwbIhoC68nCKqpB4GzRMToanV0M9VcBCm0gsepQGcPZWZOzGCM70Txzy9rPS9P1eafhIG6Jw2my4gEKyWgYBv2IqZfSetjjHgjhG9BQO361HARiEoTf/bIpHpsXPTp6SF/e2mAoGT4/dSkXKJ0sFcTPybY2S0MgjuwjTUwANr0FNmiSqppurQ1SPYhPY6xBrzU4X6HX3hljtwzDkycpSJUIFqznp78LUXKaqy+/DWDAGnXw7aTcWVSR7/K8cH5LGKUzpvW0jfXqjzJW+DdY5620saGli5XG0ibNsIr7A+0AY3a6bJ5YWbDWUWA2Z36C7bRDiOQlchlrpPEKjKWAFK7a7XCa0VPEHcy6BTTfEFZNAki9BOOdeKx4t50aXGVgWLve+VDz5Z6IHk+2TZgGg3nnBsrcxdug7OKWrpqTremQYTlgrrU55QVXCcnHgMESdTMKFgx6zPjYRRM/TxLSbLD2QMxgZxg2RbXCRtRVeePZ5WkQ0Aa0kpy3wb35bI/Xl0Ye+BtpZXRvTnIHUy9PA5txFiN2fSwZ4noz1bGgitJuoWt7T0CcngXEv813HqmFR9JKj+exx3Iv4nlAkroF8wNlVjJkO1hooGg9lJuzzm1KTmllpdMA6PJq9oUEr7zsUqDZIjcVsZei7V9uPGLhiz2Xup9TYezPe0IX2CzuHQiLVWPf2/TZlcBoX9+zaHnbTLLuZsQ5pTs3TQpbxPMDPONEseHJlH5YueYd+vSZ3kXVxKbafZ7Z/HJCQF0gnKJK8Ei2TzEb/Avjl4WQiaisukF85Y3eRNWD3uUH8hJrbgPh/DztLpDKk5dFkQEejjvzxNiLmWQgReFhIDl7+xu8kWPygEaHJN9C1dqPnXCzwlcFthA0t24i7pnXBW2ogErgmuAUt2Ta7vAOIk97lzDNiYVnazSZNJxvZh4jsUgC0NXTFPcErqCsFM71V9LK/hvoY9Q5LOQ2xqxagSoZTU60mrx/F+8TnEvy04rld021haT2K3TibVzBLAjRdMp+AjnVAVUx0r1MHGOeVyJlaXdgn7e4GUBO8pPtwQpYstUPZh3NA0U/NrHj1r3nu2BeD39YIgnwSyxGqnBEEFOv2ed42EyGN+276BRjte5v4+iYVHIR3/PigS3w6bXx5Jw1C9h2Zqy/gOljLXVSqqXz6PeG52M8JX0HjjuuP6T47aOcqGw3vkyz0jTkqnbh11a4UqtPUWqbNwIQb+AAkWwmHjeEb0J+ZBA2KD+HcniVdWIDRjVu5lyOFQX1cLF6l1+61eqh9Qmi+PlpAJFH2S29zej+GtlwLk8iIeh2gSQ+50ZMfXqLHLo80R47Eb9LlulN2GEapa6ecuQ/RAJoOBa8S8LRivfCuMOxdI';
@@ -14,14 +14,14 @@ const BASE64_NO_METADATA =
describe('Remove metadata from base64', () => {
test('should return base64 without metadata when base64 with metadata is passed', () => {
expect(removeMetadataFromBase64(BASE64)).toMatch(BASE64_NO_METADATA);
expect(removeMetadataFromDataURL(BASE64)).toMatch(BASE64_NO_METADATA);
});
test('should return base64 when base64 with no metadata is passed', () => {
expect(removeMetadataFromBase64(BASE64_NO_METADATA)).toMatch(BASE64_NO_METADATA);
expect(removeMetadataFromDataURL(BASE64_NO_METADATA)).toMatch(BASE64_NO_METADATA);
});
test('should return empty string when empty string is passed', () => {
expect(removeMetadataFromBase64('')).toMatch('');
expect(removeMetadataFromDataURL('')).toMatch('');
});
});
@@ -6,12 +6,14 @@
* you may not use this file except in compliance with the License.
*/
export function removeMetadataFromBase64(base64: string) {
const parts = base64.split(',');
const dataUrlFilter = /data:(.*?)\/(.*?);base64,(.*)/;
if (parts[1]) {
return parts[1];
export function removeMetadataFromDataURL(base64: string) {
const matches = base64.match(dataUrlFilter);
if (!matches) {
return base64;
}
return parts[0];
return matches[3];
}
@@ -46,10 +46,7 @@ export class DataGridContextMenuSaveContentService {
isDisabled: context => {
const content = context.data.model.source.getAction(context.data.resultIndex, ResultSetDataContentAction);
return (
context.data.model.isLoading() ||
(!!content.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual(context.data.key, content.activeElement))
);
return context.data.model.isLoading() || content.isLoading(context.data.key);
},
});
this.dataGridContextMenuService.add(this.dataGridContextMenuService.getMenuToken(), {
@@ -76,10 +73,7 @@ export class DataGridContextMenuSaveContentService {
isDisabled: context => {
const content = context.data.model.source.getAction(context.data.resultIndex, ResultSetDataContentAction);
return (
context.data.model.isLoading() ||
(!!content.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual(context.data.key, content.activeElement))
);
return context.data.model.isLoading() || content.isLoading(context.data.key);
},
});
}
@@ -13,4 +13,5 @@ export interface IDatabaseDataCacheAction<TKey, TResult extends IDatabaseDataRes
get<T>(key: TKey, scope: symbol): T | undefined;
set<T>(key: TKey, scope: symbol, value: T): void;
delete(key: TKey, scope: symbol): void;
deleteAll(scope: symbol): void;
}
@@ -8,13 +8,12 @@
import type { IResultSetElementKey } from './IResultSetDataKey';
export interface IResultSetDataContentAction {
activeElement: IResultSetElementKey | null;
isLoading: (element: IResultSetElementKey) => boolean;
isBlobTruncated: (element: IResultSetElementKey) => boolean;
isTextTruncated: (element: IResultSetElementKey) => boolean;
isDownloadable: (element: IResultSetElementKey) => boolean;
getFileDataUrl: (element: IResultSetElementKey) => Promise<string>;
resolveFileDataUrl: (element: IResultSetElementKey) => Promise<string>;
retrieveFileDataUrlFromCache: (element: IResultSetElementKey) => string | undefined;
resolveFileDataUrl: (element: IResultSetElementKey) => Promise<Blob>;
retrieveBlobFromCache: (element: IResultSetElementKey) => Blob | undefined;
downloadFileData: (element: IResultSetElementKey) => Promise<void>;
clearCache: () => void;
}
@@ -5,7 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { makeObservable, observable } from 'mobx';
import { action, makeObservable, observable } from 'mobx';
import { ResultDataFormat } from '@cloudbeaver/core-sdk';
@@ -33,6 +33,11 @@ export class ResultSetCacheAction
makeObservable<this, 'cache'>(this, {
cache: observable,
set: action,
setRow: action,
delete: action,
deleteAll: action,
deleteRow: action,
});
}
@@ -94,6 +99,12 @@ export class ResultSetCacheAction
}
}
deleteAll(scope: symbol) {
for (const [, keyCache] of this.cache) {
keyCache.delete(scope);
}
}
deleteRow(key: IResultSetRowKey, scope: symbol) {
const keyCache = this.getRowCache(key);
@@ -9,7 +9,7 @@ import { makeObservable, observable } from 'mobx';
import { QuotasService } from '@cloudbeaver/core-root';
import { GraphQLService, ResultDataFormat } from '@cloudbeaver/core-sdk';
import { bytesToSize, download, GlobalConstants, isNotNullDefined } from '@cloudbeaver/core-utils';
import { bytesToSize, download, downloadFromURL, GlobalConstants, isNotNullDefined } from '@cloudbeaver/core-utils';
import { DatabaseDataAction } from '../../DatabaseDataAction';
import type { IDatabaseDataSource } from '../../IDatabaseDataSource';
@@ -18,41 +18,35 @@ import { databaseDataAction } from '../DatabaseDataActionDecorator';
import type { IResultSetDataContentAction } from './IResultSetDataContentAction';
import type { IResultSetElementKey } from './IResultSetDataKey';
import { isResultSetContentValue } from './isResultSetContentValue';
import { ResultSetCacheAction } from './ResultSetCacheAction';
import { ResultSetDataAction } from './ResultSetDataAction';
import { ResultSetDataKeysUtils } from './ResultSetDataKeysUtils';
import { IResultSetValue, ResultSetFormatAction } from './ResultSetFormatAction';
import { ResultSetViewAction } from './ResultSetViewAction';
const RESULT_VALUE_PATH = 'sql-result-value';
const CONTENT_CACHE_KEY = Symbol('content-cache-key');
interface ICacheEntry {
url?: string;
blob?: Blob;
fullText?: string;
loading?: boolean;
}
@databaseDataAction()
export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabaseResultSet> implements IResultSetDataContentAction {
static dataFormat = [ResultDataFormat.Resultset];
private readonly cache: Map<string, Partial<ICacheEntry>>;
activeElement: IResultSetElementKey | null;
constructor(
source: IDatabaseDataSource<any, IDatabaseResultSet>,
private readonly view: ResultSetViewAction,
private readonly data: ResultSetDataAction,
private readonly format: ResultSetFormatAction,
private readonly graphQLService: GraphQLService,
private readonly quotasService: QuotasService,
private readonly cache: ResultSetCacheAction,
) {
super(source);
this.cache = new Map();
this.activeElement = null;
makeObservable<this, 'cache'>(this, {
cache: observable,
activeElement: observable.ref,
});
}
@@ -79,13 +73,15 @@ export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabas
return result;
}
isLoading(element: IResultSetElementKey) {
return this.getCache(element)?.loading ?? false;
}
isBlobTruncated(elementKey: IResultSetElementKey) {
const limit = this.getLimitInfo(elementKey).limit;
const content = this.format.get(elementKey);
const cachedBlobUrl = this.retrieveFileDataUrlFromCache(elementKey);
const isLoadedFullBlob = Boolean(cachedBlobUrl) && this.format.isBinary(elementKey);
if (!isNotNullDefined(limit) || !isResultSetContentValue(content) || isLoadedFullBlob || !this.format.isBinary(elementKey)) {
if (!isNotNullDefined(limit) || !isResultSetContentValue(content) || !this.format.isBinary(elementKey)) {
return false;
}
@@ -95,10 +91,8 @@ export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabas
isTextTruncated(elementKey: IResultSetElementKey) {
const limit = this.getLimitInfo(elementKey).limit;
const content = this.format.get(elementKey);
const cachedFullText = this.retrieveFileFullTextFromCache(elementKey);
const isLoadedFullText = Boolean(cachedFullText) && this.format.isText(elementKey);
if (!isNotNullDefined(limit) || !isResultSetContentValue(content) || isLoadedFullText) {
if (!isNotNullDefined(limit) || !isResultSetContentValue(content)) {
return false;
}
@@ -109,6 +103,90 @@ export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabas
return !!this.result.data?.hasRowIdentifier && isResultSetContentValue(this.format.get(element));
}
retrieveFullTextFromCache(element: IResultSetElementKey) {
return this.getCache(element)?.fullText;
}
retrieveBlobFromCache(element: IResultSetElementKey) {
return this.getCache(element)?.blob;
}
async getFileFullText(element: IResultSetElementKey) {
const column = this.data.getColumn(element.column);
const row = this.data.getRowValue(element.row);
const cachedFullText = this.retrieveFullTextFromCache(element);
if (cachedFullText) {
return cachedFullText;
}
if (!row || !column) {
throw new Error('Failed to get value metadata information');
}
const fullText = await this.source.runTask(async () => {
try {
this.updateCache(element, { loading: true });
return await this.loadFileFullText(this.result, column.position, row);
} finally {
this.updateCache(element, { loading: false });
}
});
this.updateCache(element, { fullText });
return fullText;
}
async resolveFileDataUrl(element: IResultSetElementKey) {
const cachedUrl = this.retrieveBlobFromCache(element);
if (cachedUrl) {
return cachedUrl;
}
const url = await this.getFileDataUrl(element);
const blob = await downloadFromURL(url);
this.updateCache(element, { blob });
return blob;
}
async downloadFileData(element: IResultSetElementKey) {
const url = await this.getFileDataUrl(element);
download(url);
}
clearCache() {
this.cache.deleteAll(CONTENT_CACHE_KEY);
}
dispose(): void {
this.clearCache();
}
private async getFileDataUrl(element: IResultSetElementKey) {
const column = this.data.getColumn(element.column);
const row = this.data.getRowValue(element.row);
if (!row || !column) {
throw new Error('Failed to get value metadata information');
}
const url = await this.source.runTask(async () => {
try {
this.updateCache(element, { loading: true });
return await this.loadDataURL(this.result, column.position, row);
} finally {
this.updateCache(element, { loading: false });
}
});
return url;
}
private async loadFileFullText(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) {
if (!result.id) {
throw new Error("Result's id must be provided");
@@ -127,107 +205,25 @@ export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabas
return response.text;
}
async getFileFullText(element: IResultSetElementKey) {
const column = this.data.getColumn(element.column);
const row = this.data.getRowValue(element.row);
const cachedFullText = this.retrieveFileFullTextFromCache(element);
if (cachedFullText) {
return cachedFullText;
}
if (!row || !column) {
throw new Error('Failed to get value metadata information');
}
const fullText = await this.source.runTask(async () => {
try {
this.activeElement = element;
return await this.loadFileFullText(this.result, column.position, row);
} finally {
this.activeElement = null;
}
});
this.updateCache(element, { fullText });
return fullText;
}
async getFileDataUrl(element: IResultSetElementKey) {
const column = this.data.getColumn(element.column);
const row = this.data.getRowValue(element.row);
if (!row || !column) {
throw new Error('Failed to get value metadata information');
}
const url = await this.source.runTask(async () => {
try {
this.activeElement = element;
const fileName = await this.loadFileName(this.result, column.position, row);
return this.generateFileDataUrl(fileName);
} finally {
this.activeElement = null;
}
});
return url;
}
async resolveFileDataUrl(element: IResultSetElementKey) {
const cachedUrl = this.retrieveFileDataUrlFromCache(element);
if (cachedUrl) {
return cachedUrl;
}
const url = await this.getFileDataUrl(element);
this.updateCache(element, { url });
return url;
}
private updateCache(element: IResultSetElementKey, partialCache: Partial<ICacheEntry>) {
const hash = this.getHash(element);
const cachedElement = this.cache.get(hash) ?? {};
this.cache.set(hash, { ...cachedElement, ...partialCache });
const cachedElement = this.getCache(element) ?? {};
this.setCache(element, { ...cachedElement, ...partialCache });
}
retrieveFileFullTextFromCache(element: IResultSetElementKey) {
const hash = this.getHash(element);
return this.cache.get(hash)?.fullText;
private getCache(element: IResultSetElementKey) {
return this.cache.get<ICacheEntry>(element, CONTENT_CACHE_KEY);
}
retrieveFileDataUrlFromCache(element: IResultSetElementKey) {
const hash = this.getHash(element);
return this.cache.get(hash)?.url;
private setCache(element: IResultSetElementKey, value: ICacheEntry) {
this.cache.set(element, CONTENT_CACHE_KEY, value);
}
async downloadFileData(element: IResultSetElementKey) {
const url = await this.getFileDataUrl(element);
download(url);
}
clearCache() {
this.cache.clear();
}
private generateFileDataUrl(fileName: string) {
return `${GlobalConstants.serviceURI}/${RESULT_VALUE_PATH}/${fileName}`;
}
private getHash(element: IResultSetElementKey) {
return ResultSetDataKeysUtils.serializeElementKey(element);
}
private async loadFileName(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) {
private async loadDataURL(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) {
if (!result.id) {
throw new Error("Result's id must be provided");
}
const response = await this.graphQLService.sdk.getResultsetDataURL({
const { url } = await this.graphQLService.sdk.getResultsetDataURL({
resultsId: result.id,
connectionId: result.connectionId,
contextId: result.contextId,
@@ -237,6 +233,6 @@ export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabas
},
});
return response.url;
return `${GlobalConstants.serviceURI}/${RESULT_VALUE_PATH}/${url}`;
}
}
@@ -7,13 +7,14 @@
*/
import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useMemo } from 'react';
import { ActionIconButton, Button, Container, Fill, s, useObservableRef, useS, useTranslate } from '@cloudbeaver/core-blocks';
import { selectFiles } from '@cloudbeaver/core-browser';
import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { type TabContainerPanelComponent, useTabLocalState } from '@cloudbeaver/core-ui';
import { bytesToSize, download, getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils';
import { bytesToSize, download, getMIME, isImageFormat, isValidUrl, throttle } from '@cloudbeaver/core-utils';
import { createResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/createResultSetBlobValue';
import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey';
@@ -22,7 +23,6 @@ import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/
import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
import { isResultSetFileValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetFileValue';
import { ResultSetDataContentAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction';
import { ResultSetDataKeysUtils } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataKeysUtils';
import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction';
import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction';
import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction';
@@ -31,37 +31,6 @@ import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValu
import { QuotaPlaceholder } from '../QuotaPlaceholder';
import styles from './ImageValuePresentation.m.css';
interface IToolsProps {
loading?: boolean;
stretch?: boolean;
onToggleStretch?: () => void;
onSave?: () => void;
onUpload?: () => void;
}
const Tools = observer<IToolsProps>(function Tools({ loading, stretch, onToggleStretch, onSave, onUpload }) {
const translate = useTranslate();
return (
<Container gap dense keepSize>
<Container keepSize flexStart center>
{onSave && <ActionIconButton title={translate('ui_download')} name="/icons/export.svg" disabled={loading} img onClick={onSave} />}
{onUpload && <ActionIconButton title={translate('ui_upload')} name="/icons/import.svg" disabled={loading} img onClick={onUpload} />}
</Container>
<Fill />
{onToggleStretch && (
<Container keepSize flexEnd center>
<ActionIconButton
title={translate(stretch ? 'data_viewer_presentation_value_image_original_size' : 'data_viewer_presentation_value_image_fit')}
name={stretch ? 'img-original-size' : 'img-fit-size'}
onClick={onToggleStretch}
/>
</Container>
)}
</Container>
);
});
export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelProps<any, IDatabaseResultSet>> = observer(
function ImageValuePresentation({ model, resultIndex }) {
const translate = useTranslate();
@@ -113,35 +82,52 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
return this.formatAction.get(this.selectedCell);
},
get src() {
if (this.savedSrc) {
return this.savedSrc;
get src(): string | null {
if (isResultSetBlobValue(this.cellValue)) {
// uploaded file preview
return URL.createObjectURL(this.cellValue.blob);
}
if (isResultSetBlobValue(this.cellValue)) {
return URL.createObjectURL(this.cellValue.blob);
if (this.staticSrc) {
return this.staticSrc;
}
if (this.cacheBlob) {
// TODO: this object must be released with URL.revokeObjectURL()
// it also can be released by the browser after some time
// what leads to image not accessible
return URL.createObjectURL(this.cacheBlob);
}
return null;
},
get staticSrc(): string | null {
if (this.truncated) {
return null;
}
if (isResultSetBinaryValue(this.cellValue)) {
return `data:${getMIME(this.cellValue.binary)};base64,${this.cellValue.binary}`;
} else if (typeof this.cellValue === 'string' && isValidUrl(this.cellValue) && isImageFormat(this.cellValue)) {
}
if (typeof this.cellValue === 'string' && isValidUrl(this.cellValue) && isImageFormat(this.cellValue)) {
return this.cellValue;
}
return '';
return null;
},
get savedSrc() {
get cacheBlob() {
if (!this.selectedCell) {
return undefined;
return null;
}
return this.contentAction.retrieveFileDataUrlFromCache(this.selectedCell);
return this.contentAction.retrieveBlobFromCache(this.selectedCell);
},
get canSave() {
if (this.truncated && this.selectedCell) {
return this.contentAction.isDownloadable(this.selectedCell);
}
return !!this.src;
return this.staticSrc && !this.truncated;
},
get canUpload() {
if (!this.selectedCell) {
@@ -156,18 +142,14 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
return this.selectedCell && this.contentAction.isBlobTruncated(this.selectedCell);
},
get shouldShowImage() {
const isFullImage = !this.truncated && this.savedSrc;
const isImage = this.src && !this.truncated && !this.savedSrc;
return isFullImage || isImage;
},
async save() {
async download() {
try {
if (this.truncated && this.selectedCell) {
if (this.src) {
download(this.src, '', true);
} else if (this.selectedCell) {
await this.contentAction.downloadFileData(this.selectedCell);
} else {
download(this.src, '', true);
throw new Error("Can't save image");
}
} catch (exception: any) {
this.notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error');
@@ -181,6 +163,17 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
}
});
},
async loadFullImage() {
if (!this.selectedCell) {
return;
}
try {
await this.contentAction.resolveFileDataUrl(this.selectedCell);
} catch (exception: any) {
this.notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error');
}
},
}),
{
editAction: computed,
@@ -191,59 +184,59 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
cellValue: computed,
canUpload: computed,
src: computed,
savedSrc: computed,
cacheBlob: computed,
canSave: computed,
truncated: computed,
model: observable.ref,
resultIndex: observable.ref,
save: action.bound,
download: action.bound,
upload: action.bound,
loadFullImage: action.bound,
},
{ model, resultIndex, notificationService },
);
const save = data.canSave ? data.save : undefined;
const upload = data.canUpload ? data.upload : undefined;
const loading = model.isLoading();
const value = data.cellValue;
const load = async () => {
if (!data.selectedCell) {
return;
}
const valueSize = bytesToSize(isResultSetContentValue(data.cellValue) ? data.cellValue.contentLength ?? 0 : 0);
const isTruncatedMessageDisplay = !!data.truncated && !data.src;
const isDownloadable = isTruncatedMessageDisplay && !!data.selectedCell && data.contentAction.isDownloadable(data.selectedCell);
const isCacheDownloading = isDownloadable && data.contentAction.isLoading(data.selectedCell);
try {
await data.contentAction.resolveFileDataUrl(data.selectedCell);
} catch (exception: any) {
notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error');
}
};
const valueSize = bytesToSize(isResultSetContentValue(value) ? value.contentLength ?? 0 : 0);
const isDownloadable = data.selectedCell && data.contentAction.isDownloadable(data.selectedCell);
const debouncedDownload = useMemo(() => throttle(() => data.download(), 1000, false), []);
return (
<Container vertical>
<Container fill overflow center>
{data.shouldShowImage && <img src={data.src} className={s(style, { img: true, stretch: state.stretch })} />}
{data.truncated ? (
{data.src && <img src={data.src} className={s(style, { img: true, stretch: state.stretch })} />}
{isTruncatedMessageDisplay && (
<QuotaPlaceholder model={data.model} resultIndex={data.resultIndex} elementKey={data.selectedCell}>
{isDownloadable && (
<Button
disabled={loading}
loading={
!!data.contentAction.activeElement &&
ResultSetDataKeysUtils.isElementsKeyEqual(data.contentAction.activeElement, data.selectedCell)
}
onClick={load}
>
<Button disabled={loading} loading={isCacheDownloading} onClick={data.loadFullImage}>
{`${translate('ui_view')} (${valueSize})`}
</Button>
)}
</QuotaPlaceholder>
) : null}
)}
</Container>
<Container gap dense keepSize>
<Container keepSize flexStart center>
{data.canSave && (
<ActionIconButton title={translate('ui_download')} name="/icons/export.svg" disabled={loading} img onClick={debouncedDownload} />
)}
{data.canUpload && (
<ActionIconButton title={translate('ui_upload')} name="/icons/import.svg" disabled={loading} img onClick={data.upload} />
)}
</Container>
<Fill />
<Container keepSize flexEnd center>
<ActionIconButton
title={translate(state.stretch ? 'data_viewer_presentation_value_image_original_size' : 'data_viewer_presentation_value_image_fit')}
name={state.stretch ? 'img-original-size' : 'img-fit-size'}
onClick={state.toggleStretch}
/>
</Container>
</Container>
<Tools loading={loading} stretch={state.stretch} onToggleStretch={state.toggleStretch} onSave={save} onUpload={upload} />
</Container>
);
},
@@ -7,16 +7,22 @@
*/
import { getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils';
import { isResultSetBinaryValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBinaryValue';
import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue';
import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
import type { IResultSetValue } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction';
export function isImageValuePresentationAvailable(value: IResultSetValue) {
if (isResultSetContentValue(value) && value?.binary) {
return getMIME(value.binary || '') !== null;
let contentType = null;
if (isResultSetBinaryValue(value)) {
contentType = getMIME(value.binary);
} else if (isResultSetContentValue(value) || isResultSetBlobValue(value)) {
contentType = value?.contentType ?? null;
}
if (isResultSetContentValue(value) || isResultSetBlobValue(value)) {
return value?.contentType?.startsWith('image/') ?? false;
if (contentType?.startsWith('image/')) {
return true;
}
if (typeof value !== 'string') {
@@ -127,7 +127,6 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
const valueSize =
isResultSetContentValue(contentValue) && isNotNullDefined(contentValue.contentLength) ? bytesToSize(contentValue.contentLength) : undefined;
const canSave = firstSelectedCell && contentAction.isDownloadable(firstSelectedCell);
const shouldShowPasteButton = textValueData.isTextColumn && firstSelectedCell && contentAction.isTextTruncated(firstSelectedCell);
const typeExtension = useMemo(() => getTypeExtension(contentType!) ?? [], [contentType]);
const extensions = useCodemirrorExtensions(undefined, typeExtension);
@@ -188,9 +187,9 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
onChange={valueChangeHandler}
/>
</Group>
{firstSelectedCell && contentAction.isTextTruncated(firstSelectedCell) ? (
{textValueData.isTruncated ? (
<QuotaPlaceholder model={model} resultIndex={resultIndex} elementKey={firstSelectedCell} keepSize>
{shouldShowPasteButton && (
{textValueData.isTextColumn && (
<Container keepSize>
<Button disabled={model.isLoading()} onClick={textValueData.pasteFullText}>
{`${translate('ui_show_more')} (${valueSize})`}
@@ -25,6 +25,7 @@ interface IUseTextValueArgs {
interface IUseTextValue {
textValue: string;
isTruncated: boolean;
isTextColumn: boolean;
pasteFullText(): Promise<void>;
}
@@ -35,11 +36,13 @@ export function useTextValue({ model, resultIndex, currentContentType, elementKe
const isTextColumn = elementKey ? formatAction.isText(elementKey) : false;
const contentValue = elementKey ? formatAction.get(elementKey) : null;
const isBinary = elementKey ? formatAction.isBinary(elementKey) : false;
const cachedFullText = elementKey ? contentAction.retrieveFileFullTextFromCache(elementKey) : '';
const isTruncated = elementKey ? contentAction.isTextTruncated(elementKey) : false;
const cachedFullText = elementKey ? contentAction.retrieveFullTextFromCache(elementKey) : '';
const notificationService = useService(NotificationService);
const result: IUseTextValue = {
textValue: '',
isTruncated,
isTextColumn,
async pasteFullText() {
if (!elementKey) {
@@ -60,6 +63,7 @@ export function useTextValue({ model, resultIndex, currentContentType, elementKe
if (isTextColumn && cachedFullText) {
result.textValue = cachedFullText;
result.isTruncated = false;
}
if (editAction.isElementEdited(elementKey)) {