mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
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:
co-authored by
Daria Marutkina
parent
e27d5d1de6
commit
e4e8fb92ae
+2
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+4
-4
@@ -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('');
|
||||
});
|
||||
});
|
||||
+7
-5
@@ -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];
|
||||
}
|
||||
+2
-8
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+3
-4
@@ -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;
|
||||
}
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
|
||||
+105
-109
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
+76
-83
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
+10
-4
@@ -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') {
|
||||
|
||||
+2
-3
@@ -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})`}
|
||||
|
||||
+5
-1
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user