CSV import. fix #498, fix #453

This commit is contained in:
antelle 2019-09-25 23:24:38 +02:00
parent 8060ba6453
commit bcee01cdd1
11 changed files with 352 additions and 29 deletions

View File

@ -597,5 +597,9 @@
"exportHtmlName": "Name",
"exportHtmlDate": "Export date",
"exportGenerator": "Software",
"exportDescription": "This file is generated with {}."
"exportDescription": "This file is generated with {}.",
"importCsvTitle": "Import from CSV",
"importCsvRun": "Import",
"importIgnoreField": "Ignore"
}

View File

@ -418,17 +418,19 @@ class AppModel {
}
}
createNewFile() {
let name;
for (let i = 0; ; i++) {
name = 'New' + (i || '');
if (!this.files.getByName(name) && !this.fileInfos.getByName(name)) {
break;
createNewFile(name) {
if (!name) {
for (let i = 0; ; i++) {
name = 'New' + (i || '');
if (!this.files.getByName(name) && !this.fileInfos.getByName(name)) {
break;
}
}
}
const newFile = new FileModel({ id: IdGenerator.uuid() });
newFile.create(name);
this.addFile(newFile);
return newFile;
}
openFile(params, callback) {

View File

@ -67,6 +67,7 @@ AppSettingsModel.defineModelProperties(
canOpenSettings: true,
canCreate: true,
canImportXml: true,
canImportCsv: true,
canRemoveLatest: true,
canExportXml: true,
canExportHtml: true,

View File

@ -15,7 +15,10 @@ class CsvParser {
while (this.next && this.index < this.csv.length) {
this.next = this.next(this);
}
return this.buildResult();
if (this.lines.length <= 1) {
throw new Error('Empty CSV');
}
return { headers: this.lines[0], rows: this.lines.slice(1) };
}
handleBeforeValue() {
@ -100,23 +103,6 @@ class CsvParser {
handleError() {
throw new Error(this.error);
}
buildResult() {
const result = [];
const firstLine = this.lines[0];
for (let i = 1; i < this.lines.length; i++) {
const line = this.lines[i];
const lineObj = {};
for (let col = 0; col < line.length; col++) {
const fieldName = firstLine[col];
if (fieldName) {
lineObj[fieldName] = line[col];
}
}
result.push(lineObj);
}
return result;
}
}
export { CsvParser };

View File

@ -10,6 +10,8 @@ import { Timeouts } from 'const/timeouts';
import { UpdateModel } from 'models/update-model';
import { Features } from 'util/features';
import { Locale } from 'util/locale';
import { Logger } from 'util/logger';
import { CsvParser } from 'util/data/csv-parser';
import { DetailsView } from 'views/details/details-view';
import { DragView } from 'views/drag-view';
import { DropdownView } from 'views/dropdown-view';
@ -23,6 +25,7 @@ import { MenuView } from 'views/menu/menu-view';
import { OpenView } from 'views/open-view';
import { SettingsView } from 'views/settings/settings-view';
import { TagView } from 'views/tag-view';
import { ImportCsvView } from 'views/import-csv-view';
import template from 'templates/app.hbs';
class AppView extends View {
@ -85,11 +88,10 @@ class AppView extends View {
this.listenTo(Events, 'show-context-menu', this.showContextMenu);
this.listenTo(Events, 'second-instance', this.showSingleInstanceAlert);
this.listenTo(Events, 'file-modified', this.handleAutoSaveTimer);
this.listenTo(UpdateModel, 'change:updateReady', this.updateApp);
this.listenTo(Events, 'enter-full-screen', this.enterFullScreen);
this.listenTo(Events, 'leave-full-screen', this.leaveFullScreen);
this.listenTo(Events, 'import-csv-requested', this.showImportCsv);
this.listenTo(UpdateModel, 'change:updateReady', this.updateApp);
window.onbeforeunload = this.beforeUnload.bind(this);
window.onresize = this.windowResize.bind(this);
@ -163,6 +165,7 @@ class AppView extends View {
this.hideSettings();
this.hideOpenFile();
this.hideKeyChange();
this.hideImportCsv();
this.views.open = new OpenView(this.model);
this.views.open.render();
this.views.open.on('close', () => {
@ -205,6 +208,7 @@ class AppView extends View {
this.hideOpenFile();
this.hideSettings();
this.hideKeyChange();
this.hideImportCsv();
}
hideOpenFile() {
@ -248,6 +252,13 @@ class AppView extends View {
}
}
hideImportCsv() {
if (this.views.importCsv) {
this.views.importCsv.remove();
this.views.importCsv = null;
}
}
showSettings(selectedMenuItem) {
this.model.menu.setMenu('settings');
this.views.menu.show();
@ -259,6 +270,7 @@ class AppView extends View {
this.hidePanelView();
this.hideOpenFile();
this.hideKeyChange();
this.hideImportCsv();
this.views.settings = new SettingsView(this.model);
this.views.settings.render();
if (!selectedMenuItem) {
@ -772,6 +784,58 @@ class AppView extends View {
IdleTracker.regUserAction();
Events.emit('click', e);
}
showImportCsv(file) {
const reader = new FileReader();
const logger = new Logger('import-csv');
logger.info('Reading CSV...');
reader.onload = e => {
logger.info('Parsing CSV...');
const ts = logger.ts();
const parser = new CsvParser();
let data;
try {
data = parser.parse(e.target.result);
} catch (e) {
logger.error('Error parsing CSV', e);
Alerts.error({ header: Locale.openFailedRead, body: e.toString() });
return;
}
logger.info(`Parsed CSV: ${data.rows.length} records, ${logger.ts(ts)}`);
// TODO: refactor this
this.hideSettings();
this.hidePanelView();
this.hideOpenFile();
this.hideKeyChange();
this.views.menu.hide();
this.views.listWrap.hide();
this.views.list.hide();
this.views.listDrag.hide();
this.views.details.hide();
this.views.importCsv = new ImportCsvView(data, {
appModel: this.model,
fileName: file.name
});
this.views.importCsv.render();
this.views.importCsv.on('cancel', () => {
if (this.model.files.hasOpenFiles()) {
this.showEntries();
} else {
this.showOpenFile();
}
});
this.views.importCsv.on('done', () => {
this.model.refresh();
this.showEntries();
});
};
reader.onerror = () => {
Alerts.error({ header: Locale.openFailedRead });
};
reader.readAsText(file);
}
}
export { AppView };

View File

@ -0,0 +1,136 @@
import kdbxweb from 'kdbxweb';
import { View } from 'framework/views/view';
import { Scrollable } from 'framework/views/scrollable';
import template from 'templates/import-csv.hbs';
import { EntryModel } from 'models/entry-model';
class ImportCsvView extends View {
parent = '.app__body';
template = template;
events = {
'click .back-button': 'returnToApp',
'click .import-csv__button-cancel': 'returnToApp',
'click .import-csv__button-run': 'runImport',
'change .import-csv__field-select': 'changeMapping'
};
knownFields = [
{ field: 'Title', re: /title|\bname|account/i },
{ field: 'UserName', re: /user|login/i },
{ field: 'Password', re: /pass/i },
{ field: 'URL', re: /url|site/i },
{ field: 'Notes', re: /notes|comment|extra/i }
];
fieldMapping = [];
constructor(model, options) {
super(model, options);
this.appModel = options.appModel;
this.fileName = options.fileName;
this.initScroll();
this.guessfieldMapping();
}
render() {
super.render({
headers: this.model.headers,
rows: this.model.rows,
fieldMapping: this.fieldMapping
});
this.createScroll({
root: this.$el.find('.import-csv__body')[0],
scroller: this.$el.find('.scroller')[0],
bar: this.$el.find('.scroller__bar')[0]
});
this.pageResized();
}
returnToApp() {
this.emit('cancel');
}
changeMapping(e) {
const col = +e.target.dataset.col;
const field = e.target.value;
const isBuiltIn = this.knownFields.some(f => f.field === field);
const mapping = field ? (isBuiltIn ? 'builtin' : 'custom') : 'ignore';
this.fieldMapping[col] = {
mapping,
field
};
if (field) {
let ix = 0;
for (const mapping of this.fieldMapping) {
if (mapping.field === field && col !== ix) {
mapping.type = 'ignore';
mapping.field = '';
const select = this.el.querySelector(
`.import-csv__field-select[data-col="${ix}"]`
);
select.value = '';
}
ix++;
}
}
}
guessfieldMapping() {
const usedFields = {};
for (const fieldName of this.model.headers.map(f => f.trim())) {
if (!fieldName || /^(group|grouping)$/i.test(fieldName)) {
this.fieldMapping.push({ type: 'ignore' });
continue;
}
let found = false;
for (const { field, re } of this.knownFields) {
if (!usedFields[field] && re.test(fieldName)) {
this.fieldMapping.push({ type: 'builtin', field });
usedFields[field] = true;
found = true;
break;
}
}
if (!found) {
this.fieldMapping.push({ type: 'custom', field: fieldName });
}
}
}
runImport() {
const fileName = this.fileName.replace(/\.csv$/i, '');
const file = this.appModel.createNewFile(fileName);
const group = file.groups[0];
for (const row of this.model.rows) {
const newEntry = EntryModel.newEntry(group, file);
for (let ix = 0; ix < row.length; ix++) {
let value = row[ix];
if (!value) {
continue;
}
const mapping = this.fieldMapping[ix];
if (mapping.type === 'ignore' || !mapping.field) {
continue;
}
if (mapping.field === 'password') {
value = kdbxweb.ProtectedValue.fromString(value);
}
newEntry.setField(mapping.field, value);
}
}
file.reload();
this.emit('done');
}
}
Object.assign(ImportCsvView.prototype, Scrollable);
export { ImportCsvView };

View File

@ -512,10 +512,19 @@ class OpenView extends View {
keyFile,
dataFile.path ? null : this.showLocalFileAlert.bind(this)
);
} else if (this.model.settings.canImportXml) {
return;
}
if (this.model.settings.canImportXml) {
const xmlFile = files.find(file => /\.xml$/i.test(file.name));
if (xmlFile) {
this.setFile(xmlFile, null, this.showLocalFileAlert.bind(this));
return;
}
}
if (this.model.settings.canImportCsv) {
const csvFile = files.find(file => /\.csv$/i.test(file.name));
if (csvFile) {
Events.emit('import-csv-requested', csvFile);
}
}
}

View File

@ -0,0 +1,65 @@
.import-csv {
padding: $base-padding;
display: flex;
flex-direction: column;
flex: 1;
&__body {
@include scrollbar-on-hover;
overflow: hidden;
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
> .scroller {
flex: 1 0 0;
overflow-x: scroll;
position: relative;
}
}
&__table {
border-collapse: collapse;
width: calc(100vw - #{$base-padding-h * 2});
max-width: calc(100vw - #{$base-padding-h * 2});
overflow: hidden;
td, th {
text-align: left;
padding: $base-padding;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
td {
user-select: text;
}
@include nomobile {
tbody tr:hover {
background-color: var(--intermediate-background-color);
}
}
}
&__field-select {
width: 100%;
height: 2em;
padding-right: $large-padding;
overflow: hidden;
text-overflow: ellipsis;
}
&__top {
padding: 0 $base-padding-h;
}
&__bottom {
padding: $medium-padding-v $base-padding-h;
display: flex;
justify-content: flex-end;
button ~ button {
margin-left: $small-spacing;
}
}
}

View File

@ -37,3 +37,4 @@ $fa-font-path: '~font-awesome/fonts';
@import 'areas/menu';
@import 'areas/open';
@import 'areas/settings';
@import 'areas/import-csv';

View File

@ -0,0 +1,54 @@
<div class="import-csv">
<div class="import-csv__top">
<div class="back-button">
{{res 'retToApp'}} <i class="fa fa-external-link-square"></i>
</div>
<h1>{{res 'importCsvTitle'}}</h1>
</div>
<div class="import-csv__body">
<div class="scroller">
<table class="import-csv__table">
<thead>
<tr>
{{#each headers as |header|}}
<th>{{header}}</th>
{{/each}}
</tr>
<tr>
{{#each fieldMapping as |mapped|}}
<th>
<select data-col="{{@index}}" class="import-csv__field-select">
<option value="" {{#ifeq mapped.type 'ignore'}}selected{{/ifeq}}>({{res 'importIgnoreField'}})</option>
<option value="Title" {{#ifeq mapped.field 'Title'}}selected{{/ifeq}}>{{Res 'title'}}</option>
<option value="UserName" {{#ifeq mapped.field 'UserName'}}selected{{/ifeq}}>{{Res 'user'}}</option>
<option value="Password" {{#ifeq mapped.field 'Password'}}selected{{/ifeq}}>{{Res 'password'}}</option>
<option value="URL" {{#ifeq mapped.field 'URL'}}selected{{/ifeq}}>{{Res 'website'}}</option>
<option value="Notes" {{#ifeq mapped.field 'Notes'}}selected{{/ifeq}}>{{Res 'notes'}}</option>
{{#ifeq mapped.type 'custom'}}
<option value="{{mapped.field}}" selected>{{mapped.field}}</option>
{{/ifeq}}
</select>
</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each rows as |row|}}
<tr>
{{#each row as |field|}}
<td>{{field}}</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</div>
<div class="scroller__bar-wrapper"><div class="scroller__bar"></div></div>
</div>
<div class="import-csv__bottom">
<div class="import-csv__bottom-buttons">
<button class="import-csv__button-cancel btn-silent">{{res 'alertCancel'}}</button>
<button class="import-csv__button-run">{{res 'importCsvRun'}}</button>
</div>
</div>
</div>

View File

@ -10,6 +10,7 @@ Release notes
`+` #743: copying entry fields to clipboard
`+` #713: markdown notes
`+` #336: moving entries across files
`+` #498: CSV import
`*` #156: using ServiceWorker instead of AppCache
`+` #564: Steam OTP support
`*` devtools are now opened with alt-cmd-I