Add export options
This commit is contained in:
parent
6cae652643
commit
fb4a9e5e27
4 changed files with 171 additions and 13 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
* Explain the purpose of the encryption key for first-time use
|
* Explain the purpose of the encryption key for first-time use
|
||||||
* Support saving the encryption key to the settings file to avoid being prompted (off by default)
|
* Support saving the encryption key to the settings file to avoid being prompted (off by default)
|
||||||
* Abbreviated toolbar symbols to keep things tidier. Add tooltips
|
* Abbreviated toolbar symbols to keep things tidier. Add tooltips
|
||||||
|
* Add ability to export the database to different formats
|
||||||
|
|
||||||
# 0.1.2
|
# 0.1.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,15 @@ There is deliberately no network connectivity or syncing intended.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
* Data is encrypted at rest
|
||||||
|
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
||||||
* Every 'page' is linked to the calendar day
|
* Every 'page' is linked to the calendar day
|
||||||
* Text is HTML with basic styling
|
* Text is HTML with basic styling
|
||||||
* Search
|
* Search
|
||||||
* Automatic periodic saving (or explicitly save)
|
* Automatic periodic saving (or explicitly save)
|
||||||
* Transparent integrity checking of the database when it opens
|
* Transparent integrity checking of the database when it opens
|
||||||
* Rekey the database (change the password)
|
* Rekey the database (change the password)
|
||||||
|
* Export the database to json, txt, html or csv
|
||||||
|
|
||||||
## Yet to do
|
|
||||||
|
|
||||||
* Taxonomy/tagging
|
|
||||||
* Export to other formats (plaintext, json, sql etc)
|
|
||||||
|
|
||||||
|
|
||||||
## How to install
|
## How to install
|
||||||
|
|
|
||||||
116
bouquin/db.py
116
bouquin/db.py
|
|
@ -1,9 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlcipher3 import dbapi2 as sqlite
|
from sqlcipher3 import dbapi2 as sqlite
|
||||||
|
from typing import List, Sequence, Tuple
|
||||||
|
|
||||||
|
Entry = Tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -21,6 +28,7 @@ class DBManager:
|
||||||
# Ensure parent dir exists
|
# Ensure parent dir exists
|
||||||
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.conn = sqlite.connect(str(self.cfg.path))
|
self.conn = sqlite.connect(str(self.cfg.path))
|
||||||
|
self.conn.row_factory = sqlite.Row
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
||||||
cur.execute("PRAGMA journal_mode = WAL;")
|
cur.execute("PRAGMA journal_mode = WAL;")
|
||||||
|
|
@ -102,14 +110,116 @@ class DBManager:
|
||||||
def search_entries(self, text: str) -> list[str]:
|
def search_entries(self, text: str) -> list[str]:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
pattern = f"%{text}%"
|
pattern = f"%{text}%"
|
||||||
cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
|
return cur.execute(
|
||||||
return [r for r in cur.fetchall()]
|
"SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
def dates_with_content(self) -> list[str]:
|
def dates_with_content(self) -> list[str]:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
||||||
return [r[0] for r in cur.fetchall()]
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_all_entries(self) -> List[Entry]:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
|
||||||
|
return [(row["date"], row["content"]) for row in rows]
|
||||||
|
|
||||||
|
def export_json(
|
||||||
|
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
||||||
|
) -> None:
|
||||||
|
data = [{"date": d, "content": c} for d, c in entries]
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
if pretty:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
else:
|
||||||
|
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
||||||
|
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
||||||
|
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(["date", "content"]) # header
|
||||||
|
writer.writerows(entries)
|
||||||
|
|
||||||
|
def export_txt(
|
||||||
|
self,
|
||||||
|
entries: Sequence[Entry],
|
||||||
|
file_path: str,
|
||||||
|
separator: str = "\n\n— — — — —\n\n",
|
||||||
|
strip_html: bool = True,
|
||||||
|
) -> None:
|
||||||
|
import re, html as _html
|
||||||
|
|
||||||
|
# Precompiled patterns
|
||||||
|
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
||||||
|
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
||||||
|
BR_RE = re.compile(r"(?i)<br\\s*/?>")
|
||||||
|
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
|
||||||
|
TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
|
||||||
|
MULTINEWLINE_RE = re.compile(r"\\n{3,}")
|
||||||
|
|
||||||
|
def _strip(s: str) -> str:
|
||||||
|
# 1) Remove <style> and <script> blocks *including their contents*
|
||||||
|
s = STYLE_SCRIPT_RE.sub("", s)
|
||||||
|
# 2) Remove HTML comments
|
||||||
|
s = COMMENT_RE.sub("", s)
|
||||||
|
# 3) Turn some block-ish boundaries into newlines before removing tags
|
||||||
|
s = BR_RE.sub("\n", s)
|
||||||
|
s = BLOCK_END_RE.sub("\n", s)
|
||||||
|
# 4) Drop remaining tags
|
||||||
|
s = TAG_RE.sub("", s)
|
||||||
|
# 5) Unescape entities ( etc.)
|
||||||
|
s = _html.unescape(s)
|
||||||
|
# 6) Tidy whitespace
|
||||||
|
s = WS_ENDS_RE.sub("\n", s)
|
||||||
|
s = MULTINEWLINE_RE.sub("\n\n", s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
for i, (d, c) in enumerate(entries):
|
||||||
|
body = _strip(c) if strip_html else c
|
||||||
|
f.write(f"{d}\n{body}\n")
|
||||||
|
if i < len(entries) - 1:
|
||||||
|
f.write(separator)
|
||||||
|
|
||||||
|
def export_html(
|
||||||
|
self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
|
||||||
|
) -> None:
|
||||||
|
parts = [
|
||||||
|
"<!doctype html>",
|
||||||
|
'<html lang="en">',
|
||||||
|
'<meta charset="utf-8">',
|
||||||
|
f"<title>{html.escape(title)}</title>",
|
||||||
|
"<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
|
||||||
|
"article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
|
||||||
|
"<body>",
|
||||||
|
f"<h1>{html.escape(title)}</h1>",
|
||||||
|
]
|
||||||
|
for d, c in entries:
|
||||||
|
parts.append(
|
||||||
|
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
|
||||||
|
)
|
||||||
|
parts.append("</body></html>")
|
||||||
|
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(parts))
|
||||||
|
|
||||||
|
def export_by_extension(self, file_path: str) -> None:
|
||||||
|
entries = self.get_all_entries()
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
|
||||||
|
if ext == ".json":
|
||||||
|
self.export_json(entries, file_path)
|
||||||
|
elif ext == ".csv":
|
||||||
|
self.export_csv(entries, file_path)
|
||||||
|
elif ext == ".txt":
|
||||||
|
self.export_txt(entries, file_path)
|
||||||
|
elif ext in {".html", ".htm"}:
|
||||||
|
self.export_html(entries, file_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported extension: {ext}")
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self.conn is not None:
|
if self.conn is not None:
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QDate, QTimer, Qt, QSettings
|
from pathlib import Path
|
||||||
|
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
QCursor,
|
QCursor,
|
||||||
|
|
@ -14,6 +15,7 @@ from PySide6.QtGui import (
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
QFileDialog,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
|
@ -102,15 +104,19 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Menu bar (File)
|
# Menu bar (File)
|
||||||
mb = self.menuBar()
|
mb = self.menuBar()
|
||||||
file_menu = mb.addMenu("&Application")
|
file_menu = mb.addMenu("&File")
|
||||||
act_save = QAction("&Save", self)
|
act_save = QAction("&Save", self)
|
||||||
act_save.setShortcut("Ctrl+S")
|
act_save.setShortcut("Ctrl+S")
|
||||||
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
||||||
file_menu.addAction(act_save)
|
file_menu.addAction(act_save)
|
||||||
act_settings = QAction("S&ettings", self)
|
act_settings = QAction("Settin&gs", self)
|
||||||
act_settings.setShortcut("Ctrl+E")
|
act_settings.setShortcut("Ctrl+G")
|
||||||
act_settings.triggered.connect(self._open_settings)
|
act_settings.triggered.connect(self._open_settings)
|
||||||
file_menu.addAction(act_settings)
|
file_menu.addAction(act_settings)
|
||||||
|
act_export = QAction("&Export", self)
|
||||||
|
act_export.setShortcut("Ctrl+E")
|
||||||
|
act_export.triggered.connect(self._export)
|
||||||
|
file_menu.addAction(act_export)
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
act_quit = QAction("&Quit", self)
|
act_quit = QAction("&Quit", self)
|
||||||
act_quit.setShortcut("Ctrl+Q")
|
act_quit.setShortcut("Ctrl+Q")
|
||||||
|
|
@ -338,6 +344,50 @@ class MainWindow(QMainWindow):
|
||||||
# Center the window in that screen’s available area
|
# Center the window in that screen’s available area
|
||||||
self.move(r.center() - self.rect().center())
|
self.move(r.center() - self.rect().center())
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _export(self):
|
||||||
|
try:
|
||||||
|
self.export_dialog()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Export failed", str(e))
|
||||||
|
|
||||||
|
def export_dialog(self) -> None:
|
||||||
|
filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
|
||||||
|
|
||||||
|
start_dir = os.path.join(os.path.expanduser("~"), "Documents")
|
||||||
|
filename, selected_filter = QFileDialog.getSaveFileName(
|
||||||
|
self, "Export entries", start_dir, filters
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return # user cancelled
|
||||||
|
|
||||||
|
default_ext = {
|
||||||
|
"Text (*.txt)": ".txt",
|
||||||
|
"JSON (*.json)": ".json",
|
||||||
|
"CSV (*.csv)": ".csv",
|
||||||
|
"HTML (*.html)": ".html",
|
||||||
|
}.get(selected_filter, ".txt")
|
||||||
|
|
||||||
|
if not Path(filename).suffix:
|
||||||
|
filename += default_ext
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = self.db.get_all_entries()
|
||||||
|
if selected_filter.startswith("Text"):
|
||||||
|
self.db.export_txt(entries, filename)
|
||||||
|
elif selected_filter.startswith("JSON"):
|
||||||
|
self.db.export_json(entries, filename)
|
||||||
|
elif selected_filter.startswith("CSV"):
|
||||||
|
self.db.export_csv(entries, filename)
|
||||||
|
elif selected_filter.startswith("HTML"):
|
||||||
|
self.bd.export_html(entries, filename)
|
||||||
|
else:
|
||||||
|
self.bd.export_by_extension(entries, filename)
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Export failed", str(e))
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
try:
|
try:
|
||||||
# Save window position
|
# Save window position
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue