Merge pull request #411 from padloc/feature/advanced-csv-import-ui
Advanced CSV Import UI
This commit is contained in:
commit
310b2f8bc5
|
@ -338,7 +338,7 @@ export class FieldElement extends LitElement {
|
|||
class="value-input"
|
||||
.placeholder=${$l("Enter Value Here")}
|
||||
.type=${inputType}
|
||||
.pattern=${this._fieldDef.pattern}
|
||||
.pattern=${this._fieldDef.pattern.toString()}
|
||||
@input=${() => {
|
||||
this.field.value = this._valueInput.value;
|
||||
this._updateSuggestions();
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
import { Vault } from "@padloc/core/src/vault";
|
||||
import { VaultItem } from "@padloc/core/src/item";
|
||||
import { VaultItem, FIELD_DEFS, FieldType } from "@padloc/core/src/item";
|
||||
import { translate as $l } from "@padloc/locale/src/translate";
|
||||
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 } from "lit";
|
||||
import { saveFile } from "@padloc/core/src/platform";
|
||||
import { stringToBytes } from "@padloc/core/src/encoding";
|
||||
import { ToggleButton } from "./toggle-button";
|
||||
import { customElement, query, state, queryAll } from "lit/decorators.js";
|
||||
import { html, css } from "lit";
|
||||
|
||||
const fieldTypeOptions = Object.keys(FIELD_DEFS).map((fieldType) => ({
|
||||
label: FIELD_DEFS[fieldType].name as string,
|
||||
value: fieldType,
|
||||
}));
|
||||
|
||||
fieldTypeOptions.push({
|
||||
label: "Ignore/skip this column",
|
||||
value: "skip",
|
||||
});
|
||||
|
||||
@customElement("pl-import-dialog")
|
||||
export class ImportDialog extends Dialog<File, void> {
|
||||
|
@ -20,15 +30,43 @@ export class ImportDialog extends Dialog<File, void> {
|
|||
@state()
|
||||
private _items: VaultItem[] = [];
|
||||
|
||||
@state()
|
||||
private _itemColumns: imp.ImportCSVColumn[] = [];
|
||||
|
||||
@query("#csvHasColumnsOnFirstRowButton")
|
||||
private _csvHasColumnsOnFirstRowButton: ToggleButton;
|
||||
|
||||
@query("#formatSelect")
|
||||
private _formatSelect: Select<string>;
|
||||
|
||||
@query("#vaultSelect")
|
||||
private _vaultSelect: Select<Vault>;
|
||||
|
||||
@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() {
|
||||
const csvHasColumnsOnFirstRow = this._csvHasColumnsOnFirstRowButton?.active;
|
||||
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
|
||||
|
@ -39,20 +77,92 @@ export class ImportDialog extends Dialog<File, void> {
|
|||
disabled
|
||||
></pl-select>
|
||||
|
||||
<div class="small padded" ?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}>
|
||||
${$l(
|
||||
"IMPORTANT: Before importing, please make sure that your CSV data " +
|
||||
"is structured according to {0}'s specific requirements!",
|
||||
process.env.PL_APP_NAME!
|
||||
)}
|
||||
<a href="#" @click=${this._downloadCSVSampleFile}> ${$l("Download Sample File")} </a>
|
||||
<div class="small padded" ?hidden=${this._formatSelect?.value !== imp.CSV.value}>
|
||||
${$l("Choose the correct column names and types for each column below.")}
|
||||
</div>
|
||||
|
||||
<pl-scroller class="stretch" ?hidden=${this._formatSelect?.value !== imp.CSV.value}>
|
||||
<ul class="vertical spacing layout">
|
||||
<pl-toggle-button
|
||||
class="transparent"
|
||||
id="csvHasColumnsOnFirstRowButton"
|
||||
.label=${$l("First row contains field names")}
|
||||
reverse
|
||||
@change=${() => this._parseData(true)}
|
||||
>
|
||||
</pl-toggle-button>
|
||||
|
||||
<pl-select
|
||||
id=${"nameColumnSelect"}
|
||||
.label=${$l("Name Column")}
|
||||
.options=${this._nameColumnSelectOptions()}
|
||||
.selectedIndex=${this._nameColumnSelect?.selectedIndex}
|
||||
@change=${this._handleNameColumnChange}
|
||||
></pl-select>
|
||||
|
||||
<pl-select
|
||||
id=${"tagsColumnSelect"}
|
||||
.label=${$l("Tags Column")}
|
||||
.options=${this._tagsColumnSelectOptions()}
|
||||
.selectedIndex=${this._tagsColumnSelect?.selectedIndex}
|
||||
@change=${this._handleTagsColumnChange}
|
||||
></pl-select>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
${this._itemColumns.map(
|
||||
(itemColumn, itemColumnIndex) => html`
|
||||
<li
|
||||
class="padded box vertical spacing layout"
|
||||
?hidden=${itemColumn.type === "name" || itemColumn.type === "tags"}
|
||||
>
|
||||
<div class="small margined spacing horizontal layout">
|
||||
<div class="stretch">
|
||||
${csvHasColumnsOnFirstRow
|
||||
? itemColumn.name
|
||||
: $l("Column {0}", (itemColumnIndex + 1).toString())}
|
||||
</div>
|
||||
<div class="subtle" ?hidden=${!csvHasColumnsOnFirstRow}>
|
||||
${$l("Column {0}", (itemColumnIndex + 1).toString())}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tiny horizontally-margined subtle mono ellipsis">
|
||||
${itemColumn.values
|
||||
.filter((value) => value !== "")
|
||||
.slice(0, 20)
|
||||
.map((value) => (value.includes(",") ? `"${value}"` : value))
|
||||
.join(", ")}
|
||||
</div>
|
||||
|
||||
<pl-input
|
||||
.label=${$l("Field Name")}
|
||||
class="field-name-input"
|
||||
.value=${itemColumn.displayName}
|
||||
@change=${() => this._handleFieldNameChange(itemColumnIndex)}
|
||||
?hidden=${itemColumn.type === "skip"}
|
||||
></pl-input>
|
||||
|
||||
<pl-select
|
||||
id=${`itemColumnSelect-${itemColumnIndex}`}
|
||||
class="field-type-select"
|
||||
icon=${FIELD_DEFS[itemColumn.type]?.icon || "text"}
|
||||
.label=${$l("Field Type")}
|
||||
.options=${fieldTypeOptions}
|
||||
.value=${itemColumn.type}
|
||||
@change=${() => this._handleFieldTypeChange(itemColumnIndex)}
|
||||
></pl-select>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
</pl-scroller>
|
||||
|
||||
<pl-select
|
||||
id="vaultSelect"
|
||||
.options=${app.vaults.map((v) => ({
|
||||
disabled: !app.isEditable(v),
|
||||
value: v,
|
||||
.options=${app.vaults.map((vault) => ({
|
||||
disabled: !app.isEditable(vault),
|
||||
value: vault,
|
||||
}))}
|
||||
.label=${$l("Target Vault")}
|
||||
></pl-select>
|
||||
|
@ -71,26 +181,91 @@ export class ImportDialog extends Dialog<File, void> {
|
|||
await this.updateComplete;
|
||||
const result = super.show();
|
||||
|
||||
// Reset fields
|
||||
this._items = [];
|
||||
this._itemColumns = [];
|
||||
this._csvHasColumnsOnFirstRowButton.active = true;
|
||||
this._file = file;
|
||||
this._formatSelect.value = ((await imp.guessFormat(file)) || imp.CSV).value;
|
||||
await this._parseData();
|
||||
|
||||
const importFormat = (await imp.guessFormat(file)) || imp.CSV;
|
||||
this._formatSelect.value = importFormat.value;
|
||||
|
||||
await this._parseData(true);
|
||||
|
||||
this._vaultSelect.value = app.mainVault!;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _downloadCSVSampleFile(e: Event) {
|
||||
e.preventDefault();
|
||||
saveFile(
|
||||
`${process.env.PL_APP_NAME}_csv_import_sample.csv`,
|
||||
"text/csv",
|
||||
stringToBytes(`name,tags,url,username,password,notes
|
||||
Facebook,social,https://facebook.com/,john.doe@gmail.com,3kjaf93,"Some note..."
|
||||
Github,"work,coding",https://github.com,john.doe@gmail.com,129lskdf93`)
|
||||
);
|
||||
private _nameColumnSelectOptions() {
|
||||
const csvHasColumnsOnFirstRow = this._csvHasColumnsOnFirstRowButton?.active;
|
||||
|
||||
return this._itemColumns.map((itemColumn, itemColumnIndex) => ({
|
||||
label: csvHasColumnsOnFirstRow
|
||||
? `${itemColumn.displayName} (${$l("Column {0}", (itemColumnIndex + 1).toString())})`
|
||||
: $l("Column {0}", (itemColumnIndex + 1).toString()),
|
||||
value: itemColumnIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
private async _parseData(): Promise<void> {
|
||||
private _tagsColumnSelectOptions() {
|
||||
const csvHasColumnsOnFirstRow = this._csvHasColumnsOnFirstRowButton?.active;
|
||||
|
||||
return [
|
||||
{ label: $l("None"), value: -1 },
|
||||
...this._itemColumns.map((itemColumn, itemColumnIndex) => ({
|
||||
label: csvHasColumnsOnFirstRow
|
||||
? `${itemColumn.displayName} (${$l("Column {0}", (itemColumnIndex + 1).toString())})`
|
||||
: $l("Column {0}", (itemColumnIndex + 1).toString()),
|
||||
value: itemColumnIndex,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
private _handleNameColumnChange() {
|
||||
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();
|
||||
}
|
||||
|
||||
private _handleTagsColumnChange() {
|
||||
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();
|
||||
}
|
||||
|
||||
private _handleFieldNameChange(itemColumnIndex: number) {
|
||||
const newFieldNameInput = this._fieldNameInputs[itemColumnIndex];
|
||||
const newFieldName = newFieldNameInput?.value;
|
||||
if (newFieldName) {
|
||||
this._itemColumns[itemColumnIndex].displayName = newFieldName;
|
||||
this._parseData();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleFieldTypeChange(itemColumnIndex: number) {
|
||||
const newValue = this._fieldTypeSelects[itemColumnIndex]?.value;
|
||||
if (newValue) {
|
||||
this._itemColumns[itemColumnIndex].type = newValue;
|
||||
this._parseData();
|
||||
}
|
||||
}
|
||||
|
||||
private async _parseData(resetCSVColumns = false): Promise<void> {
|
||||
const file = this._file;
|
||||
|
||||
switch (this._formatSelect.value) {
|
||||
|
@ -119,7 +294,17 @@ Github,"work,coding",https://github.com,john.doe@gmail.com,129lskdf93`)
|
|||
this._items = await imp.asLastPass(file);
|
||||
break;
|
||||
case imp.CSV.value:
|
||||
this._items = await imp.asCSV(file);
|
||||
const result = await imp.asCSV(
|
||||
file,
|
||||
resetCSVColumns ? [] : this._itemColumns,
|
||||
this._csvHasColumnsOnFirstRowButton.active
|
||||
);
|
||||
this._items = result.items;
|
||||
this._itemColumns = result.itemColumns;
|
||||
await this.updateComplete;
|
||||
this._nameColumnSelect.selectedIndex = this._itemColumns.findIndex(({ type }) => type === "name");
|
||||
// +1 because the first item is "none" for tags
|
||||
this._tagsColumnSelect.selectedIndex = this._itemColumns.findIndex(({ type }) => type === "tags") + 1;
|
||||
break;
|
||||
case imp.ONEPUX.value:
|
||||
this._items = await imp.as1Pux(file);
|
||||
|
@ -161,7 +346,6 @@ Github,"work,coding",https://github.com,john.doe@gmail.com,129lskdf93`)
|
|||
}
|
||||
|
||||
app.addItems(this._items, vault);
|
||||
// this.dispatch("data-imported", { items: items });
|
||||
this.done();
|
||||
alert($l("Successfully imported {0} items.", this._items.length.toString()), { type: "success" });
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { unmarshal, bytesToString } from "@padloc/core/src/encoding";
|
||||
import { PBES2Container } from "@padloc/core/src/container";
|
||||
import { validateLegacyContainer, parseLegacyContainer } from "@padloc/core/src/legacy";
|
||||
import { VaultItem, Field, createVaultItem, FieldType } from "@padloc/core/src/item";
|
||||
import { VaultItem, Field, createVaultItem, FieldType, guessFieldType } from "@padloc/core/src/item";
|
||||
import { Err, ErrorCode } from "@padloc/core/src/error";
|
||||
import { uuid } from "@padloc/core/src/util";
|
||||
import { uuid, capitalize } from "@padloc/core/src/util";
|
||||
import { translate as $l } from "@padloc/locale/src/translate";
|
||||
import { readFileAsText, readFileAsArrayBuffer } from "@padloc/core/src/attachment";
|
||||
|
||||
|
@ -14,6 +14,13 @@ export interface ImportFormat {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface ImportCSVColumn {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: FieldType | "name" | "tags" | "skip";
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export const CSV: ImportFormat = {
|
||||
value: "csv",
|
||||
label: "CSV",
|
||||
|
@ -48,51 +55,63 @@ 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 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'
|
||||
* might 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 columnsOnFirstRow Boolean, representing if there are columnms on the first row.
|
||||
*/
|
||||
export async function fromTable(data: string[][], nameColIndex?: number, tagsColIndex?: number): Promise<VaultItem[]> {
|
||||
// Use first row for column names
|
||||
const colNames = data[0];
|
||||
async function fromTable(
|
||||
data: string[][],
|
||||
columnTypes: ImportCSVColumn[],
|
||||
columnsOnFirstRow: boolean
|
||||
): Promise<VaultItem[]> {
|
||||
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 = columnsOnFirstRow ? data.slice(1) : data;
|
||||
|
||||
// All subsequent rows should contain values
|
||||
let items = data.slice(1).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++) {
|
||||
// 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];
|
||||
fields.push(
|
||||
new Field().fromRaw({
|
||||
name,
|
||||
value,
|
||||
})
|
||||
);
|
||||
const items = dataRows
|
||||
.filter((row) => {
|
||||
// Skip empty rows
|
||||
if (row.length === 1 && row[0] === "") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const name = row[nameColIndex!];
|
||||
const tags = row[tagsColIndex!];
|
||||
return createVaultItem({ name, fields, tags: (tags && tags.split(",")) || [] });
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.map((row) => {
|
||||
// Construct an array of field object from column names and values
|
||||
const fields: Field[] = [];
|
||||
for (let columnIndex = 0; columnIndex < row.length; ++columnIndex) {
|
||||
if (columnTypes[columnIndex]?.type === "skip") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip name column, category column (if any) and empty fields
|
||||
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,
|
||||
value,
|
||||
type,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const name = row[nameColumnIndex!];
|
||||
const tags = row[tagsColumnIndex!];
|
||||
return createVaultItem({ name, fields, tags: (tags && tags.split(",")) || [] });
|
||||
});
|
||||
|
||||
return Promise.all(items);
|
||||
}
|
||||
|
@ -102,16 +121,73 @@ export async function isCSV(data: string): Promise<Boolean> {
|
|||
return papa.parse(data).errors.length === 0;
|
||||
}
|
||||
|
||||
export async function asCSV(file: File, nameColIndex?: number, tagsColIndex?: number): Promise<VaultItem[]> {
|
||||
export async function asCSV(
|
||||
file: File,
|
||||
mappedItemColumns: ImportCSVColumn[],
|
||||
columnsOnFirstRow: boolean
|
||||
): Promise<{ items: VaultItem[]; itemColumns: ImportCSVColumn[] }> {
|
||||
const data = await readFileAsText(file);
|
||||
const papa = await loadPapa();
|
||||
const parsed = papa.parse(data);
|
||||
if (parsed.errors.length) {
|
||||
throw new Err(ErrorCode.INVALID_CSV, "Failed to parse .csv file.");
|
||||
}
|
||||
return fromTable(parsed.data, nameColIndex, tagsColIndex);
|
||||
}
|
||||
const rows = parsed.data as string[][];
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Err(ErrorCode.INVALID_CSV, "No rows found in .csv file.");
|
||||
}
|
||||
|
||||
const columnNames = columnsOnFirstRow
|
||||
? rows[0].map((column) => column.toLowerCase())
|
||||
: rows[0].map((_value, index) => $l("Column {0}", index.toString()));
|
||||
|
||||
let hasNameColumn = false;
|
||||
let hasTagsColumn = false;
|
||||
|
||||
const itemColumns =
|
||||
mappedItemColumns.length > 0
|
||||
? mappedItemColumns
|
||||
: columnNames.map((columnName, columnIndex) => {
|
||||
const values = (columnsOnFirstRow ? rows.slice(1) : rows).map((row) => row[columnIndex] || "");
|
||||
|
||||
// Guess field type based on first non-empty value
|
||||
// TODO: Sample all values for more reliable results?
|
||||
let type = guessFieldType({
|
||||
name: columnsOnFirstRow ? columnName : "",
|
||||
value: values.find((value) => Boolean(value)),
|
||||
}) as ImportCSVColumn["type"];
|
||||
|
||||
// If we're not given field names by the first row, base the name on the type
|
||||
const name = columnsOnFirstRow ? columnName.toLocaleLowerCase() : type;
|
||||
|
||||
if (!hasNameColumn && name === "name") {
|
||||
type = "name";
|
||||
hasNameColumn = true;
|
||||
}
|
||||
|
||||
if (!hasTagsColumn && ["tags", "category"].includes(name)) {
|
||||
type = "tags";
|
||||
hasTagsColumn = true;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
displayName: capitalize(name),
|
||||
type,
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
// Ensure there's at least one nameColumn
|
||||
if (!hasNameColumn) {
|
||||
itemColumns[0].type = "name";
|
||||
}
|
||||
|
||||
const items = await fromTable(rows, itemColumns, columnsOnFirstRow);
|
||||
|
||||
return { items, itemColumns };
|
||||
}
|
||||
export async function isPadlockV1(file: File): Promise<boolean> {
|
||||
try {
|
||||
const data = await readFileAsText(file);
|
||||
|
|
|
@ -32,8 +32,10 @@ export enum FieldType {
|
|||
export interface FieldDef {
|
||||
/** content type */
|
||||
type: FieldType;
|
||||
/** regular expression describing pattern of field contents */
|
||||
pattern: string;
|
||||
/** regular expression describing pattern of field contents (used for validation) */
|
||||
pattern: RegExp;
|
||||
/** regular expression describing pattern of field contents (used for matching) */
|
||||
matchPattern: RegExp;
|
||||
/** whether the field should be masked when displayed */
|
||||
mask: boolean;
|
||||
/** whether the field value can have multiple lines */
|
||||
|
@ -48,11 +50,12 @@ export interface FieldDef {
|
|||
transform?: (value: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Available field types and respective meta data */
|
||||
/** Available field types and respective meta data (order matters for pattern matching) */
|
||||
export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
||||
[FieldType.Username]: {
|
||||
type: FieldType.Username,
|
||||
pattern: ".*",
|
||||
pattern: /.*/,
|
||||
matchPattern: /.*/,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "user",
|
||||
|
@ -62,7 +65,8 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
},
|
||||
[FieldType.Password]: {
|
||||
type: FieldType.Password,
|
||||
pattern: ".*",
|
||||
pattern: /.*/,
|
||||
matchPattern: /.*/,
|
||||
mask: true,
|
||||
multiline: true,
|
||||
icon: "lock",
|
||||
|
@ -73,19 +77,10 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
return masked ? value.replace(/./g, "\u2022") : value;
|
||||
},
|
||||
},
|
||||
[FieldType.Url]: {
|
||||
type: FieldType.Url,
|
||||
pattern: ".*",
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "web",
|
||||
get name() {
|
||||
return $l("URL");
|
||||
},
|
||||
},
|
||||
[FieldType.Email]: {
|
||||
type: FieldType.Email,
|
||||
pattern: ".*",
|
||||
pattern: /(.*)@(.*)/,
|
||||
matchPattern: /^[\w-\.]+@([\w-]+\.)+[\w-]{2,8}$/,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "email",
|
||||
|
@ -93,9 +88,21 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
return $l("Email Address");
|
||||
},
|
||||
},
|
||||
[FieldType.Url]: {
|
||||
type: FieldType.Url,
|
||||
pattern: /.*/,
|
||||
matchPattern: /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,8}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/i,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "web",
|
||||
get name() {
|
||||
return $l("URL");
|
||||
},
|
||||
},
|
||||
[FieldType.Date]: {
|
||||
type: FieldType.Date,
|
||||
pattern: "\\d\\d\\d\\d-\\d\\d-\\d\\d",
|
||||
pattern: /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[0-1])$/,
|
||||
matchPattern: /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[0-1])$/,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "date",
|
||||
|
@ -108,7 +115,8 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
},
|
||||
[FieldType.Month]: {
|
||||
type: FieldType.Month,
|
||||
pattern: "\\d\\d\\d\\d-\\d\\d",
|
||||
pattern: /^\d{4}-(0[1-9]|1[012])$/,
|
||||
matchPattern: /^\d{4}-(0[1-9]|1[012])$/,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "month",
|
||||
|
@ -118,7 +126,8 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
},
|
||||
[FieldType.Credit]: {
|
||||
type: FieldType.Credit,
|
||||
pattern: "\\d*",
|
||||
pattern: /.*/,
|
||||
matchPattern: /^\d{16}/,
|
||||
mask: true,
|
||||
multiline: false,
|
||||
icon: "credit",
|
||||
|
@ -136,32 +145,10 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
return parts.join(" ");
|
||||
},
|
||||
},
|
||||
[FieldType.Phone]: {
|
||||
type: FieldType.Phone,
|
||||
pattern: ".*",
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "phone",
|
||||
get name() {
|
||||
return $l("Phone Number");
|
||||
},
|
||||
},
|
||||
[FieldType.Pin]: {
|
||||
type: FieldType.Pin,
|
||||
pattern: "\\d*",
|
||||
mask: true,
|
||||
multiline: false,
|
||||
icon: "lock",
|
||||
get name() {
|
||||
return $l("PIN");
|
||||
},
|
||||
format(value, masked) {
|
||||
return masked ? value.replace(/./g, "\u2022") : value;
|
||||
},
|
||||
},
|
||||
[FieldType.Totp]: {
|
||||
type: FieldType.Totp,
|
||||
pattern: ".*",
|
||||
pattern: /^([A-Z2-7=]{8})+$/i,
|
||||
matchPattern: /^([A-Z2-7=]{8})+$/i,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "totp",
|
||||
|
@ -172,9 +159,35 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
return await totp(base32ToBytes(value));
|
||||
},
|
||||
},
|
||||
[FieldType.Phone]: {
|
||||
type: FieldType.Phone,
|
||||
pattern: /.*/,
|
||||
matchPattern: /\d+/,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "phone",
|
||||
get name() {
|
||||
return $l("Phone Number");
|
||||
},
|
||||
},
|
||||
[FieldType.Pin]: {
|
||||
type: FieldType.Pin,
|
||||
pattern: /.*/,
|
||||
matchPattern: /\d+/,
|
||||
mask: true,
|
||||
multiline: false,
|
||||
icon: "lock",
|
||||
get name() {
|
||||
return $l("PIN");
|
||||
},
|
||||
format(value, masked) {
|
||||
return masked ? value.replace(/./g, "\u2022") : value;
|
||||
},
|
||||
},
|
||||
[FieldType.Note]: {
|
||||
type: FieldType.Note,
|
||||
pattern: ".*",
|
||||
pattern: /.*/,
|
||||
matchPattern: /(.*)(\n)?(.*)/,
|
||||
mask: false,
|
||||
multiline: true,
|
||||
icon: "note",
|
||||
|
@ -184,7 +197,8 @@ export const FIELD_DEFS: { [t in FieldType]: FieldDef } = {
|
|||
},
|
||||
[FieldType.Text]: {
|
||||
type: FieldType.Text,
|
||||
pattern: ".*",
|
||||
pattern: /.*/,
|
||||
matchPattern: /.*/,
|
||||
mask: false,
|
||||
multiline: false,
|
||||
icon: "text",
|
||||
|
@ -296,22 +310,41 @@ export async function createVaultItem({
|
|||
});
|
||||
}
|
||||
|
||||
const matchUsername = /username/i;
|
||||
const matchPassword = /password/i;
|
||||
const matchUrl = /url/i;
|
||||
const matchNote = /\n/;
|
||||
|
||||
/** Guesses the most appropriate field type based on field name and value */
|
||||
export function guessFieldType({ name = "", value = "", masked }: any): FieldType {
|
||||
return masked || name.match(matchPassword)
|
||||
? FieldType.Password
|
||||
: name.match(matchUsername)
|
||||
? FieldType.Username
|
||||
: name.match(matchUrl)
|
||||
? FieldType.Url
|
||||
: value.match(matchNote)
|
||||
? FieldType.Note
|
||||
: FieldType.Text;
|
||||
export function guessFieldType({
|
||||
name,
|
||||
value = "",
|
||||
masked = false,
|
||||
}: {
|
||||
name: string;
|
||||
value?: string;
|
||||
masked?: boolean;
|
||||
}): FieldType {
|
||||
if (masked) {
|
||||
return FieldType.Password;
|
||||
}
|
||||
|
||||
const matchedTypeByName = Object.keys(FIELD_DEFS).filter((fieldType) =>
|
||||
new RegExp(fieldType, "i").test(name)
|
||||
)[0] as FieldType;
|
||||
|
||||
if (matchedTypeByName) {
|
||||
return matchedTypeByName;
|
||||
}
|
||||
|
||||
// We skip some because they can match anything, and are only really valuable when matched by name
|
||||
const fieldTypesToSkipByValue = [FieldType.Username, FieldType.Password];
|
||||
|
||||
const matchedTypeByValue = Object.keys(FIELD_DEFS)
|
||||
// @ts-ignore this is a string, deal with it, TypeScript (can't `as` as well)
|
||||
.filter((fieldType) => !fieldTypesToSkipByValue.includes(fieldType))
|
||||
.filter((fieldType) => FIELD_DEFS[fieldType].matchPattern.test(value))[0] as FieldType;
|
||||
|
||||
if (value !== "" && matchedTypeByValue) {
|
||||
return matchedTypeByValue;
|
||||
}
|
||||
|
||||
return FieldType.Text;
|
||||
}
|
||||
|
||||
export interface ItemTemplate {
|
||||
|
|
|
@ -189,3 +189,14 @@ export async function getIdFromEmail(email: string) {
|
|||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function capitalize(string: string) {
|
||||
const words = string.split(" ");
|
||||
const phrase = words
|
||||
.filter((word) => Boolean(word))
|
||||
.map((word) =>
|
||||
word.toLocaleLowerCase() === "url" ? "URL" : `${word[0].toLocaleUpperCase()}${word.slice(1) || ""}`
|
||||
)
|
||||
.join(" ");
|
||||
return phrase;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue