kdbx-to-html

This commit is contained in:
antelle 2021-05-25 19:56:23 +02:00
parent 38466f934f
commit 5c0756855a
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
9 changed files with 335 additions and 238 deletions

View File

@ -725,6 +725,7 @@
"exportHtmlDate": "Export date",
"exportGenerator": "Software",
"exportDescription": "This file is generated with {}.",
"exportEntries": "Entries",
"importCsvTitle": "Import from CSV",
"importCsvRun": "Import",
"importIgnoreField": "Ignore",

View File

@ -1,119 +0,0 @@
/* eslint-disable import/no-commonjs */
import * as kdbxweb from 'kdbxweb';
import { RuntimeInfo } from 'const/runtime-info';
import { Links } from 'const/links';
import { DateFormat } from 'comp/i18n/date-format';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
const Templates = {
db: require('templates/export/db.hbs'),
entry: require('templates/export/entry.hbs')
};
const FieldMapping = [
{ name: 'UserName', locStr: 'user' },
{ name: 'Password', locStr: 'password', protect: true },
{ name: 'URL', locStr: 'website' },
{ name: 'Notes', locStr: 'notes' }
];
const KnownFields = { 'Title': true };
for (const { name } of FieldMapping) {
KnownFields[name] = true;
}
function walkGroup(db, group, parents) {
parents = [...parents, group];
if (
group.uuid.equals(db.meta.recycleBinUuid) ||
group.uuid.equals(db.meta.entryTemplatesGroup)
) {
return '';
}
const self = group.entries.map((entry) => walkEntry(db, entry, parents)).join('\n');
const children = group.groups
.map((childGroup) => walkGroup(db, childGroup, parents))
.join('\n');
return self + children;
}
function walkEntry(db, entry, parents) {
const path = parents.map((group) => group.name).join(' / ');
const fields = [];
for (const field of FieldMapping) {
const value = entryField(entry, field.name);
if (value) {
fields.push({
title: StringFormat.capFirst(Locale[field.locStr]),
value,
protect: field.protect
});
}
}
for (const [fieldName, fieldValue] of entry.fields) {
if (!KnownFields[fieldName]) {
const value = entryField(entry, fieldName);
if (value) {
fields.push({
title: fieldName,
value,
protect: fieldValue.isProtected
});
}
}
}
const title = entryField(entry, 'Title');
let expires;
if (entry.times.expires && entry.times.expiryTime) {
expires = DateFormat.dtStr(entry.times.expiryTime);
}
const attachments = [...entry.binaries]
.map(([name, data]) => {
if (data && data.ref) {
data = data.value;
}
if (data) {
const base64 = kdbxweb.ByteUtils.bytesToBase64(data);
data = 'data:application/octet-stream;base64,' + base64;
}
return { name, data };
})
.filter((att) => att.name && att.data);
return Templates.entry({
path,
title,
fields,
tags: entry.tags.join(', '),
created: DateFormat.dtStr(entry.times.creationTime),
modified: DateFormat.dtStr(entry.times.lastModTime),
expires,
attachments
});
}
function entryField(entry, fieldName) {
const value = entry.fields.get(fieldName);
return (value && value.isProtected && value.getText()) || value || '';
}
const KdbxToHtml = {
convert(db, options) {
const content = db.groups.map((group) => walkGroup(db, group, [])).join('\n');
return Templates.db({
name: options.name,
date: DateFormat.dtStr(Date.now()),
appLink: Links.Homepage,
appVersion: RuntimeInfo.version,
contentHtml: content
});
},
entryToHtml(db, entry) {
return walkEntry(db, entry, []);
}
};
export { KdbxToHtml };

View File

@ -0,0 +1,136 @@
import { h } from 'preact';
import * as kdbxweb from 'kdbxweb';
import { RuntimeInfo } from 'const/runtime-info';
import { Links } from 'const/links';
import { DateFormat } from 'util/formatting/date-format';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
import { HtmlRenderer } from 'util/browser/html-renderer';
import { ExportFile } from 'views/standalone/export-file';
import { ExportEntry, ExportEntryParameters } from 'views/standalone/export-entry';
const FieldMapping = [
{ name: 'UserName', loc: () => Locale.user },
{ name: 'Password', loc: () => Locale.password, protect: true },
{ name: 'URL', loc: () => Locale.website },
{ name: 'Notes', loc: () => Locale.notes }
];
const KnownFields = new Set('Title');
for (const { name } of FieldMapping) {
KnownFields.add(name);
}
function convertEntry(db: kdbxweb.Kdbx, entry: kdbxweb.KdbxEntry): ExportEntryParameters {
const path = getParents(entry).join(' / ');
const fields = [];
for (const field of FieldMapping) {
const value = entryField(entry, field.name);
if (value) {
fields.push({
title: StringFormat.capFirst(field.loc()),
value,
protect: field.protect
});
}
}
for (const [fieldName, fieldValue] of entry.fields) {
if (!KnownFields.has(fieldName)) {
const value = entryField(entry, fieldName);
if (value) {
fields.push({
title: fieldName,
value,
protect: fieldValue instanceof kdbxweb.ProtectedValue
});
}
}
}
const title = entryField(entry, 'Title');
let expires;
if (entry.times.expires && entry.times.expiryTime) {
expires = DateFormat.dtStr(entry.times.expiryTime);
}
const attachments = [...entry.binaries]
.map(([name, data]) => {
let value;
if (kdbxweb.KdbxBinaries.isKdbxBinaryWithHash(data)) {
value = data.value;
} else {
value = data;
}
if (value instanceof kdbxweb.ProtectedValue) {
value = value.getBinary();
}
let dataHref = '';
if (value) {
const base64 = kdbxweb.ByteUtils.bytesToBase64(value);
dataHref = 'data:application/octet-stream;base64,' + base64;
}
return { name, dataHref };
})
.filter((att) => att.name && att.dataHref);
return {
id: entry.uuid.id,
path,
title,
fields,
tags: entry.tags,
created: entry.times.creationTime ? DateFormat.dtStr(entry.times.creationTime) : '',
modified: entry.times.lastModTime ? DateFormat.dtStr(entry.times.lastModTime) : '',
expires,
attachments
};
}
function entryField(entry: kdbxweb.KdbxEntry, fieldName: string): string {
const value = entry.fields.get(fieldName);
if (value instanceof kdbxweb.ProtectedValue) {
return value.getText();
}
return value || '';
}
function getParents(entry: kdbxweb.KdbxEntry): string[] {
const parents = [];
const group = entry.parentGroup;
while (group?.name) {
parents.push(group.name);
}
return parents;
}
const KdbxToHtml = {
convert(db: kdbxweb.Kdbx, name: string): string {
const entries: ExportEntryParameters[] = [];
for (const group of db.groups) {
if (
group.uuid.equals(db.meta.recycleBinUuid) ||
group.uuid.equals(db.meta.entryTemplatesGroup)
) {
continue;
}
for (const entry of group.allEntries()) {
entries.push(convertEntry(db, entry));
}
}
return HtmlRenderer.renderToHtml(
h(ExportFile, {
name,
date: DateFormat.dtStr(Date.now()),
appVersion: RuntimeInfo.version,
appLink: Links.Homepage,
entries
})
);
},
entryToHtml(db: kdbxweb.Kdbx, entry: kdbxweb.KdbxEntry): string {
const params = convertEntry(db, entry);
return HtmlRenderer.renderToHtml(h(ExportEntry, params));
}
};
export { KdbxToHtml };

View File

