mirror of https://github.com/keeweb/keeweb.git
parent
8060ba6453
commit
bcee01cdd1
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -67,6 +67,7 @@ AppSettingsModel.defineModelProperties(
|
|||
canOpenSettings: true,
|
||||
canCreate: true,
|
||||
canImportXml: true,
|
||||
canImportCsv: true,
|
||||
canRemoveLatest: true,
|
||||
canExportXml: true,
|
||||
canExportHtml: true,
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,3 +37,4 @@ $fa-font-path: '~font-awesome/fonts';
|
|||
@import 'areas/menu';
|
||||
@import 'areas/open';
|
||||
@import 'areas/settings';
|
||||
@import 'areas/import-csv';
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue