mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Enable incremental imports
Summary: The import dialog now has an option to 'Update existing records', which when checked will allow for selection of 1 or more fields to match source and destination tables on. If all fields match, then the matched record in the destination table will be merged with the incoming record from the source table. This means the incoming values will replace the destination table values, unless the incoming values are blank. Additional merge strategies are implemented in the data engine, but the import dialog only uses one of the strategies currently. The others can be exposed in the UI in the future, and tweak the behavior of how source and destination values should be merged in different contexts, such as when blank values exist. Test Plan: Python and browser tests. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3020
This commit is contained in:
@@ -526,7 +526,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
multiple: true});
|
||||
if (uploadResult) {
|
||||
const dataSource = {uploadId: uploadResult.uploadId, transforms: []};
|
||||
const importResult = await this.docComm.finishImportFiles(dataSource, {}, []);
|
||||
const importResult = await this.docComm.finishImportFiles(dataSource, [], {});
|
||||
const tableId = importResult.tables[0].hiddenTableId;
|
||||
const tableRowModel = this.docModel.dataTables[tableId].tableMetaRow;
|
||||
await this.openDocPage(tableRowModel.primaryViewId());
|
||||
|
||||
@@ -15,14 +15,16 @@ 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';
|
||||
import {IOptionFull, linkSelect} from 'app/client/ui2018/menus';
|
||||
import {IOptionFull, linkSelect, multiSelect} from 'app/client/ui2018/menus';
|
||||
import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals';
|
||||
import {DataSourceTransformed, ImportResult, ImportTableResult} from "app/common/ActiveDocAPI";
|
||||
import {TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
|
||||
import {DataSourceTransformed, ImportResult, ImportTableResult, MergeOptions,
|
||||
MergeStrategy, TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
|
||||
import {byteString} from "app/common/gutil";
|
||||
import {UploadResult} from 'app/common/uploads';
|
||||
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
||||
import {Computed, Disposable, dom, DomContents, IDisposable, Observable, styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, DomContents, IDisposable, MutableObsArray, obsArray, Observable,
|
||||
styled} from 'grainjs';
|
||||
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";
|
||||
|
||||
// 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,
|
||||
@@ -45,6 +47,15 @@ export interface SourceInfo {
|
||||
transformSection: Observable<ViewSectionRec>;
|
||||
destTableId: Observable<DestId>;
|
||||
}
|
||||
// UI state of selected merge options for each source table (from SourceInfo).
|
||||
interface MergeOptionsState {
|
||||
[srcTableId: string]: {
|
||||
updateExistingRecords: Observable<boolean>;
|
||||
mergeCols: MutableObsArray<string>;
|
||||
mergeStrategy: Observable<MergeStrategy>;
|
||||
hasInvalidMergeCols: Observable<boolean>;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Importer manages an import files to Grist tables and shows Preview
|
||||
@@ -119,6 +130,7 @@ export class Importer extends Disposable {
|
||||
private _uploadResult?: UploadResult;
|
||||
|
||||
private _screen: PluginScreen;
|
||||
private _mergeOptions: MergeOptionsState = {};
|
||||
private _parseOptions = Observable.create<ParseOptions>(this, {});
|
||||
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
|
||||
private _sourceInfoSelected = Observable.create<SourceInfo|null>(this, null);
|
||||
@@ -223,6 +235,22 @@ export class Importer extends Disposable {
|
||||
return {uploadId: upload.uploadId, transforms};
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _createTransformRuleMap(uploadFileIndex: number): TransformRuleMap {
|
||||
const result: TransformRuleMap = {};
|
||||
for (const sourceInfo of this._sourceInfoArray.get()) {
|
||||
@@ -276,6 +304,16 @@ export class Importer extends Disposable {
|
||||
throw new Error("No data was imported");
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
});
|
||||
|
||||
// Select the first sourceInfo to show in preview.
|
||||
this._sourceInfoSelected.set(this._sourceInfoArray.get()[0] || null);
|
||||
|
||||
@@ -287,11 +325,16 @@ export class Importer extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private async _finishImport(upload: UploadResult) {
|
||||
private async _maybeFinishImport(upload: UploadResult) {
|
||||
const isConfigValid = this._validateImportConfiguration();
|
||||
if (!isConfigValid) { return; }
|
||||
|
||||
this._screen.renderSpinner();
|
||||
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0};
|
||||
const mergeOptions = this._getMergeOptions(upload);
|
||||
|
||||
const importResult: ImportResult = await this._docComm.finishImportFiles(
|
||||
this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds());
|
||||
this._getTransformedDataSource(upload), this._getHiddenTableIds(), {mergeOptions, parseOptions});
|
||||
|
||||
if (importResult.tables[0].hiddenTableId) {
|
||||
const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow;
|
||||
@@ -310,6 +353,28 @@ export class Importer extends Disposable {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private _buildModalTitle(rightElement?: DomContents) {
|
||||
const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file';
|
||||
return cssModalHeader(cssModalTitle(title), rightElement);
|
||||
@@ -329,18 +394,64 @@ export class Importer extends Disposable {
|
||||
cssTableList(
|
||||
dom.forEach(this._sourceInfoArray, (info) => {
|
||||
const destTableId = Computed.create(null, (use) => use(info.destTableId))
|
||||
.onWrite((destId) => this._updateTransformSection(info, destId));
|
||||
.onWrite((destId) => {
|
||||
this._resetTableMergeOptions(info.hiddenTableId);
|
||||
void this._updateTransformSection(info, destId);
|
||||
});
|
||||
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),
|
||||
dom.on('click', () => this._sourceInfoSelected.set(info)),
|
||||
dom.on('click', () => {
|
||||
if (info === this._sourceInfoSelected.get() || !this._validateImportConfiguration()) {
|
||||
return;
|
||||
}
|
||||
this._sourceInfoSelected.set(info);
|
||||
}),
|
||||
testId('importer-source'),
|
||||
);
|
||||
}),
|
||||
),
|
||||
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')
|
||||
),
|
||||
];
|
||||
})
|
||||
])
|
||||
);
|
||||
})
|
||||
),
|
||||
dom.maybe(this._previewViewSection, () => cssSectionHeader('Preview')),
|
||||
dom.maybe(this._previewViewSection, (viewSection) => {
|
||||
const gridView = this._createPreview(viewSection);
|
||||
@@ -353,7 +464,7 @@ export class Importer extends Disposable {
|
||||
),
|
||||
cssModalButtons(
|
||||
bigPrimaryButton('Import',
|
||||
dom.on('click', () => this._finishImport(upload)),
|
||||
dom.on('click', () => this._maybeFinishImport(upload)),
|
||||
testId('modal-confirm'),
|
||||
),
|
||||
bigBasicButton('Cancel',
|
||||
@@ -480,3 +591,16 @@ const cssPreviewGrid = styled('div', `
|
||||
height: 300px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
`);
|
||||
|
||||
const cssMergeOptions = styled('div', `
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
const cssMergeOptionsToggle = styled('div', `
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
const cssMergeOptionsMessage = styled('div', `
|
||||
color: ${colors.slate};
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
@@ -5,10 +5,10 @@ import {cssSelectBtn} from 'app/client/ui2018/select';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {dom, DomElementArg, DomElementMethod} from 'grainjs';
|
||||
import {MaybeObsArray, Observable, styled} from 'grainjs';
|
||||
import {Computed, dom, DomElementArg, DomElementMethod, MaybeObsArray, MutableObsArray, Observable,
|
||||
styled} from 'grainjs';
|
||||
import * as weasel from 'popweasel';
|
||||
import {IAutocompleteOptions} from 'popweasel';
|
||||
import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox';
|
||||
|
||||
export interface IOptionFull<T> {
|
||||
value: T;
|
||||
@@ -132,6 +132,95 @@ export function linkSelect<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOp
|
||||
return elem;
|
||||
}
|
||||
|
||||
export interface IMultiSelectUserOptions {
|
||||
placeholder?: string;
|
||||
error?: Observable<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a select dropdown widget that supports selecting multiple options.
|
||||
*
|
||||
* The observable array `selectedOptions` reflects the selected options, and
|
||||
* `availableOptions` is an array (normal or observable) of selectable options.
|
||||
* These may either be strings, or {label, value} objects.
|
||||
*/
|
||||
export function multiSelect<T>(selectedOptions: MutableObsArray<T>,
|
||||
availableOptions: MaybeObsArray<IOption<T>>,
|
||||
options: IMultiSelectUserOptions = {},
|
||||
...domArgs: DomElementArg[]) {
|
||||
const selectedOptionsSet = Computed.create(null, selectedOptions, (_use, opts) => new Set(opts));
|
||||
|
||||
const selectedOptionsText = Computed.create(null, selectedOptionsSet, (use, selectedOpts) => {
|
||||
if (selectedOpts.size === 0) {
|
||||
return options.placeholder ?? 'Select fields';
|
||||
}
|
||||
|
||||
const optionArray = Array.isArray(availableOptions) ? availableOptions : use(availableOptions);
|
||||
return optionArray
|
||||
.filter(opt => selectedOpts.has(weasel.getOptionFull(opt).value))
|
||||
.map(opt => weasel.getOptionFull(opt).label)
|
||||
.join(', ');
|
||||
});
|
||||
|
||||
function buildMultiSelectMenu(ctl: weasel.IOpenController) {
|
||||
return cssMultiSelectMenu(
|
||||
{ tabindex: '-1' }, // Allow menu to be focused.
|
||||
dom.cls(menuCssClass),
|
||||
dom.onKeyDown({
|
||||
Enter: () => ctl.close(),
|
||||
Escape: () => ctl.close()
|
||||
}),
|
||||
elem => {
|
||||
// Set focus on open, so that keyboard events work.
|
||||
setTimeout(() => elem.focus(), 0);
|
||||
|
||||
// Sets menu width to match parent container (button) width.
|
||||
const style = elem.style;
|
||||
style.minWidth = ctl.getTriggerElem().getBoundingClientRect().width + 'px';
|
||||
style.marginLeft = style.marginRight = '0';
|
||||
},
|
||||
dom.domComputed(selectedOptionsSet, selectedOpts => {
|
||||
return dom.forEach(availableOptions, option => {
|
||||
const fullOption = weasel.getOptionFull(option);
|
||||
return cssCheckboxLabel(
|
||||
cssCheckboxSquare(
|
||||
{type: 'checkbox'},
|
||||
dom.prop('checked', selectedOpts.has(fullOption.value)),
|
||||
dom.on('change', (_ev, elem) => {
|
||||
if (elem.checked) {
|
||||
selectedOptions.push(fullOption.value);
|
||||
} else {
|
||||
selectedOpts.delete(fullOption.value);
|
||||
selectedOptions.set([...selectedOpts]);
|
||||
}
|
||||
}),
|
||||
dom.style('position', 'relative'),
|
||||
testId('multi-select-menu-option-checkbox')
|
||||
),
|
||||
cssCheckboxText(fullOption.label, testId('multi-select-menu-option-text')),
|
||||
testId('multi-select-menu-option')
|
||||
);
|
||||
});
|
||||
}),
|
||||
testId('multi-select-menu')
|
||||
);
|
||||
}
|
||||
|
||||
return cssSelectBtn(
|
||||
dom.autoDispose(selectedOptionsSet),
|
||||
dom.autoDispose(selectedOptionsText),
|
||||
cssMultiSelectSummary(dom.text(selectedOptionsText)),
|
||||
icon('Dropdown'),
|
||||
elem => {
|
||||
weasel.setPopupToCreateDom(elem, ctl => buildMultiSelectMenu(ctl), weasel.defaultMenuOptions);
|
||||
},
|
||||
dom.style('border', use => {
|
||||
return options.error && use(options.error) ? '1px solid red' : `1px solid ${colors.darkGrey}`;
|
||||
}),
|
||||
...domArgs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a select dropdown widget that is more ideal for forms. Implemented using the <select>
|
||||
* element to work with browser form autofill and typing in the desired value to quickly set it.
|
||||
@@ -207,7 +296,7 @@ export function upgradeText(needUpgrade: boolean) {
|
||||
export function autocomplete(
|
||||
inputElem: HTMLInputElement,
|
||||
choices: MaybeObsArray<string>,
|
||||
options: IAutocompleteOptions = {}
|
||||
options: weasel.IAutocompleteOptions = {}
|
||||
) {
|
||||
return weasel.autocomplete(inputElem, choices, {
|
||||
...defaults, ...options,
|
||||
@@ -376,3 +465,27 @@ const cssAnnotateMenuItem = styled('span', `
|
||||
color: white;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssMultiSelectSummary = styled('div', `
|
||||
flex: 1 1 0px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`);
|
||||
|
||||
const cssMultiSelectMenu = styled(weasel.cssMenu, `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(max(300px, 95vh - 300px));
|
||||
max-width: 400px;
|
||||
padding-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssCheckboxLabel = styled(cssLabel, `
|
||||
padding: 8px 16px;
|
||||
`);
|
||||
|
||||
const cssCheckboxText = styled(cssLabelText, `
|
||||
margin-right: 12px;
|
||||
color: ${colors.dark};
|
||||
white-space: pre;
|
||||
`);
|
||||
|
||||
@@ -56,6 +56,20 @@ export interface ImportTableResult {
|
||||
destTableId: string|null;
|
||||
}
|
||||
|
||||
export interface MergeStrategy {
|
||||
type: 'replace-with-nonblank-source' | 'replace-all-fields' | 'replace-blank-fields-only';
|
||||
}
|
||||
|
||||
export interface MergeOptions {
|
||||
mergeCols: string[]; // Columns to use as merge keys for incremental imports.
|
||||
mergeStrategy: MergeStrategy; // Determines how matched records should be merged between 2 tables.
|
||||
}
|
||||
|
||||
export interface ImportOptions {
|
||||
parseOptions?: ParseOptions; // Options for parsing the source file.
|
||||
mergeOptions?: Array<MergeOptions|null>; // Options for merging fields, indexed by uploadFileIndex.
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a query for Grist data. The tableId is required. An empty set of filters indicates
|
||||
* the full table. Examples:
|
||||
@@ -159,8 +173,8 @@ export interface ActiveDocAPI {
|
||||
/**
|
||||
* Finishes import files, creates the new tables, and cleans up temporary hidden tables and uploads.
|
||||
*/
|
||||
finishImportFiles(dataSource: DataSourceTransformed,
|
||||
parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult>;
|
||||
finishImportFiles(dataSource: DataSourceTransformed, prevTableIds: string[],
|
||||
options: ImportOptions): Promise<ImportResult>;
|
||||
|
||||
/**
|
||||
* Cancels import files, cleans up temporary hidden tables and uploads.
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApplyUAResult,
|
||||
DataSourceTransformed,
|
||||
ForkResult,
|
||||
ImportOptions,
|
||||
ImportResult,
|
||||
QueryResult,
|
||||
ServerQuery
|
||||
@@ -467,8 +468,8 @@ export class ActiveDoc extends EventEmitter {
|
||||
* call, or empty if there was no previous call.
|
||||
*/
|
||||
public finishImportFiles(docSession: DocSession, dataSource: DataSourceTransformed,
|
||||
parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult> {
|
||||
return this._activeDocImport.finishImportFiles(docSession, dataSource, parseOptions, prevTableIds);
|
||||
prevTableIds: string[], importOptions: ImportOptions): Promise<ImportResult> {
|
||||
return this._activeDocImport.finishImportFiles(docSession, dataSource, prevTableIds, importOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import * as path from 'path';
|
||||
import * as _ from 'underscore';
|
||||
|
||||
import {DataSourceTransformed, ImportResult, ImportTableResult, TransformRuleMap} from 'app/common/ActiveDocAPI';
|
||||
import {DataSourceTransformed, ImportOptions, ImportResult, ImportTableResult, MergeOptions,
|
||||
TransformRuleMap} from 'app/common/ActiveDocAPI';
|
||||
import {ApplyUAResult} from 'app/common/ActiveDocAPI';
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
@@ -34,6 +35,21 @@ interface ReferenceDescription {
|
||||
refTableId: string;
|
||||
}
|
||||
|
||||
interface FileImportOptions {
|
||||
// Suggested name of the import file. It is sometimes used as a suggested table name, e.g. for csv imports.
|
||||
originalFilename: string;
|
||||
// Containing parseOptions as serialized JSON to pass to the import plugin.
|
||||
parseOptions: ParseOptions;
|
||||
// Options for determining how matched fields between source and destination tables should be merged.
|
||||
mergeOptions: MergeOptions|null;
|
||||
// Flag to indicate whether table is temporary and hidden or regular.
|
||||
isHidden: boolean;
|
||||
// Index of original dataSource corresponding to current imported file.
|
||||
uploadFileIndex: number;
|
||||
// Map of table names to their transform rules.
|
||||
transformRuleMap: TransformRuleMap;
|
||||
}
|
||||
|
||||
export class ActiveDocImport {
|
||||
constructor(private _activeDoc: ActiveDoc) {}
|
||||
/**
|
||||
@@ -46,7 +62,7 @@ export class ActiveDocImport {
|
||||
const userId = docSession.authorizer.getUserId();
|
||||
const accessId = this._activeDoc.makeAccessId(userId);
|
||||
const uploadInfo: UploadInfo = globalUploadSet.getUploadInfo(dataSource.uploadId, accessId);
|
||||
return this._importFiles(docSession, uploadInfo, dataSource.transforms, parseOptions, true);
|
||||
return this._importFiles(docSession, uploadInfo, dataSource.transforms, {parseOptions}, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +70,7 @@ export class ActiveDocImport {
|
||||
* the new tables
|
||||
*/
|
||||
public async finishImportFiles(docSession: DocSession, dataSource: DataSourceTransformed,
|
||||
parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult> {
|
||||
prevTableIds: string[], importOptions: ImportOptions): Promise<ImportResult> {
|
||||
this._activeDoc.startBundleUserActions(docSession);
|
||||
try {
|
||||
await this._removeHiddenTables(docSession, prevTableIds);
|
||||
@@ -62,7 +78,7 @@ export class ActiveDocImport {
|
||||
const accessId = this._activeDoc.makeAccessId(userId);
|
||||
const uploadInfo: UploadInfo = globalUploadSet.getUploadInfo(dataSource.uploadId, accessId);
|
||||
const importResult = await this._importFiles(docSession, uploadInfo, dataSource.transforms,
|
||||
parseOptions, false);
|
||||
importOptions, false);
|
||||
await globalUploadSet.cleanup(dataSource.uploadId);
|
||||
return importResult;
|
||||
} finally {
|
||||
@@ -101,11 +117,12 @@ export class ActiveDocImport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports all files as new tables, using the given transform rules and parse options.
|
||||
* Imports all files as new tables, using the given transform rules and import options.
|
||||
* The isHidden flag indicates whether to create temporary hidden tables, or final ones.
|
||||
*/
|
||||
private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[],
|
||||
parseOptions: ParseOptions, isHidden: boolean): Promise<ImportResult> {
|
||||
{parseOptions = {}, mergeOptions = []}: ImportOptions,
|
||||
isHidden: boolean): Promise<ImportResult> {
|
||||
|
||||
// Check that upload size is within the configured limits.
|
||||
const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity;
|
||||
@@ -126,8 +143,14 @@ export class ActiveDocImport {
|
||||
if (file.ext) {
|
||||
origName = path.basename(origName, path.extname(origName)) + file.ext;
|
||||
}
|
||||
const res = await this._importFileAsNewTable(docSession, index, file.absPath, origName,
|
||||
parseOptions, isHidden, transforms[index] || {});
|
||||
const res = await this._importFileAsNewTable(docSession, file.absPath, {
|
||||
parseOptions,
|
||||
mergeOptions: mergeOptions[index] || null,
|
||||
isHidden,
|
||||
originalFilename: origName,
|
||||
uploadFileIndex: index,
|
||||
transformRuleMap: transforms[index] || {}
|
||||
});
|
||||
if (index === 0) {
|
||||
// Returned parse options from the first file should be used for all files in one upload.
|
||||
importResult.options = parseOptions = res.options;
|
||||
@@ -143,27 +166,21 @@ export class ActiveDocImport {
|
||||
* Currently it starts a python parser (that relies on the messytables library) as a child process
|
||||
* outside the sandbox, and supports xls(x), csv, txt, and perhaps some other formats. It may
|
||||
* result in the import of multiple tables, in case of e.g. Excel formats.
|
||||
* @param {ActiveDoc} activeDoc: Instance of ActiveDoc.
|
||||
* @param {Number} dataSourceIdx: Index of original dataSourse corresponding to current imported file.
|
||||
* @param {OptDocSession} docSession: Session instance to use for importing.
|
||||
* @param {String} tmpPath: The path from of the original file.
|
||||
* @param {String} originalFilename: Suggested name of the import file. It is sometimes used as a
|
||||
* suggested table name, e.g. for csv imports.
|
||||
* @param {String} options: Containing parseOptions as serialized JSON to pass to the import plugin.
|
||||
* @param {Boolean} isHidden: Flag to indicate whether table is temporary and hidden or regular.
|
||||
* @param {TransformRuleMap} transformRuleMap: Containing transform rules for each table in file such as
|
||||
* `destTableId`, `destCols`, `sourceCols`.
|
||||
* @param {FileImportOptions} importOptions: File import options.
|
||||
* @returns {Promise<ImportResult>} with `options` property containing parseOptions as serialized JSON as adjusted
|
||||
* or guessed by the plugin, and `tables`, which is which is a list of objects with information about
|
||||
* tables, such as `hiddenTableId`, `dataSourceIndex`, `origTableName`, `transformSectionRef`, `destTableId`.
|
||||
* tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`.
|
||||
*/
|
||||
private async _importFileAsNewTable(docSession: OptDocSession, uploadFileIndex: number, tmpPath: string,
|
||||
originalFilename: string,
|
||||
options: ParseOptions, isHidden: boolean,
|
||||
transformRuleMap: TransformRuleMap|undefined): Promise<ImportResult> {
|
||||
private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string,
|
||||
importOptions: FileImportOptions): Promise<ImportResult> {
|
||||
const {originalFilename, parseOptions, mergeOptions, isHidden, uploadFileIndex,
|
||||
transformRuleMap} = importOptions;
|
||||
log.info("ActiveDoc._importFileAsNewTable(%s, %s)", tmpPath, originalFilename);
|
||||
const optionsAndData: ParseFileResult = await this._activeDoc.docPluginManager.parseFile(tmpPath,
|
||||
originalFilename, options);
|
||||
options = optionsAndData.parseOptions;
|
||||
const optionsAndData: ParseFileResult =
|
||||
await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions);
|
||||
const options = optionsAndData.parseOptions;
|
||||
|
||||
const parsedTables = optionsAndData.tables;
|
||||
const references = this._encodeReferenceAsInt(parsedTables);
|
||||
@@ -220,7 +237,7 @@ export class ActiveDocImport {
|
||||
const tableId = await this._activeDoc.applyUserActions(docSession,
|
||||
[['TransformAndFinishImport',
|
||||
hiddenTableId, destTable, intoNewTable,
|
||||
ruleCanBeApplied ? transformRule : null]]);
|
||||
ruleCanBeApplied ? transformRule : null, mergeOptions]]);
|
||||
|
||||
createdTableId = tableId.retValues[0]; // this is garbage for now I think?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user