Improve UX and functionally follow the mapped columns and field names.
Still buggy and the dialog seems to keep state, strangely. Still missing a checkbox to signal if the first row has data.
This commit is contained in:
parent
2ba1a590d2
commit
0b03fd0990
|
@ -5,14 +5,14 @@ import * as imp from "../lib/import";
|
|||
import { prompt, alert } from "../lib/dialog";
|
||||
import { app } from "../globals";
|
||||
import { Select } from "./select";
|
||||
import { Input } from "./input";
|
||||
import { Dialog } from "./dialog";
|
||||
import "./button";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { html /*, css*/ } from "lit";
|
||||
import { customElement, query, state, queryAll } from "lit/decorators.js";
|
||||
import { html, css } from "lit";
|
||||
import { saveFile } from "@padloc/core/src/platform";
|
||||
import { stringToBytes } from "@padloc/core/src/encoding";
|
||||
|
||||
// TODO: Force showing "Name" and "Tags" as well, as those are necessary for the import
|
||||
const fieldTypeOptions = Object.keys(FIELD_DEFS).map((fieldType) => ({
|
||||
label: FIELD_DEFS[fieldType].name as string,
|
||||
value: fieldType,
|
||||
|
@ -29,24 +29,60 @@ export class ImportDialog extends Dialog<File, void> {
|
|||
@state()
|
||||
private _itemColumns: imp.ImportCSVColumn[] = [];
|
||||
|
||||
@state()
|
||||
private _csvHasDataOnFirstRow: boolean = false;
|
||||
|
||||
@query("#formatSelect")
|
||||
private _formatSelect: Select<string>;
|
||||
|
||||
@query("#vaultSelect")
|
||||
private _vaultSelect: Select<Vault>;
|
||||
|
||||
// static styles = [
|
||||
// ...Dialog.styles,
|
||||
// css`
|
||||
// :host {
|
||||
// --pl-dialog-max-width: 40em;
|
||||
// }
|
||||
// `,
|
||||
// ];
|
||||
@query("#nameColumnSelect")
|
||||
private _nameColumnSelect: Select<number>;
|
||||
|
||||
@query("#tagsColumnSelect")
|
||||
private _tagsColumnSelect: Select<number>;
|
||||
|
||||
@queryAll("pl-select.field-type-select")
|
||||
private _fieldTypeSelects: Select<FieldType>[];
|
||||
|
||||
@queryAll("pl-input.field-name-input")
|
||||
private _fieldNameInputs: Input[];
|
||||
|
||||
static styles = [
|
||||
...Dialog.styles,
|
||||
css`
|
||||
:host {
|
||||
--pl-dialog-max-width: 40em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
renderContent() {
|
||||
if (this._formatSelect?.value === imp.CSV.value) {
|
||||
if (
|
||||
this._nameColumnSelect &&
|
||||
(this._nameColumnSelect.selectedIndex === -1 || this._nameColumnSelect.selectedIndex === undefined)
|
||||
) {
|
||||
const selectedNameIndex = this._itemColumns.findIndex((itemColumn) => itemColumn.type === "name");
|
||||
this._nameColumnSelect.value = selectedNameIndex;
|
||||
this._nameColumnSelect.selectedIndex = selectedNameIndex;
|
||||
}
|
||||
|
||||
if (
|
||||
this._tagsColumnSelect &&
|
||||
(this._tagsColumnSelect.selectedIndex === -1 || this._tagsColumnSelect.selectedIndex === undefined)
|
||||
) {
|
||||
// +1 because we have the "none" option first
|
||||
const selectedTagsIndex = this._itemColumns.findIndex((itemColumn) => itemColumn.type === "tags") + 1;
|
||||
this._tagsColumnSelect.value = selectedTagsIndex - 1;
|
||||
this._tagsColumnSelect.selectedIndex = selectedTagsIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="padded vertical spacing layout">
|
||||
<div class="padded vertical spacing layout fit-vertically">
|
||||
<h1 class="big text-centering margined">${$l("Import Data")}</h1>
|
||||
|
||||
<pl-select
|
||||
|
@ -58,29 +94,109 @@ export class ImportDialog extends Dialog<File, void> {
|
|||
></pl-select>
|
||||
|
||||
<div class="small padded" ?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}>
|
||||
${$l("Choose the field type for each column. If you are having trouble,")}
|
||||
${$l("Choose the field name and type for each column below. If you are having trouble,")}
|
||||
<a href="#" @click=${this._downloadCSVSampleFile}> ${$l("Download Sample File")} </a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="vertical stretching spacing layout"
|
||||
class="vertical spacing layout"
|
||||
?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}
|
||||
>
|
||||
<ul>
|
||||
<pl-select
|
||||
id=${"nameColumnSelect"}
|
||||
.label=${$l("Name Column")}
|
||||
.options=${this._itemColumns.map((itemColumn, itemColumnIndex) => ({
|
||||
label: `${itemColumn.displayName} (${$l("Column {0}", itemColumnIndex.toString())})`,
|
||||
value: itemColumnIndex,
|
||||
}))}
|
||||
.selectedIndex=${this._nameColumnSelect?.selectedIndex}
|
||||
@change=${() => {
|
||||
const currentNameColumnIndex = this._itemColumns.findIndex(
|
||||
(itemColumn) => itemColumn.type === "name"
|
||||
);
|
||||
const nameColumnIndex = this._nameColumnSelect?.value || 0;
|
||||
this._itemColumns[nameColumnIndex].type = "name";
|
||||
|
||||
if (currentNameColumnIndex !== -1) {
|
||||
this._itemColumns[currentNameColumnIndex].type = FieldType.Text;
|
||||
}
|
||||
|
||||
this._parseData();
|
||||
}}
|
||||
></pl-select>
|
||||
|
||||
<pl-select
|
||||
id=${"tagsColumnSelect"}
|
||||
.label=${$l("Tags Column")}
|
||||
.options=${[
|
||||
{ label: $l("None"), value: -1 },
|
||||
...this._itemColumns.map((itemColumn, itemColumnIndex) => ({
|
||||
label: `${itemColumn.displayName} (${$l("Column {0}", itemColumnIndex.toString())})`,
|
||||
value: itemColumnIndex,
|
||||
})),
|
||||
]}
|
||||
.selectedIndex=${this._tagsColumnSelect?.selectedIndex}
|
||||
@change=${() => {
|
||||
const currentTagsColumnIndex = this._itemColumns.findIndex(
|
||||
(itemColumn) => itemColumn.type === "tags"
|
||||
);
|
||||
const tagsColumnIndex = this._tagsColumnSelect?.value || 0;
|
||||
this._itemColumns[tagsColumnIndex].type = "tags";
|
||||
|
||||
if (currentTagsColumnIndex !== -1) {
|
||||
this._itemColumns[currentTagsColumnIndex].type = FieldType.Text;
|
||||
}
|
||||
|
||||
this._parseData();
|
||||
}}
|
||||
></pl-select>
|
||||
|
||||
<!-- TODO: Add checkbox/toggle for this._csvHasDataOnFirstRow -->
|
||||
</div>
|
||||
|
||||
<pl-scroller
|
||||
class="stretch"
|
||||
?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}
|
||||
>
|
||||
<ul class="vertical spacing layout">
|
||||
${this._itemColumns.map(
|
||||
(itemColumn, itemColumnIndex) => html`
|
||||
<li class="margined">
|
||||
<li
|
||||
class="padded box vertical spacing layout"
|
||||
?hidden=${itemColumn.type === "name" || itemColumn.type === "tags"}
|
||||
>
|
||||
<div class="small margined spacing horizontal layout">
|
||||
<div class="stretch">${itemColumn.name}</div>
|
||||
<div class="subtle">${$l("Column {0}", itemColumnIndex.toString())}</div>
|
||||
</div>
|
||||
|
||||
<div class="tiny horizontally-margined subtle mono">
|
||||
${itemColumn.exampleValues}
|
||||
</div>
|
||||
|
||||
<pl-input
|
||||
.label=${$l("Field Name")}
|
||||
class="field-name-input"
|
||||
.value=${itemColumn.displayName}
|
||||
@change=${() => {
|
||||
const newFieldNameInput = this._fieldNameInputs[itemColumnIndex];
|
||||
const newFieldName = newFieldNameInput?.value;
|
||||
if (newFieldName) {
|
||||
this._itemColumns[itemColumnIndex].displayName = newFieldName;
|
||||
this._parseData();
|
||||
}
|
||||
}}
|
||||
></pl-input>
|
||||
|
||||
<pl-select
|
||||
id=${`itemColumnSelect-${itemColumnIndex}`}
|
||||
icon=${FIELD_DEFS[itemColumn.type].icon}
|
||||
.label=${itemColumn.displayName}
|
||||
class="field-type-select"
|
||||
icon=${FIELD_DEFS[itemColumn.type]?.icon || "text"}
|
||||
.label=${$l("Field Type")}
|
||||
.options=${fieldTypeOptions}
|
||||
.value=${itemColumn.type}
|
||||
@change=${() => {
|
||||
const thisElement = this.shadowRoot?.querySelector(
|
||||
`#itemColumnSelect-${itemColumnIndex}`
|
||||
) as HTMLSelectElement;
|
||||
const newValue = thisElement?.value as FieldType;
|
||||
const newValue = this._fieldTypeSelects[itemColumnIndex]?.value;
|
||||
if (newValue) {
|
||||
this._itemColumns[itemColumnIndex].type = newValue;
|
||||
this._parseData();
|
||||
|
@ -91,7 +207,7 @@ export class ImportDialog extends Dialog<File, void> {
|
|||
`
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</pl-scroller>
|
||||
|
||||
<pl-select
|
||||
id="vaultSelect"
|
||||
|
@ -164,7 +280,7 @@ Github,"work,coding",https://github.com,john.doe@gmail.com,129lskdf93`)
|
|||
this._items = await imp.asLastPass(file);
|
||||
break;
|
||||
case imp.CSV.value:
|
||||
const result = await imp.asCSV(file, this._itemColumns);
|
||||
const result = await imp.asCSV(file, this._itemColumns, this._csvHasDataOnFirstRow);
|
||||
this._items = result.items;
|
||||
this._itemColumns = result.itemColumns;
|
||||
break;
|
||||
|
|
|
@ -17,7 +17,8 @@ export interface ImportFormat {
|
|||
export interface ImportCSVColumn {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: FieldType;
|
||||
type: FieldType | "name" | "tags";
|
||||
exampleValues: string;
|
||||
}
|
||||
|
||||
export const CSV: ImportFormat = {
|
||||
|
@ -54,45 +55,37 @@ export function loadPapa(): Promise<any> {
|
|||
/**
|
||||
* Takes a data table (represented by a two-dimensional array) and converts it
|
||||
* into an array of items
|
||||
* @param Array data Two-dimensional array containing tabular item data; The first 'row'
|
||||
* should contain field names. All other rows represent items, containing
|
||||
* the item name, field values and optionally a list of tags.
|
||||
* @param Array columnTypes Array containing the type of field per column.
|
||||
* @param Integer nameColIndex Index of the column containing the item names. Defaults to 0
|
||||
* @param Integer tagsColIndex Index of the column containing the item categories. If left empty
|
||||
* no categories will be used
|
||||
* @param Array data Two-dimensional array containing tabular item data; The first 'row'
|
||||
* should contain field names. All other rows represent items, containing
|
||||
* the item name, field values and optionally a list of tags.
|
||||
* @param Array columnTypes Array containing the type of field per column.
|
||||
* @param Boolean dataOnFirstRow Boolean, representing if there is data on the first row (instead of
|
||||
* columns).
|
||||
*/
|
||||
async function fromTable(
|
||||
data: string[][],
|
||||
columnTypes: ImportCSVColumn[],
|
||||
nameColIndex?: number,
|
||||
tagsColIndex?: number
|
||||
dataOnFirstRow: boolean
|
||||
): Promise<VaultItem[]> {
|
||||
// Use first row for column names
|
||||
const colNames = data[0];
|
||||
let nameColumnIndex = columnTypes.findIndex((columnType) => columnType.type === "name");
|
||||
const tagsColumnIndex = columnTypes.findIndex((columnType) => columnType.type === "tags");
|
||||
|
||||
if (nameColIndex === undefined) {
|
||||
const i = colNames.indexOf("name");
|
||||
nameColIndex = i !== -1 ? i : 0;
|
||||
if (nameColumnIndex === -1) {
|
||||
nameColumnIndex = 0;
|
||||
}
|
||||
|
||||
if (tagsColIndex === undefined) {
|
||||
tagsColIndex = colNames.indexOf("tags");
|
||||
if (tagsColIndex === -1) {
|
||||
tagsColIndex = colNames.indexOf("category");
|
||||
}
|
||||
}
|
||||
const dataRows = dataOnFirstRow ? data : data.slice(1);
|
||||
|
||||
// All subsequent rows should contain values
|
||||
let items = data.slice(1).map(function (row) {
|
||||
let items = dataRows.map(function (row) {
|
||||
// Construct an array of field object from column names and values
|
||||
let fields: Field[] = [];
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
for (let columnIndex = 0; columnIndex < row.length; ++columnIndex) {
|
||||
// Skip name column, category column (if any) and empty fields
|
||||
if (i != nameColIndex && i != tagsColIndex && row[i]) {
|
||||
const name = colNames[i];
|
||||
const value = row[i];
|
||||
const type = columnTypes[i].type || undefined;
|
||||
if (columnIndex !== nameColumnIndex && columnIndex !== tagsColumnIndex && row[columnIndex]) {
|
||||
const name = columnTypes[columnIndex].displayName;
|
||||
const value = row[columnIndex];
|
||||
const type = columnTypes[columnIndex].type || undefined;
|
||||
fields.push(
|
||||
new Field().fromRaw({
|
||||
name,
|
||||
|
@ -103,8 +96,8 @@ async function fromTable(
|
|||
}
|
||||
}
|
||||
|
||||
const name = row[nameColIndex!];
|
||||
const tags = row[tagsColIndex!];
|
||||
const name = row[nameColumnIndex!];
|
||||
const tags = row[tagsColumnIndex!];
|
||||
return createVaultItem({ name, fields, tags: (tags && tags.split(",")) || [] });
|
||||
});
|
||||
|
||||
|
@ -119,8 +112,7 @@ export async function isCSV(data: string): Promise<Boolean> {
|
|||
export async function asCSV(
|
||||
file: File,
|
||||
mappedItemColumns: ImportCSVColumn[],
|
||||
nameColIndex?: number,
|
||||
tagsColIndex?: number
|
||||
dataOnFirstRow: boolean
|
||||
): Promise<{ items: VaultItem[]; itemColumns: ImportCSVColumn[] }> {
|
||||
const data = await readFileAsText(file);
|
||||
const papa = await loadPapa();
|
||||
|
@ -135,21 +127,42 @@ export async function asCSV(
|
|||
const itemColumns =
|
||||
mappedItemColumns.length > 0
|
||||
? mappedItemColumns
|
||||
: columnNames.map((columnName) => {
|
||||
: columnNames.map((columnName, columnIndex) => {
|
||||
// TODO: Use guessFieldType instead, after improving it
|
||||
const type = (Object.keys(FIELD_DEFS).filter((fieldType) => columnName.includes(fieldType))[0] ||
|
||||
FieldType.Text) as FieldType;
|
||||
let type = (Object.keys(FIELD_DEFS).filter((fieldType) => columnName.includes(fieldType))[0] ||
|
||||
FieldType.Text) as ImportCSVColumn["type"];
|
||||
|
||||
// TODO: Also guess "Name" and "Tags"
|
||||
const lowerCaseColumnName = columnName.toLocaleLowerCase();
|
||||
|
||||
if (lowerCaseColumnName === "name" || lowerCaseColumnName === $l("name")) {
|
||||
type = "name";
|
||||
}
|
||||
|
||||
if (
|
||||
lowerCaseColumnName === "tags" ||
|
||||
lowerCaseColumnName === $l("tags") ||
|
||||
lowerCaseColumnName === "category" ||
|
||||
lowerCaseColumnName === $l("category")
|
||||
) {
|
||||
type = "tags";
|
||||
}
|
||||
|
||||
const exampleValues = [rows[1] ? rows[1][columnIndex] : "", rows[2] ? rows[2][columnIndex] : ""]
|
||||
.filter((value) => Boolean(value))
|
||||
.map((value) => (value.includes(",") ? `"${value}"` : value))
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
name: columnName,
|
||||
displayName: capitalize(columnName),
|
||||
type,
|
||||
exampleValues,
|
||||
};
|
||||
});
|
||||
|
||||
const items = await fromTable(rows, itemColumns, nameColIndex, tagsColIndex);
|
||||
// TODO: Prevent itemColumns from having more than one "name" or "tags" (force those to "text")
|
||||
|
||||
const items = await fromTable(rows, itemColumns, dataOnFirstRow);
|
||||
|
||||
return { items, itemColumns };
|
||||
}
|
||||
|
|
|
@ -192,6 +192,10 @@ export async function getIdFromEmail(email: string) {
|
|||
|
||||
export function capitalize(string: string) {
|
||||
const words = string.split(" ");
|
||||
const phrase = words.map((word) => `${word[0].toLocaleUpperCase()}${word.slice(1) || ""}`).join(" ");
|
||||
const phrase = words
|
||||
.map((word) =>
|
||||
word.toLocaleLowerCase() === "url" ? "URL" : `${word[0].toLocaleUpperCase()}${word.slice(1) || ""}`
|
||||
)
|
||||
.join(" ");
|
||||
return phrase;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue