Initial commit: Bitwarden export to PDF renderer
Adds the render.py WeasyPrint renderer, the pod-based bitwarden-print.sh wrapper, a Containerfile, a sample export (JSON + PDF), README and LICENSE.
This commit is contained in:
commit
41385b35e6
8 files changed
+1041
No files matched your search
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a Bitwarden JSON export as a dense PDF.
|
||||
|
||||
Every leaf value in the export is printed: well-known fields first in a
|
||||
readable order, then every remaining value alphabetically. The PDF starts
|
||||
with a summary and an index that carries page numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
TYPE_NAMES = {1: "login", 2: "secure note", 3: "card", 4: "identity", 5: "ssh key"}
|
||||
|
||||
# Preferred display order; matched by exact path, prefix or list index.
|
||||
PRIORITY = [
|
||||
"type",
|
||||
"name",
|
||||
"folderId",
|
||||
"organizationId",
|
||||
"collectionIds",
|
||||
"favorite",
|
||||
"reprompt",
|
||||
"login.username",
|
||||
"login.password",
|
||||
"login.totp",
|
||||
"login.passwordRevisionDate",
|
||||
"login.uris",
|
||||
"login.fido2Credentials",
|
||||
"card.cardholderName",
|
||||
"card.brand",
|
||||
"card.number",
|
||||
"card.expMonth",
|
||||
"card.expYear",
|
||||
"card.code",
|
||||
"identity.title",
|
||||
"identity.firstName",
|
||||
"identity.middleName",
|
||||
"identity.lastName",
|
||||
"identity.address1",
|
||||
"identity.address2",
|
||||
"identity.address3",
|
||||
"identity.city",
|
||||
"identity.state",
|
||||
"identity.postalCode",
|
||||
"identity.country",
|
||||
"identity.company",
|
||||
"identity.email",
|
||||
"identity.phone",
|
||||
"identity.ssn",
|
||||
"identity.username",
|
||||
"identity.passportNumber",
|
||||
"identity.licenseNumber",
|
||||
"secureNote",
|
||||
"sshKey.privateKey",
|
||||
"sshKey.publicKey",
|
||||
"sshKey.fingerprint",
|
||||
"sshKey.keyFingerprint",
|
||||
"fields",
|
||||
"notes",
|
||||
"passwordHistory",
|
||||
"attachments",
|
||||
"id",
|
||||
"creationDate",
|
||||
"revisionDate",
|
||||
"deletedDate",
|
||||
]
|
||||
|
||||
SECRET_RE = re.compile(
|
||||
r"(?:^|\.)(password|totp|code|ssn|pin|cvv|privateKey|passportNumber|licenseNumber|number)$"
|
||||
)
|
||||
|
||||
|
||||
class Empty:
|
||||
"""Marks an empty dict/list so it is visible in the output."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
|
||||
|
||||
def esc(value: object) -> str:
|
||||
return html.escape(str(value), quote=True)
|
||||
|
||||
|
||||
def flatten(obj, prefix: str = "", out=None):
|
||||
"""Flatten nested JSON into (dotted.path[index], leaf) pairs."""
|
||||
if out is None:
|
||||
out = []
|
||||
if isinstance(obj, dict):
|
||||
if not obj:
|
||||
out.append((prefix, Empty("{}")))
|
||||
return out
|
||||
for key, value in obj.items():
|
||||
flatten(value, f"{prefix}.{key}" if prefix else str(key), out)
|
||||
elif isinstance(obj, list):
|
||||
if not obj:
|
||||
out.append((prefix, Empty("[]")))
|
||||
return out
|
||||
for index, value in enumerate(obj):
|
||||
flatten(value, f"{prefix}[{index}]", out)
|
||||
else:
|
||||
out.append((prefix, obj))
|
||||
return out
|
||||
|
||||
|
||||
def natural_key(path: str):
|
||||
parts = re.split(r"(\d+)", path)
|
||||
return tuple((1, int(p)) if p.isdigit() else (0, p) for p in parts if p)
|
||||
|
||||
|
||||
def rank(path: str):
|
||||
priority = len(PRIORITY)
|
||||
for index, prefix in enumerate(PRIORITY):
|
||||
if path == prefix or path.startswith(prefix + ".") or path.startswith(prefix + "["):
|
||||
priority = index
|
||||
break
|
||||
return (priority, natural_key(path))
|
||||
|
||||
|
||||
def fmt_value(value):
|
||||
"""Return (kind, text) so CSS can style empties, numbers and text."""
|
||||
if isinstance(value, Empty):
|
||||
return ("empty", value.text)
|
||||
if isinstance(value, bool):
|
||||
return ("code", "true" if value else "false")
|
||||
if value is None:
|
||||
return ("empty", "null")
|
||||
if isinstance(value, (int, float)):
|
||||
return ("code", repr(value) if isinstance(value, float) else str(value))
|
||||
text = str(value)
|
||||
if text == "":
|
||||
return ("empty", '""')
|
||||
return ("text", text)
|
||||
|
||||
|
||||
def is_secret(path: str) -> bool:
|
||||
return bool(SECRET_RE.search(re.sub(r"\[\d+\]", "", path)))
|
||||
|
||||
|
||||
def mask_text(text: str) -> str:
|
||||
return f"{'*' * 10} ({len(text)} chars)"
|
||||
|
||||
|
||||
def type_num(item) -> int | None:
|
||||
value = item.get("type")
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||
|
||||
|
||||
def type_label(item) -> str:
|
||||
value = type_num(item)
|
||||
if value is None:
|
||||
return "unknown"
|
||||
return TYPE_NAMES.get(value, f"type {value}")
|
||||
|
||||
|
||||
def badge_class(item) -> str:
|
||||
value = type_num(item)
|
||||
return f"t-{value}" if value in TYPE_NAMES else "t-other"
|
||||
|
||||
|
||||
def folder_paths(folders) -> dict:
|
||||
by_id = {
|
||||
f["id"]: f
|
||||
for f in folders
|
||||
if isinstance(f, dict) and isinstance(f.get("id"), str)
|
||||
}
|
||||
paths = {}
|
||||
for fid, folder in by_id.items():
|
||||
parts = [folder.get("name") or fid]
|
||||
parent = folder.get("parentId")
|
||||
seen = {fid}
|
||||
while isinstance(parent, str) and parent in by_id and parent not in seen:
|
||||
seen.add(parent)
|
||||
parts.append(by_id[parent].get("name") or parent)
|
||||
parent = by_id[parent].get("parentId")
|
||||
paths[fid] = " / ".join(reversed(parts))
|
||||
return paths
|
||||
|
||||
|
||||
def sort_items(items, mode: str, folders: dict):
|
||||
def folder_of(item):
|
||||
fid = item.get("folderId")
|
||||
return folders.get(fid, str(fid)) if fid else ""
|
||||
|
||||
def name_of(item):
|
||||
return str(item.get("name") or "").casefold()
|
||||
|
||||
if mode == "as-is":
|
||||
return list(items)
|
||||
if mode == "name":
|
||||
key = lambda it: (name_of(it), folder_of(it))
|
||||
elif mode == "type":
|
||||
key = lambda it: (f"{type_num(it) or 99:03d}", folder_of(it), name_of(it))
|
||||
else: # folder
|
||||
key = lambda it: (folder_of(it), name_of(it))
|
||||
return sorted(items, key=key)
|
||||
|
||||
|
||||
CSS = """
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 9mm 8mm 11mm 8mm;
|
||||
@bottom-left {
|
||||
content: string(title) " - " string(source);
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
font-size: 5.8pt;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
@bottom-right {
|
||||
content: counter(page) " / " counter(pages);
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
font-size: 6pt;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
}
|
||||
html { font-family: "DejaVu Sans", sans-serif; font-size: 7.1pt; color: #111; }
|
||||
body { margin: 0; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1 {
|
||||
string-set: title content();
|
||||
font-size: 15pt;
|
||||
margin: 0 0 0.8mm;
|
||||
letter-spacing: -0.2pt;
|
||||
}
|
||||
h2 {
|
||||
font-size: 8.4pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5pt;
|
||||
color: #555;
|
||||
margin: 4.5mm 0 1.2mm;
|
||||
border-bottom: 0.6pt solid #333;
|
||||
padding-bottom: 0.5mm;
|
||||
}
|
||||
.sub { color: #666; font-size: 6.6pt; margin-bottom: 1mm; }
|
||||
.sub .src { string-set: source content(); font-family: "DejaVu Sans Mono", monospace; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.mono, th, td.k { font-family: "DejaVu Sans Mono", monospace; }
|
||||
table.stats th, table.stats td { text-align: left; padding: 0.35mm 1.5mm 0.35mm 0; vertical-align: top; }
|
||||
table.stats th { width: 32%; color: #666; font-weight: 400; font-size: 6.4pt; }
|
||||
table.stats td { overflow-wrap: anywhere; }
|
||||
table.split { width: 100%; }
|
||||
table.split td.col { vertical-align: top; padding: 0; width: 50%; }
|
||||
table.split td.col + td.col { padding-left: 5mm; }
|
||||
table.grid th {
|
||||
text-align: left;
|
||||
font-size: 6.1pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3pt;
|
||||
color: #666;
|
||||
border-bottom: 0.5pt solid #999;
|
||||
padding: 0.5mm 1mm;
|
||||
}
|
||||
table.grid td {
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
font-size: 6.5pt;
|
||||
padding: 0.35mm 1mm;
|
||||
border-bottom: 0.3pt dotted #dcdcdc;
|
||||
overflow-wrap: anywhere;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.grid tbody tr:nth-child(even) { background: #f6f6f6; }
|
||||
td.r, th.r { text-align: right; }
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 1.4mm;
|
||||
height: 1.4mm;
|
||||
border-radius: 0.7mm;
|
||||
margin-right: 1mm;
|
||||
background: #777;
|
||||
}
|
||||
.t-1 { background: #1f6feb; }
|
||||
.t-2 { background: #6e7781; }
|
||||
.t-3 { background: #8250df; }
|
||||
.t-4 { background: #b35c00; }
|
||||
.t-5 { background: #1a7f37; }
|
||||
.t-other { background: #555; }
|
||||
.item { margin: 0 0 2.4mm; }
|
||||
.item h3 {
|
||||
font-size: 7.8pt;
|
||||
margin: 0 0 0.6mm;
|
||||
padding: 0.5mm 0 0.5mm;
|
||||
border-bottom: 0.5pt solid #888;
|
||||
break-after: avoid;
|
||||
}
|
||||
.item h3 .idx {
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
color: #999;
|
||||
margin-right: 1.4mm;
|
||||
}
|
||||
.badge {
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
font-size: 5.8pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2pt;
|
||||
color: #fff;
|
||||
padding: 0.25mm 1mm;
|
||||
border-radius: 0.6mm;
|
||||
margin-right: 1.2mm;
|
||||
}
|
||||
.item h3 .iname { font-weight: 700; }
|
||||
.item h3 .meta {
|
||||
color: #666;
|
||||
font-size: 6.4pt;
|
||||
font-weight: 400;
|
||||
margin-left: 1.6mm;
|
||||
}
|
||||
table.kv th {
|
||||
width: 24%;
|
||||
text-align: left;
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
font-size: 6.1pt;
|
||||
font-weight: 400;
|
||||
color: #7a7a7a;
|
||||
padding: 0.4mm 1.6mm 0.4mm 0;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
table.kv td {
|
||||
font-family: "DejaVu Sans Mono", monospace;
|
||||
font-size: 6.9pt;
|
||||
padding: 0.4mm 0;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
border-bottom: 0.25pt dotted #e4e4e4;
|
||||
}
|
||||
table.kv tr { break-inside: avoid; }
|
||||
td.empty { color: #a0a0a0; }
|
||||
td.code { color: #0a5c2b; }
|
||||
td.mask { color: #8a5a00; }
|
||||
.summary-note { color: #666; font-size: 6.3pt; margin-top: 1mm; }
|
||||
"""
|
||||
|
||||
PAPER_OVERRIDE = {"a4": "A4", "letter": "letter"}
|
||||
|
||||
|
||||
def build_html(*, data, items, source_name, title, mask, sort_mode, paper):
|
||||
folders = [f for f in (data.get("folders") or []) if isinstance(f, dict)]
|
||||
fpaths = folder_paths(folders)
|
||||
collections = [c for c in (data.get("collections") or []) if isinstance(c, dict)]
|
||||
coll_names = {
|
||||
c.get("id"): (c.get("name") or c.get("id")) for c in collections if c.get("id")
|
||||
}
|
||||
|
||||
items = sort_items(items, sort_mode, fpaths)
|
||||
|
||||
leaves = sum(len(flatten(item)) for item in items)
|
||||
type_counts = Counter(type_label(item) for item in items)
|
||||
notes_count = sum(1 for item in items if item.get("notes"))
|
||||
field_count = sum(
|
||||
len(item.get("fields") or [])
|
||||
for item in items
|
||||
if isinstance(item.get("fields"), list)
|
||||
)
|
||||
attach_count = sum(
|
||||
len(item.get("attachments") or [])
|
||||
for item in items
|
||||
if isinstance(item.get("attachments"), list)
|
||||
)
|
||||
history_count = sum(
|
||||
len(item.get("passwordHistory") or [])
|
||||
for item in items
|
||||
if isinstance(item.get("passwordHistory"), list)
|
||||
)
|
||||
folder_counts = Counter(
|
||||
fpaths.get(item.get("folderId"), str(item.get("folderId")))
|
||||
if item.get("folderId")
|
||||
else "(no folder)"
|
||||
for item in items
|
||||
)
|
||||
|
||||
def date_range(key):
|
||||
values = sorted(
|
||||
str(item[key]) for item in items if isinstance(item.get(key), str) and item[key]
|
||||
)
|
||||
return f"{values[0]} .. {values[-1]}" if values else ""
|
||||
|
||||
def row(label, value, klass=""):
|
||||
cls = f' class="{klass}"' if klass else ""
|
||||
return f"<tr><th>{esc(label)}</th><td{cls}>{esc(value)}</td></tr>\n"
|
||||
|
||||
meta_rows = [
|
||||
row("source", source_name),
|
||||
row("generated", dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")),
|
||||
row("items", len(items)),
|
||||
row("folders", len(folders)),
|
||||
]
|
||||
if collections:
|
||||
meta_rows.append(row("collections", len(collections)))
|
||||
meta_rows.extend(
|
||||
[
|
||||
row("values printed", leaves),
|
||||
row("items with notes", notes_count),
|
||||
row("custom fields", field_count),
|
||||
row("attachments", attach_count),
|
||||
row("password history", history_count),
|
||||
row("created range", date_range("creationDate")),
|
||||
row("revised range", date_range("revisionDate")),
|
||||
]
|
||||
)
|
||||
for key, value in data.items():
|
||||
if key in {"items", "folders", "collections"}:
|
||||
continue
|
||||
kind, text = fmt_value(value)
|
||||
meta_rows.append(row(f"export.{key}", text, "empty" if kind == "empty" else ""))
|
||||
|
||||
type_rows = "".join(
|
||||
f'<tr><td><span class="dot {badge_class({"type": next((n for n, t in TYPE_NAMES.items() if t == label), None)})}"></span>{esc(label)}</td>'
|
||||
f'<td class="r">{count}</td></tr>\n'
|
||||
for label, count in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
)
|
||||
folder_rows = "".join(
|
||||
f"<tr><td>{esc(name)}</td><td class=\"r\">{count}</td></tr>\n"
|
||||
for name, count in sorted(folder_counts.items(), key=lambda kv: kv[0].casefold())
|
||||
)
|
||||
collection_rows = ""
|
||||
if collections:
|
||||
collection_rows = "".join(
|
||||
f"<tr><td>{esc(c.get('name') or c.get('id') or '')}</td>"
|
||||
f"<td>{esc(c.get('organizationId') or '')}</td>"
|
||||
f"<td>{esc(c.get('externalId') or '')}</td>"
|
||||
f"<td>{esc(c.get('readOnly'))}</td></tr>\n"
|
||||
for c in collections
|
||||
)
|
||||
|
||||
index_rows = []
|
||||
for index, item in enumerate(items, 1):
|
||||
fid = item.get("folderId")
|
||||
folder = fpaths.get(fid, str(fid)) if fid else ""
|
||||
login = item.get("login") if isinstance(item.get("login"), dict) else {}
|
||||
username = login.get("username") or ""
|
||||
index_rows.append(
|
||||
f'<tr><td class="r">{index}</td>'
|
||||
f'<td><span class="dot {badge_class(item)}"></span>{esc(type_label(item))}</td>'
|
||||
f'<td><a href="#item-{index}">{esc(item.get("name") or "(unnamed)")}</a></td>'
|
||||
f"<td>{esc(folder)}</td><td>{esc(username)}</td>"
|
||||
f'<td class="r"><a class="pageref" href="#item-{index}"></a></td></tr>\n'
|
||||
)
|
||||
|
||||
item_html = []
|
||||
for index, item in enumerate(items, 1):
|
||||
header_rows = []
|
||||
fid = item.get("folderId")
|
||||
folder = fpaths.get(fid, str(fid)) if fid else ""
|
||||
if fid:
|
||||
header_rows.append(row("folder", folder))
|
||||
cids = item.get("collectionIds")
|
||||
if isinstance(cids, list) and cids:
|
||||
header_rows.append(
|
||||
row("collections", ", ".join(str(coll_names.get(cid, cid)) for cid in cids))
|
||||
)
|
||||
for path, value in sorted(flatten(item), key=lambda kv: rank(kv[0])):
|
||||
kind, text = fmt_value(value)
|
||||
if mask and kind == "text" and is_secret(path):
|
||||
kind, text = "mask", mask_text(text)
|
||||
header_rows.append(row(path, text, kind))
|
||||
|
||||
meta_bits = []
|
||||
if folder:
|
||||
meta_bits.append(folder)
|
||||
if isinstance(cids, list) and cids:
|
||||
meta_bits.append(", ".join(str(coll_names.get(cid, cid)) for cid in cids))
|
||||
meta = (
|
||||
f'<span class="meta">{esc(" · ".join(meta_bits))}</span>' if meta_bits else ""
|
||||
)
|
||||
|
||||
item_html.append(
|
||||
f'<section class="item">\n'
|
||||
f'<h3 id="item-{index}"><span class="idx">{index}</span>'
|
||||
f'<span class="badge {badge_class(item)}">{esc(type_label(item))}</span>'
|
||||
f'<span class="iname">{esc(item.get("name") or "(unnamed)")}</span>{meta}</h3>\n'
|
||||
f'<table class="kv">\n{"".join(header_rows)}</table>\n'
|
||||
f"</section>\n"
|
||||
)
|
||||
|
||||
paper_css = PAPER_OVERRIDE.get(paper, "A4")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{esc(title)}</title>
|
||||
<style>{CSS}
|
||||
@page {{ size: {paper_css}; }}
|
||||
.pageref::after {{ content: target-counter(attr(href), page); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{esc(title)}</h1>
|
||||
<p class="sub"><span class="src">{esc(source_name)}</span> · {len(items)} items · {leaves} values · generated {dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")}</p>
|
||||
|
||||
<h2>Summary</h2>
|
||||
<table class="split"><tr>
|
||||
<td class="col"><table class="stats">{"".join(meta_rows)}</table></td>
|
||||
<td class="col">
|
||||
<table class="grid"><thead><tr><th>type</th><th class="r">items</th></tr></thead><tbody>{type_rows}</tbody></table>
|
||||
<table class="grid" style="margin-top:3mm"><thead><tr><th>folder</th><th class="r">items</th></tr></thead><tbody>{folder_rows}</tbody></table>
|
||||
</td></tr></table>
|
||||
{f'<h2>Collections</h2><table class="grid"><thead><tr><th>name</th><th>organizationId</th><th>externalId</th><th>readOnly</th></tr></thead><tbody>{collection_rows}</tbody></table>' if collections else ''}
|
||||
|
||||
<h2>Index</h2>
|
||||
<table class="grid"><thead><tr><th class="r" style="width:6%">#</th><th style="width:14%">type</th><th>name</th><th style="width:20%">folder</th><th style="width:16%">username</th><th class="r" style="width:6%">page</th></tr></thead>
|
||||
<tbody>{"".join(index_rows)}</tbody></table>
|
||||
|
||||
<h2>Items</h2>
|
||||
{"".join(item_html)}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def load_export(path: Path):
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("error: export root must be a JSON object")
|
||||
if data.get("encrypted") is True:
|
||||
raise SystemExit(
|
||||
"error: this is an encrypted export and cannot be read.\n"
|
||||
" Re-export from Bitwarden with 'Export as .json' and no file password."
|
||||
)
|
||||
items = data.get("items") or []
|
||||
if not isinstance(items, list):
|
||||
raise SystemExit("error: 'items' is not a list; is this a Bitwarden export?")
|
||||
items = [item for item in items if isinstance(item, dict)]
|
||||
return data, items
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render a Bitwarden JSON export as a dense PDF."
|
||||
)
|
||||
parser.add_argument("input", type=Path, help="Bitwarden .json export")
|
||||
parser.add_argument("-o", "--output", type=Path, help="output PDF (default: input.pdf)")
|
||||
parser.add_argument("--title", help="document title")
|
||||
parser.add_argument(
|
||||
"--sort",
|
||||
choices=["folder", "name", "type", "as-is"],
|
||||
default="folder",
|
||||
help="item order (default: folder)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mask-secrets",
|
||||
action="store_true",
|
||||
help="mask passwords, TOTP, card numbers/codes, SSN and private keys",
|
||||
)
|
||||
parser.add_argument("--paper", choices=["a4", "letter"], default="a4")
|
||||
parser.add_argument("--html-out", type=Path, help="also write the HTML source")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.input.is_file():
|
||||
raise SystemExit(f"error: no such file: {args.input}")
|
||||
|
||||
data, items = load_export(args.input)
|
||||
title = args.title or "Bitwarden export"
|
||||
document = build_html(
|
||||
data=data,
|
||||
items=items,
|
||||
source_name=args.input.name,
|
||||
title=title,
|
||||
mask=args.mask_secrets,
|
||||
sort_mode=args.sort,
|
||||
paper=args.paper,
|
||||
)
|
||||
|
||||
if args.html_out:
|
||||
args.html_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.html_out.write_text(document, encoding="utf-8")
|
||||
|
||||
output = args.output or args.input.with_suffix(".pdf")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from weasyprint import HTML
|
||||
|
||||
HTML(string=document, base_url=str(args.input.parent)).write_pdf(str(output))
|
||||
print(f"wrote {output} ({len(items)} items)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user