2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* Importer manages an import files to Grist tables
|
|
|
|
* TODO: hidden tables should be also deleted on page refresh, error...
|
|
|
|
*/
|
|
|
|
// tslint:disable:no-console
|
|
|
|
|
|
|
|
import {GristDoc} from "app/client/components/GristDoc";
|
|
|
|
import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions';
|
2021-08-05 15:12:46 +00:00
|
|
|
import {PluginScreen} from "app/client/components/PluginScreen";
|
2020-10-02 15:10:00 +00:00
|
|
|
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
2021-08-03 10:34:05 +00:00
|
|
|
import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {reportError} from 'app/client/models/AppModel';
|
|
|
|
import {ViewSectionRec} from 'app/client/models/DocModel';
|
|
|
|
import {openFilePicker} from "app/client/ui/FileDialog";
|
|
|
|
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
|
|
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2021-09-15 06:12:34 +00:00
|
|
|
import {IOptionFull, linkSelect, multiSelect} from 'app/client/ui2018/menus';
|
2021-08-05 15:12:46 +00:00
|
|
|
import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals';
|
2021-09-15 06:12:34 +00:00
|
|
|
import {DataSourceTransformed, ImportResult, ImportTableResult, MergeOptions,
|
|
|
|
MergeStrategy, TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
|
2020-10-02 15:10:00 +00:00
|
|
|
import {byteString} from "app/common/gutil";
|
2021-09-30 08:19:22 +00:00
|
|
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
2021-09-15 06:12:34 +00:00
|
|
|
import {Computed, Disposable, dom, DomContents, IDisposable, MutableObsArray, obsArray, Observable,
|
|
|
|
styled} from 'grainjs';
|
|
|
|
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";
|
2021-09-30 08:19:22 +00:00
|
|
|
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from "app/client/ui/googleAuth";
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// Special values for import destinations; null means "new table".
|
|
|
|
// TODO We should also support "skip table" (needs server support), so that one can open, say,
|
|
|
|
// an Excel file with many tabs, and import only some of them.
|
|
|
|
type DestId = string | null;
|
|
|
|
|
|
|
|
// We expect a function for creating the preview GridView, to avoid the need to require the
|
|
|
|
// GridView module here. That brings many dependencies, making a simple test fixture difficult.
|
|
|
|
type CreatePreviewFunc = (vs: ViewSectionRec) => GridView;
|
|
|
|
type GridView = IDisposable & {viewPane: HTMLElement};
|
|
|
|
|
|
|
|
// SourceInfo conteains information about source table and corresponding destination table id,
|
|
|
|
// transform sectionRef (can be used to show previous transform section with users changes)
|
|
|
|
// and also originalFilename and path.
|
|
|
|
export interface SourceInfo {
|
|
|
|
hiddenTableId: string;
|
|
|
|
uploadFileIndex: number;
|
|
|
|
origTableName: string;
|
|
|
|
sourceSection: ViewSectionRec;
|
|
|
|
transformSection: Observable<ViewSectionRec>;
|
|
|
|
destTableId: Observable<DestId>;
|
|
|
|
}
|
2021-09-30 08:19:22 +00:00
|
|
|
// UI state of selected merge options for each source table (from SourceInfo).
|
2021-09-15 06:12:34 +00:00
|
|
|
interface MergeOptionsState {
|
|
|
|
[srcTableId: string]: {
|
|
|
|
updateExistingRecords: Observable<boolean>;
|
|
|
|
mergeCols: MutableObsArray<string>;
|
|
|
|
mergeStrategy: Observable<MergeStrategy>;
|
|
|
|
hasInvalidMergeCols: Observable<boolean>;
|
|
|
|
} | undefined;
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Importer manages an import files to Grist tables and shows Preview
|
|
|
|
*/
|
|
|
|
export class Importer extends Disposable {
|
|
|
|
/**
|
|
|
|
* Imports using the given plugin importer, or the built-in file-picker when null is passed in.
|
|
|
|
*/
|
|
|
|
public static async selectAndImport(
|
2021-08-03 10:34:05 +00:00
|
|
|
gristDoc: GristDoc,
|
|
|
|
imports: ImportSourceElement[],
|
|
|
|
importSourceElem: ImportSourceElement|null,
|
|
|
|
createPreview: CreatePreviewFunc
|
2020-10-02 15:10:00 +00:00
|
|
|
) {
|
|
|
|
// In case of using built-in file picker we want to get upload result before instantiating Importer
|
|
|
|
// because if the user dismisses the dialog without picking a file,
|
|
|
|
// there is no good way to detect this and dispose Importer.
|
|
|
|
let uploadResult: UploadResult|null = null;
|
|
|
|
if (!importSourceElem) {
|
|
|
|
// Use the built-in file picker. On electron, it uses the native file selector (without
|
|
|
|
// actually uploading anything), which is why this requires a slightly different flow.
|
|
|
|
const files: File[] = await openFilePicker({multiple: true});
|
|
|
|
// Important to fork first before trying to import, so we end up uploading to a
|
|
|
|
// consistent doc worker.
|
|
|
|
await gristDoc.forkIfNeeded();
|
|
|
|
const label = files.map(f => f.name).join(', ');
|
|
|
|
const size = files.reduce((acc, f) => acc + f.size, 0);
|
|
|
|
const app = gristDoc.app.topAppModel.appObs.get();
|
|
|
|
const progress = app ? app.notifier.createProgressIndicator(label, byteString(size)) : null;
|
|
|
|
const onProgress = (percent: number) => progress && progress.setProgress(percent);
|
|
|
|
try {
|
|
|
|
onProgress(0);
|
|
|
|
uploadResult = await uploadFiles(files, {docWorkerUrl: gristDoc.docComm.docWorkerUrl,
|
|
|
|
sizeLimit: 'import'}, onProgress);
|
|
|
|
onProgress(100);
|
|
|
|
} finally {
|
|
|
|
if (progress) {
|
|
|
|
progress.dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-08-03 10:34:05 +00:00
|
|
|
// HACK: The url plugin does not support importing from google drive, and we don't want to
|
|
|
|
// ask a user for permission to access all his files (needed to download a single file from an URL).
|
|
|
|
// So to have a nice user experience, we will switch to the built-in google drive plugin and allow
|
|
|
|
// user to chose a file manually.
|
|
|
|
// Suggestion for the future is:
|
|
|
|
// (1) ask the user for the greater permission,
|
|
|
|
// (2) detect when the permission is not granted, and open the picker-based plugin in that case.
|
|
|
|
try {
|
|
|
|
// Importer disposes itself when its dialog is closed, so we do not take ownership of it.
|
|
|
|
await Importer.create(null, gristDoc, importSourceElem, createPreview).pickAndUploadSource(uploadResult);
|
|
|
|
} catch(err1) {
|
|
|
|
// If the url was a Google Drive Url, run the google drive plugin.
|
|
|
|
if (!(err1 instanceof GDriveUrlNotSupported)) {
|
|
|
|
reportError(err1);
|
|
|
|
} else {
|
|
|
|
const gdrivePlugin = imports.find((p) => p.plugin.definition.id === 'builtIn/gdrive' && p !== importSourceElem);
|
|
|
|
if (!gdrivePlugin) {
|
|
|
|
reportError(err1);
|
|
|
|
} else {
|
|
|
|
try {
|
|
|
|
await Importer.create(null, gristDoc, gdrivePlugin, createPreview).pickAndUploadSource(uploadResult);
|
|
|
|
} catch(err2) {
|
|
|
|
reportError(err2);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private _docComm = this._gristDoc.docComm;
|
|
|
|
private _uploadResult?: UploadResult;
|
|
|
|
|
2021-08-05 15:12:46 +00:00
|
|
|
private _screen: PluginScreen;
|
2021-09-15 06:12:34 +00:00
|
|
|
private _mergeOptions: MergeOptionsState = {};
|
2020-10-02 15:10:00 +00:00
|
|
|
private _parseOptions = Observable.create<ParseOptions>(this, {});
|
|
|
|
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
|
|
|
|
private _sourceInfoSelected = Observable.create<SourceInfo|null>(this, null);
|
|
|
|
|
|
|
|
private _previewViewSection: Observable<ViewSectionRec|null> =
|
|
|
|
Computed.create(this, this._sourceInfoSelected, (use, info) => {
|
|
|
|
if (!info) { return null; }
|
|
|
|
const viewSection = use(info.transformSection);
|
|
|
|
return viewSection && !use(viewSection._isDeleted) ? viewSection : null;
|
|
|
|
});
|
|
|
|
|
|
|
|
// destTables is a list of options for import destinations, and includes all tables in the
|
|
|
|
// document, plus two values: to import as a new table, and to skip an import table entirely.
|
|
|
|
private _destTables = Computed.create<Array<IOptionFull<DestId>>>(this, (use) => [
|
|
|
|
{value: null, label: 'New Table'},
|
|
|
|
...use(this._gristDoc.docModel.allTableIds.getObservable()).map((t) => ({value: t, label: t})),
|
|
|
|
]);
|
|
|
|
|
|
|
|
// null tells to use the built-in file picker.
|
|
|
|
constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null,
|
|
|
|
private _createPreview: CreatePreviewFunc) {
|
|
|
|
super();
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen = PluginScreen.create(this, _importSourceElem?.importSource.label || "Import from file");
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Get new import sources and update the current one.
|
|
|
|
*/
|
|
|
|
public async pickAndUploadSource(uploadResult: UploadResult|null) {
|
|
|
|
try {
|
|
|
|
if (!this._importSourceElem) {
|
|
|
|
// Use upload result if it was passed in or the built-in file picker.
|
|
|
|
// On electron, it uses the native file selector (without actually uploading anything),
|
|
|
|
// which is why this requires a slightly different flow.
|
|
|
|
uploadResult = uploadResult || await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl,
|
|
|
|
multiple: true, sizeLimit: 'import'});
|
|
|
|
} else {
|
|
|
|
const plugin = this._importSourceElem.plugin;
|
2021-08-05 15:12:46 +00:00
|
|
|
const handle = this._screen.renderPlugin(plugin);
|
2020-10-02 15:10:00 +00:00
|
|
|
const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle);
|
|
|
|
plugin.removeRenderTarget(handle);
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.renderSpinner();
|
2021-08-03 10:34:05 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
if (importSource) {
|
|
|
|
// If data has been picked, upload it.
|
|
|
|
const item = importSource.item;
|
|
|
|
if (item.kind === "fileList") {
|
|
|
|
const files = item.files.map(({content, name}) => new File([content], name));
|
|
|
|
uploadResult = await uploadFiles(files, {docWorkerUrl: this._docComm.docWorkerUrl,
|
|
|
|
sizeLimit: 'import'});
|
2021-08-03 10:34:05 +00:00
|
|
|
} else if (item.kind === "url") {
|
2021-09-30 08:19:22 +00:00
|
|
|
if (isDriveUrl(item.url)) {
|
|
|
|
uploadResult = await this._fetchFromDrive(item.url);
|
|
|
|
} else {
|
2021-08-03 10:34:05 +00:00
|
|
|
uploadResult = await fetchURL(this._docComm, item.url);
|
|
|
|
}
|
|
|
|
} else {
|
2021-04-26 21:54:09 +00:00
|
|
|
throw new Error(`Import source of kind ${(item as any).kind} are not yet supported!`);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (err) {
|
2021-09-30 08:19:22 +00:00
|
|
|
if (err instanceof CancelledError) {
|
|
|
|
await this._cancelImport();
|
|
|
|
return;
|
|
|
|
}
|
2021-08-03 10:34:05 +00:00
|
|
|
if (err instanceof GDriveUrlNotSupported) {
|
|
|
|
await this._cancelImport();
|
|
|
|
throw err;
|
|
|
|
}
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.renderError(err.message);
|
2020-10-02 15:10:00 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (uploadResult) {
|
|
|
|
this._uploadResult = uploadResult;
|
|
|
|
await this._reImport(uploadResult);
|
|
|
|
} else {
|
|
|
|
await this._cancelImport();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _getPrimaryViewSection(tableId: string): ViewSectionRec {
|
|
|
|
const tableModel = this._gristDoc.getTableModel(tableId);
|
|
|
|
const viewRow = tableModel.tableMetaRow.primaryView.peek();
|
|
|
|
return viewRow.viewSections.peek().peek()[0];
|
|
|
|
}
|
|
|
|
|
|
|
|
private _getSectionByRef(sectionRef: number): ViewSectionRec {
|
|
|
|
return this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _updateTransformSection(sourceInfo: SourceInfo, destTableId: string|null) {
|
|
|
|
const transformSectionRef = await this._gristDoc.docData.sendAction(
|
|
|
|
['GenImporterView', sourceInfo.hiddenTableId, destTableId, null]);
|
|
|
|
sourceInfo.transformSection.set(this._gristDoc.docModel.viewSections.getRowModel(transformSectionRef));
|
|
|
|
sourceInfo.destTableId.set(destTableId);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed {
|
|
|
|
const transforms: TransformRuleMap[] = upload.files.map((file, i) => this._createTransformRuleMap(i));
|
|
|
|
return {uploadId: upload.uploadId, transforms};
|
|
|
|
}
|
|
|
|
|
2021-09-15 06:12:34 +00:00
|
|
|
private _getMergeOptions(upload: UploadResult): Array<MergeOptions|null> {
|
|
|
|
return upload.files.map((_file, i) => {
|
|
|
|
const sourceInfo = this._sourceInfoArray.get().find(info => info.uploadFileIndex === i);
|
|
|
|
if (!sourceInfo) { return null; }
|
|
|
|
|
|
|
|
const mergeOptions = this._mergeOptions[sourceInfo.hiddenTableId];
|
|
|
|
if (!mergeOptions) { return null; }
|
|
|
|
|
|
|
|
const {updateExistingRecords, mergeCols, mergeStrategy} = mergeOptions;
|
|
|
|
return {
|
|
|
|
mergeCols: updateExistingRecords.get() ? mergeCols.get() : [],
|
|
|
|
mergeStrategy: mergeStrategy.get()
|
|
|
|
};
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _createTransformRuleMap(uploadFileIndex: number): TransformRuleMap {
|
|
|
|
const result: TransformRuleMap = {};
|
|
|
|
for (const sourceInfo of this._sourceInfoArray.get()) {
|
|
|
|
if (sourceInfo.uploadFileIndex === uploadFileIndex) {
|
|
|
|
result[sourceInfo.origTableName] = this._createTransformRule(sourceInfo);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _createTransformRule(sourceInfo: SourceInfo): TransformRule {
|
|
|
|
const transformFields = sourceInfo.transformSection.get().viewFields().peek();
|
|
|
|
const sourceFields = sourceInfo.sourceSection.viewFields().peek();
|
|
|
|
|
|
|
|
const destTableId: DestId = sourceInfo.destTableId.get();
|
|
|
|
return {
|
|
|
|
destTableId,
|
|
|
|
destCols: transformFields.map<TransformColumn>((field) => ({
|
|
|
|
label: field.label(),
|
|
|
|
colId: destTableId ? field.colId() : null, // if inserting into new table, colId isnt defined
|
|
|
|
type: field.column().type(),
|
|
|
|
formula: field.column().formula()
|
|
|
|
})),
|
|
|
|
sourceCols: sourceFields.map((field) => field.colId())
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
private _getHiddenTableIds(): string[] {
|
|
|
|
return this._sourceInfoArray.get().map((t: SourceInfo) => t.hiddenTableId);
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _reImport(upload: UploadResult) {
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.renderSpinner();
|
2020-10-02 15:10:00 +00:00
|
|
|
try {
|
|
|
|
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 100};
|
|
|
|
const importResult: ImportResult = await this._docComm.importFiles(
|
|
|
|
this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds());
|
|
|
|
|
|
|
|
this._parseOptions.set(importResult.options);
|
|
|
|
|
|
|
|
this._sourceInfoArray.set(importResult.tables.map((info: ImportTableResult) => ({
|
|
|
|
hiddenTableId: info.hiddenTableId,
|
|
|
|
uploadFileIndex: info.uploadFileIndex,
|
|
|
|
origTableName: info.origTableName,
|
|
|
|
sourceSection: this._getPrimaryViewSection(info.hiddenTableId)!,
|
|
|
|
transformSection: Observable.create(null, this._getSectionByRef(info.transformSectionRef)),
|
|
|
|
destTableId: Observable.create<DestId>(null, info.destTableId)
|
|
|
|
})));
|
|
|
|
|
|
|
|
if (this._sourceInfoArray.get().length === 0) {
|
|
|
|
throw new Error("No data was imported");
|
|
|
|
}
|
|
|
|
|
2021-09-15 06:12:34 +00:00
|
|
|
this._mergeOptions = {};
|
|
|
|
this._getHiddenTableIds().forEach(tableId => {
|
|
|
|
this._mergeOptions[tableId] = {
|
|
|
|
updateExistingRecords: Observable.create(null, false),
|
|
|
|
mergeCols: obsArray(),
|
|
|
|
mergeStrategy: Observable.create(null, {type: 'replace-with-nonblank-source'}),
|
|
|
|
hasInvalidMergeCols: Observable.create(null, false)
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// Select the first sourceInfo to show in preview.
|
|
|
|
this._sourceInfoSelected.set(this._sourceInfoArray.get()[0] || null);
|
|
|
|
|
|
|
|
this._renderMain(upload);
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
console.warn("Import failed", e);
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.renderError(e.message);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-15 06:12:34 +00:00
|
|
|
private async _maybeFinishImport(upload: UploadResult) {
|
|
|
|
const isConfigValid = this._validateImportConfiguration();
|
|
|
|
if (!isConfigValid) { return; }
|
|
|
|
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.renderSpinner();
|
2020-10-02 15:10:00 +00:00
|
|
|
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0};
|
2021-09-15 06:12:34 +00:00
|
|
|
const mergeOptions = this._getMergeOptions(upload);
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const importResult: ImportResult = await this._docComm.finishImportFiles(
|
2021-09-15 06:12:34 +00:00
|
|
|
this._getTransformedDataSource(upload), this._getHiddenTableIds(), {mergeOptions, parseOptions});
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
if (importResult.tables[0].hiddenTableId) {
|
|
|
|
const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow;
|
|
|
|
await this._gristDoc.openDocPage(tableRowModel.primaryViewId());
|
|
|
|
}
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.close();
|
2020-10-02 15:10:00 +00:00
|
|
|
this.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _cancelImport() {
|
|
|
|
if (this._uploadResult) {
|
|
|
|
await this._docComm.cancelImportFiles(
|
|
|
|
this._getTransformedDataSource(this._uploadResult), this._getHiddenTableIds());
|
|
|
|
}
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.close();
|
2020-10-02 15:10:00 +00:00
|
|
|
this.dispose();
|
|
|
|
}
|
|
|
|
|
2021-09-15 06:12:34 +00:00
|
|
|
private _resetTableMergeOptions(tableId: string) {
|
|
|
|
this._mergeOptions[tableId]?.mergeCols.set([]);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _validateImportConfiguration(): boolean {
|
|
|
|
let isValid = true;
|
|
|
|
|
|
|
|
const selectedSourceInfo = this._sourceInfoSelected.get();
|
|
|
|
if (!selectedSourceInfo) { return isValid; } // No configuration to validate.
|
|
|
|
|
|
|
|
const mergeOptions = this._mergeOptions[selectedSourceInfo.hiddenTableId];
|
|
|
|
if (!mergeOptions) { return isValid; } // No configuration to validate.
|
|
|
|
|
|
|
|
const {updateExistingRecords, mergeCols, hasInvalidMergeCols} = mergeOptions;
|
|
|
|
if (updateExistingRecords.get() && mergeCols.get().length === 0) {
|
|
|
|
hasInvalidMergeCols.set(true);
|
|
|
|
isValid = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return isValid;
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
private _buildModalTitle(rightElement?: DomContents) {
|
|
|
|
const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file';
|
|
|
|
return cssModalHeader(cssModalTitle(title), rightElement);
|
|
|
|
}
|
|
|
|
|
|
|
|
// The importer state showing import in progress, with a list of tables, and a preview.
|
|
|
|
private _renderMain(upload: UploadResult) {
|
|
|
|
const schema = this._parseOptions.get().SCHEMA;
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.render([
|
2020-10-02 15:10:00 +00:00
|
|
|
this._buildModalTitle(
|
|
|
|
schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options',
|
|
|
|
testId('importer-options-link'),
|
|
|
|
dom.on('click', () => this._renderParseOptions(schema, upload))
|
|
|
|
) : null,
|
|
|
|
),
|
|
|
|
cssPreviewWrapper(
|
|
|
|
cssTableList(
|
|
|
|
dom.forEach(this._sourceInfoArray, (info) => {
|
|
|
|
const destTableId = Computed.create(null, (use) => use(info.destTableId))
|
2021-09-15 06:12:34 +00:00
|
|
|
.onWrite((destId) => {
|
|
|
|
this._resetTableMergeOptions(info.hiddenTableId);
|
|
|
|
void this._updateTransformSection(info, destId);
|
|
|
|
});
|
2020-10-02 15:10:00 +00:00
|
|
|
return cssTableInfo(
|
|
|
|
dom.autoDispose(destTableId),
|
|
|
|
cssTableLine(cssToFrom('From'),
|
|
|
|
cssTableSource(getSourceDescription(info, upload), testId('importer-from'))),
|
|
|
|
cssTableLine(cssToFrom('To'), linkSelect<DestId>(destTableId, this._destTables)),
|
|
|
|
cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info),
|
2021-09-15 06:12:34 +00:00
|
|
|
dom.on('click', () => {
|
|
|
|
if (info === this._sourceInfoSelected.get() || !this._validateImportConfiguration()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this._sourceInfoSelected.set(info);
|
|
|
|
}),
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('importer-source'),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
),
|
2021-09-15 06:12:34 +00:00
|
|
|
dom.maybe(this._sourceInfoSelected, (info) =>
|
|
|
|
dom.maybe(info.destTableId, () => {
|
|
|
|
const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!;
|
|
|
|
return cssMergeOptions(
|
|
|
|
cssMergeOptionsToggle(labeledSquareCheckbox(
|
|
|
|
updateExistingRecords,
|
|
|
|
'Update existing records',
|
|
|
|
testId('importer-update-existing-records')
|
|
|
|
)),
|
|
|
|
dom.maybe(updateExistingRecords, () => [
|
|
|
|
cssMergeOptionsMessage(
|
|
|
|
'Imported rows will be merged with records that have the same values for all of these fields:',
|
|
|
|
testId('importer-merge-fields-message')
|
|
|
|
),
|
|
|
|
dom.domComputed(info.transformSection, section => {
|
|
|
|
// When changes are made to selected fields, reset the multiSelect error observable.
|
|
|
|
const invalidColsListener = mergeCols.addListener((val, _prev) => {
|
|
|
|
if (val.length !== 0 && hasInvalidMergeCols.get()) {
|
|
|
|
hasInvalidMergeCols.set(false);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return [
|
|
|
|
dom.autoDispose(invalidColsListener),
|
|
|
|
multiSelect(
|
|
|
|
mergeCols,
|
|
|
|
section.viewFields().peek().map(field => field.label()),
|
|
|
|
{
|
|
|
|
placeholder: 'Select fields to match on',
|
|
|
|
error: hasInvalidMergeCols
|
|
|
|
},
|
|
|
|
testId('importer-merge-fields-select')
|
|
|
|
),
|
|
|
|
];
|
|
|
|
})
|
|
|
|
])
|
|
|
|
);
|
|
|
|
})
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.maybe(this._previewViewSection, () => cssSectionHeader('Preview')),
|
|
|
|
dom.maybe(this._previewViewSection, (viewSection) => {
|
|
|
|
const gridView = this._createPreview(viewSection);
|
|
|
|
return cssPreviewGrid(
|
|
|
|
dom.autoDispose(gridView),
|
|
|
|
gridView.viewPane,
|
|
|
|
testId('importer-preview'),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
cssModalButtons(
|
|
|
|
bigPrimaryButton('Import',
|
2021-09-15 06:12:34 +00:00
|
|
|
dom.on('click', () => this._maybeFinishImport(upload)),
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('modal-confirm'),
|
|
|
|
),
|
|
|
|
bigBasicButton('Cancel',
|
|
|
|
dom.on('click', () => this._cancelImport()),
|
|
|
|
testId('modal-cancel'),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// The importer state showing parse options that may be changed.
|
|
|
|
private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) {
|
2021-08-05 15:12:46 +00:00
|
|
|
this._screen.render([
|
2020-10-02 15:10:00 +00:00
|
|
|
this._buildModalTitle(),
|
|
|
|
dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues,
|
|
|
|
(p: ParseOptions) => {
|
|
|
|
this._parseOptions.set(p);
|
|
|
|
this._reImport(upload).catch((err) => reportError(err));
|
|
|
|
},
|
|
|
|
() => { this._renderMain(upload); },
|
|
|
|
)
|
|
|
|
]);
|
|
|
|
}
|
2021-09-30 08:19:22 +00:00
|
|
|
|
|
|
|
private async _fetchFromDrive(itemUrl: string) {
|
|
|
|
// First we will assume that this is public file, so no need to ask for permissions.
|
|
|
|
try {
|
|
|
|
return await fetchURL(this._docComm, itemUrl);
|
|
|
|
} catch(err) {
|
|
|
|
// It is not a public file or the file id in the url is wrong,
|
|
|
|
// but we have no way to check it, so we assume that it is private file
|
|
|
|
// and ask the user for the permission (if we are configured to do so)
|
|
|
|
if (canReadPrivateFiles()) {
|
|
|
|
const options: FetchUrlOptions = {};
|
|
|
|
try {
|
|
|
|
// Request for authorization code from Google.
|
|
|
|
const code = await getGoogleCodeForReading(this);
|
|
|
|
options.googleAuthorizationCode = code;
|
|
|
|
} catch(permError) {
|
|
|
|
if (permError?.message === ACCESS_DENIED) {
|
|
|
|
// User declined to give us full readonly permission, fallback to GoogleDrive plugin
|
|
|
|
// or cancel import if GoogleDrive plugin is not configured.
|
|
|
|
throw new GDriveUrlNotSupported(itemUrl);
|
|
|
|
} else if(permError?.message === AUTH_INTERRUPTED) {
|
|
|
|
// User closed the window - we assume he doesn't want to continue.
|
|
|
|
throw new CancelledError();
|
|
|
|
} else {
|
|
|
|
// Some other error happened during authentication, report to user.
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Download file from private drive, if it fails, report the error to user.
|
|
|
|
return await fetchURL(this._docComm, itemUrl, options);
|
|
|
|
} else {
|
|
|
|
// We are not allowed to ask for full readonly permission, fallback to GoogleDrive plugin.
|
|
|
|
throw new GDriveUrlNotSupported(itemUrl);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2021-09-30 08:19:22 +00:00
|
|
|
// Used for switching from URL plugin to Google drive plugin.
|
2021-08-03 10:34:05 +00:00
|
|
|
class GDriveUrlNotSupported extends Error {
|
|
|
|
constructor(public url: string) {
|
|
|
|
super(`This url ${url} is not supported`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-30 08:19:22 +00:00
|
|
|
// Used to cancel import (close the dialog without any error).
|
|
|
|
class CancelledError extends Error {
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
function getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) {
|
2021-04-26 21:54:09 +00:00
|
|
|
const origName = upload.files[sourceInfo.uploadFileIndex].origName;
|
2020-10-02 15:10:00 +00:00
|
|
|
return sourceInfo.origTableName ? origName + ' - ' + sourceInfo.origTableName : origName;
|
|
|
|
}
|
|
|
|
|
|
|
|
const cssActionLink = styled('div', `
|
|
|
|
display: inline-flex;
|
|
|
|
align-items: center;
|
|
|
|
cursor: pointer;
|
|
|
|
color: ${colors.lightGreen};
|
|
|
|
--icon-color: ${colors.lightGreen};
|
|
|
|
&:hover {
|
|
|
|
color: ${colors.darkGreen};
|
|
|
|
--icon-color: ${colors.darkGreen};
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssLinkIcon = styled(icon, `
|
|
|
|
flex: none;
|
|
|
|
margin-right: 4px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssModalHeader = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
justify-content: space-between;
|
|
|
|
margin-bottom: 16px;
|
|
|
|
& > .${cssModalTitle.className} {
|
|
|
|
margin-bottom: 0px;
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssPreviewWrapper = styled('div', `
|
|
|
|
width: 600px;
|
|
|
|
padding: 8px 12px 8px 0;
|
|
|
|
overflow-y: auto;
|
|
|
|
`);
|
|
|
|
|
|
|
|
// This partly duplicates cssSectionHeader from HomeLeftPane.ts
|
|
|
|
const cssSectionHeader = styled('div', `
|
|
|
|
margin-bottom: 8px;
|
|
|
|
color: ${colors.slate};
|
|
|
|
text-transform: uppercase;
|
|
|
|
font-weight: 500;
|
|
|
|
font-size: ${vars.xsmallFontSize};
|
|
|
|
letter-spacing: 1px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssTableList = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
flex-flow: row wrap;
|
|
|
|
justify-content: space-between;
|
|
|
|
margin-bottom: 16px;
|
|
|
|
align-items: flex-start;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssTableInfo = styled('div', `
|
|
|
|
padding: 4px 8px;
|
|
|
|
margin: 4px 0px;
|
|
|
|
width: calc(50% - 16px);
|
|
|
|
border-radius: 3px;
|
|
|
|
border: 1px solid ${colors.darkGrey};
|
|
|
|
&:hover, &-selected {
|
|
|
|
background-color: ${colors.mediumGrey};
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssTableLine = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
margin: 4px 0;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssToFrom = styled('span', `
|
|
|
|
flex: none;
|
|
|
|
margin-right: 8px;
|
|
|
|
color: ${colors.slate};
|
|
|
|
text-transform: uppercase;
|
|
|
|
font-weight: 500;
|
|
|
|
font-size: ${vars.xsmallFontSize};
|
|
|
|
letter-spacing: 1px;
|
|
|
|
width: 40px;
|
|
|
|
text-align: right;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssTableSource = styled('div', `
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssPreviewGrid = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
height: 300px;
|
|
|
|
border: 1px solid ${colors.darkGrey};
|
|
|
|
`);
|
2021-09-15 06:12:34 +00:00
|
|
|
|
|
|
|
const cssMergeOptions = styled('div', `
|
|
|
|
margin-bottom: 16px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssMergeOptionsToggle = styled('div', `
|
|
|
|
margin-bottom: 8px;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssMergeOptionsMessage = styled('div', `
|
|
|
|
color: ${colors.slate};
|
|
|
|
margin-bottom: 8px;
|
|
|
|
`);
|