@ -1,7 +1,7 @@
const BaseLocale = require('locales/base.json') as Record<string, string>;
const BaseLocaleName = 'en-US';
interface LocWithReplace {
export interface LocWithReplace {
with(value: string): string;
}
@ -756,6 +756,7 @@ export const Locale = {
get exportHtmlDate(): string { return get('exportHtmlDate'); },
get exportGenerator(): string { return get('exportGenerator'); },
exportDescription: withReplace('exportDescription'),
get exportEntries(): string { return get('exportEntries'); },
get importCsvTitle(): string { return get('importCsvTitle'); },
get importCsvRun(): string { return get('importCsvRun'); },
get importIgnoreField(): string { return get('importIgnoreField'); },

View File

@ -0,0 +1,13 @@
import { FunctionComponent } from 'preact';
import { LocWithReplace } from 'util/locale';
export const LocalizedWith: FunctionComponent<{ str: LocWithReplace }> = ({ str, children }) => {
const [first, ...rest] = str.with('{}').split('{}');
return (
<>
{first}
{children}
{rest}
</>
);
};

View File

@ -0,0 +1,94 @@
import { FunctionComponent } from 'preact';
import { Locale } from 'util/locale';
import { StringFormat } from 'util/formatting/string-format';
export interface ExportEntryField {
title: string;
protect?: boolean;
value: string;
}
export interface ExportEntryAttachment {
name: string;
dataHref: string;
}
export interface ExportEntryParameters {
id: string;
title: string;
fields: ExportEntryField[];
tags?: string[];
path?: string;
created: string;
modified: string;
expires?: string;
attachments?: ExportEntryAttachment[];
}
export const ExportEntry: FunctionComponent<ExportEntryParameters> = ({
title,
fields,
tags,
path,
created,
modified,
expires,
attachments
}) => {
return (
<>
<h2>{title || StringFormat.capFirst(Locale.noTitle)}</h2>
<table>
{fields.map((field) => (
<tr key={`field-${field.title}`}>
<td>{field.title}</td>
<td class="field">
{field.protect ? <code>{field.value}</code> : field.value}
</td>
</tr>
))}
{tags?.length ? (
<tr key="tags">
<td>{StringFormat.capFirst(Locale.tags)}</td>
<td>{tags.join(', ')}</td>
</tr>
) : null}
{path ? (
<tr key="path">
<td>{StringFormat.capFirst(Locale.group)}</td>
<td>{path}</td>
</tr>
) : null}
{attachments?.length ? (
<tr key="attachments">
<td>{Locale.detAttachments}</td>
<td>
{attachments.map((att, ix) => (
<span key={att.name}>
<a href={att.dataHref} download={att.name}>
{att.name}
</a>
{ix < attachments?.length - 1 ? ', ' : ''}
</span>
))}
</td>
</tr>
) : null}
<tr key="created">
<td>{Locale.detCreated}</td>
<td>{created}</td>
</tr>
<tr key="modified">
<td>{Locale.detUpdated}</td>
<td>{modified}</td>
</tr>
{expires ? (
<tr key="expires">
<td>{StringFormat.capFirst(Locale.detExpires)}</td>
<td>{expires}</td>
</tr>
) : null}
</table>
</>
);
};

View File

@ -0,0 +1,89 @@
import { FunctionComponent } from 'preact';
import { Locale } from 'util/locale';
import { LocalizedWith } from 'views/helpers/localized';
import { ExportEntry, ExportEntryParameters } from './export-entry';
export const ExportFile: FunctionComponent<{
name: string;
date: string;
appVersion: string;
appLink: string;
entries: ExportEntryParameters[];
}> = ({ name, date, appVersion, appLink, entries }) => {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>{{ name }}</title>
<meta
http-equiv="Content-Security-Policy"
content="script-src 'none'; img-src data:; style-src 'unsafe-inline';"
/>
<link
href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII="
rel="icon"
type="image/x-icon"
/>
<style>{`
body {
font-family: -apple-system, "BlinkMacSystemFont", "Helvetica Neue", "Helvetica", "Roboto", "Arial", sans-serif;
font-size: 14px;
padding: 10px 20px;
}
table {
border-collapse: collapse;
border: 1px solid #ccc;
width: 100%;
}
td {
border: 1px solid #ccc;
padding: 8px 16px;
}
tr:nth-of-type(even) {
background: #fafafa;
}
td:first-of-type {
width: 30%;
}
td.field {
white-space: pre-wrap;
}
footer {
margin-top: 10px;
}
`}</style>
</head>
<body>
<h1>{{ name }}</h1>
<h2>{Locale.exportFileInfo}</h2>
<table>
<tr>
<td>{Locale.exportHtmlName}</td>
<td>{{ name }}</td>
</tr>
<tr>
<td>{Locale.exportHtmlDate}</td>
<td>{{ date }}</td>
</tr>
<tr>
<td>{Locale.exportGenerator}</td>
<td>KeeWeb v{{ appVersion }}</td>
</tr>
</table>
<h2>{Locale.exportEntries}</h2>
<div>
{entries.map((entry) => (
<ExportEntry key={entry.id} {...entry} />
))}
</div>
<footer>
<LocalizedWith str={Locale.exportDescription}>
<a href={appLink} rel="noreferrer noopener" target="_blank">
KeeWeb
</a>
</LocalizedWith>
</footer>
</body>
</html>
);
};

View File

@ -1,62 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>{{name}}</title>
<meta http-equiv="Content-Security-Policy" content="script-src 'none'; img-src data:; style-src 'unsafe-inline';" />
<link href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=" rel="icon" type="image/x-icon" />
<style>
body {
font-family: -apple-system, "BlinkMacSystemFont", "Helvetica Neue", "Helvetica", "Roboto", "Arial", sans-serif;
font-size: 14px;
padding: 10px 20px;
}
table {
border-collapse: collapse;
border: 1px solid #ccc;
width: 100%;
}
td {
border: 1px solid #ccc;
padding: 8px 16px;
}
tr:nth-of-type(even) {
background: #fafafa;
}
td:first-of-type {
width: 30%;
}
td.field {
white-space: pre-wrap;
}
footer {
margin-top: 10px;
}
</style>
</head>
<body>
<h1>{{name}}</h1>
<h2>{{res 'exportFileInfo'}}</h2>
<table>
<tr>
<td>{{res 'exportHtmlName'}}</td>
<td>{{name}}</td>
</tr>
<tr>
<td>{{res 'exportHtmlDate'}}</td>
<td>{{date}}</td>
</tr>
<tr>
<td>{{res 'exportGenerator'}}</td>
<td>KeeWeb v{{appVersion}}</td>
</tr>
</table>
<h2>{{res 'exportEntries'}}</h2>
<div>
{{{contentHtml}}}
</div>
<footer>
{{#res 'exportDescription'}}<a href="{{appLink}}" rel="noreferrer noopener" target="_blank">KeeWeb</a>{{/res}}
</footer>
</body>
</html>

View File

@ -1,56 +0,0 @@
<h2>
{{#if title}}{{title}}{{else}}({{Res 'noTitle'}}){{/if}}
</h2>
<table>
{{#each fields as |field|}}
<tr>
<td>{{field.title}}</td>
<td class="field">
{{~#if field.protect~}}
<code>{{field.value}}</code>
{{~else~}}
{{field.value}}
{{~/if~}}
</td>
</tr>
{{/each}}
{{#if tags}}
<tr>
<td>{{Res 'tags'}}</td>
<td>{{tags}}</td>
</tr>
{{/if}}
{{#if path}}
<tr>
<td>{{Res 'group'}}</td>
<td>{{path}}</td>
</tr>
{{/if}}
{{#if attachments}}
{{#if attachments.length}}
<tr>
<td>{{res 'detAttachments'}}</td>
<td>
{{#each attachments as |attachment|}}
<a href="{{attachment.data}}" download="{{attachment.name}}">{{attachment.name}}</a>
{{~#unless @last}},&nbsp;{{/unless}}
{{/each}}
</td>
</tr>
{{/if}}
{{/if}}
<tr>
<td>{{res 'detCreated'}}</td>
<td>{{created}}</td>
</tr>
<tr>
<td>{{res 'detUpdated'}}</td>
<td>{{modified}}</td>
</tr>
{{#if expires}}
<tr>
<td>{{res 'detExpires'}}</td>
<td>{{expires}}</td>
</tr>
{{/if}}
</table>